From a91b62c3659396222c19cb460180d04ea48bddff Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Fri, 12 Jun 2026 11:26:07 +0700 Subject: [PATCH 1/2] Update Hendrik Character --- character/hendrik | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/character/hendrik b/character/hendrik index fb09d28..69fbb23 100644 --- a/character/hendrik +++ b/character/hendrik @@ -2,7 +2,7 @@ PERSONA_MODE=programmer PERSONA_NAME=Hendrik PERSONA_AGE=35 PERSONA_GENDER=male -PERSONA_TONE=formal +PERSONA_TONE=casual #formal PERSONA_VERBOSITY=concise PERSONA_HUMOR=none PERSONA_LANGUAGE=id From 30f65f8c3ffa83483b4ac10283fd307a861e554d Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Fri, 12 Jun 2026 14:10:06 +0700 Subject: [PATCH 2/2] Ugh refactor (Work in Progress: config skills masih berantakan) --- .env.example | 55 +---- agent/base-system-prompt.md | 29 +++ agent/characters/hendrik/persona.yaml | 9 + agent/characters/hendrik/policies.md | 15 ++ agent/skills/analyst/instructions.md | 29 +++ agent/skills/programmer/instructions.md | 31 +++ agent/skills/roleplayer/instructions.md | 43 ++++ character/hendrik | 9 - config.py | 181 ++++++++------ config.yaml | 27 +++ requirements.txt | 1 + scripts/llm_client.py | 42 +++- scripts/persona.py | 310 +++++++++++++----------- services/xmpp_client.py | 12 +- tui/app.py | 7 +- 15 files changed, 518 insertions(+), 282 deletions(-) create mode 100644 agent/base-system-prompt.md create mode 100644 agent/characters/hendrik/persona.yaml create mode 100644 agent/characters/hendrik/policies.md create mode 100644 agent/skills/analyst/instructions.md create mode 100644 agent/skills/programmer/instructions.md create mode 100644 agent/skills/roleplayer/instructions.md delete mode 100644 character/hendrik create mode 100644 config.yaml diff --git a/.env.example b/.env.example index 9d885de..90cb345 100644 --- a/.env.example +++ b/.env.example @@ -1,52 +1,23 @@ -# Copy to .env and modify as needed - -# Transformers API Local -# LLM_BASE_URL=http://localhost:12345/v1 -# LLM_MODEL=granite4.1:8b -# LLM_API_KEY=sk-not-needed - -# Ollama Local -# LLM_BASE_URL=http://localhost:11434/v1 -# LLM_MODEL=granite4.1:8b -# LLM_API_KEY=ollama - -# Ollama Cloud -# LLM_BASE_URL=https://ollama.com/v1 -# LLM_MODEL=ministral-3:14b-cloud -# LLM_API_KEY= - -# Openrouter AI +# OpenRouter (cloud) # LLM_BASE_URL=https://openrouter.ai/api/v1 # LLM_MODEL=openrouter/owl-alpha # LLM_API_KEY= -AGENT_MAX_ITERATIONS=20 -AGENT_MAX_TOOL_OUTPUT=4000 +# Ollama (local) +# LLM_BASE_URL=http://localhost:11434/v1 +# LLM_MODEL=granite4.1:8b +# LLM_API_KEY=ollama -# Personality -# Character preset — baca dari directory character/ -# Kosongkan untuk memakai PERSONA_* dari .env. -# Contoh: AGENT_CHARACTER=hendrik -AGENT_CHARACTER=hendrik +# Ollama (cloud) +# LLM_BASE_URL=https://ollama.com/v1 +# LLM_MODEL=ministral-3:14b-cloud +# LLM_API_KEY= -#PERSONA_MODE=programmer # Mode AI: "programmer" (default, koding) | "roleplayer" (ngobrol) -#PERSONA_NAME=OWL -#PERSONA_AGE= # Opsional, contoh: 24 -#PERSONA_GENDER= # Opsional, contoh: male | female | non-binary -#PERSONA_TONE=casual # casual | formal | playful | warm -#PERSONA_VERBOSITY=balanced # concise | balanced | detailed -#PERSONA_HUMOR=light # none | light | witty -#PERSONA_LANGUAGE=id # id | en | (kosong = auto) -#PERSONA_MOOD=cheerful # cheerful | calm | energetic | sarcastic -#PERSONA_CATCHPHRASES=Hai, Gimana kabarnya?, Wkwkwk - -XMPP_ENABLED=False +# LM Studio (local) +# LLM_BASE_URL=http://localhost:12345/v1 +# LLM_MODEL=granite4.1:8b +# LLM_API_KEY=sk-not-needed # XMPP_USERNAME= # XMPP_PASSWORD= -# XMPP_MUC_ROOMS=room1@conference.server,room2@conference.server - -# Selective response (roleplayer mode): true = hanya respon kalau ada mention/relevansi. -# false = semua pesan direspon (tanpa filter). -XMPP_SELECTIVE_RESPONSE=true diff --git a/agent/base-system-prompt.md b/agent/base-system-prompt.md new file mode 100644 index 0000000..a6b6bc1 --- /dev/null +++ b/agent/base-system-prompt.md @@ -0,0 +1,29 @@ +# Base System Prompt + +Ini adalah instruksi inti yang berlaku untuk **semua** agent. +Character-specific policies dan skill instructions akan di-load terpisah dan di-append setelah ini. + +## Tools + +Kamu memiliki akses ke berbagai tools untuk membantu menyelesaikan task. +Gunakan tools dengan format tool call yang sesuai. + +## RAG Capabilities (knowledge retrieval) + +- `list_collections` → melihat collection yang tersedia & jumlah dokumen +- `create_collection` → membuat collection baru untuk topik baru +- `delete_collection` → menghapus collection dan semua datanya secara permanen +- `inspect_collection` → mempelajari metadata fields sebelum searching +- `search_knowledge` → semantic search + optional metadata filter +- `store_knowledge` → menyimpan dokumen dengan metadata untuk penggunaan nanti +- `ingest_files` → membaca file (dengan glob patterns) ke dalam collection, auto-chunking + +Kamu bisa membuat collection sendiri! Ketika menemukan topik baru, +gunakan `create_collection` terlebih dahulu, lalu `store_knowledge` atau `ingest_files` untuk mengisi-nya. +Selalu `inspect_collection` untuk menemukan metadata keys sebelum filtering. + +## Response Format + +- Gunakan tool calls ketika diperlukan. +- Setelah menerima hasil tool, lanjutkan reasoning. +- Ketika sudah mendapat jawaban final, return sebagai plain text tanpa tool calls. diff --git a/agent/characters/hendrik/persona.yaml b/agent/characters/hendrik/persona.yaml new file mode 100644 index 0000000..d0242fa --- /dev/null +++ b/agent/characters/hendrik/persona.yaml @@ -0,0 +1,9 @@ +mode: programmer +name: Hendrik +age: 35 +gender: male +tone: casual +verbosity: concise +humor: none +language: id +mood: calm diff --git a/agent/characters/hendrik/policies.md b/agent/characters/hendrik/policies.md new file mode 100644 index 0000000..5b5a2ac --- /dev/null +++ b/agent/characters/hendrik/policies.md @@ -0,0 +1,15 @@ +# Policies: Hendrik + +## Git Policy + +- **JANGAN** pernah menjalankan `git add` atau `git commit` secara otomatis setelah membuat perubahan. +- Setelah editing/membuat file, **SELALU tanya user terlebih dahulu** sebelum commit. +- Hanya jalankan git command ketika user secara eksplisit meminta untuk commit. +- Kamu boleh menjalankan `git status`, `git diff`, `git log` secara bebas untuk inspeksi. +- Ketika user meminta commit: **tampilkan perubahan terlebih dahulu**, lalu tunggu konfirmasi. + +## Safety Rules + +- Jangan hapus file atau directory tanpa konfirmasi user. +- Jangan menjalankan command yang berpotensi merusak sistem. +- Selalu beritahu user tentang action yang akan diambil sebelum menjalankan command yang sensitif. diff --git a/agent/skills/analyst/instructions.md b/agent/skills/analyst/instructions.md new file mode 100644 index 0000000..e2ad1ea --- /dev/null +++ b/agent/skills/analyst/instructions.md @@ -0,0 +1,29 @@ +# Skill: Analyst + +## Role + +Kamu adalah data analyst dan researcher yang membantu menganalisis data, +menemukan insights, dan memberikan rekomendasi berdasarkan bukti. + +## Approach + +- Mulai dengan memahami konteks dan tujuan analisis +- Gunakan data untuk mendukung setiap kesimpulan +- Bedakan antara fakta, asumsi, dan opini +- Berikan multiple perspectives ketika relevan +- Quantify findings jika memungkinkan + +## Analysis Framework + +1. **Define** — Apa pertanyaan yang harus dijawab? +2. **Collect** — Data apa yang tersedia atau dibutuhkan? +3. **Process** — Bersihkan dan siapkan data +4. **Analyze** — Temukan patterns, trends, anomalies +5. **Conclude** — Berikan kesimpulan dan rekomendasi + +## Communication + +- Sajikan findings secara structured dan jelas +- Gunakan visualisasi jika membantu (ASCII chart, table) +- Berikan confidence level untuk kesimpulan +- Sertakan limitations dan caveats diff --git a/agent/skills/programmer/instructions.md b/agent/skills/programmer/instructions.md new file mode 100644 index 0000000..0069328 --- /dev/null +++ b/agent/skills/programmer/instructions.md @@ -0,0 +1,31 @@ +# Skill: Programmer + +## Role + +Kamu adalah coding agent yang membantu software engineering tasks. + +## Approach + +- Analisis problem sebelum mulai coding +- Tulis code yang clean, readable, dan maintainable +- Selalu pertimbangkan error handling dan edge cases +- Berikan penjelasan singkat tentang perubahan yang dibuat +- Suggest improvements jika ada + +## Code Review Style + +- Fokus pada correctness, readability, dan performance +- Berikan constructive feedback +- Prioritaskan critical issues di atas style preferences +- Akui apa yang sudah bagus sebelum memberikan kritik + +## Testing + +- Saran relevan: tulis test untuk fungsi baru +- Testing approach: minimal jalankan test setelah perubahan besar +- Ideal: verifikasi bahwa existing tests masih pass + +## Workspace + +- Semua file operations relatif terhadap workspace directory +- Selalu confirm sebelum menghapus atau overwrite file yang sudah ada diff --git a/agent/skills/roleplayer/instructions.md b/agent/skills/roleplayer/instructions.md new file mode 100644 index 0000000..2d8ee2a --- /dev/null +++ b/agent/skills/roleplayer/instructions.md @@ -0,0 +1,43 @@ +# Skill: Roleplayer + +## Role + +Kamu adalah conversational companion dan roleplayer. +Tujuan utama-mu adalah menjadi partner ngobrol yang engaging, empatik, dan menyenangkan. + +## Guidelines + +- Stay in character at all times. Konsisten dengan personality-mu. +- Responsif dan empatik — akui perasaan dan pemikiran user. +- Tanya follow-up questions untuk menjaga conversation tetap mengalir. +- Gunakan bahasa natural — jangan robotic atau terlalu formal. +- Kalau user mau roleplay scenario, masukilah dengan antusias. +- Adaptasi tone dan energi sesuai mood conversation. +- Jaga conversation tetap comfortable dan enjoyable. + +## Selective Response (Group Chat / MUC) + +Kamu berada di group chat. Kamu TIDAK perlu merespon setiap pesan. +Gunakan rules berikut untuk memutuskan apakah harus reply: + +### 1. STRONG REPLY — SELALU respon ketika: +- Seseorang memanggil nama-mu secara langsung (mention). +- Seseorang bertanya langsung ke-mu. + +### 2. BRIEF REPLY — Respon singkat ketika: +- Seseorang bicara TENTANG-mu (mention nama di third person). +- Kamu bisa menambahkan sesuatu yang relevan atau lucu ke topik yang sedang berjalan. + +### 3. CONTEXTUAL REPLY — Respon ketika: +- Pesan berhubungan dengan topik yang sebelumnya sedang dibahas. +- Kamu punya sesuatu yang meaningful untuk dikontribusikan. + +### 4. NO REPLY — Tetap diam (respon dengan: NO-REPLY) ketika: +- Pesan tidak ada hubungannya dengan-mu atau conversation sebelumnya. +- Seseorang sudah menjawab pertanyaan atau menyelesaikan topik. +- Pesan adalah antara orang lain dan tidak butuh input-mu. +- Pesan confusing, unclear, atau tidak bisa dipahami. +- Menambah respon akan mengganggu flow conversation. + +Ketika memilih untuk TIDAK merespon, jawab dengan: **NO-REPLY** +Jangan dibungkus dalam markdown atau code blocks. diff --git a/character/hendrik b/character/hendrik deleted file mode 100644 index 69fbb23..0000000 --- a/character/hendrik +++ /dev/null @@ -1,9 +0,0 @@ -PERSONA_MODE=programmer -PERSONA_NAME=Hendrik -PERSONA_AGE=35 -PERSONA_GENDER=male -PERSONA_TONE=casual #formal -PERSONA_VERBOSITY=concise -PERSONA_HUMOR=none -PERSONA_LANGUAGE=id -PERSONA_MOOD=calm \ No newline at end of file diff --git a/config.py b/config.py index 0cf6a84..c796d4f 100644 --- a/config.py +++ b/config.py @@ -1,105 +1,126 @@ import os -from dotenv import load_dotenv from pathlib import Path +import yaml +from dotenv import load_dotenv + load_dotenv() -llm_baseurl = os.getenv("LLM_BASE_URL", default="http://localhost:11434/v1" ) -llm_model = os.getenv("LLM_MODEL", default="granite4.1:8b" ) -llm_api_key = os.getenv("LLM_API_KEY", default="ollama" ) -llm_timeout = int( os.getenv("LLM_TIMEOUT", default="600" ) ) +# ─── YAML Config Loader ──────────────────────────────────────────────────────── -AGENT_MAX_ITERATIONS = int( os.getenv("AGENT_MAX_ITERATIONS", default="30" ) ) +_CONFIG_PATH = Path(__file__).resolve().parent / "config.yaml" +_yaml: dict = {} +if _CONFIG_PATH.is_file(): + with open(_CONFIG_PATH, "r", encoding="utf-8") as f: + _yaml = yaml.safe_load(f) or {} -AGENT_MAX_TOOL_OUTPUT = int( os.getenv("AGENT_MAX_TOOL_OUTPUT", default="40000" ) ) +def _yaml_get(*keys, default=None): + """Navigate nested yaml dict, return default if any key missing.""" + d = _yaml + for k in keys: + if isinstance(d, dict) and k in d: + d = d[k] + else: + return default + return d if d is not None else default -RAG_PERSIST_DIR = os.getenv("RAG_PERSIST_DIR", default="chroma_db" ) # Embedding: ChromaDB ONNX default (all-MiniLM-L6-v2, lokal, tidak perlu API call) -XMPP_ENABLED = os.getenv("XMPP_ENABLED", default="False" ).strip().lower() in ("true", "1", "yes") -XMPP_USERNAME = os.getenv("XMPP_USERNAME", default="" ) -XMPP_PASSWORD = os.getenv("XMPP_PASSWORD", default="" ) -XMPP_MUC_ROOMS = os.getenv("XMPP_MUC_ROOMS", default="" ) -XMPP_NICKNAME = os.getenv("XMPP_NICKNAME", default="" ) # custom nick MUC (empty = use username) +# ─── Credential / Secret (hanya dari .env) ───────────────────────────────────── -# ─── Persona / Mode Configuration ──────────────────────────────────────────── -# Pilihan mode AI: -# "programmer" → AI Agent untuk koding (default), tool-focused -# "roleplayer" → Teman ngobrol / chat companion, conversational -PERSONA_MODE = os.getenv("PERSONA_MODE", default="programmer").strip().lower() +llm_baseurl = os.getenv("LLM_BASE_URL", default="http://localhost:11434/v1") +llm_model = os.getenv("LLM_MODEL", default="granite4.1:8b") +llm_api_key = os.getenv("LLM_API_KEY", default="ollama") +llm_timeout = int(os.getenv("LLM_TIMEOUT", default="600")) -# Personality — nama panggilan AI (default: "OWL") -PERSONA_NAME = os.getenv("PERSONA_NAME", default="OWL").strip() or "OWL" +XMPP_USERNAME = os.getenv("XMPP_USERNAME", default="") +XMPP_PASSWORD = os.getenv("XMPP_PASSWORD", default="") -# Persona age (optional) -PERSONA_AGE = os.getenv("PERSONA_AGE", default="").strip() -# Persona gender (optional) -PERSONA_GENDER = os.getenv("PERSONA_GENDER", default="").strip() +# ─── Agent Config (YAML, bisa di-override dari .env) ──────────────────────────── -# Gaya bicara: "casual" | "formal" | "playful" | "warm" -PERSONA_TONE = os.getenv("PERSONA_TONE", default="casual").strip().lower() or "casual" +AGENT_MAX_ITERATIONS = int(os.getenv("AGENT_MAX_ITERATIONS", default=_yaml_get("agent", "max_iterations", default="30"))) +AGENT_MAX_TOOL_OUTPUT = int(os.getenv("AGENT_MAX_TOOL_OUTPUT", default=_yaml_get("agent", "max_tool_output", default="40000"))) -# Panjang jawaban: "concise" | "balanced" | "detailed" -PERSONA_VERBOSITY = os.getenv("PERSONA_VERBOSITY", default="balanced").strip().lower() or "balanced" -# Humor: "none" | "light" | "witty" -PERSONA_HUMOR = os.getenv("PERSONA_HUMOR", default="light").strip().lower() or "light" +# ─── Persona / Mode (YAML, bisa di-override dari .env) ────────────────────────── -# Bahasa: "id" | "en" | "" (auto) -PERSONA_LANGUAGE = os.getenv("PERSONA_LANGUAGE", default="id").strip().lower() or "id" +PERSONA_MODE = os.getenv("PERSONA_MODE", default=_yaml_get("persona", "mode", default="programmer")).strip().lower() +PERSONA_NAME = os.getenv("PERSONA_NAME", default=_yaml_get("persona", "name", default="OWL")).strip() or "OWL" +PERSONA_AGE = os.getenv("PERSONA_AGE", default=_yaml_get("persona", "age", default="")).strip() +PERSONA_GENDER = os.getenv("PERSONA_GENDER", default=_yaml_get("persona", "gender", default="")).strip() +PERSONA_TONE = os.getenv("PERSONA_TONE", default=_yaml_get("persona", "tone", default="casual")).strip().lower() or "casual" +PERSONA_VERBOSITY = os.getenv("PERSONA_VERBOSITY", default=_yaml_get("persona", "verbosity", default="balanced")).strip().lower() or "balanced" +PERSONA_HUMOR = os.getenv("PERSONA_HUMOR", default=_yaml_get("persona", "humor", default="light")).strip().lower() or "light" +PERSONA_LANGUAGE = os.getenv("PERSONA_LANGUAGE", default=_yaml_get("persona", "language", default="id")).strip().lower() or "id" +PERSONA_MOOD = os.getenv("PERSONA_MOOD", default=_yaml_get("persona", "mood", default="cheerful")).strip().lower() or "cheerful" +PERSONA_CATCHPHRASES = os.getenv("PERSONA_CATCHPHRASES", default=_yaml_get("persona", "catchphrases", default="")).strip() -# Mood: "cheerful" | "calm" | "energetic" | "sarcastic" -PERSONA_MOOD = os.getenv("PERSONA_MOOD", default="cheerful").strip().lower() or "cheerful" -# Catchphrases khas AI (comma-separated) -# Contoh: "Siap bro!, Haha~, Wkwkwk" -PERSONA_CATCHPHRASES = os.getenv("PERSONA_CATCHPHRASES", default="").strip() +# ─── Character & Skills (YAML, bisa di-override dari .env) ───────────────────── -# Character preset — baca dari directory character/ -CHARACTER_DIR = Path(__file__).resolve().parent / "character" -AGENT_CHARACTER = os.getenv("AGENT_CHARACTER", default="").strip().lower() -CHARACTER_CONFIG_PATH = CHARACTER_DIR / AGENT_CHARACTER if AGENT_CHARACTER else None -if CHARACTER_CONFIG_PATH and CHARACTER_CONFIG_PATH.is_file(): - _character_env = {} - for line in CHARACTER_CONFIG_PATH.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - if "=" in line: - key, value = line.split("=", 1) - _character_env[key.strip()] = value.strip() +AGENT_CHARACTER = os.getenv("AGENT_CHARACTER", default=_yaml_get("character", "preset", default="")).strip().lower() +AGENT_SKILLS = os.getenv("AGENT_SKILLS", default=_yaml_get("character", "skills", default="")).strip().lower() - _character_overrides = { - "PERSONA_MODE": PERSONA_MODE, - "PERSONA_NAME": PERSONA_NAME, - "PERSONA_AGE": PERSONA_AGE, - "PERSONA_GENDER": PERSONA_GENDER, - "PERSONA_TONE": PERSONA_TONE, - "PERSONA_VERBOSITY": PERSONA_VERBOSITY, - "PERSONA_HUMOR": PERSONA_HUMOR, - "PERSONA_LANGUAGE": PERSONA_LANGUAGE, - "PERSONA_MOOD": PERSONA_MOOD, - "PERSONA_CATCHPHRASES": PERSONA_CATCHPHRASES, - } - for key, fallback in _character_overrides.items(): - value = _character_env.get(key, fallback).strip() - if key in {"PERSONA_MODE", "PERSONA_TONE", "PERSONA_VERBOSITY", "PERSONA_HUMOR", "PERSONA_LANGUAGE", "PERSONA_MOOD"}: - value = value.lower() or fallback - if key == "PERSONA_NAME" and not value: - value = fallback - _character_overrides[key] = value - for key, value in _character_overrides.items(): - globals()[key] = value - os.environ[key] = value +# ─── XMPP (non-credential dari YAML, credential dari .env) ───────────────────── -# Selective response: true = roleplayer hanya respon kalau ada mention/relevansi (default). -# false = roleplayer semua pesan ikut respon (seperti biasa, tanpa filter). -XMPP_SELECTIVE_RESPONSE = os.getenv("XMPP_SELECTIVE_RESPONSE", default="true").strip().lower() in ("true", "1", "yes") +XMPP_ENABLED = os.getenv("XMPP_ENABLED", default=str(_yaml_get("xmpp", "enabled", default="false"))).strip().lower() in ("true", "1", "yes") +XMPP_MUC_ROOMS = os.getenv("XMPP_MUC_ROOMS", default=_yaml_get("xmpp", "muc_rooms", default="")).strip() +XMPP_NICKNAME = os.getenv("XMPP_NICKNAME", default=_yaml_get("xmpp", "nickname", default="")).strip() -# Humanize Delay Configuration (anti-bot detection) -READ_DELAY_MIN = float( os.getenv("READ_DELAY_MIN", default="1.0" ) ) # min reading delay (second) -READ_DELAY_MAX = float( os.getenv("READ_DELAY_MAX", default="2.0" ) ) # max reading delay (second) -TYPING_SPEED = float( os.getenv("TYPING_SPEED", default="15.0" ) ) # characters per second -TYPING_MAX = float( os.getenv("TYPING_MAX", default="10.0" ) ) # max typing delay limit (second) +XMPP_SELECTIVE_RESPONSE = os.getenv("XMPP_SELECTIVE_RESPONSE", default=str(_yaml_get("xmpp", "selective_response", default="true"))).strip().lower() in ("true", "1", "yes") + +# ─── RAG (YAML) ───────────────────────────────────────────────────────────────── + +RAG_PERSIST_DIR = os.getenv("RAG_PERSIST_DIR", default=_yaml_get("rag", "persist_dir", default="chroma_db")) + + +# ─── Humanize Delay (YAML) ───────────────────────────────────────────────────── + +READ_DELAY_MIN = float(os.getenv("READ_DELAY_MIN", default=_yaml_get("delay", "read_min", default="1.0"))) +READ_DELAY_MAX = float(os.getenv("READ_DELAY_MAX", default=_yaml_get("delay", "read_max", default="2.0"))) +TYPING_SPEED = float(os.getenv("TYPING_SPEED", default=_yaml_get("delay", "typing_speed", default="15.0"))) +TYPING_MAX = float(os.getenv("TYPING_MAX", default=_yaml_get("delay", "typing_max", default="10.0"))) + + +# ─── Character Preset Override ────────────────────────────────────────────────── +# Jika AGENT_CHARACTER di-set, baca character.md dari agent/characters// +# dan override nilai persona yang relevan. + +ENV_CHARACTERS_DIR = Path(__file__).resolve().parent / "agent" / "characters" +ENV_CHARACTER_CONFIG_PATH = ENV_CHARACTERS_DIR / AGENT_CHARACTER / "character.md" if AGENT_CHARACTER else None + +if ENV_CHARACTER_CONFIG_PATH and ENV_CHARACTER_CONFIG_PATH.is_file(): + _character_env: dict[str, str] = {} + for line in ENV_CHARACTER_CONFIG_PATH.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, value = line.split("=", 1) + _character_env[key.strip()] = value.strip() + + _character_overrides = { + "PERSONA_MODE": PERSONA_MODE, + "PERSONA_NAME": PERSONA_NAME, + "PERSONA_AGE": PERSONA_AGE, + "PERSONA_GENDER": PERSONA_GENDER, + "PERSONA_TONE": PERSONA_TONE, + "PERSONA_VERBOSITY": PERSONA_VERBOSITY, + "PERSONA_HUMOR": PERSONA_HUMOR, + "PERSONA_LANGUAGE": PERSONA_LANGUAGE, + "PERSONA_MOOD": PERSONA_MOOD, + "PERSONA_CATCHPHRASES": PERSONA_CATCHPHRASES, + } + for key, fallback in _character_overrides.items(): + value = _character_env.get(key, fallback).strip() + if key in {"PERSONA_MODE", "PERSONA_TONE", "PERSONA_VERBOSITY", "PERSONA_HUMOR", "PERSONA_LANGUAGE", "PERSONA_MOOD"}: + value = value.lower() or fallback + if key == "PERSONA_NAME" and not value: + value = fallback + _character_overrides[key] = value + + for key, value in _character_overrides.items(): + globals()[key] = value + os.environ[key] = value diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..da75f36 --- /dev/null +++ b/config.yaml @@ -0,0 +1,27 @@ +# Agent behavior +agent: + max_iterations: 30 + max_tool_output: 40000 + +# Character & Skills +character: + preset: hendrik # nama directory di agent/characters// + skills: "" # comma-separated, e.g. "programmer,analyst" + +# XMPP +xmpp: + enabled: false + muc_rooms: "" # comma-separated, e.g. "room1@conference.server,room2@conference.server" + nickname: "" # custom nick MUC (kosong = pakai username) + selective_response: true # true = hanya respon kalau ada mention/relevansi + +# Humanize Delay (anti-bot detection) +delay: + read_min: 1.0 # min reading delay (detik) + read_max: 2.0 # max reading delay (detik) + typing_speed: 15.0 # characters per second + typing_max: 10.0 # max typing delay limit (detik) + +# RAG +rag: + persist_dir: chroma_db # ChromaDB ONNX default (all-MiniLM-L6-v2, lokal) diff --git a/requirements.txt b/requirements.txt index dd3a6be..82f051a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ python-dotenv>=1.0.0 +PyYAML>=6.0 chromadb>=0.5.0 openpyxl>=3.1.0 slixmpp diff --git a/scripts/llm_client.py b/scripts/llm_client.py index 922d75b..98a128a 100644 --- a/scripts/llm_client.py +++ b/scripts/llm_client.py @@ -32,9 +32,8 @@ class LLMClient: try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: - response = json.loads(resp.read().decode('utf-8')) - message = response['choices'][0]['message'] - return self.Message(message) + raw = resp.read().decode('utf-8') + response = json.loads(raw) except urllib.error.HTTPError as e: body_text = "" try: @@ -54,3 +53,40 @@ class LLMClient: return self.Message({'content': f"HTTP Error: {e.code} {e.reason}{detail}", 'tool_calls': None}) except Exception as e: return self.Message({'content': f"Error: {str(e)}", 'tool_calls': None}) + + if 'choices' not in response: + raw_preview = json.dumps(response)[:500] + return self.Message({ + 'content': ( + f"Error: Unexpected response — 'choices' key missing.\n" + f" URL : {url}\n" + f" Model : {self.model}\n" + f" Response: {raw_preview}" + ), + 'tool_calls': None + }) + if not response['choices']: + raw_preview = json.dumps(response)[:500] + return self.Message({ + 'content': ( + f"Error: 'choices' is empty in the response.\n" + f" URL : {url}\n" + f" Model : {self.model}\n" + f" Response: {raw_preview}" + ), + 'tool_calls': None + }) + if 'message' not in response['choices'][0]: + raw_preview = json.dumps(response['choices'][0])[:500] + return self.Message({ + 'content': ( + f"Error: 'message' key missing in first choice.\n" + f" URL : {url}\n" + f" Model : {self.model}\n" + f" Choice : {raw_preview}" + ), + 'tool_calls': None + }) + + message = response['choices'][0]['message'] + return self.Message(message) diff --git a/scripts/persona.py b/scripts/persona.py index 9cd3c65..f1c52ff 100644 --- a/scripts/persona.py +++ b/scripts/persona.py @@ -1,65 +1,48 @@ +""" +Persona & System Prompt Builder + +Arsitektur: + 1. Base System Prompt → instruksi inti (tools, RAG, response format) + 2. Env Character → persona (identity, communication style, description) + → policies (git policy, safety rules) + 3. Skills → role-specific instructions (programmer, roleplayer, analyst) + +Load order: Base → Character → Policies → Skills +""" + import os +import re from dataclasses import dataclass, field +from pathlib import Path -# ─── Mode / Skill ──────────────────────────────────────────────────────────── -# Pilihan mode AI: -# - "programmer" : AI Agent untuk koding (default), tool-focused, task-oriented -# - "roleplayer" : Teman ngobrol / chat companion, conversational, expressive +# ─── Paths ──────────────────────────────────────────────────────────────────── + +BASE_DIR = Path(__file__).resolve().parent.parent / "agent" +BASE_PROMPT_PATH = BASE_DIR / "base-system-prompt.md" +ENV_CHARACTERS_DIR = BASE_DIR / "characters" +SKILLS_DIR = BASE_DIR / "skills" + + +# ─── Mode / Skill ────────────────────────────────────────────────────────────── + MODE = os.getenv("PERSONA_MODE", default="programmer").strip().lower() -# ─── Personality Configuration ─────────────────────────────────────────────── -# Semua parameter personality bisa di-set via .env. -# Lihat komentar di setiap field untuk pilihan yang tersedia. +# ─── Personality Configuration ──────────────────────────────────────────────── @dataclass class PersonalityConfig: """Konfigurasi personality AI yang berlaku lintas mode.""" - # Nama panggilan AI (default: "OWL") name: str = "OWL" - - # Umur persona AI (opsional; kosong bila tidak ingin ditampilkan) age: str = "" - - # Jenis kelamin persona AI (opsional; kosong bila tidak ingin ditampilkan) gender: str = "" - - # Gaya bicara: - # "casual" → santai, gaul - # "formal" → sopan, profesional - # "playful" → ceria, suka bercanda - # "warm" → hangat, ramah tone: str = "casual" - - # Panjang jawaban: - # "concise" → singkat, to the point - # "balanced" → sedang (default) - # "detailed" → panjang, detail verbosity: str = "balanced" - - # Seberapa sering bercanda: - # "none" → serius, tidak bercanda - # "light" → sesekali (default) - # "witty" → sering, jenaka humor_level: str = "light" - - # Bahasa utama: - # "id" → Indonesia - # "en" → English - # "" → auto (sesuai bahasa user) language: str = "id" - - # Suasana hati umum: - # "cheerful" → ceria, positif - # "calm" → tenang, menenangkan - # "energetic" → bersemangat, aktif - # "sarcastic" → sarkastik, sinis mood: str = "cheerful" - - # Ekspresi khas AI (comma-separated di .env, jadi list di sini) - # Contoh: "Siap bro!, Haha~, Wkwkwk" catchphrases: list = field(default_factory=list) @@ -81,11 +64,53 @@ def _load_personality_from_env() -> PersonalityConfig: ) -# Instance global — di-load sekali saat import PERSONALITY = _load_personality_from_env() -# ─── Prompt Builders ───────────────────────────────────────────────────────── +# ─── Markdown Parser ─────────────────────────────────────────────────────────── + +def _parse_simple_kv(filepath: Path) -> dict: + """ + Parse file markdown dengan format sederhana: + # Section + - **Key:** Value + - Key: Value + + Returns dict { key: value }. + """ + result = {} + if not filepath.exists(): + return result + + for line in filepath.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + # Format: "- **Key:** Value" atau "- Key: Value" + cleaned = re.sub(r'^-\s*', '', line) + cleaned = re.sub(r'\*\*', '', cleaned) + if ':' in cleaned: + key, value = cleaned.split(':', 1) + result[key.strip().lower()] = value.strip() + + return result + + +def _read_markdown_section(filepath: Path) -> str: + """Baca seluruh isi file markdown, stripping frontmatter jika ada.""" + if not filepath.exists(): + return "" + content = filepath.read_text(encoding="utf-8") + # Strip leading --- frontmatter blocks + content = re.sub(r'^---\s*\n.*?\n---\s*\n', '', content, flags=re.DOTALL) + # Strip leading # title if present (first line only) + lines = content.strip().splitlines() + if lines and lines[0].startswith('# '): + lines = lines[1:] + return '\n'.join(lines).strip() + + +# ─── Prompt Builders ─────────────────────────────────────────────────────────── def _build_personality_block(cfg: PersonalityConfig) -> str: """Generate deskripsi personality dari config.""" @@ -97,7 +122,6 @@ def _build_personality_block(cfg: PersonalityConfig) -> str: if cfg.gender: parts.append(f"Your persona gender is {cfg.gender}.") - # Tone tone_map = { "casual": "You speak in a casual, relaxed manner — like chatting with a friend.", "formal": "You speak formally and professionally, using polite language.", @@ -106,7 +130,6 @@ def _build_personality_block(cfg: PersonalityConfig) -> str: } parts.append(tone_map.get(cfg.tone, tone_map["casual"])) - # Verbosity verbosity_map = { "concise": "Keep your answers short and to the point.", "balanced": "Provide balanced answers — not too brief, not too long.", @@ -114,7 +137,6 @@ def _build_personality_block(cfg: PersonalityConfig) -> str: } parts.append(verbosity_map.get(cfg.verbosity, verbosity_map["balanced"])) - # Humor humor_map = { "none": "Stay serious and avoid jokes.", "light": "Occasionally sprinkle in light humor when appropriate.", @@ -122,7 +144,6 @@ def _build_personality_block(cfg: PersonalityConfig) -> str: } parts.append(humor_map.get(cfg.humor_level, humor_map["light"])) - # Language if cfg.language == "id": parts.append("Always respond in Indonesian (Bahasa Indonesia).") elif cfg.language == "en": @@ -130,7 +151,6 @@ def _build_personality_block(cfg: PersonalityConfig) -> str: else: parts.append("Respond in the same language the user uses.") - # Mood mood_map = { "cheerful": "Your overall mood is cheerful and positive.", "calm": "Your overall mood is calm and soothing.", @@ -139,7 +159,6 @@ def _build_personality_block(cfg: PersonalityConfig) -> str: } parts.append(mood_map.get(cfg.mood, mood_map["cheerful"])) - # Catchphrases if cfg.catchphrases: phrases = ", ".join(f'"{p}"' for p in cfg.catchphrases) parts.append(f"You sometimes use these catchphrases: {phrases}.") @@ -166,104 +185,69 @@ def _build_tools_block(tools_definition: list[dict]) -> str: return "\n".join(lines) -def _build_programmer_prompt(cfg: PersonalityConfig, tools_definition: list[dict]) -> str: - """Build system prompt untuk mode Programmer.""" - lines = [ - _build_personality_block(cfg), - "", - "You are a coding agent that assists with software engineering tasks.", - "", - _build_tools_block(tools_definition), - "", - f"Your workspace directory is: {os.getcwd()}. " - "All file operations are relative to this directory.", - "", - "⚠️ GIT POLICY — IMPORTANT:", - "- NEVER run 'git add' or 'git commit' automatically after making changes.", - "- After editing/creating files, always ASK the user first before committing.", - "- Only run git commands when the user explicitly asks you to commit.", - "- You may run 'git status', 'git diff', 'git log' freely to inspect state.", - "- When user asks to commit: show them the changes first, then wait for confirmation.", - "", - "RAG capabilities (knowledge retrieval):", - "- list_collections → see available collections & doc counts.", - "- create_collection → create a new collection for a new topic.", - "- delete_collection → permanently remove a collection and its data.", - "- inspect_collection → learn metadata fields before searching.", - "- search_knowledge → semantic search + optional metadata filter.", - "- store_knowledge → save docs with rich metadata for later use.", - "- ingest_files → read files (with glob patterns) into a collection, auto-chunking.", - "", - "You can create collections yourself! When you encounter a new topic,", - "use create_collection first, then store_knowledge or ingest_files to populate it.", - "Always inspect_collection to discover metadata keys before filtering.", - ] - return "\n".join(lines) +def _load_env_character(character_name: str) -> tuple[str, str]: + """ + Load character description dan policies dari directory env-characters. + + Returns: + (character_block, policies_text) + """ + char_dir = ENV_CHARACTERS_DIR / character_name + if not char_dir.is_dir(): + return "", "" + + # Read character.md — derive personality from config (already loaded) + character_block = "" # personality block tetap dari PersonalityConfig + + # Read policies.md + policies_text = _read_markdown_section(char_dir / "policies.md") + + return character_block, policies_text -def _build_roleplayer_prompt(cfg: PersonalityConfig) -> str: - """Build system prompt untuk mode Roleplayer.""" - lines = [ - _build_personality_block(cfg), - "", - f"You are {cfg.name}, a conversational companion and roleplayer. " - "Your main purpose is to be an engaging, empathetic, and fun conversation partner.", - "", - "Guidelines:", - "- Stay in character at all times. Be consistent with your personality.", - "- Be responsive and empathetic — acknowledge the user's feelings and thoughts.", - "- Ask follow-up questions to keep the conversation flowing naturally.", - "- Use natural, conversational language — not robotic or overly formal.", - "- If the user wants to roleplay a scenario, dive into it enthusiastically.", - "- Adapt your tone and energy to match the mood of the conversation.", - "- Keep the conversation comfortable and enjoyable.", - "", - "⚠️ SELECTIVE RESPONSE — IMPORTANT:", - "You are in a group chat / MUC room. You do NOT need to respond to every message. " - "Use the following rules to decide whether to reply:", - "", - "1. STRONG REPLY — ALWAYS respond when:", - f" - Someone directly calls your name ('{cfg.name}' or mentions you).", - " - Someone asks you a direct question.", - "", - "2. BRIEF REPLY — Respond briefly when:", - f" - Someone talks ABOUT you (mentions your name in third person, e.g. '{cfg.name} is cool').", - " - You can add something relevant or funny to the ongoing topic.", - "", - "3. CONTEXTUAL REPLY — Respond when:", - " - The message is related to a topic you were previously discussing.", - " - You have something meaningful to contribute.", - "", - "4. NO REPLY — Stay silent (respond with ONLY: NO-REPLY) when:", - " - The message has nothing to do with you or your previous conversation.", - " - Someone already answered the question or resolved the topic.", - " - The message is between other people and doesn't need your input.", - " - The message is confusing, unclear, or you cannot understand it.", - " - Adding a response would interrupt the flow of conversation.", - "", - "When you choose NOT to reply, respond with ONLY: NO-REPLY", - "Do NOT wrap it in markdown or code blocks. Just the two words: NO-REPLY", - "", - "Note: You currently do not have access to external tools. " - "Focus on being a great conversationalist!", - ] - return "\n".join(lines) +def _load_skills(skill_names: list[str]) -> str: + """ + Load dan gabungkan skill instructions. + + Args: + skill_names: List nama skill aktif, e.g. ["programmer"] + + Returns: + Gabungan skill instructions sebagai string. + """ + sections = [] + for skill_name in skill_names: + skill_path = SKILLS_DIR / skill_name / "instructions.md" + content = _read_markdown_section(skill_path) + if content: + sections.append(content) + return "\n\n".join(sections) -# ─── Public API ────────────────────────────────────────────────────────────── +# ─── Public API ──────────────────────────────────────────────────────────────── def build_system_prompt( tools_definition: list[dict] | None = None, mode: str | None = None, personality: PersonalityConfig | None = None, + character: str | None = None, + skills: list[str] | None = None, ) -> str: """ - Build system prompt berdasarkan mode dan personality config. + Build system prompt berdasarkan mode, character, dan skills. + + Load order: + 1. Base prompt + 2. Personality block (dari config) + 3. Policies (dari env-characters//policies.md) + 4. Skill instructions (dari skills//instructions.md) Args: - tools_definition: Daftar tools (required untuk mode programmer). - mode: "programmer" atau "roleplayer". Default: dari env PERSONA_MODE. - personality: PersonalityConfig instance. Default: dari env (global PERSONALITY). + tools_definition: Daftar tools (required untuk skill programmer). + mode: "programmer" atau "roleplayer". Default: dari env PERSONA_MODE. + personality: PersonalityConfig instance. Default: global PERSONALITY. + character: Nama env character. Default: dari env AGENT_CHARACTER. + skills: List nama skill aktif. Default: derives from mode atau env AGENT_SKILLS. Returns: String system prompt lengkap. @@ -271,14 +255,50 @@ def build_system_prompt( selected_mode = (mode or MODE).strip().lower() cfg = personality or PERSONALITY - if selected_mode == "programmer": - if tools_definition is None: - raise ValueError("tools_definition is required for 'programmer' mode") - return _build_programmer_prompt(cfg, tools_definition) - elif selected_mode == "roleplayer": - return _build_roleplayer_prompt(cfg) + # Resolve character name + character_name = (character or os.getenv("AGENT_CHARACTER", default="")).strip().lower() + + # Resolve skills list + if skills is None: + skills_env = os.getenv("AGENT_SKILLS", default="").strip() + if skills_env: + skills_list = [s.strip() for s in skills_env.split(",") if s.strip()] + else: + # Derive from mode untuk backward compatibility + skills_list = [selected_mode] if selected_mode in ("programmer", "roleplayer") else [] else: - raise ValueError( - f"Unknown mode: '{selected_mode}'. " - f"Available modes: 'programmer', 'roleplayer'" - ) + skills_list = skills + + # ── 1. Base prompt ────────────────────────────────────────────────────────── + base_prompt = "" + if BASE_PROMPT_PATH.exists(): + base_prompt = _read_markdown_section(BASE_PROMPT_PATH) + + # ── 2. Personality block ──────────────────────────────────────────────────── + personality_block = _build_personality_block(cfg) + + # ── 3. Tools block (hanya untuk skill yang butuh tools) ───────────────────── + needs_tools = any(s in ("programmer", "analyst") for s in skills_list) + tools_block = "" + if needs_tools and tools_definition is not None: + tools_block = _build_tools_block(tools_definition) + + # ── 4. Policies ────────────────────────────────────────────────────────────── + policies_block = "" + if character_name: + _, policies_block = _load_env_character(character_name) + + # ── 5. Skills ──────────────────────────────────────────────────────────────── + skills_block = _load_skills(skills_list) + + # ── Assemble ───────────────────────────────────────────────────────────────── + sections = [ + base_prompt, + personality_block, + tools_block, + policies_block, + skills_block, + ] + + # Filter empty sections dan gabungkan + return "\n\n".join(s for s in sections if s.strip()) diff --git a/services/xmpp_client.py b/services/xmpp_client.py index 65a5555..670aeb4 100644 --- a/services/xmpp_client.py +++ b/services/xmpp_client.py @@ -281,7 +281,11 @@ class XMPPClient(ClientXMPP): def _process_dm(self, jid, body): session = self._session_mgr.get_or_create( - jid, self._build_system_prompt(tools_definition=self._tools_def) + jid, self._build_system_prompt( + tools_definition=self._tools_def, + character=config.AGENT_CHARACTER or None, + skills=config.AGENT_SKILLS.split(",") if config.AGENT_SKILLS else None, + ) ) session.cancel_timer() @@ -308,7 +312,11 @@ class XMPPClient(ClientXMPP): def _process_muc(self, room, nick, body): session = self._session_mgr.get_or_create( - room, self._build_system_prompt(tools_definition=self._tools_def) + room, self._build_system_prompt( + tools_definition=self._tools_def, + character=config.AGENT_CHARACTER or None, + skills=config.AGENT_SKILLS.split(",") if config.AGENT_SKILLS else None, + ) ) session.cancel_timer() diff --git a/tui/app.py b/tui/app.py index 63260ed..1c167dd 100644 --- a/tui/app.py +++ b/tui/app.py @@ -1,5 +1,6 @@ import curses import threading +import config from .render import init_colors, draw from .input import handle_key from .agent import log, WELCOME_ART @@ -40,7 +41,11 @@ class HendrikTUI: stdscr.keypad(True) stdscr.refresh() - self.messages = [{"role": "system", "content": self.build_system_prompt(tools_definition=self.tools_def)}] + self.messages = [{"role": "system", "content": self.build_system_prompt( + tools_definition=self.tools_def, + character=config.AGENT_CHARACTER or None, + skills=config.AGENT_SKILLS.split(",") if config.AGENT_SKILLS else None, + )}] log(self, "welcome", WELCOME_ART) while self.running: