Merge branch 'master' into product/public

This commit is contained in:
Dita Aji Pratama 2026-10-13 09:48:54 +07:00
commit 484ab13471
29 changed files with 1206 additions and 313 deletions

View File

@ -2,9 +2,9 @@
agent:
character : hendrik # Directory name in agent/characters/<character>/
max_iterations : 40 # step(s)
max_iterations : 80 # step(s)
max_history_chat : 0 # message(s) - 0 for unlimited (Dead Config)
max_tool_output : 0 # character(s) - 0 for unlimited (Connected to AGENT_MAX_TOOL_OUTPUT but Dead Config)
max_tool_output : 0 # character(s) - 0 for unlimited
user:
id: # Fill your user UUID here, empty = TUI/MUC guest
@ -15,26 +15,23 @@ llm:
moop: # Model Option
db_path: "~/.config/hendrik/moop.sqlite3"
asr: # Speech-to-Text (push-to-talk TUI). Model dikelola via moop tipe 'asr'.
record_dir : "~/.config/hendrik/recordings" # lokasi file hasil rekaman
record_key : f9 # tombol push-to-talk (f1..f12 / ctrl-<huruf>)
record_backend : pulse # ffmpeg input: pulse | alsa | oss | windows
device : default # nama device input
windows_mic : "" # backend=windows: nama device dshow,
# contoh "Microphone Array (Realtek(R) Audio)"
format : wav # wav | ogg (opus)
sample_rate : 16000 # Hz
channels : 1
max_seconds : 60 # auto-stop pengaman
transcribe_timeout: 60 # detik timeout request ASR
keep_recordings : true # false = hapus file setelah ditranskrip
auto_submit : false # true = hasil transkrip langsung dikirim
# false = hanya mengisi form (Ctrl+Enter)
asr:
record_dir : "~/.config/hendrik/recordings"
record_backend : pulse # ffmpeg input: pulse | alsa | oss | windows
device : default
windows_mic : "" # if backend==windows: device dshow name, example: "Microphone Array (Realtek(R) Audio)"
format : wav # wav | ogg (opus)
sample_rate : 16000 # Hz
channels : 1
max_seconds : 120 # second
transcribe_timeout : 60 # second
keep_recordings : false
auto_submit : true
session: # Chat session
session:
db_path: "~/.config/hendrik/sessions.json"
ragroleplay: # Use nomic-embed-text
ragroleplay:
db_path : "~/.config/hendrik/ragroleplay"
vector_size : 768 # used on table create only
@ -53,7 +50,7 @@ xmpp:
# - "room2@conference.server"
nickname: "" # custom MUC nickname (empty = use username)
selective_response: true # true = only response if mentioned/relevant
debug: false # true = tampilkan log debug menyeluruh (semua stanza XML, lifecycle, dll)
debug: false # true = display debug log (stanza XML, lifecycle, etc)
mute_session_end: false # true = tidak kirim pesan saat sesi berakhir (timeout)
telegram:

View File

@ -58,8 +58,8 @@ ASR_MAX_SECONDS = int(_yaml_get("asr", "max_seconds", default=60))
ASR_TIMEOUT = int(_yaml_get("asr", "transcribe_timeout", default=60))
ASR_KEEP_RECORDINGS = _as_bool(_yaml_get("asr", "keep_recordings", default=True), default=True)
# Tombol push-to-talk: "f1".."f12" atau "ctrl-<huruf>". Default F9.
ASR_RECORD_KEY = str(_yaml_get("asr", "record_key", default="f9")).strip().lower()
# Tombol push-to-talk dipatok tetap ke F9 (tidak bisa dikonfigurasi).
# Lihat interfaces/tui/actions.py -> ACTIONS["push_to_talk"].
# true = hasil transkripsi langsung dikirim sebagai pesan,
# false = hasil hanya disisipkan ke form input (user kirim sendiri via Ctrl+Enter).

View File

@ -1,5 +1,5 @@
#!/bin/bash
# wrapper to run the TUI agent from anywhere
# wrapper to run the hendrik agent from anywhere
# Set HENDRIK_DIR env var to override, or update the default below
DEFAULT_DIR="/opt/hendrik"

View File

@ -14,23 +14,25 @@ from interfaces.tui import HendrikTUI
tools_definition = [
# Coder Tools
gadget.tools_mapping( schema = coder.schema_read_file, handler = coder.read_file ),
gadget.tools_mapping( schema = coder.schema_write_file, handler = coder.write_file ),
gadget.tools_mapping( schema = coder.schema_edit_file, handler = coder.edit_file ),
gadget.tools_mapping( schema = coder.schema_run_bash, handler = coder.run_bash ),
gadget.tools_mapping( schema = coder.schema_search_code, handler = coder.search_code ),
gadget.tools_mapping( schema = coder.schema_git_operation, handler = coder.git_operation ),
gadget.tools_mapping( schema = coder.schema_search_grep, handler = coder.search_grep ),
gadget.tools_mapping( schema = coder.schema_search_glob, handler = coder.search_glob ),
gadget.tools_mapping( schema = coder.schema_read_file, handler = coder.read_file ),
gadget.tools_mapping( schema = coder.schema_write_file, handler = coder.write_file ),
gadget.tools_mapping( schema = coder.schema_edit_file, handler = coder.edit_file ),
gadget.tools_mapping( schema = coder.schema_run_bash, handler = coder.run_bash ),
gadget.tools_mapping( schema = coder.schema_search_code, handler = coder.search_code ),
gadget.tools_mapping( schema = coder.schema_git_operation, handler = coder.git_operation ),
gadget.tools_mapping( schema = coder.schema_search_grep, handler = coder.search_grep ),
gadget.tools_mapping( schema = coder.schema_search_glob, handler = coder.search_glob ),
# Carrack Tools
gadget.tools_mapping( schema = carrack.schema_sendhttprequest, handler = carrack.sendhttprequest ),
gadget.tools_mapping( schema = carrack.schema_sendhttprequest, handler = carrack.sendhttprequest ),
# Vision Tools
gadget.tools_mapping( schema = vision.schema_describe_image, handler = vision.describe_image ),
gadget.tools_mapping( schema = vision.schema_describe_image, handler = vision.describe_image ),
# Image Generation Tools
gadget.tools_mapping( schema = imagegen.schema_generate_image, handler = imagegen.generate_image ),
gadget.tools_mapping( schema = imagegen.schema_generate_image, handler = imagegen.generate_image ),
# Instruction Tools
gadget.tools_mapping( schema = smart.schema_instruction_use, handler = smart.instruction_use ), # snippet
gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_use, handler = smart_rp.rp_instruction_use ), # roleplay
gadget.tools_mapping( schema = smart.schema_instruction_use, handler = smart.instruction_use ), # snippet (global)
gadget.tools_mapping( schema = smart.schema_instruction_char_use, handler = smart.instruction_char_use ), # snippet (private per character)
gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_use, handler = smart_rp.rp_instruction_use ), # roleplay (global)
gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_char_use, handler = smart_rp.rp_instruction_char_use ), # roleplay (private per character)
# Rag Roleplay Tools
gadget.tools_mapping( schema = ragroleplay_tools.schema_users_store, handler = ragroleplay_tools.users_store ),
gadget.tools_mapping( schema = ragroleplay_tools.schema_user_load, handler = ragroleplay_tools.user_load ), # s_load

View File

@ -0,0 +1,324 @@
# ERD / PlantUML / Buat
Berikut panduan cara membuat ERD dengan PlantUML.
Acuan format standarnya adalah `plan/milestone/moop.puml` (sumber) dan
`plan/milestone/moop.png` (hasil render). Ikuti gaya itu persis — header-nya sama,
tata letak entity-nya sama, cara menulis constraint dan relasinya juga sama.
Yang perlu disiapkan:
- prefix modul/domain (misal `moop`, `chat`, `memories`) — dipakai sebagai prefix nama tabel/entity
- daftar tabel beserta kolomnya
- relasi antar tabel (siapa mereferensi siapa, wajib atau boleh kosong)
- direktori tujuan untuk pasangan file `.puml` + `.png` (bawaan: `plan/milestone/`)
Bahasa penulisan: **isi diagram tetap bahasa Inggris** (nama tabel, tipe, dan teks
constraint seperti `on update cascade` / `null priority = disabled`) supaya konsisten
dengan skema SQL dan `moop.puml`. Penjelasan di dokumen ini saja yang bahasa Indonesia.
## Penamaan file dan lokasinya
- satu diagram = satu pasangan file: `<name>.puml` dan `<name>.png`
- file `.puml` dan `.png` diletakkan berdampingan di direktori yang sama
- nama diagram mengikuti nama fitur/modul, kebab-case atau huruf kecil biasa
(contoh: `moop.puml`, `2-basic-features.puml`)
- jangan pernah men-commit `.puml` tanpa `.png` hasil render-nya, dan render ulang setiap
kali `.puml` diubah
## Header wajib
Setiap ERD dibuka persis seperti ini. Tanpa judul, tanpa legenda, tanpa blok penulis:
```plantuml
@startuml
hide circle
skinparam linetype ortho
...
@enduml
```
- `hide circle` menghapus lingkaran actor di depan nama entity.
- `skinparam linetype ortho` membuat garis relasi menyiku/lurus supaya diagram tetap enak dibaca.
- sisakan satu baris kosong setelah `@startuml` dan sebelum `@enduml`.
- jangan menambah `skinparam` lain, warna, atau blok `<style>`. File ERD tetap polos.
## Kelompokkan tabel dengan package
Cluster entity berdasarkan sub-domain memakai `package <nama> { ... }` (huruf kecil,
sedapatnya satu kata, contoh: `option`, `provider`). Semua entity dalam satu ranah
dimasukkan ke package yang sama, dan relasi ranah itu juga ditulis di dalam blok yang
sama. Antar package dipisah satu baris kosong.
```plantuml
package provider {
...entity dan relasi milik domain provider...
}
```
## Blok entity
Nama entity adalah nama tabelnya, apa adanya: berprefix nama modul, `snake_case`,
tunggal atau jamak mengikuti skema aslinya (contoh: `moop_model_set`, `moop_api`).
```plantuml
entity moop_model {
id : binary(40) <<PK>>
api : binary(40) <<FK>>
set : binary(40) <<FK>>
model : longtext
priority : int null
--
* api = moop_api.id
on update cascade
on delete cascade
* set = moop_model_set.id
on update cascade
on delete cascade
* null priority = disabled
}
```
Aturannya:
- isi entity di-indent 4 spasi (kalau di dalam package: 8 spasi untuk atribut).
- satu atribut per baris, formatnya `nama : tipe` diikuti penanda stereotype.
- `--` memisahkan bagian kolom dari bagian constraint/aturan. Selalu tulis pemisahnya,
walaupun di bawahnya tidak ada apa-apa.
- tidak ada baris kosong di dalam blok entity.
- jangan pakai ekstensi `<<primary key>>`/`{mask}` dari PlantUML, jangan pakai warna,
jangan pakai alias (`entity "foo" as f`). Nama di sebelah kiri ya nama tabel aslinya.
### Sintaks atribut
| Kondisi | Sintaks | Contoh |
|---|---|---|
| primary key | `id : binary(40) <<PK>>` | selalu baris pertama, selalu `binary(40)` |
| foreign key | `api : binary(40) <<FK>>` | kolom FK juga `binary(40)` |
| kolom unique | `type : binary(40) unique <<FK>>` | `unique` ditulis tepat setelah tipe |
| boleh null | `priority : int null` | `null` ditulis setelah tipe |
| teks | `name : longtext` | |
| angka | `priority : int` | |
Tipe ditulis sebagai tipe SQL (`binary(40)`, `longtext`, `int`), bukan tipe Python.
Kunci surrogate dan semua referensi ke baris tabel lain memakai `binary(40)`.
### Bagian constraint
Setiap baris di bawah `--` diawali `* `. Aturan yang panjangnya lebih dari satu baris
dilanjutkan di baris berikutnya dengan indent lebih dalam, tanpa mengulang tanda `*`.
Tulis di sini, berurutan seperti ini:
1. Tujuan FK: `* <kolom> = <tabel_induk>.id`, lalu `on update cascade` dan
`on delete cascade` masing-masing di baris sendiri.
2. Aturan unique/komposit: `* unique(set, type)`.
3. Integritas antar kolom: `* set_type.type must equal type`.
4. Nilai yang diizinkan: `* type availability =` lalu daftar nilainya di baris
berikutnya, diberi kutip dan dipisah pipe — `"llm" | "embedding" | "image generation"`.
5. Catatan bisnis/semantik dengan kalimat biasa: `* null priority = disabled`,
`* empty key = no bearer`, `* one default set per model type`.
Bagian constraint inilah yang membuat diagram bisa di-review tanpa membaca SQL-nya.
Kalau ada aturan di skema atau di spesifikasi produk, tempatnya ada di sini.
## Relasi
Tulis setiap relasi di baris sendiri, tepat setelah blok entity yang memilikinya
(entity anak yang punya kolom FK, atau induknya kalau relasi itu memang dikenalkan dari
sana). Tanpa label, tanpa klausa `to`, tanpa baris kosong antara entity dan relasinya.
```plantuml
moop_model_set ||--|{ moop_model_set_type
moop_model_type ||-o{ moop_model_set_type
moop_model_type ||--|| moop_model_type_default
moop_model_set_type ||--o| moop_model_type_default
moop_api ||--o{ moop_key
```
Kardinalitas selalu `induk <--> anak`, memakai notasi crow-foot:
| Kiri (induk) | Arti | Kanan (anak) | Arti |
|---|---|---|---|
| `\|\|` | tepat satu | `\|\|` | tepat satu |
| `\|\|` | tepat satu | `\|{` | satu atau lebih |
| `\|\|` | tepat satu | `o{` | nol atau lebih |
| `\|\|` | tepat satu | `o\|` | nol atau satu |
Simbolnya: `||` = wajib satu, `o|` = opsional satu, `|{` = satu atau lebih, `o{` = nol atau lebih.
**Jumlah dash bukan kardinalitas.** Yang membawa makna hanya karakter di kedua ujungnya
(`|`, `o`, `{`, `}`). Jumlah `-` adalah **knob tata letak yang mengatur orientasi**:
| Sintaks | Efek (sudah diverifikasi ke jar-nya) |
|---|---|
| `\|-` (satu dash) | **horizontal** — kedua entity berdampingan, edge-nya tidak menambah rank |
| `\|--` (dua dash) | **vertikal** — entity anak ditumpuk di bawah induknya. Ini bawaannya |
| `\|---`, `\|----` | tetap vertikal, cuma lebih panjang. Pakai saat garis saling menutupi |
Diukur pada rantai 3 entity yang identik: satu dash merender 543×95 (a→b→c berjajar),
dua dash merender 171×349 (a→b→c menurun). Menambah dash ke tiga atau empat hanya
menambah tinggi (275 → 336 px), tidak pernah mengubah orientasi.
Ini disengaja di `moop.puml`: `moop_model_type ||-o{ moop_model_set_type` memakai
**satu** dash supaya `moop_model_type` jatuh ke sebelah *kiri* tabel junction, sedangkan
semua relasi induk→anak memakai **dua** dash supaya diagram tetap mengalir dari atas ke bawah.
Jadi aturannya:
- pakai `--` sebagai default untuk semua relasi — diagramnya terbaca sebagai hierarki vertikal
- pindah ke satu `-` hanya kalau dua tabel memang perlu berdampingan agar render tidak kusut
- jangan pernah memakai jumlah dash untuk arti "kuat vs lemah" atau "identifying vs
non-identifying". Makna itu tidak ada, dan pembaca akan salah tafsir
- jangan pakai `..` (garis putus-putus). Hasilnya garis dashed dan tidak dipakai sama sekali
di format standar ini
## Menentukan posisi entity (mana yang atas/bawah, mana yang kiri/kanan)
Sisi penulisan entity pada baris relasi **adalah** posisinya. Entity di sisi kiri akan
ditempatkan **di atas** (relasi vertikal) atau **di sebelah kiri** (relasi horizontal)
dari entity di sisi kanan. Sudah diverifikasi ke jar-nya:
| Baris relasi | Hasil |
|---|---|
| `induk \|--\|{ anak` | `induk` di atas, `anak` di bawah |
| `anak }--\|\| induk` | `anak` di atas, `induk` di bawah (terbalik) |
| `induk \|-o{ anak` | `induk` di kiri, `anak` di kanan |
| `anak }o-\|\| induk` | `anak` di kiri, `induk` di kanan (terbalik) |
Dua konsekuensi yang penting:
1. **Urutan deklarasi `entity` di file tidak berpengaruh.** Menulis `anak` sebelum
`induk` tetap merender `induk` di atas, selama baris relasinya `induk ||--|{ anak`.
Jangan memperbaiki posisi dengan memindah-mindah blok entity — yang menggeser posisi
hanya baris relasi.
2. **Menulis barisnya terbalik akan membalik diagram dan merusak notasi.** Pertahankan
induk di sisi kiri, supaya crow's foot jatuh di sisi anak. Kalau karena alasan tata
letak anak harus muncul di atas induk, pindahkan *package/entity*-nya dan tetap tulis
`induk <--> anak`, jangan membalik baris relasinya.
Resep praktis supaya hasilnya enak dilihat:
- gambar hierarki utama dari atas ke bawah dengan `--`, induk selalu di kiri
- untuk tabel junction, pisahkan dua induknya: induk utama di atasnya dengan `||--|{`,
induk lookup di sampingnya dengan `||-o{` (satu dash) supaya induk itu jatuh ke kiri,
bukan menumpuk — persis kasus `moop_model_type`
- kalau dua entity sibling saling desak, beri satu dash tambahan pada salah satu
relasinya (`||---|{`) untuk membuka ruang vertikal
- kalau rantai tabelnya terlalu panjang sampai diagramnya terlalu tinggi, pecah per
sub-domain ke `package` sendiri — bukan dengan membalik baris relasi
Cek konsistensi yang wajib dilakukan:
- kalau kolom FK-nya `NOT NULL`, sisi anak harus memakai `|` (`||--|{`, `||--o|`)
- kalau kolom FK-nya nullable/opsional, sisi anak memakai `o` (`||-o{`, `||--o|`)
- kolom FK yang `UNIQUE` berarti relasi 1-ke-1 (`||--||` atau `||--o|`), bukan 1-ke-banyak
- setiap `<<FK>>` yang dideklarasikan di entity harus punya baris relasi yang cocok di
suatu tempat, dan setiap baris relasi harus punya kolom FK yang cocok. Tidak boleh ada
relasi yatim, tidak boleh ada FK yang tidak terdokumentasi.
## Relasi antar package
Kalau sebuah tabel di satu package mereferensi tabel di package lain, tetap deklarasikan
FK-nya di dalam entity anak dan gambar relasinya dari sisi anak, di dalam blok package
anaknya. Jangan membuat bagian "relations" terpisah di bawah file.
## Urutan penulisan
Aturan di bagian ini hanya untuk **keterbacaan sumbernya** — tidak mengubah tata letak
hasil render. Posisi ditentukan oleh baris relasi (lihat "Menentukan posisi entity").
- package: dari domain yang paling mandiri ke yang paling bergantung
- di dalam package: tabel induk dulu, lalu tabel junction/anaknya, lalu tabel yang
bergantung pada tabel-tabel itu
- atribut: `id` dulu, lalu FK dan kolom data, lalu kolom nullable/opsional paling akhir
## Render
Menggunakan jar yang sudah tersedia di `plan/milestone/`:
```bash
cd plan/milestone && java -jar plantuml-mit-1.2026.8.jar -tpng -charset UTF-8 moop.puml -o .
```
- `-tpng` untuk keluaran PNG, `-charset UTF-8` supaya nilai enum yang dikutip tidak rusak
- `-o <dir>` menulis gambar di samping file sumbernya
- setelah render, buka `.png`-nya dan pastikan: diagramnya mengalir atas ke bawah, tidak
ada kotak yang tumpang tindih, tidak ada garis bersilangan yang sebenarnya bisa dihindari
dengan memindah satu relasi ke satu dash (berdampingan) atau menambah dash untuk membuka
ruang vertikal, setiap entity berada di tepat satu package, dan setiap relasi benar-benar
tersambung. Ingat: memindah blok `entity` di sumber **tidak** menggeser apa pun — yang
menggeser hanya baris relasi
## Template minimal
```plantuml
@startuml
hide circle
skinparam linetype ortho
package <domain> {
entity <prefix>_parent {
id : binary(40) <<PK>>
name : longtext
--
}
entity <prefix>_type {
id : binary(40) <<PK>>
type : longtext
--
* type availability =
"a" | "b" | "c"
}
entity <prefix>_parent_type {
id : binary(40) <<PK>>
parent : binary(40) <<FK>>
type : binary(40) <<FK>>
--
* parent = <prefix>_parent.id
on update cascade
on delete cascade
* type = <prefix>_type.id
on update cascade
on delete cascade
* unique(parent, type)
}
<prefix>_parent ||--|{ <prefix>_parent_type
<prefix>_type ||-o{ <prefix>_parent_type
entity <prefix>_child {
id : binary(40) <<PK>>
parent : binary(40) <<FK>>
priority : int null
--
* parent = <prefix>_parent.id
on update cascade
on delete cascade
* null priority = disabled
}
<prefix>_parent ||--o{ <prefix>_child
}
@enduml
```
## Checklist review
- [ ] `@startuml` / `hide circle` / `skinparam linetype ortho` / `@enduml`, tidak ada yang lain di header
- [ ] semua entity dikelompokkan dalam `package`, tidak ada entity mengambang
- [ ] nama entity = nama tabel asli, berprefix modul, snake_case
- [ ] `id : binary(40) <<PK>>` jadi atribut pertama di setiap entity
- [ ] pemisah `--` ada di setiap entity
- [ ] kolom FK ditandai `<<FK>>` dan diulang di bagian constraint beserta aturan cascade-nya
- [ ] nilai enum/domain diberi kutip dan dipisah pipe
- [ ] setiap relasi di baris sendiri, tepat setelah entity-nya, induk di sisi kiri
- [ ] kardinalitas cocok dengan nullability dan uniqueness FK
- [ ] jumlah dash hanya dipakai sebagai knob tata letak (`-` = berdampingan, `--` = menumpuk), bukan sebagai makna. Tanpa `..`, tanpa tafsir "kuat vs lemah"
- [ ] induk selalu ditulis di sisi kiri setiap baris relasi, supaya dia yang muncul di atas / di kiri
- [ ] `.png` hasil render ada di samping `.puml` dan hasilnya bersih secara visual

6
instruction/erd/erd.md Normal file
View File

@ -0,0 +1,6 @@
# ERD
Berikut daftar instruction yang bisa kamu panggil.
Pembuatan Diagram
- `erd-plantuml-create.md` Membuat ERD (Entity Relationship Diagram) dengan PlantUML sesuai format standar

View File

@ -16,6 +16,7 @@ from .keycodes import (
KEY_F2,
KEY_F4,
KEY_F6,
KEY_F9,
)
from .input import (
new_session_popup,
@ -29,11 +30,8 @@ from .moopui import (
providers_manage_popup,
)
from .agent import submit, log
from .keycodes import resolve_key
from . import talk as talk_mod
import config as _config
# ---------------------------------------------------------------- handlers ---
def _exit_app(app, stdscr):
@ -55,8 +53,8 @@ def _record_talk(app, stdscr):
def _talk_configured(app):
# Tombol rekam valid (di-resolve dari config). Ketersediaan chain 'asr'
# divalidasi di handler supaya bisa memberi pesan panduan, bukan diam.
# Tombol rekam dipatok tetap (F9) sehingga selalu valid. Ketersediaan
# chain 'asr' divalidasi di handler supaya bisa memberi pesan panduan.
return getattr(app, "talk_key", None) is not None
@ -118,12 +116,12 @@ ACTIONS = {
"label": "Change Workspace", "mnemonic": "C", "shortcut": "F6",
"handler": workspace_popup, "enabled": _ready,
},
# Push-to-Talk (ASR): tombol berasal dari config.asr.record_key (default F9).
# Push-to-Talk (ASR): tombol dipatok tetap F9 (tidak dikonfigurasi).
# Handler talk_toggle memvalidasi chain moop 'asr' + ketersediaan mic.
"push_to_talk": {
"key": resolve_key(getattr(_config, "ASR_RECORD_KEY", "f9")),
"key": KEY_F9,
"label": "Push-to-Talk", "mnemonic": "T",
"shortcut": (getattr(_config, "ASR_RECORD_KEY", "f9") or "").upper(),
"shortcut": "F9",
"handler": _record_talk,
"enabled": lambda app: _ready(app) and _talk_configured(app),
},

View File

@ -1,9 +1,8 @@
import json
import threading
from datetime import datetime
import config
from lib import ntro, agent_loop
from tools.vision import ImagePayload
def _add_msg(app, role, content, **kwargs):
@ -15,6 +14,19 @@ def _add_msg(app, role, content, **kwargs):
app.current_session.doc_id, role, content, **kwargs
)
def _persist_msg(app, message):
"""Persist satu pesan yang baru ditulis engine ke store TUI.
Dipasang sebagai SessionStore.on_append: engine menulis ke app.messages,
kita hanya menyalinnya ke session DB (TinyDB)."""
if not app.current_session:
return
extra = {k: v for k, v in message.items() if k not in ("role", "content")}
app.session_mgr.add_message(
app.current_session.doc_id, message["role"], message["content"], **extra
)
WELCOME_ART = """
,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--.
| '--' || .---'| ,'.| || .-. \\ | .--. '| || .' /
@ -44,7 +56,7 @@ WELCOME_ART = """
╚══════════════════════════════════════════╝
"""
def log(app, role, text, model_info=None):
def log(app, role, text, model_info=None, usage=None):
entry = {
"role": role,
"text": text,
@ -53,7 +65,11 @@ def log(app, role, text, model_info=None):
if model_info:
# Display pair: (provider, model)
entry["model_info"] = model_info
if usage:
# Token pemakaian: prompt/completion/total dari provider
entry["usage"] = usage
app.log.append(entry)
return entry
def _pair(model_info):
@ -63,20 +79,188 @@ def _pair(model_info):
return (model_info.get("provider"), model_info.get("model"))
def _tag_ai_logs(app, prev_entries, pair):
"""Tempelkan info model yang benar-benar dipakai ke entri log AI yang baru
dibuat pada langkah ini.
def _tag_ai_logs(app, prev_entries, pair, usage=None):
"""Tempelkan info model (dan token usage) yang benar-benar dipakai ke entri
log AI yang baru dibuat pada langkah ini.
Berbasis identitas entri, bukan index: entri "Thinking..." di-pop setelah
llm.chat() kembali sehingga index entri AI ikut bergeser. `prev_entries`
menahan referensi entri lama agar id-nya tidak dipakai ulang oleh Python.
"""
if not pair:
if not pair and not usage:
return
seen = {id(e) for e in prev_entries}
for entry in app.log:
if entry.get("role") == "ai" and id(entry) not in seen:
entry["model_info"] = pair
if pair:
entry["model_info"] = pair
if usage:
entry["usage"] = usage
class CursesObserver(agent_loop.AgentObserver):
"""Terjemahkan hook engine ke state tampilan TUI (app.log, scroll, ntro).
Semua yang tadinya hardcoded di dalam _agent_loop pindah ke sini, sehingga
lib.agent_loop tidak lagi mengenal curses. Interface lain cukup membuat
observer sendiri tanpa menyentuh loop.
"""
wants_streaming = True
# Konten final dengan awalan ini datang dari LLMClient sebagai kegagalan,
# bukan jawaban normal — tampilkan sebagai error, bukan balasan AI.
_ERROR_PREFIXES = ("Error:", "HTTP Error:")
def __init__(self, app):
self.app = app
self.pair = None
self.usage = None # token pemakaian langkah LLM terakhir
self._buf = []
self._placeholder = None
self._stamp = None
self._stamp_step = None
self._tool_total = 0 # jumlah tool call yang dijadwalkan langkah ini
self._tool_seen = 0 # tool call yang sudah di-log langkah ini
# ------------------------------------------------------------- lifecycle ---
def on_run_start(self):
self._stamp = ntro.start()
def on_run_end(self):
ntro.end(self._stamp)
self.app.agent_done.set()
def on_step_start(self, step):
self._stamp_step = ntro.start()
self._buf = []
self._placeholder = None
self.usage = None
self._tool_total = 0
self._tool_seen = 0
log(self.app, "system", f" step {step + 1} \u2014 Thinking...")
# Ditahan setelah "Thinking..." masuk, supaya referensi entri lama ikut
# terjaga dan id()-nya tidak dipakai ulang saat entri itu di-pop.
self._log_prev = list(self.app.log)
self._follow()
def on_step_end(self, step):
ntro.end(self._stamp_step)
def on_response(self, model_info, warning, usage=None):
self.pair = _pair(model_info)
# Token pemakaian langkah LLM ini (untuk label usage di entri ai).
# Total satu turn sudah diakumulasi engine di AgentResult.usage.
self.usage = usage
self._pop_thinking()
if warning:
log(self.app, "system", f" {warning}")
# --------------------------------------------------------------- stream ---
def on_stream_chunk(self, chunk):
if self._placeholder is None:
# Placeholder dibuat di chunk pertama; isinya diperbarui real-time.
self._placeholder = log(self.app, "ai", "")
self._buf.append(chunk)
self._placeholder["text"] = "".join(self._buf)
self._follow()
# ----------------------------------------------------------------- tools ---
def on_tool_calls(self, content, names):
# Narasi assistant sebelum tools; placeholder streaming dibuang karena
# konten yang sama akan di-log utuh di sini.
self._drop_placeholder()
# Baris token satu panggilan LLM dititipkan ke tool_call TERAKHIR dari
# langkah ini (lihat on_tool_call), supaya urutan render tetap benar.
self._tool_total = len(names)
self._tool_seen = 0
if content and content.strip():
log(self.app, "ai", content, model_info=self.pair, usage=self.usage)
_tag_ai_logs(self.app, self._log_prev, self.pair)
def on_tool_call(self, name, arguments):
entry = log(self.app, "tool_call", json.dumps({
"name": name,
"arguments": arguments,
}), model_info=self.pair)
self._tool_seen += 1
if self.usage and self._tool_seen >= self._tool_total:
entry["usage"] = self.usage
self._follow()
# ---------------------------------------------------------------- final ---
def on_final(self, result):
content = result.content
if isinstance(content, str) and content.startswith(self._ERROR_PREFIXES):
self._drop_placeholder()
log(self.app, "error", content)
log(self.app, "sep", "")
return
if result.is_multimodal:
entry = log(self.app, "ai", "[Image generated]",
model_info=self.pair, usage=result.usage)
elif content is None:
# Respons kosong: tidak ada yang ditampilkan selain pemisah.
entry = None
elif self._placeholder is None and content:
# Tidak ada chunk streaming sama sekali (provider tidak mengirim
# delta) — pastikan teks tetap terlihat.
entry = log(self.app, "ai", content,
model_info=self.pair, usage=result.usage)
else:
# placeholder streaming tidak perlu diisi ulang: isinya sudah teks
# final. Gunakan sebagai target tempelan usage.
entry = self._placeholder
# Total token satu turn (akumulasi semua langkah LLM) ditaruh di
# entri respons final, dirender setelah teks pesan.
if entry is not None and result.usage:
entry["usage"] = result.usage
_tag_ai_logs(self.app, self._log_prev, self.pair)
log(self.app, "sep", "")
def on_max_iterations(self, max_iterations=None):
log(self.app, "error", "Max iterations reached without final answer.")
# ----------------------------------------------------------------- utils ---
def _follow(self):
if self.app.scroll_follow:
self.app.scroll = 999999
def _pop_thinking(self):
for i in range(len(self.app.log) - 1, -1, -1):
if self.app.log[i].get("role") == "system" and "Thinking" in self.app.log[i].get("text", ""):
self.app.log.pop(i)
break
def _drop_placeholder(self):
if self._placeholder is None:
return
entry, self._placeholder = self._placeholder, None
for i in range(len(self.app.log) - 1, -1, -1):
if self.app.log[i] is entry:
self.app.log.pop(i)
break
def _run_agent(app):
"""Entry point thread agent: satu-satunya tempat TUI memanggil engine."""
store = agent_loop.SessionStore(
app.messages,
on_append=lambda msg: _persist_msg(app, msg),
keep_model_info=True,
)
agent_loop.run_agent_loop(
store,
app.llm,
app.TOOLS,
app.TOOL_HANDLERS,
app.agent_max_iterations,
observer=CursesObserver(app),
tool_reminder=None,
)
def submit(app, stdscr):
@ -107,147 +291,8 @@ def submit(app, stdscr):
app.agent_done.clear()
app.agent_thread = threading.Thread(
target=_agent_loop,
target=_run_agent,
args=(app,),
daemon=True,
)
app.agent_thread.start()
def _agent_loop(app):
stamp = ntro.start()
for step in range(app.agent_max_iterations):
stamp_step = ntro.start()
log(app, "system", f" step {step + 1} \u2014 Thinking...")
if app.scroll_follow:
app.scroll = 999999
# Streaming response
stream_buffer = []
placeholder_marker = None
def on_stream_chunk(chunk):
nonlocal placeholder_marker
# Buat placeholder di chunk pertama saja
if placeholder_marker is None:
placeholder_marker = f"_stream_placeholder_{step}"
log(app, "ai", placeholder_marker)
stream_buffer.append(chunk)
current_text = ''.join(stream_buffer)
# Update placeholder secara real-time
for i in range(len(app.log) - 1, -1, -1):
# Cari placeholder berdasarkan content aslinya (atau apa yang sudah diupdate)
# Karena placeholder text berubah seiring streaming, kita harus teliti
if app.log[i].get('role') == 'ai' and (app.log[i].get('text') == placeholder_marker or (i > 0 and app.log[i-1].get('role') == 'system' and 'Thinking' in app.log[i-1].get('text', ''))):
app.log[i]['text'] = current_text
break
if app.scroll_follow:
app.scroll = 999999
skill = config.AGENT_SKILLS
tool_reminder = None
log_prev = list(app.log)
response = app.llm.chat(app.messages, tools=app.TOOLS, on_stream_chunk=on_stream_chunk, tool_reminder=tool_reminder)
# Model final: diambil dari response, bukan dari state llm saat ini,
# supaya auto-switch/rotasi key/fallback ikut tercatat dengan benar.
used = getattr(response, "model_info", None) or {}
pair = _pair(used)
akwargs = {"model_info": used} if used else {}
# Hapus "Thinking..." log
for i in range(len(app.log) - 1, -1, -1):
if app.log[i].get('role') == 'system' and 'Thinking' in app.log[i].get('text', ''):
app.log.pop(i)
break
if response.warning:
log(app, "system", f" {response.warning}")
# Cek apakah response adalah error dari LLM client
content = response.content
is_error = isinstance(content, str) and (content.startswith("Error:") or content.startswith("HTTP Error:"))
if is_error:
# Hapus "Thinking..." log dan placeholder
for i in range(len(app.log) - 1, -1, -1):
if app.log[i].get('role') == 'system' and 'Thinking' in app.log[i].get('text', ''):
app.log.pop(i)
break
if placeholder_marker is not None:
for i in range(len(app.log) - 1, -1, -1):
if app.log[i].get('role') == 'ai':
text = app.log[i].get('text', '')
if placeholder_marker in text or text == "" or text == "...":
app.log.pop(i)
break
log(app, "error", content)
_add_msg(app, "assistant", content)
log(app, "sep", "")
ntro.end(stamp)
app.agent_done.set()
return
# Cek apakah ada tool_calls
has_tool_calls = bool(response.tool_calls)
if has_tool_calls:
# Hapus placeholder jika ada tool_calls (cari semua AI log yang mungkin placeholder)
if placeholder_marker is not None:
for i in range(len(app.log) - 1, -1, -1):
# Jika ini adalah log AI yang muncul tepat setelah "Thinking..." atau contains placeholder marker
if app.log[i].get('role') == 'ai':
text = app.log[i].get('text', '')
# Hapus jika text-nya adalah placeholder atau jika itu adalah entry AI yang baru saja kita buat untuk streaming
if placeholder_marker in text or text == "" or text == "...":
app.log.pop(i)
_add_msg(app, "assistant", response.content, tool_calls=response.tool_calls, **akwargs)
# Log content AI dulu (penjelasan sebelum tools)
if response.content and response.content.strip():
log(app, "ai", response.content, model_info=pair)
_tag_ai_logs(app, log_prev, pair)
# Log dan execute tool_calls
for tc in response.tool_calls:
tname = tc["function"]["name"]
targs = tc["function"]["arguments"]
log(app, "tool_call", json.dumps({
"name": tname,
"arguments": targs,
}), model_info=pair)
if app.scroll_follow:
app.scroll = 999999
result = agent_loop.execute_tool(tc, app.TOOL_HANDLERS)
if isinstance(result, ImagePayload):
_add_msg(app, "tool", [
{"type": "text", "text": f"[Image loaded: {result.path}]"},
{"type": "image_url", "image_url": {"url": f"data:{result.mime};base64,{result.data}"}}
], tool_call_id=tc["id"])
else:
_add_msg(app, "tool", str(result), tool_call_id=tc["id"])
else:
has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0)
if has_content:
if isinstance(response.content, list):
log(app, "ai", "[Image generated]", model_info=pair)
_add_msg(app, "assistant", response.content, **akwargs)
_tag_ai_logs(app, log_prev, pair)
log(app, "sep", "")
ntro.end(stamp)
app.agent_done.set()
return
ntro.end(stamp_step)
log(app, "error", "Max iterations reached without final answer.")
_add_msg(app, "assistant", "Max iterations reached without final answer.")
ntro.end(stamp)
app.agent_done.set()
app.agent_done.set()
ntro.end(stamp)

