This commit is contained in:
Dita Aji Pratama 2026-06-13 10:17:20 +07:00
commit 4058d3954f
15 changed files with 518 additions and 282 deletions

View File

@ -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/<AGENT_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

View File

@ -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.

View File

@ -0,0 +1,9 @@
mode: programmer
name: Hendrik
age: 35
gender: male
tone: casual
verbosity: concise
humor: none
language: id
mood: calm

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -1,9 +0,0 @@
PERSONA_MODE=programmer
PERSONA_NAME=Hendrik
PERSONA_AGE=35
PERSONA_GENDER=male
PERSONA_TONE=formal
PERSONA_VERBOSITY=concise
PERSONA_HUMOR=none
PERSONA_LANGUAGE=id
PERSONA_MOOD=calm

131
config.py
View File

@ -1,67 +1,99 @@
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/<AGENT_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():
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()
# ─── XMPP (non-credential dari YAML, credential dari .env) ─────────────────────
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()
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/<preset>/
# 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
@ -92,14 +124,3 @@ if CHARACTER_CONFIG_PATH and CHARACTER_CONFIG_PATH.is_file():
for key, value in _character_overrides.items():
globals()[key] = value
os.environ[key] = value
# 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")
# 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)

27
config.yaml Normal file
View File

@ -0,0 +1,27 @@
# Agent behavior
agent:
max_iterations: 30
max_tool_output: 40000
# Character & Skills
character:
preset: hendrik # nama directory di agent/characters/<preset>/
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)

View File

@ -1,4 +1,5 @@
python-dotenv>=1.0.0
PyYAML>=6.0
chromadb>=0.5.0
openpyxl>=3.1.0
slixmpp

View File

@ -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)

View File

@ -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/<name>/policies.md)
4. Skill instructions (dari skills/<name>/instructions.md)
Args:
tools_definition: Daftar tools (required untuk mode programmer).
tools_definition: Daftar tools (required untuk skill programmer).
mode: "programmer" atau "roleplayer". Default: dari env PERSONA_MODE.
personality: PersonalityConfig instance. Default: dari env (global PERSONALITY).
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:
raise ValueError(
f"Unknown mode: '{selected_mode}'. "
f"Available modes: 'programmer', 'roleplayer'"
)
# Derive from mode untuk backward compatibility
skills_list = [selected_mode] if selected_mode in ("programmer", "roleplayer") else []
else:
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())

View File

@ -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()

View File

@ -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: