diff --git a/config.default.yaml b/config.default.yaml index 702ee42..2a4d9c0 100644 --- a/config.default.yaml +++ b/config.default.yaml @@ -2,9 +2,9 @@ agent: character : hendrik # Directory name in agent/characters// - max_iterations : 40 # step(s) + max_iterations : 80 # step(s) max_history_chat : 0 # message(s) - 0 for unlimited (Dead Config) - max_tool_output : 0 # character(s) - 0 for unlimited (Connected to AGENT_MAX_TOOL_OUTPUT but Dead Config) + max_tool_output : 0 # character(s) - 0 for unlimited user: id: # Fill your user UUID here, empty = TUI/MUC guest @@ -15,26 +15,23 @@ llm: moop: # Model Option db_path: "~/.config/hendrik/moop.sqlite3" -asr: # Speech-to-Text (push-to-talk TUI). Model dikelola via moop tipe 'asr'. - record_dir : "~/.config/hendrik/recordings" # lokasi file hasil rekaman - record_key : f9 # tombol push-to-talk (f1..f12 / ctrl-) - record_backend : pulse # ffmpeg input: pulse | alsa | oss | windows - device : default # nama device input - windows_mic : "" # backend=windows: nama device dshow, - # contoh "Microphone Array (Realtek(R) Audio)" - format : wav # wav | ogg (opus) - sample_rate : 16000 # Hz - channels : 1 - max_seconds : 60 # auto-stop pengaman - transcribe_timeout: 60 # detik timeout request ASR - keep_recordings : true # false = hapus file setelah ditranskrip - auto_submit : false # true = hasil transkrip langsung dikirim - # false = hanya mengisi form (Ctrl+Enter) +asr: + record_dir : "~/.config/hendrik/recordings" + record_backend : pulse # ffmpeg input: pulse | alsa | oss | windows + device : default + windows_mic : "" # if backend==windows: device dshow name, example: "Microphone Array (Realtek(R) Audio)" + format : wav # wav | ogg (opus) + sample_rate : 16000 # Hz + channels : 1 + max_seconds : 120 # second + transcribe_timeout : 60 # second + keep_recordings : false + auto_submit : true -session: # Chat session +session: db_path: "~/.config/hendrik/sessions.json" -ragroleplay: # Use nomic-embed-text +ragroleplay: db_path : "~/.config/hendrik/ragroleplay" vector_size : 768 # used on table create only @@ -53,7 +50,7 @@ xmpp: # - "room2@conference.server" nickname: "" # custom MUC nickname (empty = use username) selective_response: true # true = only response if mentioned/relevant - debug: false # true = tampilkan log debug menyeluruh (semua stanza XML, lifecycle, dll) + debug: false # true = display debug log (stanza XML, lifecycle, etc) mute_session_end: false # true = tidak kirim pesan saat sesi berakhir (timeout) telegram: diff --git a/config.py b/config.py index d281d49..e23c22d 100644 --- a/config.py +++ b/config.py @@ -58,8 +58,8 @@ ASR_MAX_SECONDS = int(_yaml_get("asr", "max_seconds", default=60)) ASR_TIMEOUT = int(_yaml_get("asr", "transcribe_timeout", default=60)) ASR_KEEP_RECORDINGS = _as_bool(_yaml_get("asr", "keep_recordings", default=True), default=True) -# Tombol push-to-talk: "f1".."f12" atau "ctrl-". Default F9. -ASR_RECORD_KEY = str(_yaml_get("asr", "record_key", default="f9")).strip().lower() +# Tombol push-to-talk dipatok tetap ke F9 (tidak bisa dikonfigurasi). +# Lihat interfaces/tui/actions.py -> ACTIONS["push_to_talk"]. # true = hasil transkripsi langsung dikirim sebagai pesan, # false = hasil hanya disisipkan ke form input (user kirim sendiri via Ctrl+Enter). diff --git a/hendrik b/hendrik index 32c3118..6ab73b1 100755 --- a/hendrik +++ b/hendrik @@ -1,5 +1,5 @@ #!/bin/bash -# wrapper to run the TUI agent from anywhere +# wrapper to run the hendrik agent from anywhere # Set HENDRIK_DIR env var to override, or update the default below DEFAULT_DIR="/opt/hendrik" diff --git a/hendrik.py b/hendrik.py index 545b0ef..77f8b5a 100644 --- a/hendrik.py +++ b/hendrik.py @@ -14,23 +14,25 @@ from interfaces.tui import HendrikTUI tools_definition = [ # Coder Tools - gadget.tools_mapping( schema = coder.schema_read_file, handler = coder.read_file ), - gadget.tools_mapping( schema = coder.schema_write_file, handler = coder.write_file ), - gadget.tools_mapping( schema = coder.schema_edit_file, handler = coder.edit_file ), - gadget.tools_mapping( schema = coder.schema_run_bash, handler = coder.run_bash ), - gadget.tools_mapping( schema = coder.schema_search_code, handler = coder.search_code ), - gadget.tools_mapping( schema = coder.schema_git_operation, handler = coder.git_operation ), - gadget.tools_mapping( schema = coder.schema_search_grep, handler = coder.search_grep ), - gadget.tools_mapping( schema = coder.schema_search_glob, handler = coder.search_glob ), + gadget.tools_mapping( schema = coder.schema_read_file, handler = coder.read_file ), + gadget.tools_mapping( schema = coder.schema_write_file, handler = coder.write_file ), + gadget.tools_mapping( schema = coder.schema_edit_file, handler = coder.edit_file ), + gadget.tools_mapping( schema = coder.schema_run_bash, handler = coder.run_bash ), + gadget.tools_mapping( schema = coder.schema_search_code, handler = coder.search_code ), + gadget.tools_mapping( schema = coder.schema_git_operation, handler = coder.git_operation ), + gadget.tools_mapping( schema = coder.schema_search_grep, handler = coder.search_grep ), + gadget.tools_mapping( schema = coder.schema_search_glob, handler = coder.search_glob ), # Carrack Tools - gadget.tools_mapping( schema = carrack.schema_sendhttprequest, handler = carrack.sendhttprequest ), + gadget.tools_mapping( schema = carrack.schema_sendhttprequest, handler = carrack.sendhttprequest ), # Vision Tools - gadget.tools_mapping( schema = vision.schema_describe_image, handler = vision.describe_image ), + gadget.tools_mapping( schema = vision.schema_describe_image, handler = vision.describe_image ), # Image Generation Tools - gadget.tools_mapping( schema = imagegen.schema_generate_image, handler = imagegen.generate_image ), + gadget.tools_mapping( schema = imagegen.schema_generate_image, handler = imagegen.generate_image ), # Instruction Tools - gadget.tools_mapping( schema = smart.schema_instruction_use, handler = smart.instruction_use ), # snippet - gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_use, handler = smart_rp.rp_instruction_use ), # roleplay + gadget.tools_mapping( schema = smart.schema_instruction_use, handler = smart.instruction_use ), # snippet (global) + gadget.tools_mapping( schema = smart.schema_instruction_char_use, handler = smart.instruction_char_use ), # snippet (private per character) + gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_use, handler = smart_rp.rp_instruction_use ), # roleplay (global) + gadget.tools_mapping( schema = smart_rp.schema_rp_instruction_char_use, handler = smart_rp.rp_instruction_char_use ), # roleplay (private per character) # Rag Roleplay Tools gadget.tools_mapping( schema = ragroleplay_tools.schema_users_store, handler = ragroleplay_tools.users_store ), gadget.tools_mapping( schema = ragroleplay_tools.schema_user_load, handler = ragroleplay_tools.user_load ), # s_load diff --git a/instruction/erd/erd-plantuml-create.md b/instruction/erd/erd-plantuml-create.md new file mode 100644 index 0000000..9a2610d --- /dev/null +++ b/instruction/erd/erd-plantuml-create.md @@ -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: `.puml` dan `.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 ` + +;top to bottom direction + +* Full-Duplex Model +**_ Voice Activity Detection +**_ Noise interruption detector included +** Mic & Voice Frontend +** Models +*** GPT-Live-1 + +* Speech <> +** Push to talk <> +*** STT (ASR) <> +**** Inference <> +***** OpenRouter API Inference <> +***** sherpa-onnx (local) +**** Model +***** Qwen3-ASR 1.7B int8 +*** TTS +**** Inference +***** sherpa-onnx +**** Model +***** ZipVoice distill int8 +** Live +*** Full-Duplex +**** Inference +**** Model + +@endmindmap diff --git a/plan/milestone/coding-standard.png b/plan/milestone/coding-standard.png index 6e8ef04..0468f58 100644 Binary files a/plan/milestone/coding-standard.png and b/plan/milestone/coding-standard.png differ diff --git a/plan/milestone/coding-standard.puml b/plan/milestone/coding-standard.puml index 85a4354..de199cd 100644 --- a/plan/milestone/coding-standard.puml +++ b/plan/milestone/coding-standard.puml @@ -28,12 +28,10 @@ mindmapDiagram { ;top to bottom direction * root <> -** .env <> -** .env.example <> ** .gitignore <> +** LICENSE <> +** config.default.yaml <> ** config.py -** config.yaml <> -** config.default.yaml ** hendrik <> ** hendrik.py <> ** requirements.txt <> diff --git a/plan/milestone/gado-gado.png b/plan/milestone/gado-gado.png index 9bc8fa7..a37d3c7 100644 Binary files a/plan/milestone/gado-gado.png and b/plan/milestone/gado-gado.png differ diff --git a/plan/milestone/gado-gado.puml b/plan/milestone/gado-gado.puml index 9b08ce5..7faeb78 100644 --- a/plan/milestone/gado-gado.puml +++ b/plan/milestone/gado-gado.puml @@ -57,30 +57,6 @@ mindmapDiagram { ** Detailed ***_ Give thorough, detailed answers with explanations. -* Full-Duplex Model -**_ Voice Activity Detection -**_ Noise interruption detector included -** Mic & Voice Frontend -** Models -*** GPT-Live-1 - -* Speech -** Push to talk -*** STT (ASR) -**** Inference -***** sherpa-onnx -**** Model -***** Qwen3-ASR 1.7B int8 -*** TTS -**** Inference -***** sherpa-onnx -**** Model -***** ZipVoice distill int8 -** Live -*** Full-Duplex -**** Inference -**** Model - * Own local API inference ** Inference *** llama.cpp diff --git a/services/llm_client.py b/services/llm_client.py index 87f25b9..073a971 100644 --- a/services/llm_client.py +++ b/services/llm_client.py @@ -35,6 +35,8 @@ class LLMClient: self.content = gadget.strip_thinking(raw_content) if isinstance(raw_content, str) else raw_content # Auto-strip dari content self.tool_calls = msg.get('tool_calls', None) # Ambil tool calls self.warning = None + # Pemakaian token dari provider (prompt/completion/total) jika ada. + self.usage = None # Model yang benar-benar dipakai menghasilkan pesan ini. Bisa berbeda # dengan model awal permintaan karena auto-switch/fallback. self.model_info = model_info or {} @@ -232,23 +234,36 @@ class LLMClient: # ------------------------------------------------------- single attempt --- def _chat_once(self, base_url, model, api_key, messages, tools=None, - on_stream_chunk=None, disable_reasoning=False, tool_reminder=None): + on_stream_chunk=None, disable_reasoning=False, tool_reminder=None, + include_usage=True): url = f"{base_url.rstrip('/')}/chat/completions" + # Sanitasi: buang metadata display (model_info/usage) dari pesan sebelum + # jadi payload — beberapa provider memvalidasi field pesan dengan ketat. + msgs_clean = [ + {k: v for k, v in m.items() if k not in ("model_info", "usage")} + if isinstance(m, dict) else m + for m in (messages or []) + ] payload = { "model": model, - "messages": messages, + "messages": msgs_clean, "stream": True # Enable streaming } + if include_usage: + # Minta provider menyertakan pemakaian token (input/output) pada + # chunk terakhir stream. Provider yang tidak mendukungnya akan + # di-retry tanpa parameter ini (lihat penanganan HTTP 400). + payload["stream_options"] = {"include_usage": True} if tools: payload["tools"] = tools payload["tool_choice"] = "auto" if tool_reminder: # Inject tool reminder sebagai system message sebelum user message terakhir - for i in range(len(messages) - 1, -1, -1): - if messages[i].get("role") == "user": - payload["messages"] = messages[:i] + [ + for i in range(len(msgs_clean) - 1, -1, -1): + if msgs_clean[i].get("role") == "user": + payload["messages"] = msgs_clean[:i] + [ {"role": "system", "content": tool_reminder} - ] + messages[i:] + ] + msgs_clean[i:] break # Hanya kirim parameter reasoning jika diminta eksplisit @@ -268,6 +283,7 @@ class LLMClient: reasoning_content = "" full_multimodal_content = [] stream_started = False + usage = None # prompt/completion/total tokens dari chunk terakhir try: self.cancel_requested = False @@ -292,9 +308,15 @@ class LLMClient: except json.JSONDecodeError: continue + # Pemakaian token datang di chunk penutup (choices kosong + # pada banyak provider) — ambil selama masih None / terbaru. + if chunk.get('usage'): + usage = chunk['usage'] + # Parse delta dari chunk - delta = chunk.get('choices', [{}])[0].get('delta', {}) - finish_reason = chunk.get('choices', [{}])[0].get('finish_reason', None) + choices = chunk.get('choices') or [] + delta = choices[0].get('delta', {}) if choices else {} + finish_reason = choices[0].get('finish_reason', None) if choices else None # Stream reasoning content jika ada if 'reasoning_content' in delta: @@ -399,6 +421,16 @@ class LLMClient: return result except Exception: pass + if include_usage and e.code in (400, 422) and "stream_options" in (body_text or "").lower(): + # Provider menolak parameter stream_options — ulangi permintaan + # tanpanya; token usage tidak tersedia tapi respons tetap jalan. + return self._chat_once( + base_url, model, api_key, messages, tools=tools, + on_stream_chunk=on_stream_chunk, + disable_reasoning=disable_reasoning, + tool_reminder=tool_reminder, + include_usage=False, + ) detail = f" - {body_text[:500]}" if body_text else "" if e.code in (401, 403): raise _LLMFail("auth", f"HTTP {e.code} {e.reason}{detail}") @@ -448,4 +480,6 @@ class LLMClient: message.pop('reasoning_content', None) message.pop('reasoning', None) - return self.Message(message) + result = self.Message(message) + result.usage = usage + return result diff --git a/services/telegram_client.py b/services/telegram_client.py index a2b4f01..f325a89 100644 --- a/services/telegram_client.py +++ b/services/telegram_client.py @@ -7,7 +7,7 @@ from datetime import datetime import config from services.session_manager import SessionManager -from lib.agent_loop import run_agent_loop +from lib.agent_loop import run_agent_loop, SessionStore, ConsoleObserver from lib import personality from lib import ragroleplay from scripts import loggorilla @@ -259,10 +259,13 @@ class TelegramClient: tool_reminder = None - final_content, should_close = run_agent_loop( - session, self._llm, self._TOOLS, self._TOOL_HANDLERS, - self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder + outcome = run_agent_loop( + SessionStore(session.messages), + self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations, + observer=ConsoleObserver(on_tool_calls=on_tool_calls), + tool_reminder=tool_reminder, ) + final_content, should_close = outcome.content, outcome.session_ended def _pop_ghost(sentinel: str): # Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang diff --git a/services/xmpp_client.py b/services/xmpp_client.py index 93923c1..f5b4b15 100644 --- a/services/xmpp_client.py +++ b/services/xmpp_client.py @@ -8,7 +8,7 @@ from datetime import datetime from slixmpp import ClientXMPP from services.session_manager import SessionManager -from lib.agent_loop import run_agent_loop +from lib.agent_loop import run_agent_loop, SessionStore, ConsoleObserver import config from tools.roleplayer import should_respond @@ -624,10 +624,13 @@ class XMPPClient(ClientXMPP): tool_reminder = None - final_content, should_close = run_agent_loop( - session, self._llm, self._TOOLS, self._TOOL_HANDLERS, - self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder + outcome = run_agent_loop( + SessionStore(session.messages), + self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations, + observer=ConsoleObserver(on_tool_calls=on_tool_calls), + tool_reminder=tool_reminder, ) + final_content, should_close = outcome.content, outcome.session_ended if final_content is not None: if is_roleplay: @@ -709,10 +712,13 @@ class XMPPClient(ClientXMPP): tool_reminder = None - final_content, should_close = run_agent_loop( - session, self._llm, self._TOOLS, self._TOOL_HANDLERS, - self._max_iterations, on_tool_calls=on_tool_calls, tool_reminder=tool_reminder + outcome = run_agent_loop( + SessionStore(session.messages), + self._llm, self._TOOLS, self._TOOL_HANDLERS, self._max_iterations, + observer=ConsoleObserver(on_tool_calls=on_tool_calls), + tool_reminder=tool_reminder, ) + final_content, should_close = outcome.content, outcome.session_ended def _pop_ghost(sentinel: str): # Hapus pesan assistant sentinel (NO-REPLY / END-SESSION) yang diff --git a/tools/instrbase.py b/tools/instrbase.py new file mode 100644 index 0000000..6a8f62a --- /dev/null +++ b/tools/instrbase.py @@ -0,0 +1,128 @@ +""" +Shared helper untuk varian instruction tool "private per character". + +Base directory yang dipakai: + agent/characters//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 .") + _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 diff --git a/tools/smart.py b/tools/smart.py index 7f13e5b..1446db4 100644 --- a/tools/smart.py +++ b/tools/smart.py @@ -1,7 +1,9 @@ import os + # NOTE: loggorilla dimatikan di tool ini. print()-nya bisa bikin error saat # proses keluar (SCI), jadi semua pemanggilan di bawah hanya di-comment, jangan dihapus. # from scripts import loggorilla +from tools import instrbase schema_instruction_use = { "type": "function", @@ -48,3 +50,43 @@ def instruction_use(directory, filename): res = f"Error reading file: {str(e)}" # loggorilla.fyinf("tools.smart", f"Exception occurred: {res}") return res + + +# ─── instruction_char_use (private / per-character variant) ──────────────────── + +schema_instruction_char_use = { + "type": "function", + "function": { + "name": "instruction_char_use", + "description": ( + "Reads a specific instruction file from the PRIVATE 'instruction' directory of the " + "active character (agent/characters//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) + diff --git a/tools/smart_rp.py b/tools/smart_rp.py index 7b48ef8..4601ed9 100644 --- a/tools/smart_rp.py +++ b/tools/smart_rp.py @@ -1,5 +1,7 @@ import os + from scripts import loggorilla +from tools import instrbase schema_rp_instruction_use = { "type": "function", @@ -46,3 +48,46 @@ def rp_instruction_use(directory, filename): res = f"Error reading RP file: {str(e)}" loggorilla.fyinf("tools.smart_rp", f"Exception occurred: {res}") return res + + +# ─── rp_instruction_char_use (private / per-character variant) ───────────────── + +schema_rp_instruction_char_use = { + "type": "function", + "function": { + "name": "rp_instruction_char_use", + "description": ( + "CRITICAL ROLEPLAY VALIDATION TOOL (character-scoped): Use this tool ONLY when the " + "character is interacting with a specific object, outfit, or location defined in this " + "character's PRIVATE roleplay instruction files, located at " + "agent/characters//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" + )