View File

@ -60,7 +60,7 @@ class HendrikTUI:
self.talk_done = threading.Event()
self._talk_thread: threading.Thread | None = None
self._talk_result: dict | None = None
# Kode tombol rekam dari config (None = tombol tidak valid/fitur mati)
# Kode tombol rekam: dipatok tetap F9 (lihat ACTIONS["push_to_talk"])
from .actions import ACTIONS as _ACTIONS
self.talk_key = _ACTIONS["push_to_talk"]["key"]
self.talk_label = _ACTIONS["push_to_talk"]["shortcut"]
@ -214,16 +214,21 @@ class HendrikTUI:
pair = (mi.get("provider"), mi.get("model")) if mi else None
# Log tool calls jika ada
if msg.get("tool_calls"):
for tc in msg["tool_calls"]:
tcl = msg["tool_calls"]
for j, tc in enumerate(tcl):
# Baris token langkah ini dititipkan di tool_call terakhir
# (konvensi sama dengan CursesObserver.on_tool_call).
last = (j == len(tcl) - 1)
log(self, "tool_call", json.dumps({
"name": tc["function"]["name"],
"arguments": tc["function"]["arguments"],
}), model_info=pair)
}), model_info=pair,
usage=msg.get("usage") if last else None)
# Log AI content jika ada dan ini adalah response final
next_role = session.messages[i + 1]["role"] if i + 1 < len(session.messages) else None
if next_role in ("user", None):
if msg.get("content"):
log(self, "ai", msg["content"])
log(self, "ai", msg["content"], usage=msg.get("usage"))
if pair:
self.log[-1]["model_info"] = pair
elif msg["role"] == "tool":

