diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b36b2dc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dita Aji Pratama + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/agent/characters/musashi/.gitignore b/agent/characters/musashi/.gitignore new file mode 100644 index 0000000..6b15748 --- /dev/null +++ b/agent/characters/musashi/.gitignore @@ -0,0 +1 @@ +service.yaml \ No newline at end of file diff --git a/agent/characters/musashi/service.default.yaml b/agent/characters/musashi/service.default.yaml new file mode 100644 index 0000000..d6d7c2e --- /dev/null +++ b/agent/characters/musashi/service.default.yaml @@ -0,0 +1,23 @@ +# This file serves as a template for character-specific service overrides. +# Copy this to agent/characters//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) diff --git a/hendrik.py b/hendrik.py index 83503bd..545b0ef 100644 --- a/hendrik.py +++ b/hendrik.py @@ -2,9 +2,9 @@ import os, sys, threading, time, signal import config -from lib import gadget, personality, moop, ragroleplay as ragroleplay_lib +from lib import gadget, personality, moop, workspace, ragroleplay as ragroleplay_lib from modules import argsparser -from tools import coder, carrack, ragroleplay as ragroleplay_tools, vision, imagegen, smart, smart_rp +from tools import coder, carrack, ragroleplay as ragroleplay_tools, vision, imagegen, smart, smart_rp, syshendrik from services.llm_client import LLMClient from services.xmpp_client import XMPPClient @@ -20,7 +20,6 @@ tools_definition = [ 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_workspace_change, handler = coder.workspace_change ), 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 @@ -47,6 +46,8 @@ tools_definition = [ gadget.tools_mapping( schema = ragroleplay_tools.schema_memories_delete, handler = ragroleplay_tools.memories_delete ), gadget.tools_mapping( schema = ragroleplay_tools.schema_end_session, handler = ragroleplay_tools.end_session ), + # SysHendrik Tools + gadget.tools_mapping( schema = syshendrik.schema_workspace_change, handler = syshendrik.workspace_change ), ] @@ -67,7 +68,7 @@ def main(): if not os.path.isdir(resolved): print(f"Error: '{resolved}' is not a valid directory", flush=True) sys.exit(1) - coder.set_current_workspace(resolved) + workspace.set_current_workspace(resolved) # Inference llm_client = LLMClient("", "", "", config.llm_timeout) diff --git a/interfaces/tui/input.py b/interfaces/tui/input.py index 8d3ed94..d1f97f9 100644 --- a/interfaces/tui/input.py +++ b/interfaces/tui/input.py @@ -9,7 +9,7 @@ import curses import os import config from .agent import log -from tools.coder import set_current_workspace +from lib import workspace from . import keycodes from .moopui import ( model_selector_popup, @@ -157,7 +157,7 @@ def workspace_popup(app, stdscr): if ws: resolved = os.path.abspath(ws) if os.path.isdir(resolved): - set_current_workspace(resolved) + workspace.set_current_workspace(resolved) log(app, "system", f"Workspace \u2192 {resolved}") else: log(app, "error", f"Invalid directory: {resolved}") diff --git a/interfaces/tui/render.py b/interfaces/tui/render.py index 2b82940..64083a5 100644 --- a/interfaces/tui/render.py +++ b/interfaces/tui/render.py @@ -6,7 +6,7 @@ import curses import json -from tools.coder import get_current_workspace +from lib import workspace from . import theme from .actions import ACTIONS @@ -361,7 +361,7 @@ def draw_status(app, stdscr): mode = " PROCESSING " if app.processing else " READY " set_name = (getattr(app, "model_set_name", "") or "-").strip() or "-" model = (app.llm.model or "-").strip() or "-" - ws = get_current_workspace() or "-" + ws = workspace.get_current_workspace() or "-" send_key = ACTIONS["send_prompt"].get("shortcut") or "Ctrl+Return" right = f" {app.character_name} {send_key}:Send " diff --git a/lib/workspace.py b/lib/workspace.py new file mode 100644 index 0000000..3822491 --- /dev/null +++ b/lib/workspace.py @@ -0,0 +1,18 @@ +import os + +# Pemilik tunggal state workspace sesi agent. +# Modul lain (tools.coder, tools.syshendrik, tools.vision, interfaces.tui) +# membaca/mengganti workspace lewat getter/setter di sini, bukan menyimpan +# salinan state sendiri, agar tidak ada dua sumber kebenaran. + +CURRENT_WORKSPACE = os.getcwd() + +def get_current_workspace(): + global CURRENT_WORKSPACE + return CURRENT_WORKSPACE + +def set_current_workspace(path): + global CURRENT_WORKSPACE + # chdir dulu agar state hanya ter-update ketika perpindahan benar-benar sukses + os.chdir(path) + CURRENT_WORKSPACE = path diff --git a/plan/done/mute-session-end.md b/plan/done/mute-session-end.md new file mode 100644 index 0000000..64a928d --- /dev/null +++ b/plan/done/mute-session-end.md @@ -0,0 +1,78 @@ +# Feature: Mute Session End Notification + +## Goal +Tambah field konfigurasi baru per-service (xmpp & telegram) untuk menonaktifkan +pesan pemberitahuan saat sesi berakhir. Jika `true`, agent tidak mengirim pesan +"pemberitahuan sesi berakhir" (`*session end*` / `Sesi ditutup. Sampai jumpa`) +ketika sesi ditutup karena timeout/kegiatan berhenti. + +Field mengikuti pola yang sudah ada di `service.yaml` (sudah mendukung override +per karakter untuk xmpp & telegram). + +## Field +- Nama: `mute_session_end` +- Tipe: bool +- Default: `false` (perilaku lama: tetap kirim pesan penutup) +- Letak: di bawah section `xmpp:` dan `telegram:` (service.yaml & config.yaml) +- Konstanta global baru: `XMPP_MUTE_SESSION_END`, `TELEGRAM_MUTE_SESSION_END` + +## Scope pesan yang di-mute (jalur target) +Hanya pesan penutup yang dihasilkan oleh sistem saat sesi berakhir otomatis: +- `xmpp_client._timeout_session` +- `telegram_client._timeout_session` + +Sesi tetap di-reset seperti semula; hanya pengiriman teks penutupnya yang dilewati. + +**Dikonfirmasi:** natural-close via tool `end_session` TIDAK ikut di-mute (farewell +adalah bagian percakapan model, bukan pesan sistem). + +## Perubahan tambahan: timeout group/MUC +Diperpanjang dari 5 menit (300s) menjadi 30 menit (1800s) untuk group/MUC. +Titik yang diubah: +- `telegram_client.py` (`_process_message`): `... else 300` → `1800` (chat_type group). +- `xmpp_client.py`: dua `session.start_timer(300, ..., 'groupchat')` → `1800`. +Private/DM tetap 86400 (di luar scope). + +## Files to change + +### 1. config.py +- Tambah pembacaan default dari `config.yaml` pada blok modul-level: + - `XMPP_MUTE_SESSION_END` (xmpp section, default "false") + - `TELEGRAM_MUTE_SESSION_END` (telegram section, default "false") +- Tambah keduanya ke `global` + reset di `_reset_service_from_config()`. +- Tambah keduanya ke `global` + aplikasi override di `load_service_config()` + memakai pola yang sama seperti field lain: + - `if "mute_session_end" in xmpp_data: XMPP_MUTE_SESSION_END = _to_bool(...)` + - `if "mute_session_end" in telegram_data: TELEGRAM_MUTE_SESSION_END = _to_bool(...)` +- Parsing bool pakai pola string existing: `str(v).strip().lower() in ("true","1","yes")`. + +### 2. services/xmpp_client.py (`_timeout_session`) +- Bungkus `self._schedule_send(session_id, body, mtype)` dengan guard: + `if not config.XMPP_MUTE_SESSION_END:` — tetap panggil `self._session_mgr.reset(session_id)`. +- Opsional: log bahwa notifikasi di-mute. + +### 3. services/telegram_client.py (`_timeout_session`) +- Bungkus `self._schedule_send(chat_id, body)` dengan guard: + `if not config.TELEGRAM_MUTE_SESSION_END:` — tetap `self._session_mgr.reset(...)`. + +### 4. Templates (dokumentasi, agar field mudah ditemukan) +- `config.default.yaml`: tambah `mute_session_end: false` di section xmpp & telegram. +- `agent/characters/*/service.default.yaml` (semua karakter): tambah field yang sama. +- Catatan: `service.yaml` runtime bersifat gitignored; karena loader cek keberadaan + key, karakter tanpa key baru akan fallback ke default `false` (backward compatible). + +## Backward compatibility +- Tidak ada service.yaml existing yang wajib diubah. +- Key hilang => default `false` => behavior identik dengan sekarang. + +## Validation plan +- `python -c "import config; print(config.XMPP_MUTE_SESSION_END, config.TELEGRAM_MUTE_SESSION_END)"` + untuk default. +- Load karakter dengan `mute_session_end: true` (via service.yaml test / char override) + dan verifikasi konstanta menjadi True. +- Kode review manual kedua `_timeout_session`: saat True, tidak ada `send`/`schedule_send`, + sesi tetap reset. + +## Resolved +Scope final: hanya `_timeout_session`. Natural-close `end_session` tidak disentuh +(sudah dikonfirmasi user). Timeout group/MUC dinaikkan ke 30 menit. diff --git a/plan/milestone/10-settings.png b/plan/milestone/10-settings.png new file mode 100644 index 0000000..f29f7b4 Binary files /dev/null and b/plan/milestone/10-settings.png differ diff --git a/plan/milestone/10-settings.puml b/plan/milestone/10-settings.puml new file mode 100644 index 0000000..f3ce72e --- /dev/null +++ b/plan/milestone/10-settings.puml @@ -0,0 +1,38 @@ +@startmindmap + + + +;top to bottom direction + +* TUI/GUI/API Settings +** TUI +*** Parameters for tokenizer +*** Service list +****_ Kontak dengan daemon +*** Model set & Provider list <> +*** Roleplayer user ID + +@endmindmap \ No newline at end of file diff --git a/plan/milestone/11-configuration-per-character.png b/plan/milestone/11-configuration-per-character.png new file mode 100644 index 0000000..d9c5f8f Binary files /dev/null and b/plan/milestone/11-configuration-per-character.png differ diff --git a/plan/milestone/11-configuration-per-character.puml b/plan/milestone/11-configuration-per-character.puml new file mode 100644 index 0000000..e88c7ab --- /dev/null +++ b/plan/milestone/11-configuration-per-character.puml @@ -0,0 +1,36 @@ +@startmindmap + + + +;top to bottom direction + +* Configuration per character <> +** service.yaml <> +***_ Untuk kredensial service yang di gunakan +** model.yaml <> +***_ Untuk menetapkan model set default yang digunakan + +@endmindmap \ No newline at end of file diff --git a/plan/milestone/9-tools-improvement.png b/plan/milestone/9-tools-improvement.png new file mode 100644 index 0000000..316d787 Binary files /dev/null and b/plan/milestone/9-tools-improvement.png differ diff --git a/plan/milestone/9-tools-improvement.puml b/plan/milestone/9-tools-improvement.puml new file mode 100644 index 0000000..d4617bc --- /dev/null +++ b/plan/milestone/9-tools-improvement.puml @@ -0,0 +1,42 @@ +@startmindmap + + + +;top to bottom direction + +* Tools for configuration +** Workspace Switch <> +** Service toggler +** Listing model set and provider +** Model set switch <> +***_ Sepertinya mustahil dan potensi ngerusak sistem + +* Tools for generation +** plantuml generator +** Slide deck +** Motion graphic + +@endmindmap \ No newline at end of file diff --git a/plan/milestone/catatan-kertas.png b/plan/milestone/catatan-kertas.png deleted file mode 100644 index 6e79782..0000000 Binary files a/plan/milestone/catatan-kertas.png and /dev/null differ diff --git a/plan/milestone/catatan-kertas.puml b/plan/milestone/catatan-kertas.puml deleted file mode 100644 index 936ccd7..0000000 --- a/plan/milestone/catatan-kertas.puml +++ /dev/null @@ -1,76 +0,0 @@ -@startmindmap - - - -;top to bottom direction - -* Tools for configuration -** Workspace Switch <> -** Service toggler -** Listing model set and provider -** Model set switch <> -***_ Sepertinya mustahil dan potensi ngerusak sistem - -* Tools for generation -** plantuml generator -** Slide deck -** Motion graphic - -* TUI/GUI/API Settings -** TUI -*** Parameters for tokenizer -*** Service list -****_ Kontak dengan daemon -*** Model set & Provider list <> -*** Roleplayer user ID - -* Configuration per character <> -** service.yaml <> -***_ Untuk kredensial service yang di gunakan -** model.yaml <> -***_ Untuk menetapkan model set default yang digunakan - -* opencode main skill -** Explore (Passive) -** Plan -** Build - -* Verbosity -** Concise -***_ Keep your answers short and to the point. -** Balanced -***_ Provide balanced answers. Not too brief, not too long. -** Detailed -***_ Give thorough, detailed answers with explanations. - -* Full-Duplex Model -**_ Voice Activity Detection -**_ Noise interruption detector included -** Mic & Voice Frontend -** Models -*** GPT-Live-1 - -@endmindmap \ No newline at end of file diff --git a/plan/milestone/gado-gado.png b/plan/milestone/gado-gado.png index 889470c..9bc8fa7 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 d80f8ed..9b08ce5 100644 --- a/plan/milestone/gado-gado.puml +++ b/plan/milestone/gado-gado.puml @@ -27,6 +27,43 @@ mindmapDiagram { ;top to bottom direction +* Decision/Classification Features +** API Inference +*** Library +****_ Dependency +*****_ .venv/bin/pip install huggingface_hub transformers +****_ Download +*****_ .venv/bin/hf download convaiinnovations/laya --local-dir models/convaiinnovations/laya +*****_ .venv/bin/hf download convaiinnovations/laya-multilingual --local-dir models/convaiinnovations/laya-multilingual +*** Model +**** Classification +***** convaiinnovations/laya +***** convaiinnovations/laya-multilingual +*** Inference +**** llama.cpp +**** laya.cpp (lkarlslund) +*****_ HTTP Server bawaan + +* opencode main skill +** Explore (Passive) +** Plan +** Build + +* Verbosity +** Concise +***_ Keep your answers short and to the point. +** Balanced +***_ Provide balanced answers. Not too brief, not too long. +** 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) diff --git a/tools/coder.py b/tools/coder.py index e29c35e..c508d97 100644 --- a/tools/coder.py +++ b/tools/coder.py @@ -4,18 +4,7 @@ import re import fnmatch import glob as glob_module -# Global state to track the agent's current working directory -# This ensures that subsequent bash commands run in the correct directory -CURRENT_WORKSPACE = os.getcwd() - -def get_current_workspace(): - global CURRENT_WORKSPACE - return CURRENT_WORKSPACE - -def set_current_workspace(path): - global CURRENT_WORKSPACE - CURRENT_WORKSPACE = path - os.chdir(path) +from lib import workspace schema_read_file = { "type": "function", @@ -34,8 +23,8 @@ schema_read_file = { def read_file(path): try: - # Use absolute path if it's relative to CURRENT_WORKSPACE - full_path = os.path.join(get_current_workspace(), path) if not os.path.isabs(path) else path + # Use absolute path if it's relative to current workspace + full_path = os.path.join(workspace.get_current_workspace(), path) if not os.path.isabs(path) else path with open(full_path, 'r', encoding='utf-8', errors='replace') as f: lines = f.readlines() return ''.join(f"{i+1}: {line}" for i, line in enumerate(lines)) @@ -60,7 +49,7 @@ schema_write_file = { def write_file(path, content): try: - full_path = os.path.join(get_current_workspace(), path) if not os.path.isabs(path) else path + full_path = os.path.join(workspace.get_current_workspace(), path) if not os.path.isabs(path) else path with open(full_path, 'w', encoding='utf-8') as f: f.write(content) return f"Successfully wrote to {full_path}" @@ -86,7 +75,7 @@ schema_edit_file = { def edit_file(path, old_string, new_string): try: - full_path = os.path.join(get_current_workspace(), path) if not os.path.isabs(path) else path + full_path = os.path.join(workspace.get_current_workspace(), path) if not os.path.isabs(path) else path with open(full_path, 'r', encoding='utf-8', errors='replace') as f: content = f.read() if old_string not in content: @@ -116,7 +105,7 @@ schema_run_bash = { def run_bash(command): try: # Prepend 'cd' to the command to ensure it runs in the correct workspace - full_command = f"cd '{get_current_workspace()}' && {command}" + full_command = f"cd '{workspace.get_current_workspace()}' && {command}" result = subprocess.run(full_command, shell=True, capture_output=True, text=True, timeout=30) return f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}\nreturn code: {result.returncode}" except Exception as e: @@ -142,7 +131,7 @@ schema_search_code = { def search_code(pattern, search_type, path="."): try: # Resolve path relative to workspace - search_path = os.path.join(get_current_workspace(), path) if not os.path.isabs(path) else path + search_path = os.path.join(workspace.get_current_workspace(), path) if not os.path.isabs(path) else path if search_type == "glob": files = glob_module.glob(f"{search_path}/**/{pattern}", recursive=True) @@ -187,41 +176,11 @@ def git_operation(args): try: # Execute git command from the current workspace full_command = ["git", *args] - result = subprocess.run(full_command, cwd=get_current_workspace(), capture_output=True, text=True, timeout=10) + result = subprocess.run(full_command, cwd=workspace.get_current_workspace(), capture_output=True, text=True, timeout=10) return f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}\nreturn code: {result.returncode}" except Exception as e: return f"Error running git command: {str(e)}" -schema_workspace_change = { - "type": "function", - "function": { - "name": "workspace_change", - "description": "Change the current working directory of the agent session. Supports tilde (~) expansion for the user home directory (/home/aji/).", - "parameters": { - "type": "object", - "properties": { - "path": {"type": "string", "description": "The target directory path to change to."} - }, - "required": ["path"] - } - } -} - -def workspace_change(path): - try: - if path.startswith("~"): - target_path = os.path.expanduser(path) - else: - target_path = os.path.abspath(path) - - if os.path.exists(target_path) and os.path.isdir(target_path): - set_current_workspace(target_path) - return f"Successfully changed workspace to: {target_path}" - else: - return f"Error: The path {target_path} does not exist or is not a directory." - except Exception as e: - return f"Error changing workspace: {str(e)}" - schema_search_grep = { "type": "function", "function": { @@ -241,7 +200,7 @@ schema_search_grep = { def search_grep(pattern, path=".", include=None): try: - search_path = os.path.join(get_current_workspace(), path) if not os.path.isabs(path) else path + search_path = os.path.join(workspace.get_current_workspace(), path) if not os.path.isabs(path) else path results = [] for root, dirs, files in os.walk(search_path): for file in files: @@ -277,7 +236,7 @@ schema_search_glob = { def search_glob(pattern, path="."): try: - search_path = os.path.join(get_current_workspace(), path) if not os.path.isabs(path) else path + search_path = os.path.join(workspace.get_current_workspace(), path) if not os.path.isabs(path) else path files = glob_module.glob(f"{search_path}/{pattern}", recursive=True) def _isfile(f): try: diff --git a/tools/syshendrik.py b/tools/syshendrik.py new file mode 100644 index 0000000..054c9ba --- /dev/null +++ b/tools/syshendrik.py @@ -0,0 +1,49 @@ +import os +from lib import workspace + +# Tools untuk membantu sistem Hendrik. +# State workspace sesi dimiliki lib.workspace dan dipakai bersama oleh +# tool coding, vision, dan TUI -- di sini hanya dibungkus sebagai tool aksi. + +schema_workspace_change = { + "type": "function", + "function": { + "name": "workspace_change", + "description": "Change the current working directory of the agent session. Supports tilde (~) expansion for the user home directory (/home/aji/).", + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "The target directory path to change to."} + }, + "required": ["path"] + } + } +} + +def workspace_change(path): + previous_path = workspace.get_current_workspace() + try: + if path.startswith("~"): + target_path = os.path.expanduser(path) + else: + target_path = os.path.abspath(path) + + if os.path.exists(target_path) and os.path.isdir(target_path): + workspace.set_current_workspace(target_path) + return ( + f"Successfully changed workspace.\n" + f"previous workspace: {previous_path}\n" + f"current workspace : {target_path}" + ) + else: + return ( + f"Error: The path {target_path} does not exist or is not a directory.\n" + f"previous workspace: {previous_path}\n" + f"current workspace : {previous_path} (unchanged)" + ) + except Exception as e: + return ( + f"Error changing workspace: {str(e)}\n" + f"previous workspace: {previous_path}\n" + f"current workspace : {workspace.get_current_workspace()}" + ) diff --git a/tools/vision.py b/tools/vision.py index fee9526..b802858 100644 --- a/tools/vision.py +++ b/tools/vision.py @@ -6,7 +6,7 @@ import tempfile import urllib.request import ftplib from urllib.parse import urlparse -from tools.coder import get_current_workspace +from lib import workspace MIME_MAP = { ".jpg": "image/jpeg", @@ -152,7 +152,7 @@ def describe_image(path): return f"Error fetching SFTP: {e}" # ── Local file ───────────────────────────────────────────────────── - full_path = os.path.join(get_current_workspace(), path) if not os.path.isabs(path) else path + full_path = os.path.join(workspace.get_current_workspace(), path) if not os.path.isabs(path) else path if not os.path.isfile(full_path): return f"Error: file not found: {full_path}"