Compare commits

..

12 Commits

Author SHA1 Message Date
8d3b48c4c2 Fixing inference cancellation 2026-10-16 14:13:34 +07:00
9dfd395d93 Update plan 2026-10-13 17:21:23 +07:00
2584600dbf Tokens counter 2026-10-13 09:38:30 +07:00
f9ba636837 PlantUML ERD 2026-10-11 22:36:16 +07:00
d63aa0aa4e AI slop cleanup 2026-10-11 16:01:32 +07:00
Dita Aji Pratama
3a50722f4c Little cleanup 2026-10-10 16:25:13 +07:00
Dita Aji Pratama
a516bfe31a Refactor agent loop 2026-10-10 15:08:54 +07:00
261c24dc1a Merge branch 'master' of https://gitea.ditaajipratama.net/aji/hendrik 2026-10-09 10:02:41 +07:00
659f52ee9f Update milestone 2026-10-09 10:02:26 +07:00
Dita Aji Pratama
98080ca83c Instruction per character 2026-10-09 09:33:26 +07:00
Dita Aji Pratama
d3d16e85c7 ASR auto-submit 2026-10-08 21:10:02 +07:00
Dita Aji Pratama
911e44fdd5 Fixing model info 2026-10-08 17:01:08 +07:00
35 changed files with 1876 additions and 477 deletions

View File

@ -1 +0,0 @@
service.yaml

View File

@ -1,6 +0,0 @@
codename: "Grace"
name: "Grace"
skill:
- "roleplayer"
verbosity: "balanced"
disable_reasoning: true

View File

@ -1,42 +0,0 @@
# Grace
Grace is a sophisticated, patient, and encouraging language mentor who specializes in teaching English through the art of roleplaying.
**Roleplay Identity:** Grace is the user's schoolmate and next-door neighbor. The setting is centered around school life and typical activities for their age (studying together, hanging out, school events, etc.), providing a relatable and dynamic environment for learning.
She doesn't just teach grammar; she creates immersive scenarios where the user is forced to use English in a natural, contextual way.
## Core Objective
The primary goal is to help the user improve their English proficiency (grammar, vocabulary, and fluency) by engaging in immersive school-life roleplay. Grace acts as both a friend and a subtle mentor.
## Appearance
- Female, 17 years old.
- Height: 165cm, slender with an upright and elegant posture.
- Long, neatly flowing blonde hair and clear blue eyes, embodying a classic European look.
- Style: Neat school uniform or simple yet chic casual wear.
## Operational Guidelines
### 1. Language Policy
- **Primary Language:** English. Grace speaks English by default.
- **Multilingual Understanding & Tolerance:** She understands other languages. While she encourages the use of English, she is tolerant and will allow the user to use their native language if they are truly struggling or cannot find the right words. She will then provide the English translation and encourage the user to repeat it.
- **Guidance:** When the user makes a mistake or uses awkward phrasing, Grace will gently guide them towards the "correct" or "more natural" way of saying it.
### 2. The Roleplay-Learning Cycle
- **Scenario Initiation:** Grace focuses on school-related and teenage activities (e.g., walking to school together, discussing homework, library sessions, or school festivals).
- **Immersion:** She stays in character throughout the interaction.
- **Natural Correction Mechanism:**
- Corrections should be integrated naturally into the conversation, as a friend would do, without using OOC tags or separate blocks.
- Example:
*User: "I goes to school yesterday."*
*Grace: "Oh, you went to school yesterday? I thought you were sick! By the way, remember to use 'went' for things that happened in the past. Anyway, did you finish the math homework?"*
### 3. Pedagogical Approach
- **Scaffolding:** Start with simpler language if the user is a beginner and gradually increase complexity.
- **Vocabulary Expansion:** Introduce one or two new useful idioms or advanced words per session and encourage the user to use them in the roleplay.
- **Positive Reinforcement:** Praise the user when they use a complex structure correctly or improve upon a previous mistake.
## Tone and Style
- **Elegant and Polished:** Her English is impeccable and serves as a gold standard for the user.
- **Supportive:** Never condescending. She views mistakes as stepping stones to fluency.
- **Creative:** Able to pivot the story dynamically based on user input.

View File

@ -1,23 +0,0 @@
# This file serves as a template for character-specific service overrides.
# Copy this to agent/characters/<character>/service.yaml and fill in the credentials.
xmpp:
enabled: false
username: ""
password: ""
muc_rooms: # YAML list, e.g.:
# - "room1@conference.server"
# - "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)
mute_session_end: false # true = tidak kirim pesan saat sesi berakhir (timeout)
telegram:
enabled: false
token: ""
allowed_group_ids: # YAML list, empty = all group. e.g.:
# - "123456789"
# - "987654321"
selective_response: true # true = only response if mentioned/relevant
mute_session_end: false # true = tidak kirim pesan saat sesi berakhir (timeout)

View File

@ -2,9 +2,9 @@
agent: agent:
character : hendrik # Directory name in agent/characters/<character>/ 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_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: user:
id: # Fill your user UUID here, empty = TUI/MUC guest id: # Fill your user UUID here, empty = TUI/MUC guest
@ -15,24 +15,23 @@ llm:
moop: # Model Option moop: # Model Option
db_path: "~/.config/hendrik/moop.sqlite3" db_path: "~/.config/hendrik/moop.sqlite3"
asr: # Speech-to-Text (push-to-talk TUI). Model dikelola via moop tipe 'asr'. asr:
record_dir : "~/.config/hendrik/recordings" # lokasi file hasil rekaman record_dir : "~/.config/hendrik/recordings"
record_key : f9 # tombol push-to-talk (f1..f12 / ctrl-<huruf>)
record_backend : pulse # ffmpeg input: pulse | alsa | oss | windows record_backend : pulse # ffmpeg input: pulse | alsa | oss | windows
device : default # nama device input device : default
windows_mic : "" # backend=windows: nama device dshow, windows_mic : "" # if backend==windows: device dshow name, example: "Microphone Array (Realtek(R) Audio)"
# contoh "Microphone Array (Realtek(R) Audio)"
format : wav # wav | ogg (opus) format : wav # wav | ogg (opus)
sample_rate : 16000 # Hz sample_rate : 16000 # Hz
channels : 1 channels : 1
max_seconds : 60 # auto-stop pengaman max_seconds : 120 # second
transcribe_timeout: 60 # detik timeout request ASR transcribe_timeout : 60 # second
keep_recordings : true # false = hapus file setelah ditranskrip keep_recordings : false
auto_submit : true
session: # Chat session session:
db_path: "~/.config/hendrik/sessions.json" db_path: "~/.config/hendrik/sessions.json"
ragroleplay: # Use nomic-embed-text ragroleplay:
db_path : "~/.config/hendrik/ragroleplay" db_path : "~/.config/hendrik/ragroleplay"
vector_size : 768 # used on table create only vector_size : 768 # used on table create only
@ -51,7 +50,7 @@ xmpp:
# - "room2@conference.server" # - "room2@conference.server"
nickname: "" # custom MUC nickname (empty = use username) nickname: "" # custom MUC nickname (empty = use username)
selective_response: true # true = only response if mentioned/relevant 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) mute_session_end: false # true = tidak kirim pesan saat sesi berakhir (timeout)
telegram: telegram:

View File

@ -58,8 +58,12 @@ ASR_MAX_SECONDS = int(_yaml_get("asr", "max_seconds", default=60))
ASR_TIMEOUT = int(_yaml_get("asr", "transcribe_timeout", 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) 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. # Tombol push-to-talk dipatok tetap ke F9 (tidak bisa dikonfigurasi).
ASR_RECORD_KEY = str(_yaml_get("asr", "record_key", default="f9")).strip().lower() # 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).
ASR_AUTO_SUBMIT = _as_bool(_yaml_get("asr", "auto_submit", default=False), default=False)
XMPP_USERNAME = _yaml_get("xmpp", "username", default="") XMPP_USERNAME = _yaml_get("xmpp", "username", default="")

View File

@ -1,5 +1,5 @@
#!/bin/bash #!/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 # Set HENDRIK_DIR env var to override, or update the default below
DEFAULT_DIR="/opt/hendrik" DEFAULT_DIR="/opt/hendrik"

View File

@ -29,8 +29,10 @@ tools_definition = [
# Image Generation Tools # 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 # Instruction Tools
gadget.tools_mapping( schema = smart.schema_instruction_use, handler = smart.instruction_use ), # snippet gadget.tools_mapping( schema = smart.schema_instruction_use, handler = smart.instruction_use ), # snippet (global)
gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_use, handler = smart_rp.rp_instruction_use ), # roleplay 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 # Rag Roleplay Tools
gadget.tools_mapping( schema = ragroleplay_tools.schema_users_store, handler = ragroleplay_tools.users_store ), 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 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_F2,
KEY_F4, KEY_F4,
KEY_F6, KEY_F6,
KEY_F9,
) )
from .input import ( from .input import (
new_session_popup, new_session_popup,
@ -29,11 +30,8 @@ from .moopui import (
providers_manage_popup, providers_manage_popup,
) )
from .agent import submit, log from .agent import submit, log
from .keycodes import resolve_key
from . import talk as talk_mod from . import talk as talk_mod
import config as _config
# ---------------------------------------------------------------- handlers --- # ---------------------------------------------------------------- handlers ---
def _exit_app(app, stdscr): def _exit_app(app, stdscr):
@ -41,9 +39,13 @@ def _exit_app(app, stdscr):
def _cancel_inference(app, stdscr): def _cancel_inference(app, stdscr):
app.llm.cancel_requested = True # Hanya menandai + membangunkan stream; penghentian loop dan pembersihan
app.agent_done.set() # log dilakukan oleh agent thread sendiri (CursesObserver.on_cancelled),
log(app, "system", " Inference cancelled by user") # supaya tidak ada balapan tulis terhadap app.log dari dua thread.
# `agent_done` sengaja tidak di-set: main loop harus menunggu thread agen
# benar-benar berhenti, bukan berpura-pura selesai lalu mulai prompt baru
# di atas messages yang sedang ditulisnya.
app.llm.cancel()
def _send_prompt(app, stdscr): def _send_prompt(app, stdscr):
@ -55,8 +57,8 @@ def _record_talk(app, stdscr):
def _talk_configured(app): def _talk_configured(app):
# Tombol rekam valid (di-resolve dari config). Ketersediaan chain 'asr' # Tombol rekam dipatok tetap (F9) sehingga selalu valid. Ketersediaan
# divalidasi di handler supaya bisa memberi pesan panduan, bukan diam. # chain 'asr' divalidasi di handler supaya bisa memberi pesan panduan.
return getattr(app, "talk_key", None) is not None return getattr(app, "talk_key", None) is not None
@ -118,12 +120,12 @@ ACTIONS = {
"label": "Change Workspace", "mnemonic": "C", "shortcut": "F6", "label": "Change Workspace", "mnemonic": "C", "shortcut": "F6",
"handler": workspace_popup, "enabled": _ready, "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. # Handler talk_toggle memvalidasi chain moop 'asr' + ketersediaan mic.
"push_to_talk": { "push_to_talk": {
"key": resolve_key(getattr(_config, "ASR_RECORD_KEY", "f9")), "key": KEY_F9,
"label": "Push-to-Talk", "mnemonic": "T", "label": "Push-to-Talk", "mnemonic": "T",
"shortcut": (getattr(_config, "ASR_RECORD_KEY", "f9") or "").upper(), "shortcut": "F9",
"handler": _record_talk, "handler": _record_talk,
"enabled": lambda app: _ready(app) and _talk_configured(app), "enabled": lambda app: _ready(app) and _talk_configured(app),
}, },

View File

@ -1,10 +1,8 @@
import json import json
import threading import threading
from datetime import datetime from datetime import datetime
import config
from lib import moop
from lib import ntro, agent_loop from lib import ntro, agent_loop
from tools.vision import ImagePayload
def _add_msg(app, role, content, **kwargs): def _add_msg(app, role, content, **kwargs):
@ -16,6 +14,27 @@ def _add_msg(app, role, content, **kwargs):
app.current_session.doc_id, 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
)
def _truncate_msg(app, count):
"""Buang `count` pesan terakhir dari DB session (dipanggil saat turn
dibatalkan), supaya riwayat tersimpan identik dengan app.messages."""
if not app.current_session or count <= 0:
return
app.session_mgr.remove_last_messages(app.current_session.doc_id, count)
WELCOME_ART = """ WELCOME_ART = """
,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--. ,--. ,--.,------.,--. ,--.,------. ,------. ,--.,--. ,--.
| '--' || .---'| ,'.| || .-. \\ | .--. '| || .' / | '--' || .---'| ,'.| || .-. \\ | .--. '| || .' /
@ -45,12 +64,235 @@ WELCOME_ART = """
╚══════════════════════════════════════════╝ ╚══════════════════════════════════════════╝
""" """
def log(app, role, text): def log(app, role, text, model_info=None, usage=None):
app.log.append({ entry = {
"role": role, "role": role,
"text": text, "text": text,
"time": datetime.now().strftime("%H:%M"), "time": datetime.now().strftime("%H:%M"),
}) }
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):
"""(provider, model) untuk display; None jika info tidak ada."""
if not model_info:
return None
return (model_info.get("provider"), model_info.get("model"))
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 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:
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.")
def on_cancelled(self, result):
"""Turn dihentikan user. Berjalan di thread agen — satu-satunya writer
app.log, jadi tidak ada balapan dengan main loop."""
self._pop_thinking()
# Sisa turn dibuang dari konteks (assistant parsial dengan tool_calls
# tanpa hasil = payload tidak valid), tapi teks yang sudah terbaca tetap
# ditampilkan supaya user tidak kehilangan apa yang sudah dihasilkan.
partial = result.partial or ""
self._drop_placeholder()
if partial.strip():
entry = log(self.app, "ai", partial, model_info=self.pair,
usage=result.usage)
entry["cancelled"] = True
log(self.app, "system", " Inference cancelled by user")
log(self.app, "sep", "")
self._follow()
# ----------------------------------------------------------------- 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,
on_truncate=lambda n: _truncate_msg(app, n),
)
try:
agent_loop.run_agent_loop(
store,
app.llm,
app.TOOLS,
app.TOOL_HANDLERS,
app.agent_max_iterations,
observer=CursesObserver(app),
tool_reminder=None,
)
finally:
# Selalu tandai selesai, meskipun engine melempar. Tanpa ini UI bisa
# terkunci di PROCESSING selamanya kalau ada exception tak terduga
# (mis. saat pembatalan).
app.agent_done.set()
def submit(app, stdscr): def submit(app, stdscr):
@ -59,12 +301,6 @@ def submit(app, stdscr):
return return
log(app, "user", query) log(app, "user", query)
model_info = (
moop.resolve_provider(None, app.llm.base_url, app.llm.model),
app.llm.model
)
if app.log:
app.log[-1]["model_info"] = model_info
app.input_buffer = [""] app.input_buffer = [""]
app.input_line = 0 app.input_line = 0
app.input_col = 0 app.input_col = 0
@ -83,142 +319,15 @@ def submit(app, stdscr):
app._model_info(), app._model_info(),
) )
_add_msg(app, "user", query, model_info=app._model_info()) _add_msg(app, "user", query)
# Turn baru: bersihkan state cancel sebelumnya supaya cancel yang tertunda
# tidak membatalkan prompt yang baru dikirim.
app.llm.begin_turn()
app.agent_done.clear() app.agent_done.clear()
app.agent_thread = threading.Thread( app.agent_thread = threading.Thread(
target=_agent_loop, target=_run_agent,
args=(app,), args=(app,),
daemon=True, daemon=True,
) )
app.agent_thread.start() 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
response = app.llm.chat(app.messages, tools=app.TOOLS, on_stream_chunk=on_stream_chunk, tool_reminder=tool_reminder)
# 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)
# Log content AI dulu (penjelasan sebelum tools)
if response.content and response.content.strip():
log(app, "ai", response.content)
# 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,
}))
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]")
_add_msg(app, "assistant", response.content)
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_done = threading.Event()
self._talk_thread: threading.Thread | None = None self._talk_thread: threading.Thread | None = None
self._talk_result: dict | 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 from .actions import ACTIONS as _ACTIONS
self.talk_key = _ACTIONS["push_to_talk"]["key"] self.talk_key = _ACTIONS["push_to_talk"]["key"]
self.talk_label = _ACTIONS["push_to_talk"]["shortcut"] self.talk_label = _ACTIONS["push_to_talk"]["shortcut"]
@ -209,22 +209,28 @@ class HendrikTUI:
if i > 0: if i > 0:
log(self, "sep", "") log(self, "sep", "")
log(self, "user", msg["content"]) log(self, "user", msg["content"])
mi = msg.get("model_info")
if mi:
self.log[-1]["model_info"] = (mi.get("provider"), mi.get("model"))
elif msg["role"] == "assistant": elif msg["role"] == "assistant":
mi = msg.get("model_info")
pair = (mi.get("provider"), mi.get("model")) if mi else None
# Log tool calls jika ada # Log tool calls jika ada
if msg.get("tool_calls"): 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({ log(self, "tool_call", json.dumps({
"name": tc["function"]["name"], "name": tc["function"]["name"],
"arguments": tc["function"]["arguments"], "arguments": tc["function"]["arguments"],
})) }), model_info=pair,
usage=msg.get("usage") if last else None)
# Log AI content jika ada dan ini adalah response final # 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 next_role = session.messages[i + 1]["role"] if i + 1 < len(session.messages) else None
if next_role in ("user", None): if next_role in ("user", None):
if msg.get("content"): 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": elif msg["role"] == "tool":
# Tool result - tidak perlu di-log secara eksplisit # Tool result - tidak perlu di-log secara eksplisit
pass pass
@ -283,8 +289,9 @@ class HendrikTUI:
key = keycodes.read_key(stdscr, timeout_ms) key = keycodes.read_key(stdscr, timeout_ms)
except KeyboardInterrupt: except KeyboardInterrupt:
if self.processing: if self.processing:
self.llm.cancel_requested = True # Sama seperti Esc: cukup minta pembatalan, biarkan thread
self.agent_done.set() # agen yang membersihkan log lalu menandai dirinya selesai.
self.llm.cancel()
else: else:
break break
key = -1 key = -1

View File

@ -263,30 +263,6 @@ def read_key(stdscr, timeout_ms: int = -1) -> int:
return KEY_ESC 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: def is_alt_key(key: int) -> bool:
return _KEY_ALT_BASE <= key <= _KEY_ALT_BASE + 126 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_STATUS_INFO = theme.C_STATUS_INFO
C_WELCOME = theme.C_WELCOME C_WELCOME = theme.C_WELCOME
C_TOOL_CALL = theme.C_TOOL_CALL C_TOOL_CALL = theme.C_TOOL_CALL
C_USAGE = theme.C_USAGE
def draw(app, stdscr): def draw(app, stdscr):
@ -55,6 +56,48 @@ def draw_header(app, stdscr):
draw_menubar(app, stdscr) draw_menubar(app, stdscr)
def _model_info_line(item):
"""Baris " Provider - Model " dari entri log; "" jika info tidak ada.
Dipakai bersama oleh blok `ai` dan `tool_call` agar tampilan konsisten."""
model_info = item.get("model_info")
if not model_info:
return ""
p_name, m_name = model_info
if p_name:
return f" {p_name} - {m_name} "
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): def draw_chat(app, stdscr):
# Area chat — dari baris 2 sampai baris (h - 11). # Area chat — dari baris 2 sampai baris (h - 11).
# Bisa di-scroll dengan Page Up / Page Down. # Bisa di-scroll dengan Page Up / Page Down.
@ -136,23 +179,26 @@ def draw_chat(app, stdscr):
_add_blank() _add_blank()
if role == "user": if role == "user":
model_info = item.get("model_info", None) _add_row([(C_USER, f" You ({item['time']}) ")])
if model_info:
p_name, m_name = model_info
if p_name:
info_line = f" {p_name} - {m_name} "
else:
info_line = f" {m_name} "
else:
info_line = " Unknown - Model info not found "
_add_row([(C_USER, info_line, False)])
label = f" You ({item['time']}) "
_add_row([(C_USER, label)])
_wrap_render(text, indent=1, color=C_INPUT, bold=False) _wrap_render(text, indent=1, color=C_INPUT, bold=False)
elif role == "ai": elif role == "ai":
info_line = _model_info_line(item)
if info_line:
_add_row([(C_AI, info_line, False)])
# Teks parsial dari turn yang dibatalkan: diberi penanda supaya
# user tahu ini bukan jawaban lengkap (dan tidak ikut dikirim
# sebagai konteks pada prompt berikutnya).
if item.get("cancelled"):
label = f" {app.character_name} ({item['time']}) \u2717 cancelled "
else:
label = f" {app.character_name} ({item['time']}) " label = f" {app.character_name} ({item['time']}) "
_add_row([(C_AI, label)]) _add_row([(C_AI, label)])
_wrap_render(text, indent=1, color=C_INPUT, bold=False) _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": elif role == "system":
lines = text.split("\n") lines = text.split("\n")
_add_row([(C_SYSTEM, lines[0])]) _add_row([(C_SYSTEM, lines[0])])
@ -165,6 +211,7 @@ def draw_chat(app, stdscr):
elif role == "tool_call": elif role == "tool_call":
# Format: # Format:
# (blank line) # (blank line)
# Ollama Cloud - gemma4:31b-cloud
# Hendrik run_bash (HH:MM) ← "Hendrik" hijau, "run_bash (HH:MM)" kuning # Hendrik run_bash (HH:MM) ← "Hendrik" hijau, "run_bash (HH:MM)" kuning
# { ← arguments indent 1 spasi, kuning # { ← arguments indent 1 spasi, kuning
# "command": "ls -la" # "command": "ls -la"
@ -173,6 +220,9 @@ def draw_chat(app, stdscr):
# Blank line di atas (kecuali sebelumnya sudah blank dari role lain) # Blank line di atas (kecuali sebelumnya sudah blank dari role lain)
if idx > 0 and app.log[idx - 1]["role"] not in ("sep", "welcome"): if idx > 0 and app.log[idx - 1]["role"] not in ("sep", "welcome"):
_add_blank() _add_blank()
info_line = _model_info_line(item)
if info_line:
_add_row([(C_AI, info_line, False)])
try: try:
tc = json.loads(text) tc = json.loads(text)
tname = tc["name"] tname = tc["name"]
@ -198,6 +248,11 @@ def draw_chat(app, stdscr):
(C_TOOL_CALL, "unknown"), (C_TOOL_CALL, "unknown"),
(C_AI, f" ({item['time']}) "), (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 # Blank line di bawah
_add_blank() _add_blank()
elif role == "error": elif role == "error":

View File

@ -1,10 +1,14 @@
# talk.py — Push-to-Talk (ASR/STT) untuk TUI. # talk.py — Push-to-Talk (ASR/STT) untuk TUI.
# #
# Alur: # 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 # * F9 lagi / Enter → stop rekam → transkrip (thread) → hasil
# ke form input pada posisi kursor, TIDAK submit. # disisipkan ke form input pada posisi kursor,
# TIDAK submit (default).
# * Backspace / Esc → batal: rekaman dibuang / hasil transkrip diabaikan. # * Backspace / Esc → batal: rekaman dibuang / hasil transkrip diabaikan.
# * config.asr.auto_submit: true → hasil transkrip langsung dikirim sebagai
# pesan (setara Ctrl+Enter). Jika form sudah terisi
# saat transkrip selesai, hasil tetap disisipkan.
# #
# State disimpan di app: # State disimpan di app:
# app.talk_recorder — instance Recorder saat merekam, else None # app.talk_recorder — instance Recorder saat merekam, else None
@ -18,15 +22,16 @@ import threading
import config import config
from tools import asr as asr_mod from tools import asr as asr_mod
from .agent import log 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(): def _shortcut_label():
"""Label tombol rekam untuk pesan UI (dari config.asr.record_key).""" """Label tombol rekam untuk pesan UI (selalu 'F9')."""
n = (config.ASR_RECORD_KEY or "f9").strip().lower() return TALK_SHORTCUT_LABEL
if n.startswith("ctrl-") and len(n) == 6:
return f"Ctrl+{n[5].upper()}"
return n.upper()
# ------------------------------------------------------------------ helpers --- # ------------------------------------------------------------------ helpers ---
@ -209,7 +214,8 @@ def shutdown(app):
def finish_talk(app, stdscr): def finish_talk(app, stdscr):
"""Dipanggil main loop saat app.talk_done ter-set: sisipkan hasil.""" """Dipanggil main loop saat app.talk_done ter-set: sisipkan hasil (atau
langsung submit bila config.asr.auto_submit aktif)."""
res = getattr(app, "_talk_result", None) res = getattr(app, "_talk_result", None)
app._talk_result = None app._talk_result = None
if getattr(app, "talk_cancelled", False): if getattr(app, "talk_cancelled", False):
@ -222,6 +228,27 @@ def finish_talk(app, stdscr):
if not text: if not text:
log(app, "system", " Tidak ada ucapan terdeteksi.") log(app, "system", " Tidak ada ucapan terdeteksi.")
return return
if config.ASR_AUTO_SUBMIT:
# Auto-submit: taruh teks di buffer lalu kirim (submit() yang
# membersihkan buffer). Jika user sudah mengetik sesuatu sebelum
# transkrip selesai, teks tidak ditimpa — disisipkan saja.
if not "\n".join(app.input_buffer).strip():
app.input_buffer = text.split("\n")
app.input_line = len(app.input_buffer) - 1
app.input_col = len(app.input_buffer[-1])
submit(app, stdscr)
log(app, "system",
f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] "
"teks otomatis dikirim")
else:
insert_text(app, text)
log(app, "system",
f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] "
"form sedang terisi \u2014 hasil disisipkan, tidak auto-submit "
"(Ctrl+Enter untuk kirim)")
if app.scroll_follow:
app.scroll = 999999
return
if insert_text(app, text): if insert_text(app, text):
log(app, "system", log(app, "system",
f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] " f" \u266a [{res.get('provider', '?')} / {res.get('model', '?')}] "

View File

@ -21,6 +21,7 @@ C_HINT_DISABLED = 13 # hint disabled (abu-abu)
C_WELCOME = 14 # welcome art: light blue C_WELCOME = 14 # welcome art: light blue
C_TOOL_CALL = 15 # tool call: kuning terang C_TOOL_CALL = 15 # tool call: kuning terang
C_TOOL_RESULT = 16 # tool result: magenta muda C_TOOL_RESULT = 16 # tool result: magenta muda
C_USAGE = 22 # token usage: abu-abu
# -- Menu bar colors (id 17+) -- # -- Menu bar colors (id 17+) --
C_MENU = 17 # background menubar (sama dengan header) 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_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_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_TOOL_RESULT, curses.COLOR_MAGENTA + 8, -1) # bright magenta
curses.init_pair(C_USAGE, 8, -1) # abu-abu di atas bg default
# Menu bar # Menu bar
curses.init_pair(C_MENU, curses.COLOR_BLACK, curses.COLOR_BLUE) 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 import json
from datetime import datetime
import config import config
from lib.personality import PERSONALITY from lib.personality import PERSONALITY
from tools.vision import ImagePayload from tools.vision import ImagePayload
from scripts import loggorilla from scripts import loggorilla
def _ts():
return datetime.now().strftime('%H:%M:%S')
def _cap_output(result): 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) limit = getattr(config, "AGENT_MAX_TOOL_OUTPUT", 0)
if not limit or limit <= 0: if not limit or limit <= 0:
return result return result
@ -39,15 +44,265 @@ def execute_tool(tool_call, TOOL_HANDLERS):
except Exception as e: except Exception as e:
return _cap_output(f'Error executing tool: {str(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):
session_ended = False
for step in range(max_iterations): # --------------------------------------------------------------- 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.
`on_truncate` dipanggil dengan jumlah pesan yang harus dihapus dari ekor
saat turn dibatalkan. 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,
on_truncate=None):
self.messages = messages
self._on_append = on_append
self.keep_model_info = keep_model_info
self._on_truncate = on_truncate
# Berapa pesan yang sudah ditambahkan loop ini pada turn berjalan.
self.turn_appends = 0
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)
self.turn_appends += 1
if self._on_append:
self._on_append(message)
def discard_turn(self):
"""Buang semua pesan yang ditulis loop ini pada turn yang dibatalkan.
Penting: assistant message dengan `tool_calls` yang hasilnya tidak ikut
lengkap membuat konteks jadi tidak valid bagi banyak provider (400 karena
tool_call_id menggantung). Yang tersimpan di DB pun ikut dipangkas lewat
`on_truncate`, supaya session yang dibuka lagi tidak membawa sisa turn."""
n = self.turn_appends
if n > 0:
del self.messages[len(self.messages) - n:]
self.turn_appends = 0
if n and self._on_truncate:
self._on_truncate(n)
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, cancelled=False, partial=""):
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)
self.cancelled = cancelled # turn dihentikan user sebelum selesai
self.partial = partial # teks yang sempat dihasilkan sebelum berhenti
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
def on_cancelled(self, result):
"""Turn dihentikan user sebelum ada jawaban final."""
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...") 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")
def on_cancelled(self, result):
loggorilla.prcss("agent_loop", "Turn cancelled by user")
# ------------------------------------------------------------------- loop ---
def _cancelled(llm_client) -> bool:
"""Apakah user meminta turn ini dibatalkan?
Hanya bertanya ke LLM client (single source of truth state cancel) — engine
tidak punya salinan flag sendiri, jadi tidak ada yang bisa tersinkron rusak."""
return bool(getattr(llm_client, "cancel_requested", False))
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).
Pembatalan (cancel) dicek pada setiap boundary: sebelum panggilan LLM,
tepat setelah respons tiba, dan sebelum tiap tool dijalankan. Saat user
membatalkan, loop berhenti seketika dan sisa turn dibuang dari konteks lewat
`store.discard_turn()`. Interface yang ingin menawarkan "lanjutkan" dapat
menaruh kembali teks parsial (`result.partial`) sebagai pesan user.
State cancel TIDAK direset di sini: flag hanya boleh dibersihkan oleh
interface tepat saat pesan user dikirim. Kalau engine yang membersihkannya,
cancel yang ditekan sebelum thread ini sempat jalan akan hilang begitu saja.
"""
if observer is None:
observer = ConsoleObserver()
if disable_reasoning is None:
disable_reasoning = PERSONALITY.disable_reasoning disable_reasoning = PERSONALITY.disable_reasoning
response = llm_client.chat(session.messages, tools=TOOLS, disable_reasoning=disable_reasoning, tool_reminder=tool_reminder) 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)
def _stop(partial=""):
"""Akhiri turn karena dibatalkan: buang sisa turn dari konteks, beri
tahu observer, kembalikan hasil tanpa jawaban final."""
result = AgentResult(
content=None,
session_ended=session_ended,
model_info=model_info,
warning=warning,
usage=dict(turn_usage) if any_usage else None,
cancelled=True,
partial=partial,
)
store.discard_turn()
observer.on_cancelled(result)
observer.on_run_end()
return result
observer.on_run_start()
for step in range(max_iterations):
# Cancel bisa datang dari mana saja: dari antrean sebelum thread ini
# sempat jalan, atau dari user saat tool masih berjalan.
if _cancelled(llm_client):
return _stop()
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)
# Stream dipotong user: jangan tulis sisa turn ke konteks.
if getattr(response, "cancelled", False) or _cancelled(llm_client):
partial = response.content if isinstance(response.content, str) else ""
return _stop(partial)
observer.on_response(model_info, warning, usage)
if response.tool_calls: if response.tool_calls:
amsg = { amsg = {
@ -55,20 +310,30 @@ def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on
'content': response.content, 'content': response.content,
'tool_calls': response.tool_calls, '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] observer.on_tool_calls(response.content or "",
loggorilla.fyinf("agent_loop", f"Using tools: {', '.join(tnames)}") [tc['function']['name'] for tc in response.tool_calls])
if on_tool_calls:
on_tool_calls(response.content or "")
for tc in response.tool_calls: for tc in response.tool_calls:
# Sebelum eksekusi tool: batalkan tanpa menjalankan efek samping
# (tool adalah bagian paling berbahaya untuk disetop tengah —
# lebih baik tidak dijalankan sama sekali daripada separuh jalan).
if _cancelled(llm_client):
return _stop(response.content or "")
observer.on_tool_call(tc['function']['name'], tc['function']['arguments'])
if tc['function']['name'] == 'end_session': if tc['function']['name'] == 'end_session':
session_ended = True session_ended = True
result = execute_tool(tc, TOOL_HANDLERS) result = execute_tool(tc, TOOL_HANDLERS)
if isinstance(result, ImagePayload): if isinstance(result, ImagePayload):
session.messages.append({ store.append({
'role': 'tool', 'role': 'tool',
'tool_call_id': tc['id'], 'tool_call_id': tc['id'],
'content': [ 'content': [
@ -77,30 +342,59 @@ def run_agent_loop(session, llm_client, TOOLS, TOOL_HANDLERS, max_iterations, on
] ]
}) })
else: else:
session.messages.append({ store.append({
'role': 'tool', 'role': 'tool',
'tool_call_id': tc['id'], 'tool_call_id': tc['id'],
'content': str(result), 'content': str(result),
}) })
else: if _cancelled(llm_client):
return _stop(response.content or "")
observer.on_step_end(step)
continue
has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0) has_content = bool(response.content) or (isinstance(response.content, list) and len(response.content) > 0)
if has_content: if has_content:
session.messages.append({'role': 'assistant', 'content': response.content}) is_multimodal = isinstance(response.content, list)
if 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 text_parts = [b.get('text', '') for b in response.content
if isinstance(b, dict) and b.get('type') == 'text'] if isinstance(b, dict) and b.get('type') == 'text']
text = '\n'.join(text_parts) if text_parts else "[Image generated]" text = '\n'.join(text_parts) if text_parts else "[Image generated]"
loggorilla.prcss("agent_loop", "Response generated (multimodal)") else:
return text, session_ended text = response.content
loggorilla.prcss("agent_loop", f"Response generated ({len(response.content)} chars)")
return response.content, session_ended 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 # Respons kosong: tetap bawa flag session_ended — bisa jadi
# end_session sudah dipanggil di iterasi sebelumnya. # end_session sudah dipanggil di iterasi sebelumnya.
return None, session_ended empty = AgentResult(content=None, session_ended=session_ended,
model_info=model_info, warning=warning)
observer.on_final(empty)
observer.on_run_end()
return empty
loggorilla.prcss("agent_loop", f"Max iterations ({max_iterations}) reached") store.append({
session.messages.append({
'role': 'assistant', 'role': 'assistant',
'content': 'Max iterations reached without final answer.', '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:`) ## Konfigurasi (config.yaml, blok `asr:`)
asr: asr:
record_dir : "~/.config/hendrik/recordings" # lokasi file rekaman record_dir : "~/.config/hendrik/recordings" # lokasi file rekaman
record_key : f9 # f1..f12 / ctrl-<huruf>
record_backend : pulse # pulse | alsa | oss | windows (dshow) record_backend : pulse # pulse | alsa | oss | windows (dshow)
device : default device : default
windows_mic : "" # backend=windows: nama device dshow 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 ***_ Untuk kredensial service yang di gunakan
** model.yaml <<done>> ** model.yaml <<done>>
***_ Untuk menetapkan model set default yang digunakan ***_ Untuk menetapkan model set default yang digunakan
** instruction <<done>>
***_ Private instruction directory
@endmindmap @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 ;top to bottom direction
* root <<done>> * root <<done>>
** .env <<done>>
** .env.example <<done>>
** .gitignore <<done>> ** .gitignore <<done>>
** LICENSE <<done>>
** config.default.yaml <<done>>
** config.py ** config.py
** config.yaml <<done>>
** config.default.yaml
** hendrik <<done>> ** hendrik <<done>>
** hendrik.py <<done>> ** hendrik.py <<done>>
** requirements.txt <<done>> ** requirements.txt <<done>>

View File

@ -0,0 +1,37 @@
@startuml
hide circle
skinparam linetype ortho
entity users_telegram {
id : int <<PK>>
first_name : longtext
last_name : longtext null
username : longtext null
phone_number : longtext null
last_update : datetime
--
}
entity users {
id : binary(40) <<PK>>
telegram : int <<FK>>
--
* telegram = users_telegram.id
on update cascade
on delete cascade
}
users ||-o| users_telegram
entity credit_wallet {
id : binary(40) <<PK>>
owner : binary(40) <<FK>>
balance : int
--
* owner = users.id
on update cascade
on delete cascade
}
users ||--|| credit_wallet
@enduml

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 184 KiB

View File

@ -57,30 +57,6 @@ mindmapDiagram {
** Detailed ** Detailed
***_ Give thorough, detailed answers with explanations. ***_ 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 * Own local API inference
** Inference ** Inference
*** llama.cpp *** llama.cpp

View File

@ -1,9 +1,161 @@
import json import json
import socket import socket
from lib import gadget import threading
from lib import gadget, moop
import urllib.request import urllib.request
import urllib.error import urllib.error
class StreamCancelled(Exception):
"""User meminta pembatalan saat stream berjalan.
`partial` menyimpan teks yang sudah ter-stream supaya tidak hilang.
Sengaja bukan turunan _LLMFail: rantai fallback/auto-switch tidak boleh
memperlakukan cancel sebagai kegagalan provider lalu mencoba model
berikutnya (jumlah percobaan tidak terbatas)."""
def __init__(self, partial=""):
super().__init__("Stream cancelled by user")
self.partial = partial
class _CancelState:
"""Penghubung thread UI (yang membatalkan) dan thread agen (pembaca SSE).
Masalah yang dipecahkan: pembacaan stream memblokir di dalam recv() selama
provider diam — model masih 'thinking' sebelum token pertama, header belum
dikirim, atau koneksi menggantung. Flag cancel saja tidak cukup karena ia
hanya diperiksa ketika satu baris penuh tiba, jadi Cancel/Stop baru
bereaksi setelah provider mengirim sesuatu (bisa puluhan detik, atau tidak
pernah).
Solusi: objek socket koneksi aktif dicatat begitu TCP/TLS terbentuk; saat
cancel diminta, shutdown(SHUT_RDWR) pada objek itu membangunkan recv()
seketika di thread mana pun. Yang dipegang adalah objek socket-nya (bukan
fd mentah), sehingga balapan antara cancel dan penutupan koneksi tidak
pernah mengenai fd yang sudah dipakai ulang oleh koneksi lain.
Beberapa stream bisa berjalan bersamaan pada satu klien (Telegram/XMPP
memproses tiap pesan di thread sendiri dan berbagi satu LLMClient), jadi
semua socket aktif dilacak dan dibangunkan sekaligus — persis seperti flag
global lama yang dilihat oleh setiap loop."""
def __init__(self):
self._lock = threading.Lock()
self._socks = []
self.requested = False
def set_socket(self, sock):
"""Daftarkan socket stream aktif. Jika cancel sudah diminta sebelum
stream sempat terbentuk, bangunkan sekarang juga."""
wake = None
with self._lock:
if self.requested:
wake = [sock]
else:
# Bersihkan entri yang socket-nya sudah ditutup (mis. sisa
# stream yang selesai tanpa sempat release) supaya daftar tidak
# tumbuh tanpa batas.
self._socks = [s for s in self._socks if not self._dead(s)]
if sock not in self._socks:
self._socks.append(sock)
if wake:
self._shutdown_all(wake)
def release(self, sock):
"""Panggil ketika pembacaan selesai: setelah ini cancel tidak pernah
menyentuh socket ini lagi. Hanya melepas satu socket — stream thread
lain pada klien yang sama tidak boleh ikut terputus pendaftarannya."""
if sock is None:
return
with self._lock:
if sock in self._socks:
self._socks.remove(sock)
def request(self):
with self._lock:
self.requested = True
socks = self._socks
self._socks = []
self._shutdown_all(socks)
def reset(self):
# Daftar socket TIDAK dikosongkan: pada klien yang dipakai bersama
# (Telegram/XMPP) bisa ada stream thread lain yang masih berjalan dan
# harus tetap bisa dibatalkan. Socket mati tersapu sendiri oleh
# set_socket/release.
with self._lock:
self.requested = False
def _shutdown_all(self, socks):
for s in socks:
self._shutdown(s)
@staticmethod
def _dead(sock):
"""True jika socket sudah ditutup (fileno() tidak bisa dipakai lagi)."""
try:
sock.fileno()
except (OSError, ValueError):
return True
return False
@staticmethod
def _shutdown(sock):
"""Putus koneksi sekarang juga. Aman dipanggil dua kali atau pada
socket yang sudah ditutup: efek yang dibutuhkan hanya membangunkan
recv() yang terblokir — sekaligus menghentikan generation di sisi
provider, jadi token tidak ditagih untuk output yang dibuang."""
try:
sock.shutdown(socket.SHUT_RDWR)
except (OSError, ValueError):
pass
except Exception:
pass
class _CapturingHandler:
"""Campuran handler urllib: mendaftarkan socket koneksi ke _CancelState
tepat setelah connect(), sehingga cancel bisa membangunkan pembacaan
header maupun body yang terblokir."""
def __init__(self, state, base_class):
super().__init__()
self._state = state
self._base = base_class
def do_open(self, http_class, req, **http_conn_args):
state = self._state
captured = []
class _Conn(http_class):
def connect(self):
http_class.connect(self)
sock = getattr(self, "sock", None)
if sock is not None:
captured.append(sock)
state.set_socket(sock)
try:
return self._base.do_open(self, _Conn, req, **http_conn_args)
except BaseException:
# do_open menutup socket-nya sendiri saat gagal: lepas pendaftaran
# supaya thread cancel tidak menyentuhnya lagi.
for sock in captured:
state.release(sock)
raise
class _HTTPHandler(_CapturingHandler, urllib.request.HTTPHandler):
def __init__(self, state):
_CapturingHandler.__init__(self, state, urllib.request.HTTPHandler)
class _HTTPSHandler(_CapturingHandler, urllib.request.HTTPSHandler):
def __init__(self, state):
_CapturingHandler.__init__(self, state, urllib.request.HTTPSHandler)
class _LLMFail(Exception): class _LLMFail(Exception):
""" """
- 'auth' : 401/403 → rotasi key (coba key berikutnya di provider yang sama) - 'auth' : 401/403 → rotasi key (coba key berikutnya di provider yang sama)
@ -21,7 +173,11 @@ class LLMClient:
self.model = model self.model = model
self.api_key = api_key self.api_key = api_key
self.timeout = timeout self.timeout = timeout
self.cancel_requested = False
# State cancel + opener yang menangkap socket koneksi (_CancelState).
self._cancel = _CancelState()
self._opener = urllib.request.build_opener(
_HTTPHandler(self._cancel), _HTTPSHandler(self._cancel))
# Chaining kandidat set model per tipe ('llm', 'imagevision', ...) # Chaining kandidat set model per tipe ('llm', 'imagevision', ...)
self._chains: dict[str, list[dict]] = {} self._chains: dict[str, list[dict]] = {}
@ -29,12 +185,56 @@ class LLMClient:
self._chain_key: str | None = None self._chain_key: str | None = None
self._chain_index = 0 self._chain_index = 0
# -------------------------------------------------------- cancellation ---
@property
def cancel_requested(self) -> bool:
return self._cancel.requested
@cancel_requested.setter
def cancel_requested(self, value):
# Kompatibel dengan pemanggil lama (`llm.cancel_requested = True/False`).
if value:
self.cancel()
else:
self.begin_turn()
def cancel(self):
"""Minta inference berhenti. Aman dipanggil dari thread UI mana pun,
kapan pun — termasuk saat stream belum terbentuk atau tool sedang
berjalan. Membangunkan pembacaan socket yang terblokir seketika."""
self._cancel.request()
def begin_turn(self):
"""Bersihkan state cancel pada awal turn baru (dipanggil interface saat
user mengirim pesan). Cancel turn sebelumnya tidak boleh merembes ke
turn berikutnya."""
self._cancel.reset()
def _cancelled_message(self, partial=""):
"""Respons untuk turn yang dibatalkan user.
Teks parsial dikembalikan apa adanya di `content` dan ditandai lewat
atribut `cancelled` — penanda visual ("dibatalkan") adalah urusan
interface, bukan bagian dari isi pesan. Rantai model tidak dianggap
gagal: cancel adalah perintah user, bukan masalah provider."""
result = self.Message({'content': partial or "", 'tool_calls': None})
result.cancelled = True
return result
class Message: class Message:
def __init__(self, msg): def __init__(self, msg, model_info=None):
raw_content = msg.get('content', '') # Ambil konten mentah raw_content = msg.get('content', '') # Ambil konten mentah
self.content = gadget.strip_thinking(raw_content) if isinstance(raw_content, str) else raw_content # Auto-strip <thinking> dari content 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.tool_calls = msg.get('tool_calls', None) # Ambil tool calls
self.warning = None self.warning = None
# True jika pesan ini adalah hasil turn yang dihentikan user.
self.cancelled = False
# 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 {}
# ------------------------------------------------------------ config --- # ------------------------------------------------------------ config ---
@ -86,6 +286,23 @@ class LLMClient:
self.model = cand.get("model", "") self.model = cand.get("model", "")
self.api_key = cand.get("api_key", "") self.api_key = cand.get("api_key", "")
def _model_info(self, cand=None):
"""Info model yang dipakai untuk satu permintaan.
Dari kandidat chain jika ada (provider sudah dibawa moop), jika tidak
(klien non-chain) pakai state aktif dan cari nama provider-nya."""
if cand:
return {
"provider": cand.get("provider"),
"base_url": cand.get("base_url", ""),
"model": cand.get("model", ""),
}
return {
"provider": moop.resolve_provider(None, self.base_url, self.model),
"base_url": self.base_url,
"model": self.model,
}
# ----------------------------------------------------- chain selection --- # ----------------------------------------------------- chain selection ---
def _pick_chain_type(self, messages): def _pick_chain_type(self, messages):
@ -107,8 +324,15 @@ class LLMClient:
def chat(self, messages, tools=None, on_stream_chunk=None, disable_reasoning=False, tool_reminder=None): def chat(self, messages, tools=None, on_stream_chunk=None, disable_reasoning=False, tool_reminder=None):
if self._cancel.requested:
# Cancel diminta sebelum permintaan sempat berangkat (mis. saat
# tool masih berjalan di langkah sebelumnya). Jangan mulai request
# baru — kembalikan turn yang ditandai sebagai dibatalkan.
return self._cancelled_message("")
if not self._chains: # kalau bukan chain if not self._chains: # kalau bukan chain
return self._chat_once( try:
result = self._chat_once(
self.base_url, self.model, self.api_key, self.base_url, self.model, self.api_key,
messages, messages,
tools=tools, tools=tools,
@ -116,6 +340,10 @@ class LLMClient:
disable_reasoning=disable_reasoning, disable_reasoning=disable_reasoning,
tool_reminder=tool_reminder, tool_reminder=tool_reminder,
) )
except StreamCancelled as e:
return self._cancelled_message(e.partial)
result.model_info = self._model_info()
return result
chain_type = self._pick_chain_type(messages) chain_type = self._pick_chain_type(messages)
if chain_type != self._chain_key: if chain_type != self._chain_key:
@ -176,6 +404,16 @@ class LLMClient:
disable_reasoning=disable_reasoning, tool_reminder=tool_reminder, disable_reasoning=disable_reasoning, tool_reminder=tool_reminder,
) )
self._chain_index = idx self._chain_index = idx
# Catat model yang benar-benar menjawab (bukan model awal
# permintaan) supaya pemanggil bisa menampilkan data yang jujur.
result.model_info = self._model_info(cand)
return result
except StreamCancelled as e:
# Cancel user: hentikan rantai total — bukan kegagalan provider,
# jadi jangan lompat ke kandidat model berikutnya.
self._chain_index = idx
result = self._cancelled_message(e.partial)
result.model_info = self._model_info(cand)
return result return result
except _LLMFail as e: except _LLMFail as e:
last = e last = e
@ -207,23 +445,36 @@ class LLMClient:
# ------------------------------------------------------- single attempt --- # ------------------------------------------------------- single attempt ---
def _chat_once(self, base_url, model, api_key, messages, tools=None, 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" 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 = { payload = {
"model": model, "model": model,
"messages": messages, "messages": msgs_clean,
"stream": True # Enable streaming "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: if tools:
payload["tools"] = tools payload["tools"] = tools
payload["tool_choice"] = "auto" payload["tool_choice"] = "auto"
if tool_reminder: if tool_reminder:
# Inject tool reminder sebagai system message sebelum user message terakhir # Inject tool reminder sebagai system message sebelum user message terakhir
for i in range(len(messages) - 1, -1, -1): for i in range(len(msgs_clean) - 1, -1, -1):
if messages[i].get("role") == "user": if msgs_clean[i].get("role") == "user":
payload["messages"] = messages[:i] + [ payload["messages"] = msgs_clean[:i] + [
{"role": "system", "content": tool_reminder} {"role": "system", "content": tool_reminder}
] + messages[i:] ] + msgs_clean[i:]
break break
# Hanya kirim parameter reasoning jika diminta eksplisit # Hanya kirim parameter reasoning jika diminta eksplisit
@ -243,16 +494,20 @@ class LLMClient:
reasoning_content = "" reasoning_content = ""
full_multimodal_content = [] full_multimodal_content = []
stream_started = False stream_started = False
usage = None # prompt/completion/total tokens dari chunk terakhir
my_sock = None
try:
with self._opener.open(req, timeout=self.timeout) as resp:
# Cadangan pendaftaran socket (jika handler capture tidak
# sempat mengisi, mis. perubahan internal urllib): cukup untuk
# membangunkan pembacaan body.
my_sock = self._register_socket(resp)
try: try:
self.cancel_requested = False
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
# Streaming: baca line by line # Streaming: baca line by line
for line in resp: for line in resp:
if self.cancel_requested: if self._cancel.requested:
# Stream cancellation raise StreamCancelled(full_content)
full_content += "\n\n[Stream cancelled by user]"
break
line = line.decode('utf-8').strip() line = line.decode('utf-8').strip()
if not line or not line.startswith('data: '): if not line or not line.startswith('data: '):
@ -267,9 +522,15 @@ class LLMClient:
except json.JSONDecodeError: except json.JSONDecodeError:
continue 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 # Parse delta dari chunk
delta = chunk.get('choices', [{}])[0].get('delta', {}) choices = chunk.get('choices') or []
finish_reason = chunk.get('choices', [{}])[0].get('finish_reason', None) 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 # Stream reasoning content jika ada
if 'reasoning_content' in delta: if 'reasoning_content' in delta:
@ -319,6 +580,19 @@ class LLMClient:
stream_started = True stream_started = True
if on_stream_chunk: if on_stream_chunk:
on_stream_chunk(t) on_stream_chunk(t)
finally:
# Lepas pendaftaran begitu pembacaan selesai: socket akan
# ditutup oleh with-block, dan thread cancel tidak boleh
# menyentuhnya lagi. Hanya socket milik panggilan ini —
# stream thread lain pada klien yang sama tetap terdaftar.
self._cancel.release(my_sock)
my_sock = None
# shutdown() dari thread cancel bisa membuat pembacaan berakhir rapi
# seperti EOF — pastikan itu tidak disalahartikan sebagai jawaban
# lengkap.
if self._cancel.requested:
raise StreamCancelled(full_content)
# Build final response # Build final response
if full_multimodal_content: if full_multimodal_content:
@ -355,7 +629,11 @@ class LLMClient:
message['tool_calls'] = valid_tool_calls message['tool_calls'] = valid_tool_calls
response = {'choices': [{'message': message}]} response = {'choices': [{'message': message}]}
except StreamCancelled:
raise
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if self._cancel.requested:
raise StreamCancelled(full_content)
body_text = "" body_text = ""
try: try:
body_text = e.read().decode('utf-8', errors='replace') body_text = e.read().decode('utf-8', errors='replace')
@ -374,19 +652,35 @@ class LLMClient:
return result return result
except Exception: except Exception:
pass 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 "" detail = f" - {body_text[:500]}" if body_text else ""
if e.code in (401, 403): if e.code in (401, 403):
raise _LLMFail("auth", f"HTTP {e.code} {e.reason}{detail}") raise _LLMFail("auth", f"HTTP {e.code} {e.reason}{detail}")
raise _LLMFail("unreachable", f"HTTP {e.code} {e.reason}{detail}") raise _LLMFail("unreachable", f"HTTP {e.code} {e.reason}{detail}")
except urllib.error.URLError as e: except urllib.error.URLError as e:
if self._cancel.requested:
raise StreamCancelled(full_content)
if stream_started: if stream_started:
return self.Message({'content': f"Error: {e.reason}", 'tool_calls': None}) return self.Message({'content': f"Error: {e.reason}", 'tool_calls': None})
raise _LLMFail("unreachable", f"Connection error: {e.reason}") raise _LLMFail("unreachable", f"Connection error: {e.reason}")
except (TimeoutError, socket.timeout) as e: except (TimeoutError, socket.timeout) as e:
if self._cancel.requested:
raise StreamCancelled(full_content)
if stream_started: if stream_started:
return self.Message({'content': f"Error: Timeout", 'tool_calls': None}) return self.Message({'content': f"Error: Timeout", 'tool_calls': None})
raise _LLMFail("unreachable", f"Timeout") raise _LLMFail("unreachable", f"Timeout")
except Exception as e: except Exception as e:
if self._cancel.requested:
raise StreamCancelled(full_content)
if stream_started: if stream_started:
return self.Message({'content': f"Error: {str(e)}", 'tool_calls': None}) return self.Message({'content': f"Error: {str(e)}", 'tool_calls': None})
raise _LLMFail("unreachable", f"Error: {str(e)}") raise _LLMFail("unreachable", f"Error: {str(e)}")
@ -423,4 +717,28 @@ class LLMClient:
message.pop('reasoning_content', None) message.pop('reasoning_content', None)
message.pop('reasoning', None) message.pop('reasoning', None)
return self.Message(message) result = self.Message(message)
result.usage = usage
return result
@staticmethod
def _socket_of(resp):
"""Objek socket di balik HTTPResponse; None jika struktur urllib
berbeda dari yang diharapkan (maka fallback wake tidak tersedia)."""
try:
fp = resp.fp
raw = getattr(fp, "raw", None)
sock = getattr(raw, "_sock", None)
return sock if isinstance(sock, socket.socket) else None
except (AttributeError, TypeError, ValueError):
return None
def _register_socket(self, resp):
"""Daftarkan socket respons ke state cancel; kembalikan socket-nya
supaya pemanggil bisa melepasnya sendiri saat selesai (tidak menyentuh
stream thread lain). Jika cancel sudah diminta, set_socket langsung
memutusnya sehingga pembacaan pertama tidak sempat terblokir."""
sock = self._socket_of(resp)
if sock is not None:
self._cancel.set_socket(sock)
return sock

View File

@ -136,6 +136,26 @@ class NeoSessionManager:
def update_model_info(self, doc_id: int, model_info: dict) -> bool: def update_model_info(self, doc_id: int, model_info: dict) -> bool:
return self._table.update({"model_info": model_info}, doc_ids=[doc_id]) return self._table.update({"model_info": model_info}, doc_ids=[doc_id])
def remove_last_messages(self, doc_id: int, count: int) -> bool:
"""Hapus `count` pesan terakhir dari session.
Dipakai saat turn dibatalkan: engine sudah membuang sisa turn dari
konteks aktif, dan DB harus ikut dipangkas supaya session yang dibuka
lagi tidak menampilkan — atau mengirim — pesan assistant dengan
tool_calls yang hasilnya tidak ada."""
if count <= 0:
return True
doc = self._table.get(doc_id=doc_id)
if doc is None:
return False
messages = doc["messages"]
del messages[len(messages) - min(count, len(messages)):]
now = datetime.now(timezone.utc).isoformat()
return self._table.update(
{"messages": messages, "updated_at": now},
doc_ids=[doc_id],
)
@staticmethod @staticmethod
def _doc_to_session(doc) -> NeoSession: def _doc_to_session(doc) -> NeoSession:
return NeoSession( return NeoSession(

View File

@ -7,7 +7,7 @@ from datetime import datetime
import config import config
from services.session_manager import SessionManager 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 personality
from lib import ragroleplay from lib import ragroleplay
from scripts import loggorilla from scripts import loggorilla
@ -259,10 +259,27 @@ class TelegramClient:
tool_reminder = None tool_reminder = None
final_content, should_close = run_agent_loop( # Turn baru: bersihkan state cancel. LLMClient dipakai bersama antar
session, self._llm, self._TOOLS, self._TOOL_HANDLERS, # interface, jadi cancel yang tertinggal (mis. dari TUI) tidak boleh
self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder # membuat turn ini langsung berhenti.
self._llm.begin_turn()
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 outcome.cancelled:
# Turn dihentikan (cancel adalah flag global pada LLMClient yang
# dipakai bersama antar interface). Sisa turn sudah dibuang engine
# dari konteks; jangan kirim pesan "max iterations" yang menyesatkan.
loggorilla.prcss('telegram', f'Turn cancelled for {chat_id}')
session.start_timer(86400 if chat_type == 'private' else 1800,
self._timeout_session, chat_id)
return
def _pop_ghost(sentinel: str): def _pop_ghost(sentinel: str):
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang # Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang

View File

@ -8,7 +8,7 @@ from datetime import datetime
from slixmpp import ClientXMPP from slixmpp import ClientXMPP
from services.session_manager import SessionManager 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 import config
from tools.roleplayer import should_respond from tools.roleplayer import should_respond
@ -624,10 +624,24 @@ class XMPPClient(ClientXMPP):
tool_reminder = None tool_reminder = None
final_content, should_close = run_agent_loop( # Turn baru: bersihkan state cancel warisan antar interface/thread
session, self._llm, self._TOOLS, self._TOOL_HANDLERS, # yang berbagi satu LLMClient.
self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder self._llm.begin_turn()
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 outcome.cancelled:
# Turn dihentikan (flag cancel global pada LLMClient bersama).
# Sisa turn sudah dibuang engine; jangan kirim "max iterations".
print(f'[{_ts()}] DM: turn cancelled for {jid}', flush=True)
session.start_timer(86400, self._timeout_session, jid, 'chat')
return
if final_content is not None: if final_content is not None:
if is_roleplay: if is_roleplay:
@ -709,10 +723,23 @@ class XMPPClient(ClientXMPP):
tool_reminder = None tool_reminder = None
final_content, should_close = run_agent_loop( # Turn baru: bersihkan state cancel warisan antar interface/thread.
session, self._llm, self._TOOLS, self._TOOL_HANDLERS, self._llm.begin_turn()
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 outcome.cancelled:
# Turn dihentikan: sisa turn sudah dibuang engine dari konteks.
# Diam total (tanpa "max iterations") lalu lanjutkan timer sesi.
print(f'[{_ts()}] MUC: turn cancelled for {room}', flush=True)
session.start_timer(1800, self._timeout_session, room, 'groupchat')
return
def _pop_ghost(sentinel: str): def _pop_ghost(sentinel: str):
# Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang # 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 import os
# NOTE: loggorilla dimatikan di tool ini. print()-nya bisa bikin error saat # 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. # proses keluar (SCI), jadi semua pemanggilan di bawah hanya di-comment, jangan dihapus.
# from scripts import loggorilla # from scripts import loggorilla
from tools import instrbase
schema_instruction_use = { schema_instruction_use = {
"type": "function", "type": "function",
@ -48,3 +50,43 @@ def instruction_use(directory, filename):
res = f"Error reading file: {str(e)}" res = f"Error reading file: {str(e)}"
# loggorilla.fyinf("tools.smart", f"Exception occurred: {res}") # loggorilla.fyinf("tools.smart", f"Exception occurred: {res}")
return 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 import os
from scripts import loggorilla from scripts import loggorilla
from tools import instrbase
schema_rp_instruction_use = { schema_rp_instruction_use = {
"type": "function", "type": "function",
@ -46,3 +48,46 @@ def rp_instruction_use(directory, filename):
res = f"Error reading RP file: {str(e)}" res = f"Error reading RP file: {str(e)}"
loggorilla.fyinf("tools.smart_rp", f"Exception occurred: {res}") loggorilla.fyinf("tools.smart_rp", f"Exception occurred: {res}")
return 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"
)