View File

@ -263,30 +263,6 @@ def read_key(stdscr, timeout_ms: int = -1) -> int:
return KEY_ESC
def resolve_key(name) -> int | None:
"""Petakan nama tombol dari config menjadi kode kunci.
Format: "f1".."f12", "ctrl-<huruf>", atau "esc"/"enter".
Mengembalikan None jika tidak dikenal."""
n = str(name or "").strip().lower().replace("+", "-").replace(" ", "")
if not n:
return None
_F_KEYS = {f"f{i}": k for i, k in enumerate(
[KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6,
KEY_F7, KEY_F8, KEY_F9, KEY_F10, KEY_F11, KEY_F12], start=1)}
if n in _F_KEYS:
return _F_KEYS[n]
if n == "esc":
return KEY_ESC
if n == "enter":
return 13
if n.startswith("ctrl-") and len(n) == 6:
ch = n[-1]
if "a" <= ch <= "z":
return ord(ch) & 0x1F
return None
def is_alt_key(key: int) -> bool:
return _KEY_ALT_BASE <= key <= _KEY_ALT_BASE + 126

View File

@ -25,6 +25,7 @@ C_INPUT_BORDER = theme.C_INPUT_BORDER
C_STATUS_INFO = theme.C_STATUS_INFO
C_WELCOME = theme.C_WELCOME
C_TOOL_CALL = theme.C_TOOL_CALL
C_USAGE = theme.C_USAGE
def draw(app, stdscr):
@ -68,6 +69,35 @@ def _model_info_line(item):
return f" {m_name} " if m_name else ""
def _fmt_tokens(n):
"""1234 -> '1.2k', 999 -> '999'."""
if n >= 1000:
return f"{n / 1000:.1f}k".replace(".0k", "k")
return str(n)
def _usage_line(item, label="tokens"):
"""Baris token usage dari entri log; "" jika tidak ada.
Contoh: ' tokens in 48.2k → out 260 · total 48.5k · ctx 48.2k '.
`ctx` = ukuran context window saat itu (prompt tokens panggilan terakhir;
pada riwayat lama tanpa data itu, jatuh ke total in).
`label` membedakan cakupan angka: 'tokens' untuk total satu turn (blok ai
final), 'step tokens' untuk satu panggilan LLM (blok tool_call)."""
usage = item.get("usage")
if not isinstance(usage, dict):
return ""
try:
pt = int(usage.get("prompt_tokens") or 0)
ct = int(usage.get("completion_tokens") or 0)
tt = int(usage.get("total_tokens") or (pt + ct))
ctx = int(usage.get("context_tokens") or pt or tt)
except (TypeError, ValueError):
return ""
return (f" {label} in {_fmt_tokens(pt)} → out {_fmt_tokens(ct)}"
f" · total {_fmt_tokens(tt)} · ctx {_fmt_tokens(ctx)} ")
def draw_chat(app, stdscr):
# Area chat — dari baris 2 sampai baris (h - 11).
# Bisa di-scroll dengan Page Up / Page Down.
@ -159,6 +189,10 @@ def draw_chat(app, stdscr):
label = f" {app.character_name} ({item['time']}) "
_add_row([(C_AI, label)])
_wrap_render(text, indent=1, color=C_INPUT, bold=False)
# Baris token usage setelah teks pesan respons
usage_line = _usage_line(item)
if usage_line:
_add_row([(C_USAGE, usage_line, False)])
elif role == "system":
lines = text.split("\n")
_add_row([(C_SYSTEM, lines[0])])
@ -208,6 +242,11 @@ def draw_chat(app, stdscr):
(C_TOOL_CALL, "unknown"),
(C_AI, f" ({item['time']}) "),
])
# Baris token panggilan LLM yang menghasilkan tool call ini,
# ditempel di tool_call terakhir langkah (lihat agent.CursesObserver).
usage_line = _usage_line(item, label="step tokens")
if usage_line:
_add_row([(C_USAGE, usage_line, False)])
# Blank line di bawah
_add_blank()
elif role == "error":

View File

@ -1,7 +1,7 @@
# talk.py — Push-to-Talk (ASR/STT) untuk TUI.
#
# Alur:
# * F9 (configurable) → mulai rekam (ffmpeg subprocess, non-blocking).
# * F9 (dipatok, tidak configurable) → mulai rekam (ffmpeg subprocess, non-blocking).
# * F9 lagi / Enter → stop rekam → transkrip (thread) → hasil
# disisipkan ke form input pada posisi kursor,
# TIDAK submit (default).
@ -25,12 +25,13 @@ from tools import asr as asr_mod
from .agent import log, submit
# Tombol push-to-talk dipatok tetap ke F9 (lihat actions.py & keycodes.py).
TALK_SHORTCUT_LABEL = "F9"
def _shortcut_label():
"""Label tombol rekam untuk pesan UI (dari config.asr.record_key)."""
n = (config.ASR_RECORD_KEY or "f9").strip().lower()
if n.startswith("ctrl-") and len(n) == 6:
return f"Ctrl+{n[5].upper()}"
return n.upper()
"""Label tombol rekam untuk pesan UI (selalu 'F9')."""
return TALK_SHORTCUT_LABEL
# ------------------------------------------------------------------ helpers ---

View File

@ -21,6 +21,7 @@ C_HINT_DISABLED = 13 # hint disabled (abu-abu)
C_WELCOME = 14 # welcome art: light blue
C_TOOL_CALL = 15 # tool call: kuning terang
C_TOOL_RESULT = 16 # tool result: magenta muda
C_USAGE = 22 # token usage: abu-abu
# -- Menu bar colors (id 17+) --
C_MENU = 17 # background menubar (sama dengan header)
@ -49,6 +50,7 @@ def init_colors():
curses.init_pair(C_WELCOME, curses.COLOR_BLUE + 8, -1) # light blue
curses.init_pair(C_TOOL_CALL, curses.COLOR_YELLOW + 8, -1) # bright yellow
curses.init_pair(C_TOOL_RESULT, curses.COLOR_MAGENTA + 8, -1) # bright magenta
curses.init_pair(C_USAGE, 8, -1) # abu-abu di atas bg default
# Menu bar
curses.init_pair(C_MENU, curses.COLOR_BLACK, curses.COLOR_BLUE)

