Merge branch 'master' into product/public
@ -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:
|
||||
|
||||
@ -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).
|
||||
|
||||
2
hendrik
@ -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"
|
||||
|
||||
28
hendrik.py
@ -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
|
||||
|
||||
324
instruction/erd/erd-plantuml-create.md
Normal 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
@ -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
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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 ---
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 9.1 KiB |
@ -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
|
||||
BIN
plan/milestone/12-speech-features.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
55
plan/milestone/12-speech-features.puml
Normal 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
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 19 KiB |
@ -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>>
|
||||
|
||||
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 184 KiB |
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||