View File

@ -1,16 +1,21 @@
"""Agent loop terpusat — satu engine untuk semua interface (TUI, GUI, service).
Dua boundary pemisah, supaya engine tidak pernah mengenal interface:
SessionStore → ke mana pesan percakapan ditulis (list aktif + persist opsional)
AgentObserver → siapa yang melihat prosesnya (log, streaming, timing)
Engine sengaja tetap sync (semua handler tool saat ini sync; MCP masih jauh).
"""
import json
from datetime import datetime
import config
from lib.personality import PERSONALITY
from tools.vision import ImagePayload
from scripts import loggorilla
def _ts():
return datetime.now().strftime('%H:%M:%S')
def _cap_output(result):
# Jaring pengaman ukuran output tool. Hormati semantics config:
# AGENT_MAX_TOOL_OUTPUT <= 0 = unlimited (tidak dipotong).
limit = getattr(config, "AGENT_MAX_TOOL_OUTPUT", 0)
if not limit or limit <= 0:
return result
@ -39,15 +44,188 @@ def execute_tool(tool_call, TOOL_HANDLERS):
except Exception as e:
return _cap_output(f'Error executing tool: {str(e)}')
def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on_tool_calls=None, tool_reminder=None):
# --------------------------------------------------------------- contracts ---
class SessionStore:
"""Tujuan tulis untuk setiap pesan percakapan selama satu loop berjalan.
`messages` state aktif yang dikirim ke LLM (list dict).
`on_append` dipanggil setelah tiap pesan masuk — untuk persistensi
(TUI/GUI/API via NeoSessionManager). Boleh None.
`keep_model_info`
False (default): kunci `model_info` dibuang sebelum pesan
masuk list, jadi payload ke provider identik dengan sebelum
refactor. Interface yang menampilkan info model menyalakannya.
"""
def __init__(self, messages, on_append=None, keep_model_info=False):
self.messages = messages
self._on_append = on_append
self.keep_model_info = keep_model_info
def append(self, message):
if not self.keep_model_info:
message.pop('model_info', None)
message.pop('usage', None) # metadata display, bukan payload LLM
self.messages.append(message)
if self._on_append:
self._on_append(message)
class AgentResult:
# Hasil satu kali jalan loop.
def __init__(self, content=None, session_ended=False, model_info=None, warning=None, is_multimodal=False, usage=None):
self.content = content # str | None
self.session_ended = session_ended # tool end_session pernah dipanggil
self.model_info = model_info or {} # model yang benar-benar menjawab
self.warning = warning # mis. fallback chain model
self.is_multimodal = is_multimodal
self.usage = usage # token turn ini: prompt/completion/total (dict | None)
class AgentObserver:
"""Hook tampilan. Semua no-op — subclass cukup menimpa yang dibutuhkan.
Engine tidak pernah mencetak atau menyentuh UI; seluruh efek visual hidup
di sini, sehingga interface baru tinggal membuat subclass sendiri.
"""
# True bila observer membutuhkan delta streaming dari LLM client.
wants_streaming = False
def on_run_start(self):
pass
def on_run_end(self):
pass
def on_step_start(self, step):
pass
def on_response(self, model_info, warning, usage=None):
"""llm.chat() sudah kembali, sebelum engine memproses response.
`usage` : token pemakaian satu permintaan (prompt/completion/total),
None jika provider tidak mengirimnya."""
pass
def on_step_end(self, step):
"""Satu iterasi selesai dan loop berlanjut ke iterasi berikutnya."""
pass
def on_stream_chunk(self, chunk):
pass
def on_tool_calls(self, content, names):
"""Assistant menjadwalkan tool call; `content` narasi sebelumnya."""
pass
def on_tool_call(self, name, arguments):
pass
def on_final(self, result):
pass
def on_max_iterations(self, max_iterations=None):
pass
class ConsoleObserver(AgentObserver):
"""Observer default proses headless (service bot): log gorilla ke stdout.
`on_tool_calls` mengirim narasi assistant sebelum tool dijalankan —
dipakai Telegram/XMPP untuk mengirim pesan interim ke chat.
"""
def __init__(self, on_tool_calls=None):
self._on_tool_calls = on_tool_calls
def on_step_start(self, step):
loggorilla.prcss("agent_loop", f"Step {step + 1} — calling LLM...")
def on_tool_calls(self, content, names):
loggorilla.fyinf("agent_loop", f"Using tools: {', '.join(names)}")
if self._on_tool_calls:
self._on_tool_calls(content)
def on_final(self, result):
# Content None = respons final kosong (mis. sesudah end_session):
# tidak ada yang dicetak, sama seperti sebelumnya.
if result.content is None:
return
if result.is_multimodal:
loggorilla.prcss("agent_loop", "Response generated (multimodal)")
else:
loggorilla.prcss("agent_loop",
f"Response generated ({len(result.content)} chars)")
def on_max_iterations(self, max_iterations=None):
loggorilla.prcss("agent_loop",
f"Max iterations ({max_iterations}) reached")
# ------------------------------------------------------------------- loop ---
def run_agent_loop(store, llm_client, TOOLS, TOOL_HANDLERS, max_iterations,
observer=None, tool_reminder=None, disable_reasoning=None):
"""Jalankan loop LLM <-> tool sampai ada jawaban final atau batas iterasi.
`store` : SessionStore (bukan list mentah — biar persistensi eksplisit).
`observer` : None → ConsoleObserver (headless, tanpa streaming).
`disable_reasoning` None → ikut PERSONALITY (yaml karakter).
Return AgentResult. Engine tidak mengklasifikasikan konten sebagai error;
itu keputusan interface (lihat CursesObserver di interfaces/tui/agent.py).
"""
if observer is None:
observer = ConsoleObserver()
if disable_reasoning is None:
disable_reasoning = PERSONALITY.disable_reasoning
session_ended = False
model_info = {}
warning = None
stream_cb = observer.on_stream_chunk if observer.wants_streaming else None
# Akumulator token satu turn (semua langkah LLM dijumlahkan — tiap
# panggilan mengirim ulang konteks sehingga dihitung ulang oleh provider).
any_usage = False
turn_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
def _accumulate(u):
nonlocal any_usage
if not isinstance(u, dict):
return
any_usage = True
for k in ("prompt_tokens", "completion_tokens", "total_tokens"):
v = u.get(k)
if isinstance(v, (int, float)):
turn_usage[k] += int(v)
# Ukuran context window saat itu = prompt tokens panggilan terakhir.
pt = u.get("prompt_tokens")
if isinstance(pt, (int, float)):
turn_usage["context_tokens"] = int(pt)
observer.on_run_start()
for step in range(max_iterations):
loggorilla.prcss("agent_loop", f"Step {step + 1} — calling LLM...")
disable_reasoning = PERSONALITY.disable_reasoning
response = llm_client.chat(session.messages, tools=TOOLS, disable_reasoning=disable_reasoning, tool_reminder=tool_reminder)
observer.on_step_start(step)
response = llm_client.chat(
store.messages,
tools=TOOLS,
on_stream_chunk=stream_cb,
disable_reasoning=disable_reasoning,
tool_reminder=tool_reminder,
)
# Model final diambil dari response (bukan state llm saat ini) supaya
# auto-switch / rotasi key / fallback tercatat dengan benar.
model_info = getattr(response, "model_info", None) or {}
warning = getattr(response, "warning", None)
usage = getattr(response, "usage", None)
_accumulate(usage)
observer.on_response(model_info, warning, usage)
if response.tool_calls:
amsg = {
@ -55,20 +233,25 @@ def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on
'content': response.content,
'tool_calls': response.tool_calls,
}
session.messages.append(amsg)
if model_info:
amsg['model_info'] = model_info
if isinstance(usage, dict) and usage:
# Usage per langkah (bukan akumulasi turn) — dipakai interface
# untuk menampilkan token di blok tool call yang dihasilkannya.
# Hanya metadata display: dibuang sebelum jadi payload LLM.
amsg['usage'] = dict(usage)
store.append(amsg)
tnames = [tc['function']['name'] for tc in response.tool_calls]
loggorilla.fyinf("agent_loop", f"Using tools: {', '.join(tnames)}")
if on_tool_calls:
on_tool_calls(response.content or "")
observer.on_tool_calls(response.content or "",
[tc['function']['name'] for tc in response.tool_calls])
for tc in response.tool_calls:
observer.on_tool_call(tc['function']['name'], tc['function']['arguments'])
if tc['function']['name'] == 'end_session':
session_ended = True
result = execute_tool(tc, TOOL_HANDLERS)
if isinstance(result, ImagePayload):
session.messages.append({
store.append({
'role': 'tool',
'tool_call_id': tc['id'],
'content': [
@ -77,30 +260,57 @@ def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on
]
})
else:
session.messages.append({
store.append({
'role': 'tool',
'tool_call_id': tc['id'],
'content': str(result),
})
else:
has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0)
if has_content:
session.messages.append({'role': 'assistant', 'content': response.content})
if isinstance(response.content, list):
text_parts = [b.get('text', '') for b in response.content
if isinstance(b, dict) and b.get('type') == 'text']
text = '\n'.join(text_parts) if text_parts else "[Image generated]"
loggorilla.prcss("agent_loop", "Response generated (multimodal)")
return text, session_ended
loggorilla.prcss("agent_loop", f"Response generated ({len(response.content)} chars)")
return response.content, session_ended
# Respons kosong: tetap bawa flag session_ended — bisa jadi
# end_session sudah dipanggil di iterasi sebelumnya.
return None, session_ended
observer.on_step_end(step)
continue
loggorilla.prcss("agent_loop", f"Max iterations ({max_iterations}) reached")
session.messages.append({
has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0)
if has_content:
is_multimodal = isinstance(response.content, list)
msg = {'role': 'assistant', 'content': response.content}
if model_info:
msg['model_info'] = model_info
if any_usage:
# Total token turn ini — ikut dipersist supaya tampil saat
# session dibuka kembali.
msg['usage'] = dict(turn_usage)
store.append(msg)
if is_multimodal:
text_parts = [b.get('text', '') for b in response.content
if isinstance(b, dict) and b.get('type') == 'text']
text = '\n'.join(text_parts) if text_parts else "[Image generated]"
else:
text = response.content
final = AgentResult(
content=text,
session_ended=session_ended,
model_info=model_info,
warning=warning,
is_multimodal=is_multimodal,
usage=dict(turn_usage) if any_usage else None,
)
observer.on_final(final)
observer.on_run_end()
return final
# Respons kosong: tetap bawa flag session_ended — bisa jadi
# end_session sudah dipanggil di iterasi sebelumnya.
empty = AgentResult(content=None, session_ended=session_ended,
model_info=model_info, warning=warning)
observer.on_final(empty)
observer.on_run_end()
return empty
store.append({
'role': 'assistant',
'content': 'Max iterations reached without final answer.',
})
return None, session_ended
observer.on_max_iterations(max_iterations)
observer.on_run_end()
return AgentResult(content=None, session_ended=session_ended, model_info=model_info, warning=warning)

View File

@ -13,7 +13,6 @@ tekan tombol → bicara → stop → teks hasil transkripsi masuk ke form input
## Konfigurasi (config.yaml, blok `asr:`)
asr:
record_dir : "~/.config/hendrik/recordings" # lokasi file rekaman
record_key : f9 # f1..f12 / ctrl-<huruf>
record_backend : pulse # pulse | alsa | oss | windows (dshow)
device : default
windows_mic : "" # backend=windows: nama device dshow

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@ -32,5 +32,7 @@ mindmapDiagram {
***_ Untuk kredensial service yang di gunakan
** model.yaml <<done>>
***_ Untuk menetapkan model set default yang digunakan
** instruction <<done>>
***_ Private instruction directory
@endmindmap

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,55 @@
@startmindmap
<style>
mindmapDiagram {
//FontColor white
.done {
BackgroundColor green
BackgroundColor lightgreen
//FontColor black
}
.unknown {
BackgroundColor #FFBBCC
BackgroundColor purple
FontColor white
}
.skip {
BackgroundColor #FFBBCC
BackgroundColor red
FontColor white
}
.info {
BackgroundColor blue
BackgroundColor lightblue
}
}
</style>
;top to bottom direction
* Full-Duplex Model
**_ Voice Activity Detection
**_ Noise interruption detector included
** Mic & Voice Frontend
** Models
*** GPT-Live-1
* Speech <<done>>
** Push to talk <<done>>
*** STT (ASR) <<done>>
**** Inference <<done>>
***** OpenRouter API Inference <<done>>
***** sherpa-onnx (local)
**** Model
***** Qwen3-ASR 1.7B int8
*** TTS
**** Inference
***** sherpa-onnx
**** Model
***** ZipVoice distill int8
** Live
*** Full-Duplex
**** Inference
**** Model
@endmindmap

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -28,12 +28,10 @@ mindmapDiagram {
;top to bottom direction
* root <<done>>
** .env <<done>>
** .env.example <<done>>
** .gitignore <<done>>
** LICENSE <<done>>
** config.default.yaml <<done>>
** config.py
** config.yaml <<done>>
** config.default.yaml
** hendrik <<done>>
** hendrik.py <<done>>
** requirements.txt <<done>>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 184 KiB

View File

@ -57,30 +57,6 @@ mindmapDiagram {
** Detailed
***_ Give thorough, detailed answers with explanations.
* Full-Duplex Model
**_ Voice Activity Detection
**_ Noise interruption detector included
** Mic & Voice Frontend
** Models
*** GPT-Live-1
* Speech
** Push to talk
*** STT (ASR)
**** Inference
***** sherpa-onnx
**** Model
***** Qwen3-ASR 1.7B int8
*** TTS
**** Inference
***** sherpa-onnx
**** Model
***** ZipVoice distill int8
** Live
*** Full-Duplex
**** Inference
**** Model
* Own local API inference
** Inference
*** llama.cpp

View File

@ -35,6 +35,8 @@ class LLMClient:
self.content = gadget.strip_thinking(raw_content) if isinstance(raw_content, str) else raw_content # Auto-strip <thinking> dari content
self.tool_calls = msg.get('tool_calls', None) # Ambil tool calls
self.warning = None
# Pemakaian token dari provider (prompt/completion/total) jika ada.
self.usage = None
# Model yang benar-benar dipakai menghasilkan pesan ini. Bisa berbeda
# dengan model awal permintaan karena auto-switch/fallback.
self.model_info = model_info or {}
@ -232,23 +234,36 @@ class LLMClient:
# ------------------------------------------------------- single attempt ---
def _chat_once(self, base_url, model, api_key, messages, tools=None,
on_stream_chunk=None, disable_reasoning=False, tool_reminder=None):
on_stream_chunk=None, disable_reasoning=False, tool_reminder=None,
include_usage=True):
url = f"{base_url.rstrip('/')}/chat/completions"
# Sanitasi: buang metadata display (model_info/usage) dari pesan sebelum
# jadi payload — beberapa provider memvalidasi field pesan dengan ketat.
msgs_clean = [
{k: v for k, v in m.items() if k not in ("model_info", "usage")}
if isinstance(m, dict) else m
for m in (messages or [])
]
payload = {
"model": model,
"messages": messages,
"messages": msgs_clean,
"stream": True # Enable streaming
}
if include_usage:
# Minta provider menyertakan pemakaian token (input/output) pada
# chunk terakhir stream. Provider yang tidak mendukungnya akan
# di-retry tanpa parameter ini (lihat penanganan HTTP 400).
payload["stream_options"] = {"include_usage": True}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
if tool_reminder:
# Inject tool reminder sebagai system message sebelum user message terakhir
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "user":
payload["messages"] = messages[:i] + [
for i in range(len(msgs_clean) - 1, -1, -1):
if msgs_clean[i].get("role") == "user":
payload["messages"] = msgs_clean[:i] + [
{"role": "system", "content": tool_reminder}
] + messages[i:]
] + msgs_clean[i:]
break
# Hanya kirim parameter reasoning jika diminta eksplisit
@ -268,6 +283,7 @@ class LLMClient:
reasoning_content = ""
full_multimodal_content = []
stream_started = False
usage = None # prompt/completion/total tokens dari chunk terakhir
try:
self.cancel_requested = False
@ -292,9 +308,15 @@ class LLMClient:
except json.JSONDecodeError:
continue
# Pemakaian token datang di chunk penutup (choices kosong
# pada banyak provider) — ambil selama masih None / terbaru.
if chunk.get('usage'):
usage = chunk['usage']
# Parse delta dari chunk
delta = chunk.get('choices', [{}])[0].get('delta', {})
finish_reason = chunk.get('choices', [{}])[0].get('finish_reason', None)
choices = chunk.get('choices') or []
delta = choices[0].get('delta', {}) if choices else {}
finish_reason = choices[0].get('finish_reason', None) if choices else None
# Stream reasoning content jika ada
if 'reasoning_content' in delta:
@ -399,6 +421,16 @@ class LLMClient:
return result
except Exception:
pass
if include_usage and e.code in (400, 422) and "stream_options" in (body_text or "").lower():
# Provider menolak parameter stream_options — ulangi permintaan
# tanpanya; token usage tidak tersedia tapi respons tetap jalan.
return self._chat_once(
base_url, model, api_key, messages, tools=tools,
on_stream_chunk=on_stream_chunk,
disable_reasoning=disable_reasoning,
tool_reminder=tool_reminder,
include_usage=False,
)
detail = f" - {body_text[:500]}" if body_text else ""
if e.code in (401, 403):
raise _LLMFail("auth", f"HTTP {e.code} {e.reason}{detail}")
@ -448,4 +480,6 @@ class LLMClient:
message.pop('reasoning_content', None)
message.pop('reasoning', None)
return self.Message(message)
result = self.Message(message)
result.usage = usage
return result

View File

@ -7,7 +7,7 @@ from datetime import datetime
import config
from services.session_manager import SessionManager
from lib.agent_loop import run_agent_loop
from lib.agent_loop import run_agent_loop, SessionStore, ConsoleObserver
from lib import personality
from lib import ragroleplay
from scripts import loggorilla
@ -259,10 +259,13 @@ class TelegramClient:
tool_reminder = None
final_content, should_close = run_agent_loop(
session, self._llm, self._TOOLS, self._TOOL_HANDLERS,
self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder
outcome = run_agent_loop(
SessionStore(session.messages),
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
observer=ConsoleObserver(on_tool_calls=on_tool_calls),
tool_reminder=tool_reminder,
)
final_content, should_close = outcome.content, outcome.session_ended
def _pop_ghost(sentinel: str):
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang

View File

@ -8,7 +8,7 @@ from datetime import datetime
from slixmpp import ClientXMPP
from services.session_manager import SessionManager
from lib.agent_loop import run_agent_loop
from lib.agent_loop import run_agent_loop, SessionStore, ConsoleObserver
import config
from tools.roleplayer import should_respond
@ -624,10 +624,13 @@ class XMPPClient(ClientXMPP):
tool_reminder = None
final_content, should_close = run_agent_loop(
session, self._llm, self._TOOLS, self._TOOL_HANDLERS,
self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder
outcome = run_agent_loop(
SessionStore(session.messages),
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
observer=ConsoleObserver(on_tool_calls=on_tool_calls),
tool_reminder=tool_reminder,
)
final_content, should_close = outcome.content, outcome.session_ended
if final_content is not None:
if is_roleplay:
@ -709,10 +712,13 @@ class XMPPClient(ClientXMPP):
tool_reminder = None
final_content, should_close = run_agent_loop(
session, self._llm, self._TOOLS, self._TOOL_HANDLERS,
self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder
outcome = run_agent_loop(
SessionStore(session.messages),
self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations,
observer=ConsoleObserver(on_tool_calls=on_tool_calls),
tool_reminder=tool_reminder,
)
final_content, should_close = outcome.content, outcome.session_ended
def _pop_ghost(sentinel: str):
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang

128
tools/instrbase.py Normal file
View File

@ -0,0 +1,128 @@
"""
Shared helper untuk varian instruction tool "private per character".
Base directory yang dipakai:
agent/characters/<AGENT_CHARACTER>/instruction/
Dipakai oleh:
tools/smart.py -> instruction_char_use
tools/smart_rp.py -> rp_instruction_char_use
"""
import os
import config
def char_from_config():
"""Nama karakter aktif (lowercase) — dibaca saat dipanggil supaya
override `--char` / config.load_character() ikut terpakai."""
return (getattr(config, "AGENT_CHARACTER", "") or "").strip().lower()
def char_instruction_base(char=None):
"""Return (char, base_dir) untuk instruction privat karakter.
base_dir bernilai None bila tidak ada karakter aktif atau CHARACTERS_DIR
tidak tersedia di config."""
char = (char or char_from_config())
base_dir = None
if char:
chars_dir = getattr(config, "CHARACTERS_DIR", None)
if chars_dir is not None:
base_dir = str(chars_dir / char / "instruction")
return char, base_dir
def sanitize_path(directory, filename):
"""Sanitasi input supaya tetap di dalam base_dir (anti path traversal).
Segment kosong, '.' dan '..' dibuang, slash absolut dinetralkan."""
parts = [p for p in str(directory).replace('\\', '/').split('/')
if p and p not in ('.', '..')]
safe_directory = os.path.join(*parts) if parts else ""
safe_filename = os.path.basename(str(filename).replace('\\', '/'))
return safe_directory, safe_filename
def inside_base(base_dir, file_path):
"""Guard tambahan: file harus benar-benar berada di dalam base_dir
(menutup celah symlink / penulisan path aneh)."""
try:
real_base = os.path.realpath(base_dir)
real_file = os.path.realpath(file_path)
except Exception:
return False
return real_file == real_base or real_file.startswith(real_base + os.sep)
def list_available(base_dir, limit=50):
"""Daftar file (relatif terhadap base_dir) untuk pesan error yang membantu.
Return None bila directory privat karakter belum ada sama sekali."""
if not base_dir or not os.path.isdir(base_dir):
return None
found = []
for root, _dirs, files in os.walk(base_dir):
for fname in sorted(files):
rel = os.path.relpath(os.path.join(root, fname), base_dir)
found.append(rel.replace(os.sep, "/"))
return sorted(found)[:limit]
def missing_hint(char, base_dir):
"""Pesan penutup untuk error 'file not found'."""
available = list_available(base_dir)
if available is None:
return (f"The character '{char}' has no private instruction directory yet "
f"(expected at {base_dir}).")
if available:
return f"Available files in '{base_dir}': {', '.join(available)}"
return f"The private instruction directory '{base_dir}' exists but is empty."
def read_instruction_file(tool_name, logger, directory, filename, label="instruction",
caller="instruction_char_use"):
"""Resolver + reader bersama untuk varian char (private per character).
Args:
tool_name: nama tool untuk log (mis. 'tools.smart_rp').
logger : loggorilla module, atau None untuk tanpa logging.
directory: sub-directory di dalam instruction privat karakter ('' = root).
filename : nama file instruksi.
label : penanda jenis instruction di pesan error ('instruction' | 'roleplay instruction').
Returns:
Isi file (str) atau string pesan error.
"""
def _log(msg):
if logger is not None:
logger.fyinf(tool_name, msg)
_log(f"Calling {caller} with directory='{directory}', filename='{filename}'")
char, base_dir = char_instruction_base()
if not char or not base_dir:
res = (f"Error: No active character is configured, so there is no private {label} "
"directory. Set the character in config.yaml or pass --char <character>.")
_log(res)
return res
safe_directory, safe_filename = sanitize_path(directory, filename)
file_path = os.path.join(base_dir, safe_directory, safe_filename)
if (not os.path.isfile(file_path)) or (not inside_base(base_dir, file_path)):
res = (f"Error: Character {label} file not found at {file_path}. "
f"{missing_hint(char, base_dir)}")
_log(f"File not found: {res}")
return res
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
_log(f"Successfully read char {label} file for '{char}'. Content snippet: {content[:100]}...")
return content
except Exception as e:
res = f"Error reading character {label} file: {str(e)}"
_log(f"Exception occurred: {res}")
return res

View File

@ -1,7 +1,9 @@
import os
# NOTE: loggorilla dimatikan di tool ini. print()-nya bisa bikin error saat
# proses keluar (SCI), jadi semua pemanggilan di bawah hanya di-comment, jangan dihapus.
# from scripts import loggorilla
from tools import instrbase
schema_instruction_use = {
"type": "function",
@ -48,3 +50,43 @@ def instruction_use(directory, filename):
res = f"Error reading file: {str(e)}"
# loggorilla.fyinf("tools.smart", f"Exception occurred: {res}")
return res
# ─── instruction_char_use (private / per-character variant) ────────────────────
schema_instruction_char_use = {
"type": "function",
"function": {
"name": "instruction_char_use",
"description": (
"Reads a specific instruction file from the PRIVATE 'instruction' directory of the "
"active character (agent/characters/<character>/instruction) and returns its content. "
"This is the character-scoped variant of instruction_use: use it to load snippets, "
"guidelines or system prompt extensions that belong only to the current character, "
"instead of the shared/global 'instruction' directory."
),
"parameters": {
"type": "object",
"properties": {
"directory": {
"type": "string",
"description": (
"The subdirectory within the character's own 'instruction' folder "
"(e.g., 'snippets', 'scenes', 'world'). Pass an empty string to read "
"directly from the root of that folder."
)
},
"filename": {
"type": "string",
"description": "The name of the instruction file to read (e.g., 'opening.md', 'rules.md')."
}
},
"required": ["directory", "filename"]
}
}
}
def instruction_char_use(directory, filename):
# loggorilla dimatikan di tool ini (lihat catatan di atas), logger dikirim None.
return instrbase.read_instruction_file("tools.smart", None, directory, filename)

View File

@ -1,5 +1,7 @@
import os
from scripts import loggorilla
from tools import instrbase
schema_rp_instruction_use = {
"type": "function",
@ -46,3 +48,46 @@ def rp_instruction_use(directory, filename):
res = f"Error reading RP file: {str(e)}"
loggorilla.fyinf("tools.smart_rp", f"Exception occurred: {res}")
return res
# ─── rp_instruction_char_use (private / per-character variant) ─────────────────
schema_rp_instruction_char_use = {
"type": "function",
"function": {
"name": "rp_instruction_char_use",
"description": (
"CRITICAL ROLEPLAY VALIDATION TOOL (character-scoped): Use this tool ONLY when the "
"character is interacting with a specific object, outfit, or location defined in this "
"character's PRIVATE roleplay instruction files, located at "
"agent/characters/<character>/instruction. This is the character-scoped variant of "
"rp_instruction_use — it must be called to fetch the authoritative description before "
"describing the item, to ensure narrative consistency and prevent hallucination. "
"DO NOT use if the character is not interacting with a specific listed item."
),
"parameters": {
"type": "object",
"properties": {
"directory": {
"type": "string",
"description": (
"The subdirectory within the character's own 'instruction' folder "
"(e.g., 'rp/item', 'rp/armor', 'rp/world', 'gameplay'). Pass an empty "
"string to read directly from the root of that folder."
)
},
"filename": {
"type": "string",
"description": "The name of the instruction file to read (e.g., 'rules.md', 'iron-sword.md')."
}
},
"required": ["directory", "filename"]
}
}
}
def rp_instruction_char_use(directory, filename):
return instrbase.read_instruction_file(
"tools.smart_rp", loggorilla, directory, filename,
label="roleplay instruction", caller="rp_instruction_char_use"
)