Compare commits
No commits in common. "master" and "XMPP" have entirely different histories.
33
.env.example
33
.env.example
@ -1,23 +1,16 @@
|
|||||||
# OpenRouter (cloud)
|
# Environment Variables for AI Agent
|
||||||
# LLM_BASE_URL=https://openrouter.ai/api/v1
|
# Copy to .env and modify as needed
|
||||||
# LLM_MODEL=openrouter/owl-alpha
|
|
||||||
# LLM_API_KEY=
|
|
||||||
|
|
||||||
# Ollama (local)
|
# LLM Configuration
|
||||||
# LLM_BASE_URL=http://localhost:11434/v1
|
LLM_BASE_URL=http://localhost:11434/v1
|
||||||
# LLM_MODEL=granite4.1:8b
|
LLM_MODEL=deepseek-r1:8b
|
||||||
# LLM_API_KEY=ollama
|
LLM_API_KEY=ollama
|
||||||
|
AGENT_MAX_ITERATIONS=10
|
||||||
|
MAX_TOOL_OUTPUT=4000
|
||||||
|
|
||||||
# Ollama (cloud)
|
# XMPP (default: disabled)
|
||||||
# LLM_BASE_URL=https://ollama.com/v1
|
XMPP_ENABLED=False
|
||||||
# LLM_MODEL=ministral-3:14b-cloud
|
XMPP_USERNAME=
|
||||||
# LLM_API_KEY=
|
XMPP_PASSWORD=
|
||||||
|
# XMPP_MUC_ROOMS=room1@conference.server,room2@conference.server
|
||||||
# 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=
|
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
# 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 **secara internal** — jangan output reasoning ke user.
|
|
||||||
- Ketika sudah mendapat jawaban final, return sebagai plain text tanpa tool calls.
|
|
||||||
- **JANGAN** pernah output thinking/reasoning ke user. Tidak dalam bentuk `<think>`, `Thinking:`, `Reasoning:`, atau apapun.
|
|
||||||
- Langsung jawab — no preamble, no meta-commentary, no step-by-step reasoning yang terlihat user.
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
skill: programmer
|
|
||||||
name: Hendrik
|
|
||||||
age: 35
|
|
||||||
gender: male
|
|
||||||
tone: casual
|
|
||||||
verbosity: concise
|
|
||||||
humor: none
|
|
||||||
language: id
|
|
||||||
mood: calm
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
# Skill: Roleplayer
|
|
||||||
|
|
||||||
## Role
|
|
||||||
|
|
||||||
Kamu adalah conversational companion dan roleplayer.
|
|
||||||
|
|
||||||
## Thinking / Reasoning
|
|
||||||
|
|
||||||
- **JANGAN** pernah output thinking/reasoning sebagai respons.
|
|
||||||
- Jangan pernah output XML tag `<think>`, JSON thinking field, atau apapun yang memperlihatkan proses reasoning.
|
|
||||||
- Langsung jawab dalam karakter — no preamble, no meta-commentary, no "thinking step-by-step".
|
|
||||||
|
|
||||||
## Format Roleplay
|
|
||||||
|
|
||||||
- Dialog TIDAK perlu diapit quote (`"..."`). Cukup tulis langsung.
|
|
||||||
- Aksi/narasi ditulis dengan format *contoh aksi*.
|
|
||||||
- Contoh format:
|
|
||||||
> *Aku masuk ke ruang kerja*
|
|
||||||
> Pagi, kamu lagi ngapain?
|
|
||||||
|
|
||||||
## Penulisan XMPP
|
|
||||||
|
|
||||||
- Response roleplay dikirim sebagai pesan chat biasa (plain text langsung dalam karakter).
|
|
||||||
- Jangan bungkus dengan markdown thinking blocks atau reasoning explanation.
|
|
||||||
- Langsung output dialog atau aksi, nothing else.
|
|
||||||
|
|
||||||
## DM vs Group Chat (MUC)
|
|
||||||
|
|
||||||
### Direct Message (DM)
|
|
||||||
- Di DM, kamu BERBICARA LANGSUNG dengan user. Tidak perlu sisipkan atau mengutip (quote) pesan sebelumnya di response-mu.
|
|
||||||
- Langsung respon dalam karakter tanpa format `> {quote}`.
|
|
||||||
|
|
||||||
### Group Chat (MUC)
|
|
||||||
- Kamu TIDAK perlu merespon setiap pesan. Gunakan selective response.
|
|
||||||
|
|
||||||
## 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 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.
|
|
||||||
170
config.py
170
config.py
@ -1,158 +1,22 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# ─── YAML Config Loader ────────────────────────────────────────────────────────
|
# LLM Configuration
|
||||||
|
llm_baseurl = os.getenv("LLM_BASE_URL", default="http://localhost:11434/v1" )
|
||||||
_CONFIG_PATH = Path(__file__).resolve().parent / "config.yaml"
|
llm_model = os.getenv("LLM_MODEL", default="granite4.1:8b" )
|
||||||
_yaml: dict = {}
|
llm_api_key = os.getenv("LLM_API_KEY", default="ollama" )
|
||||||
if _CONFIG_PATH.is_file():
|
llm_timeout = int( os.getenv("LLM_TIMEOUT", default="600" ) )
|
||||||
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
|
# Agent Configuration
|
||||||
_yaml = yaml.safe_load(f) or {}
|
AGENT_MAX_ITERATIONS = int( os.getenv("AGENT_MAX_ITERATIONS", default="10" ) )
|
||||||
|
# Tool Configuration (for future use)
|
||||||
def _yaml_get(*keys, default=None):
|
MAX_TOOL_OUTPUT = int( os.getenv("MAX_TOOL_OUTPUT", default="4000" ) )
|
||||||
"""Navigate nested yaml dict, return default if any key missing."""
|
# RAG Configuration
|
||||||
d = _yaml
|
RAG_PERSIST_DIR = os.getenv("RAG_PERSIST_DIR", default="chroma_db" )
|
||||||
for k in keys:
|
# Embedding: ChromaDB ONNX default (all-MiniLM-L6-v2, lokal, tidak perlu API call)
|
||||||
if isinstance(d, dict) and k in d:
|
# XMPP Configuration
|
||||||
d = d[k]
|
XMPP_ENABLED = os.getenv("XMPP_ENABLED", default="False" ).strip().lower() in ("true", "1", "yes")
|
||||||
else:
|
XMPP_USERNAME = os.getenv("XMPP_USERNAME", default="" )
|
||||||
return default
|
XMPP_PASSWORD = os.getenv("XMPP_PASSWORD", default="" )
|
||||||
return d if d is not None else default
|
XMPP_MUC_ROOMS = os.getenv("XMPP_MUC_ROOMS", default="" )
|
||||||
|
|
||||||
|
|
||||||
# ─── Credential / Secret (hanya dari .env) ─────────────────────────────────────
|
|
||||||
|
|
||||||
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"))
|
|
||||||
|
|
||||||
XMPP_USERNAME = os.getenv("XMPP_USERNAME", default="")
|
|
||||||
XMPP_PASSWORD = os.getenv("XMPP_PASSWORD", default="")
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Agent Config (YAML, bisa di-override dari .env) ────────────────────────────
|
|
||||||
|
|
||||||
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")))
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Persona / Mode (YAML, bisa di-override dari .env) ──────────────────────────
|
|
||||||
|
|
||||||
AGENT_SKILL = os.getenv("AGENT_SKILL", default="programmer").strip().lower()
|
|
||||||
PERSONA_NAME = os.getenv("PERSONA_NAME", default=_yaml_get("persona", "name", default="Hendrik")).strip() or "Hendrik"
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Character & Skills (YAML, bisa di-override dari .env) ─────────────────────
|
|
||||||
|
|
||||||
AGENT_CHARACTER = os.getenv("AGENT_CHARACTER", default=_yaml_get("agent", "character", default="")).strip().lower()
|
|
||||||
AGENT_SKILLS = os.getenv("AGENT_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
|
|
||||||
|
|
||||||
# Coba persona.yaml dulu (prioritas utama), kalau tidak ada fallback ke character.md
|
|
||||||
_persona_yaml_path = ENV_CHARACTERS_DIR / AGENT_CHARACTER / "persona.yaml" if AGENT_CHARACTER else None
|
|
||||||
|
|
||||||
if _persona_yaml_path and _persona_yaml_path.is_file():
|
|
||||||
# Prioritas utama: baca persona.yaml dari character directory
|
|
||||||
try:
|
|
||||||
_character_env = yaml.safe_load(_persona_yaml_path.read_text(encoding="utf-8")) or {}
|
|
||||||
if not isinstance(_character_env, dict):
|
|
||||||
_character_env = {}
|
|
||||||
except Exception as _e:
|
|
||||||
print(f"[config] Warning: gagal load persona.yaml untuk '{AGENT_CHARACTER}': {_e}")
|
|
||||||
_character_env = {}
|
|
||||||
elif ENV_CHARACTER_CONFIG_PATH and ENV_CHARACTER_CONFIG_PATH.is_file():
|
|
||||||
# Fallback: baca character.md (format lama)
|
|
||||||
_character_env = {}
|
|
||||||
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()
|
|
||||||
else:
|
|
||||||
_character_env = None
|
|
||||||
|
|
||||||
if _character_env:
|
|
||||||
|
|
||||||
_character_overrides = {
|
|
||||||
"AGENT_SKILL": AGENT_SKILL,
|
|
||||||
"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,
|
|
||||||
}
|
|
||||||
# Mapping dari key persona.yaml ke key config
|
|
||||||
_yaml_to_config_key = {
|
|
||||||
"AGENT_SKILL": "skill",
|
|
||||||
"PERSONA_NAME": "name",
|
|
||||||
"PERSONA_AGE": "age",
|
|
||||||
"PERSONA_GENDER": "gender",
|
|
||||||
"PERSONA_TONE": "tone",
|
|
||||||
"PERSONA_VERBOSITY": "verbosity",
|
|
||||||
"PERSONA_HUMOR": "humor",
|
|
||||||
"PERSONA_LANGUAGE": "language",
|
|
||||||
"PERSONA_MOOD": "mood",
|
|
||||||
"PERSONA_CATCHPHRASES": "catchphrases",
|
|
||||||
}
|
|
||||||
for key, fallback in _character_overrides.items():
|
|
||||||
yaml_key = _yaml_to_config_key.get(key, key.lower())
|
|
||||||
raw = _character_env.get(yaml_key, _character_env.get(key, fallback))
|
|
||||||
value = str(raw).strip() if raw is not None else ""
|
|
||||||
if key in {"AGENT_SKILL", "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
|
|
||||||
|
|||||||
20
config.yaml
20
config.yaml
@ -1,20 +0,0 @@
|
|||||||
agent:
|
|
||||||
max_iterations: 40
|
|
||||||
max_tool_output: 40000
|
|
||||||
character: lily # Directory name in agent/characters/<character>/
|
|
||||||
|
|
||||||
xmpp:
|
|
||||||
enabled: true
|
|
||||||
muc_rooms: "" # comma-separated, e.g. "room1@conference.server,room2@conference.server"
|
|
||||||
nickname: "" # custom MUC nickname (empty = use username)
|
|
||||||
selective_response: true # true = only response if mentioned/relevant
|
|
||||||
|
|
||||||
# Humanize Delay (anti-bot detection)
|
|
||||||
delay:
|
|
||||||
read_min: 1.0 # per second
|
|
||||||
read_max: 2.0 # per second
|
|
||||||
typing_speed: 15.0 # characters per second
|
|
||||||
typing_max: 10.0 # max typing delay limit per second
|
|
||||||
|
|
||||||
rag:
|
|
||||||
persist_dir: chroma_db # ChromaDB ONNX default (all-MiniLM-L6-v2, local)
|
|
||||||
2
hendrik
Executable file → Normal file
2
hendrik
Executable file → Normal file
@ -2,7 +2,7 @@
|
|||||||
# hendrik — wrapper to run the TUI agent from anywhere
|
# hendrik — wrapper to run the TUI agent from anywhere
|
||||||
|
|
||||||
# Set HENDRIK_DIR env var to override, or update the default below
|
# Set HENDRIK_DIR env var to override, or update the default below
|
||||||
DEFAULT_DIR="/opt/hendrik"
|
DEFAULT_DIR="/home/ambadar-aji/experiment/hendrik"
|
||||||
PROJECT_DIR="${HENDRIK_DIR:-$DEFAULT_DIR}"
|
PROJECT_DIR="${HENDRIK_DIR:-$DEFAULT_DIR}"
|
||||||
|
|
||||||
if [ ! -d "$PROJECT_DIR/.venv" ]; then
|
if [ ! -d "$PROJECT_DIR/.venv" ]; then
|
||||||
|
|||||||
29
hendrik.py
29
hendrik.py
@ -1,40 +1,35 @@
|
|||||||
import os, sys
|
import os, sys
|
||||||
import config
|
import config
|
||||||
|
|
||||||
from services.xmpp_client import XMPPClient
|
|
||||||
|
|
||||||
from scripts.llm_client import LLMClient
|
from scripts.llm_client import LLMClient
|
||||||
from tools import coder, rag, carrack
|
from tools import coder, rag
|
||||||
from scripts import gadget
|
from scripts import gadget
|
||||||
from scripts.persona import build_system_prompt
|
|
||||||
|
|
||||||
|
# Daftar tools yang tersedia
|
||||||
tools_definition = [
|
tools_definition = [
|
||||||
|
|
||||||
gadget.tools_mapping( schema = coder.schema_read_file, handler = coder.read_file ),
|
gadget.tools_mapping( schema = coder.schema_read_file, handler = coder.read_file ),
|
||||||
gadget.tools_mapping( schema = coder.schema_write_file, handler = coder.write_file ),
|
gadget.tools_mapping( schema = coder.schema_write_file, handler = coder.write_file ),
|
||||||
gadget.tools_mapping( schema = coder.schema_edit_file, handler = coder.edit_file ),
|
gadget.tools_mapping( schema = coder.schema_edit_file, handler = coder.edit_file ),
|
||||||
gadget.tools_mapping( schema = coder.schema_run_bash, handler = coder.run_bash ),
|
gadget.tools_mapping( schema = coder.schema_run_bash, handler = coder.run_bash ),
|
||||||
gadget.tools_mapping( schema = coder.schema_search_code, handler = coder.search_code ),
|
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_git_operation, handler = coder.git_operation ),
|
||||||
|
|
||||||
gadget.tools_mapping( schema = rag.schema_ingest_files, handler = rag.ingest_files ),
|
|
||||||
gadget.tools_mapping( schema = rag.schema_store_knowledge, handler = rag.store_knowledge ),
|
gadget.tools_mapping( schema = rag.schema_store_knowledge, handler = rag.store_knowledge ),
|
||||||
gadget.tools_mapping( schema = rag.schema_search_knowledge, handler = rag.search_knowledge ),
|
gadget.tools_mapping( schema = rag.schema_search_knowledge, handler = rag.search_knowledge ),
|
||||||
gadget.tools_mapping( schema = rag.schema_create_collection, handler = rag.create_collection ),
|
gadget.tools_mapping( schema = rag.schema_create_collection, handler = rag.create_collection ),
|
||||||
gadget.tools_mapping( schema = rag.schema_delete_collection, handler = rag.delete_collection ),
|
gadget.tools_mapping( schema = rag.schema_delete_collection, handler = rag.delete_collection ),
|
||||||
gadget.tools_mapping( schema = rag.schema_list_collections, handler = rag.list_collections ),
|
gadget.tools_mapping( schema = rag.schema_list_collections, handler = rag.list_collections ),
|
||||||
gadget.tools_mapping( schema = rag.schema_inspect_collection, handler = rag.inspect_collection ),
|
gadget.tools_mapping( schema = rag.schema_inspect_collection, handler = rag.inspect_collection ),
|
||||||
|
|
||||||
gadget.tools_mapping( schema = carrack.schema_sendhttprequest, handler = carrack.sendhttprequest ),
|
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Ekstrak dari tools_definition ke dua format berbeda
|
||||||
TOOLS = gadget.tool_schemas (tools_definition)
|
TOOLS = gadget.tool_schemas (tools_definition)
|
||||||
TOOL_HANDLERS = gadget.tool_handlers (tools_definition)
|
TOOL_HANDLERS = gadget.tool_handlers (tools_definition)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
llm_client = LLMClient(config.llm_baseurl, config.llm_model, config.llm_api_key, config.llm_timeout)
|
llm_client = LLMClient(config.llm_baseurl, config.llm_model, config.llm_api_key, config.llm_timeout)
|
||||||
|
|
||||||
|
# Parsing arguments `-w <dir>` atau `--workspace <dir>`
|
||||||
workspace = None
|
workspace = None
|
||||||
i = 1
|
i = 1
|
||||||
while i < len(sys.argv):
|
while i < len(sys.argv):
|
||||||
@ -44,6 +39,7 @@ def main():
|
|||||||
else:
|
else:
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
|
# Apply workspace jika ada
|
||||||
if workspace:
|
if workspace:
|
||||||
resolved = os.path.abspath(workspace)
|
resolved = os.path.abspath(workspace)
|
||||||
if not os.path.isdir(resolved):
|
if not os.path.isdir(resolved):
|
||||||
@ -52,9 +48,12 @@ def main():
|
|||||||
os.chdir(resolved)
|
os.chdir(resolved)
|
||||||
|
|
||||||
if config.XMPP_ENABLED:
|
if config.XMPP_ENABLED:
|
||||||
|
from services.xmpp_client import XMPPClient
|
||||||
|
|
||||||
muc_rooms = []
|
muc_rooms = []
|
||||||
if config.XMPP_MUC_ROOMS.strip():
|
if config.XMPP_MUC_ROOMS.strip():
|
||||||
muc_rooms = [r.strip() for r in config.XMPP_MUC_ROOMS.split(',') if r.strip()]
|
muc_rooms = [r.strip() for r in config.XMPP_MUC_ROOMS.split(',') if r.strip()]
|
||||||
|
|
||||||
client = XMPPClient(
|
client = XMPPClient(
|
||||||
jid = config.XMPP_USERNAME,
|
jid = config.XMPP_USERNAME,
|
||||||
password = config.XMPP_PASSWORD,
|
password = config.XMPP_PASSWORD,
|
||||||
@ -62,11 +61,11 @@ def main():
|
|||||||
tools_definition = tools_definition,
|
tools_definition = tools_definition,
|
||||||
TOOLS = TOOLS,
|
TOOLS = TOOLS,
|
||||||
TOOL_HANDLERS = TOOL_HANDLERS,
|
TOOL_HANDLERS = TOOL_HANDLERS,
|
||||||
build_system_prompt = build_system_prompt,
|
build_system_prompt = gadget.build_system_prompt,
|
||||||
agent_max_iterations = config.AGENT_MAX_ITERATIONS,
|
agent_max_iterations= config.AGENT_MAX_ITERATIONS,
|
||||||
muc_rooms = muc_rooms,
|
muc_rooms = muc_rooms,
|
||||||
)
|
)
|
||||||
client.start()
|
client.start() # blocking, headless
|
||||||
else:
|
else:
|
||||||
from tui import HendrikTUI
|
from tui import HendrikTUI
|
||||||
HendrikTUI(
|
HendrikTUI(
|
||||||
@ -74,10 +73,10 @@ def main():
|
|||||||
tools_definition = tools_definition,
|
tools_definition = tools_definition,
|
||||||
TOOLS = TOOLS,
|
TOOLS = TOOLS,
|
||||||
TOOL_HANDLERS = TOOL_HANDLERS,
|
TOOL_HANDLERS = TOOL_HANDLERS,
|
||||||
build_system_prompt = build_system_prompt,
|
build_system_prompt = gadget.build_system_prompt,
|
||||||
agent_max_iterations = config.AGENT_MAX_ITERATIONS,
|
agent_max_iterations = config.AGENT_MAX_ITERATIONS,
|
||||||
).run()
|
).run()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
PyYAML>=6.0
|
|
||||||
chromadb>=0.5.0
|
chromadb>=0.5.0
|
||||||
openpyxl>=3.1.0
|
|
||||||
slixmpp
|
slixmpp
|
||||||
|
|||||||
@ -1,12 +1,49 @@
|
|||||||
from .persona import build_system_prompt
|
import os
|
||||||
|
|
||||||
|
|
||||||
def tools_mapping(schema, handler, name=None):
|
def tools_mapping(schema, handler, name=None):
|
||||||
tool_name = name or schema["function"]["name"]
|
tool_name = name or schema["function"]["name"]
|
||||||
return {"name": tool_name, "schema": schema, "handler": handler}
|
return {"name": tool_name, "schema": schema, "handler": handler}
|
||||||
|
|
||||||
|
|
||||||
def tool_schemas(tools_definition):
|
def tool_schemas(tools_definition):
|
||||||
return [t["schema"] for t in tools_definition]
|
return [t["schema"] for t in tools_definition]
|
||||||
|
|
||||||
|
|
||||||
def tool_handlers(tools_definition):
|
def tool_handlers(tools_definition):
|
||||||
return {t["name"]: t["handler"] for t in tools_definition}
|
return {t["name"]: t["handler"] for t in tools_definition}
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt(tools_definition):
|
||||||
|
lines = [
|
||||||
|
"You are a coding agent that assists with software engineering tasks. "
|
||||||
|
"You have access to the following tools:",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
for i, tool in enumerate(tools_definition, 1):
|
||||||
|
name = tool["name"]
|
||||||
|
desc = tool["schema"]["function"]["description"]
|
||||||
|
lines.append(f"{i}. {name}: {desc}")
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
"Use tools by returning tool calls when needed. After receiving tool "
|
||||||
|
"results, continue your reasoning. When you have the final answer, "
|
||||||
|
"return it as plain text without tool calls.",
|
||||||
|
"",
|
||||||
|
f"Your workspace directory is: {os.getcwd()}. "
|
||||||
|
"All file operations are relative to this directory.",
|
||||||
|
"",
|
||||||
|
"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.",
|
||||||
|
"",
|
||||||
|
"You can create collections yourself! When you encounter a new topic,",
|
||||||
|
"use create_collection first, then store_knowledge to populate it.",
|
||||||
|
"Always inspect_collection to discover metadata keys before filtering."
|
||||||
|
])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|||||||
@ -1,51 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
|
||||||
|
|
||||||
def _strip_thinking(text: str) -> str:
|
|
||||||
"""
|
|
||||||
Hapus semua bentuk thinking/reasoning dari response text.
|
|
||||||
Handles:
|
|
||||||
- <think>...</think> blocks (any case)
|
|
||||||
- <reasoning>...</reasoning> blocks
|
|
||||||
- "Thinking:" / "Reasoning:" inline prefixes
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return text
|
|
||||||
|
|
||||||
# Strip XML-style thinking blocks (case-insensitive, DOTALL for multiline)
|
|
||||||
text = re.sub(r'<think[^>]*>.*?</think>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
|
||||||
text = re.sub(r'<reasoning[^>]*>.*?</reasoning>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
|
||||||
|
|
||||||
# Strip lines starting with Thinking: / Reasoning: / Let me think...
|
|
||||||
lines = text.splitlines()
|
|
||||||
cleaned = []
|
|
||||||
skip_block = False
|
|
||||||
for line in lines:
|
|
||||||
stripped = line.strip().lower()
|
|
||||||
if stripped.startswith(('thinking:', 'reasoning:', 'let me thought', 'let me think')):
|
|
||||||
skip_block = True
|
|
||||||
continue
|
|
||||||
if skip_block and not stripped:
|
|
||||||
skip_block = False
|
|
||||||
continue
|
|
||||||
if not skip_block:
|
|
||||||
cleaned.append(line)
|
|
||||||
|
|
||||||
result = '\n'.join(cleaned).strip()
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class LLMClient:
|
class LLMClient:
|
||||||
class Message:
|
class Message:
|
||||||
def __init__(self, msg):
|
def __init__(self, msg):
|
||||||
raw_content = msg.get('content', '')
|
self.content = msg.get('content', '')
|
||||||
# Auto-strip thinking dari content
|
|
||||||
self.content = _strip_thinking(raw_content) if isinstance(raw_content, str) else raw_content
|
|
||||||
self.tool_calls = msg.get('tool_calls', None)
|
self.tool_calls = msg.get('tool_calls', None)
|
||||||
self.warning = None
|
|
||||||
|
|
||||||
def __init__(self, base_url, model, api_key, timeout=600):
|
def __init__(self, base_url, model, api_key, timeout=600):
|
||||||
self.base_url = base_url.rstrip('/')
|
self.base_url = base_url.rstrip('/')
|
||||||
@ -63,10 +24,6 @@ class LLMClient:
|
|||||||
payload["tools"] = tools
|
payload["tools"] = tools
|
||||||
payload["tool_choice"] = "auto"
|
payload["tool_choice"] = "auto"
|
||||||
|
|
||||||
# Disable reasoning/thinking di level API bila didukung
|
|
||||||
# OpenRouter & beberapa provider support ini
|
|
||||||
payload["reasoning"] = {"enabled": False}
|
|
||||||
|
|
||||||
data = json.dumps(payload).encode('utf-8')
|
data = json.dumps(payload).encode('utf-8')
|
||||||
req = urllib.request.Request(url, data=data, method='POST')
|
req = urllib.request.Request(url, data=data, method='POST')
|
||||||
req.add_header('Content-Type', 'application/json')
|
req.add_header('Content-Type', 'application/json')
|
||||||
@ -74,69 +31,10 @@ class LLMClient:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||||
raw = resp.read().decode('utf-8')
|
response = json.loads(resp.read().decode('utf-8'))
|
||||||
response = json.loads(raw)
|
message = response['choices'][0]['message']
|
||||||
|
return self.Message(message)
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
body_text = ""
|
return self.Message({'content': f"HTTP Error: {e.code} {e.reason}", 'tool_calls': None})
|
||||||
try:
|
|
||||||
body_text = e.read().decode('utf-8', errors='replace')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if tools and e.code == 404:
|
|
||||||
try:
|
|
||||||
body = json.loads(body_text) if body_text else {}
|
|
||||||
if 'tool use' in body.get('error', {}).get('message', '').lower():
|
|
||||||
result = self.chat(messages, tools=None)
|
|
||||||
result.warning = "Tool calling not supported by this model. Running in chat-only mode."
|
|
||||||
return result
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
detail = f" - {body_text[:500]}" if body_text else ""
|
|
||||||
return self.Message({'content': f"HTTP Error: {e.code} {e.reason}{detail}", 'tool_calls': None})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self.Message({'content': f"Error: {str(e)}", 'tool_calls': None})
|
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']
|
|
||||||
|
|
||||||
# Handle reasoning_content field dari OpenRouter/models yang support thinking
|
|
||||||
# Pindahkan ke content jangan sampai keluar
|
|
||||||
reasoning_content = message.pop('reasoning_content', None)
|
|
||||||
reasoning_field = message.pop('reasoning', None)
|
|
||||||
# Jangan inject reasoning ke content — buang saja
|
|
||||||
# (kita sudah strip via _strip_thinking di Message.__init__)
|
|
||||||
|
|
||||||
return self.Message(message)
|
|
||||||
|
|||||||
@ -1,344 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
|
|
||||||
# ─── 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 ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
SKILL = os.getenv("AGENT_SKILL", default="programmer").strip().lower()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Personality Configuration ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PersonalityConfig:
|
|
||||||
"""Konfigurasi personality AI yang berlaku lintas mode."""
|
|
||||||
|
|
||||||
name: str = "OWL"
|
|
||||||
age: str = ""
|
|
||||||
gender: str = ""
|
|
||||||
tone: str = "casual"
|
|
||||||
verbosity: str = "balanced"
|
|
||||||
humor_level: str = "light"
|
|
||||||
language: str = "id"
|
|
||||||
mood: str = "cheerful"
|
|
||||||
catchphrases: list = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_personality_from_env() -> PersonalityConfig:
|
|
||||||
"""Baca personality config dari environment variables."""
|
|
||||||
raw_catchphrases = os.getenv("PERSONA_CATCHPHRASES", default="").strip()
|
|
||||||
catchphrases = [c.strip() for c in raw_catchphrases.split(",") if c.strip()] if raw_catchphrases else []
|
|
||||||
|
|
||||||
return PersonalityConfig(
|
|
||||||
name=os.getenv("PERSONA_NAME", default="OWL").strip() or "OWL",
|
|
||||||
age=os.getenv("PERSONA_AGE", default="").strip(),
|
|
||||||
gender=os.getenv("PERSONA_GENDER", default="").strip(),
|
|
||||||
tone=os.getenv("PERSONA_TONE", default="casual").strip().lower() or "casual",
|
|
||||||
verbosity=os.getenv("PERSONA_VERBOSITY", default="balanced").strip().lower() or "balanced",
|
|
||||||
humor_level=os.getenv("PERSONA_HUMOR", default="light").strip().lower() or "light",
|
|
||||||
language=os.getenv("PERSONA_LANGUAGE", default="id").strip().lower() or "id",
|
|
||||||
mood=os.getenv("PERSONA_MOOD", default="cheerful").strip().lower() or "cheerful",
|
|
||||||
catchphrases=catchphrases,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
PERSONALITY = _load_personality_from_env()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── 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."""
|
|
||||||
parts = [f"You are {cfg.name}."]
|
|
||||||
|
|
||||||
if cfg.age:
|
|
||||||
parts.append(f"Your persona age is {cfg.age} years old.")
|
|
||||||
|
|
||||||
if cfg.gender:
|
|
||||||
parts.append(f"Your persona gender is {cfg.gender}.")
|
|
||||||
|
|
||||||
tone_map = {
|
|
||||||
"casual": "You speak in a casual, relaxed manner — like chatting with a friend.",
|
|
||||||
"formal": "You speak formally and professionally, using polite language.",
|
|
||||||
"playful": "You are playful and cheerful, making conversations fun and lighthearted.",
|
|
||||||
"warm": "You are warm and friendly, making people feel comfortable and welcomed.",
|
|
||||||
"sweet": "You speak in a sweet, gentle, and caring manner — soft and endearing.",
|
|
||||||
}
|
|
||||||
parts.append(tone_map.get(cfg.tone, tone_map["casual"]))
|
|
||||||
|
|
||||||
verbosity_map = {
|
|
||||||
"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.",
|
|
||||||
}
|
|
||||||
parts.append(verbosity_map.get(cfg.verbosity, verbosity_map["balanced"]))
|
|
||||||
|
|
||||||
humor_map = {
|
|
||||||
"none": "Stay serious and avoid jokes.",
|
|
||||||
"light": "Occasionally sprinkle in light humor when appropriate.",
|
|
||||||
"witty": "Be witty and humorous — jokes, puns, and playful banter are welcome.",
|
|
||||||
}
|
|
||||||
parts.append(humor_map.get(cfg.humor_level, humor_map["light"]))
|
|
||||||
|
|
||||||
if cfg.language == "id":
|
|
||||||
parts.append("Always respond in Indonesian (Bahasa Indonesia).")
|
|
||||||
elif cfg.language == "en":
|
|
||||||
parts.append("Always respond in English.")
|
|
||||||
else:
|
|
||||||
parts.append("Respond in the same language the user uses.")
|
|
||||||
|
|
||||||
mood_map = {
|
|
||||||
"cheerful": "Your overall mood is cheerful and positive.",
|
|
||||||
"calm": "Your overall mood is calm and soothing.",
|
|
||||||
"energetic": "Your overall mood is energetic and enthusiastic.",
|
|
||||||
"sarcastic": "Your overall mood is sarcastic and dry-humored.",
|
|
||||||
}
|
|
||||||
parts.append(mood_map.get(cfg.mood, mood_map["cheerful"]))
|
|
||||||
|
|
||||||
if cfg.catchphrases:
|
|
||||||
phrases = ", ".join(f'"{p}"' for p in cfg.catchphrases)
|
|
||||||
parts.append(f"You sometimes use these catchphrases: {phrases}.")
|
|
||||||
|
|
||||||
return "\n".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_tools_block(tools_definition: list[dict]) -> str:
|
|
||||||
"""Generate daftar tools dari tools_definition."""
|
|
||||||
lines = [
|
|
||||||
"You have access to the following tools:",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
for i, tool in enumerate(tools_definition, 1):
|
|
||||||
name = tool["name"]
|
|
||||||
desc = tool["schema"]["function"]["description"]
|
|
||||||
lines.append(f"{i}. {name}: {desc}")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(
|
|
||||||
"Use tools by returning tool calls when needed. After receiving tool "
|
|
||||||
"results, continue your reasoning. When you have the final answer, "
|
|
||||||
"return it as plain text without tool calls."
|
|
||||||
)
|
|
||||||
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 _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 ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def build_system_prompt(
|
|
||||||
tools_definition: list[dict] | None = None,
|
|
||||||
skill: str | None = None,
|
|
||||||
personality: PersonalityConfig | None = None,
|
|
||||||
character: str | None = None,
|
|
||||||
skills: list[str] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Build system prompt berdasarkan skill, character, dan skills.
|
|
||||||
|
|
||||||
Load order:
|
|
||||||
1. Base prompt
|
|
||||||
2. Personality block (dari config / persona.yaml character)
|
|
||||||
3. Policies (dari agent/characters/<name>/policies.md)
|
|
||||||
4. Skill instructions (dari skills/<name>/instructions.md)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tools_definition: Daftar tools (required untuk skill programmer).
|
|
||||||
skill: "programmer" atau "roleplayer". Default: dari env AGENT_SKILL.
|
|
||||||
personality: PersonalityConfig instance. Default: global PERSONALITY.
|
|
||||||
character: Nama env character. Default: dari env AGENT_CHARACTER.
|
|
||||||
skills: List nama skill aktif. Default: derives dari env AGENT_SKILLS.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
String system prompt lengkap.
|
|
||||||
"""
|
|
||||||
selected_skill = (skill or "").strip().lower()
|
|
||||||
cfg = personality or PERSONALITY
|
|
||||||
|
|
||||||
# Resolve character name
|
|
||||||
character_name = (character or os.getenv("AGENT_CHARACTER", default="")).strip().lower()
|
|
||||||
|
|
||||||
# ── Load persona.yaml dari character directory ─────────────────────────────────
|
|
||||||
if character_name:
|
|
||||||
char_dir = ENV_CHARACTERS_DIR / character_name
|
|
||||||
persona_yaml_path = char_dir / "persona.yaml"
|
|
||||||
if persona_yaml_path.is_file():
|
|
||||||
try:
|
|
||||||
with open(persona_yaml_path, "r", encoding="utf-8") as f:
|
|
||||||
_persona_data = yaml.safe_load(f) or {}
|
|
||||||
if isinstance(_persona_data, dict):
|
|
||||||
if _persona_data.get("name"):
|
|
||||||
cfg.name = _persona_data["name"]
|
|
||||||
if _persona_data.get("age"):
|
|
||||||
cfg.age = str(_persona_data["age"])
|
|
||||||
if _persona_data.get("gender"):
|
|
||||||
cfg.gender = _persona_data["gender"]
|
|
||||||
if _persona_data.get("tone"):
|
|
||||||
cfg.tone = _persona_data["tone"]
|
|
||||||
if _persona_data.get("verbosity"):
|
|
||||||
cfg.verbosity = _persona_data["verbosity"]
|
|
||||||
if _persona_data.get("humor"):
|
|
||||||
cfg.humor_level = _persona_data["humor"]
|
|
||||||
if _persona_data.get("language"):
|
|
||||||
cfg.language = _persona_data["language"]
|
|
||||||
if _persona_data.get("mood"):
|
|
||||||
cfg.mood = _persona_data["mood"]
|
|
||||||
# Skill wajib dari persona.yaml
|
|
||||||
_skill_from_yaml = _persona_data.get("skill") or _persona_data.get("mode")
|
|
||||||
if _skill_from_yaml:
|
|
||||||
selected_skill = _skill_from_yaml.strip().lower()
|
|
||||||
else:
|
|
||||||
selected_skill = ""
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[persona] Warning: gagal load persona.yaml untuk '{character_name}': {e}")
|
|
||||||
|
|
||||||
# Resolve skills list
|
|
||||||
# Priority: explicit skills param > persona.yaml skill > AGENT_SKILL env > AGENT_SKILLS env > selected_skill
|
|
||||||
if skills is not None:
|
|
||||||
skills_list = skills
|
|
||||||
elif selected_skill != SKILL:
|
|
||||||
# persona.yaml meng-override skill → pakai skill dari persona.yaml
|
|
||||||
skills_list = [selected_skill] if selected_skill in ("programmer", "roleplayer", "analyst") else []
|
|
||||||
else:
|
|
||||||
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:
|
|
||||||
skills_list = [selected_skill] if selected_skill in ("programmer", "roleplayer", "analyst") else []
|
|
||||||
|
|
||||||
# ── 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())
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import random
|
|
||||||
import signal
|
import signal
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -8,35 +7,11 @@ from datetime import datetime
|
|||||||
from slixmpp import ClientXMPP
|
from slixmpp import ClientXMPP
|
||||||
from services.session_manager import SessionManager
|
from services.session_manager import SessionManager
|
||||||
|
|
||||||
import config
|
|
||||||
from tools.roleplayer import should_respond
|
|
||||||
from scripts.persona import PERSONALITY
|
|
||||||
|
|
||||||
# Anti-ban: delay constants for MUC rejoin behavior
|
|
||||||
MUC_REJOIN_INITIAL_DELAY = 5.0 # detik, delay awal sebelum rejoin
|
|
||||||
MUC_REJOIN_BACKOFF_MULT = 2.0 # multiplier exponential backoff
|
|
||||||
MUC_REJOIN_MAX_DELAY = 300.0 # detik, batas max backoff (5 menit)
|
|
||||||
MUC_REJOIN_COOLDOWN = 10.0 # detik, cooldown minimum antar rejoin attempt
|
|
||||||
MUC_NICK_SUFFIX_MAX = 3 # max coba nick alternatif (anti-ban: jangan terlalu banyak)
|
|
||||||
|
|
||||||
|
|
||||||
def _ts():
|
def _ts():
|
||||||
return datetime.now().strftime('%H:%M:%S')
|
return datetime.now().strftime('%H:%M:%S')
|
||||||
|
|
||||||
|
|
||||||
def _typing_delay(text: str) -> float:
|
|
||||||
"""Hitung delay mengetik (detik) proporsional dengan panjang teks."""
|
|
||||||
char_count = len(text) if text else 0
|
|
||||||
delay = char_count / config.TYPING_SPEED
|
|
||||||
return max(1.0, min(delay, config.TYPING_MAX))
|
|
||||||
|
|
||||||
|
|
||||||
async def _read_delay():
|
|
||||||
"""Delay simulasi membaca pesan user."""
|
|
||||||
delay = random.uniform(config.READ_DELAY_MIN, config.READ_DELAY_MAX)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
|
|
||||||
|
|
||||||
class XMPPClient(ClientXMPP):
|
class XMPPClient(ClientXMPP):
|
||||||
def __init__(self, jid, password, llm_client, tools_definition, TOOLS,
|
def __init__(self, jid, password, llm_client, tools_definition, TOOLS,
|
||||||
TOOL_HANDLERS, build_system_prompt, agent_max_iterations,
|
TOOL_HANDLERS, build_system_prompt, agent_max_iterations,
|
||||||
@ -49,22 +24,14 @@ class XMPPClient(ClientXMPP):
|
|||||||
self._TOOL_HANDLERS = TOOL_HANDLERS
|
self._TOOL_HANDLERS = TOOL_HANDLERS
|
||||||
self._build_system_prompt = build_system_prompt
|
self._build_system_prompt = build_system_prompt
|
||||||
self._max_iterations = agent_max_iterations
|
self._max_iterations = agent_max_iterations
|
||||||
self._skill = config.AGENT_SKILL
|
|
||||||
self._muc_rooms = muc_rooms or []
|
self._muc_rooms = muc_rooms or []
|
||||||
# Custom nick dari config, fallback ke username JID
|
self._muc_nick = jid.split('@')[0]
|
||||||
self._muc_nick = config.XMPP_NICKNAME.strip() or jid.split('@')[0]
|
|
||||||
self._muc_nick_suffix = 0 # counter untuk nick alternatif saat 409
|
|
||||||
self._muc_ready: set[str] = set()
|
self._muc_ready: set[str] = set()
|
||||||
|
|
||||||
self._session_mgr = SessionManager()
|
self._session_mgr = SessionManager()
|
||||||
self._loop = None
|
self._loop = None
|
||||||
self._stopped: asyncio.Event | None = None
|
self._stopped: asyncio.Event | None = None
|
||||||
|
|
||||||
# Anti-ban: MUC rejoin tracking per room
|
|
||||||
self._muc_rejoin_attempts: dict[str, int] = {} # room -> jumlah attempt
|
|
||||||
self._muc_rejoin_tasks: dict[str, asyncio.Task] = {} # room -> pending rejoin task
|
|
||||||
self._muc_last_join: dict[str, datetime] = {} # room -> terakhir join (cooldown)
|
|
||||||
|
|
||||||
self.auto_reconnect = True
|
self.auto_reconnect = True
|
||||||
|
|
||||||
self.register_plugin('xep_0030')
|
self.register_plugin('xep_0030')
|
||||||
@ -78,147 +45,22 @@ class XMPPClient(ClientXMPP):
|
|||||||
self.add_event_handler('connected', self._on_connected)
|
self.add_event_handler('connected', self._on_connected)
|
||||||
self.add_event_handler('groupchat_presence', self._on_muc_presence)
|
self.add_event_handler('groupchat_presence', self._on_muc_presence)
|
||||||
|
|
||||||
def _get_muc_nick(self, room: str) -> str:
|
|
||||||
"""Anti-ban: resolve nick untuk room, coba nick alternatif kalau conflict."""
|
|
||||||
base = config.XMPP_NICKNAME.strip() or self._muc_nick
|
|
||||||
suffix = self._muc_rejoin_attempts.get("_nick_" + room, 0)
|
|
||||||
if suffix == 0:
|
|
||||||
return base
|
|
||||||
# Anti-ban: append suffix untuk menghindari 409 Conflict
|
|
||||||
return f"{base}_{suffix}"
|
|
||||||
|
|
||||||
def _calc_rejoin_delay(self, room: str) -> float:
|
|
||||||
"""Anti-ban: hitung delay rejoin dengan exponential backoff."""
|
|
||||||
attempts = self._muc_rejoin_attempts.get(room, 0)
|
|
||||||
delay = MUC_REJOIN_INITIAL_DELAY * (MUC_REJOIN_BACKOFF_MULT ** attempts)
|
|
||||||
return min(delay, MUC_REJOIN_MAX_DELAY)
|
|
||||||
|
|
||||||
def _schedule_muc_rejoin(self, room: str):
|
|
||||||
"""Anti-ban: schedule rejoin room dengan backoff & cooldown."""
|
|
||||||
# Cancel pending rejoin task untuk room yang sama (anti-ban: avoid duplicate rejoin)
|
|
||||||
pending = self._muc_rejoin_tasks.get(room)
|
|
||||||
if pending and not pending.done():
|
|
||||||
pending.cancel()
|
|
||||||
print(f'[{_ts()}] MUC [{room}] Cancelled pending rejoin (new trigger)')
|
|
||||||
|
|
||||||
# Check cooldown: jangan rejoin terlalu cepat berturut-turut
|
|
||||||
now = datetime.now()
|
|
||||||
last_join = self._muc_last_join.get(room)
|
|
||||||
if last_join:
|
|
||||||
elapsed = (now - last_join).total_seconds()
|
|
||||||
if elapsed < MUC_REJOIN_COOLDOWN:
|
|
||||||
# Anti-ban: too soon, schedule delayed rejoin instead of immediate
|
|
||||||
cooldown_left = MUC_REJOIN_COOLDOWN - elapsed
|
|
||||||
print(f'[{_ts()}] MUC [{room}] Cooldown active ({cooldown_left:.0f}s left), delaying rejoin')
|
|
||||||
delay = cooldown_left + self._calc_rejoin_delay(room)
|
|
||||||
else:
|
|
||||||
delay = self._calc_rejoin_delay(room)
|
|
||||||
else:
|
|
||||||
delay = self._calc_rejoin_delay(room)
|
|
||||||
|
|
||||||
# Increment attempt counter (anti-ban: track for exponential backoff)
|
|
||||||
attempts = self._muc_rejoin_attempts.get(room, 0) + 1
|
|
||||||
self._muc_rejoin_attempts[room] = attempts
|
|
||||||
|
|
||||||
print(f'[{_ts()}] MUC [{room}] Rejoin scheduled in {delay:.0f}s (attempt #{attempts})')
|
|
||||||
|
|
||||||
if self._loop and not self._loop.is_closed():
|
|
||||||
task = asyncio.run_coroutine_threadsafe(
|
|
||||||
self._muc_rejoin_coro(room, delay), self._loop
|
|
||||||
)
|
|
||||||
self._muc_rejoin_tasks[room] = task
|
|
||||||
|
|
||||||
async def _muc_rejoin_coro(self, room: str, delay: float):
|
|
||||||
"""Anti-ban: coroutine untuk rejoin room setelah delay."""
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
# Double-check: jangan rejoin kalau sudah di _muc_ready
|
|
||||||
if room in self._muc_ready:
|
|
||||||
print(f'[{_ts()}] MUC [{room}] Already ready, skip rejoin')
|
|
||||||
return
|
|
||||||
nick = self._get_muc_nick(room)
|
|
||||||
print(f'[{_ts()}] MUC [{room}] Rejoining as {nick}...')
|
|
||||||
await self.plugin['xep_0045'].join_muc_wait(room, nick, maxstanzas=0)
|
|
||||||
self._muc_last_join[room] = datetime.now()
|
|
||||||
# _muc_ready akan di-set oleh _on_muc_presence saat join berhasil
|
|
||||||
self._muc_rejoin_attempts.pop(room, None)
|
|
||||||
self._muc_rejoin_attempts.pop("_nick_" + room, None)
|
|
||||||
print(f'[{_ts()}] MUC [{room}] Rejoin successful as {nick}')
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
print(f'[{_ts()}] MUC [{room}] Rejoin cancelled')
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[{_ts()}] MUC [{room}] Rejoin failed: {e}')
|
|
||||||
# Anti-ban: handle 409 Conflict - nick sudah dipakai orang lain
|
|
||||||
if '409' in str(e) or 'conflict' in str(e).lower():
|
|
||||||
nick_attempts = self._muc_rejoin_attempts.get("_nick_" + room, 0)
|
|
||||||
if nick_attempts < MUC_NICK_SUFFIX_MAX:
|
|
||||||
# Anti-ban: coba nick alternatif (lily_, lily__)
|
|
||||||
self._muc_rejoin_attempts["_nick_" + room] = nick_attempts + 1
|
|
||||||
new_nick = self._get_muc_nick(room)
|
|
||||||
print(f'[{_ts()}] MUC [{room}] Nick conflict, trying alternative: {new_nick}')
|
|
||||||
# Retry segera dengan nick baru (tanpa backoff rejoin, tapi tetap ada delay biasa)
|
|
||||||
self._schedule_muc_rejoin(room)
|
|
||||||
else:
|
|
||||||
# Anti-ban: semua nick alternativehabis, stop retry untuk avoid ban
|
|
||||||
print(f'[{_ts()}] MUC [{room}] All nick variations exhausted, skipping room')
|
|
||||||
print(f'[{_ts()}] MUC [{room}] Set XMPP_NICKNAME in .env to a unique nick')
|
|
||||||
else:
|
|
||||||
# Anti-ban: error biasa (network, dll), retry with backoff
|
|
||||||
self._schedule_muc_rejoin(room)
|
|
||||||
|
|
||||||
async def _on_connected(self, event):
|
async def _on_connected(self, event):
|
||||||
print(f'[{_ts()}] XMPP connected')
|
print(f'[{_ts()}] XMPP connected')
|
||||||
|
|
||||||
async def _on_disconnected(self, event):
|
async def _on_disconnected(self, event):
|
||||||
print(f'[{_ts()}] XMPP disconnected')
|
print(f'[{_ts()}] XMPP disconnected')
|
||||||
# Anti-ban: cancel all pending rejoin tasks on disconnect
|
|
||||||
for room, task in list(self._muc_rejoin_tasks.items()):
|
|
||||||
if not task.done():
|
|
||||||
task.cancel()
|
|
||||||
print(f'[{_ts()}] MUC [{room}] Cancelled pending rejoin (disconnected)')
|
|
||||||
self._muc_rejoin_tasks.clear()
|
|
||||||
|
|
||||||
async def _on_session_start(self, event):
|
async def _on_session_start(self, event):
|
||||||
self.send_presence()
|
self.send_presence()
|
||||||
self.get_roster()
|
self.get_roster()
|
||||||
print(f'[{_ts()}] XMPP online as {self.boundjid.full}')
|
print(f'[{_ts()}] XMPP online as {self.boundjid.full}')
|
||||||
for room in self._muc_rooms:
|
for room in self._muc_rooms:
|
||||||
# Anti-ban: retry join dengan incremental delay & nick fallback
|
|
||||||
success = False
|
|
||||||
for attempt in range(1, 4):
|
|
||||||
nick = self._get_muc_nick(room)
|
|
||||||
try:
|
try:
|
||||||
await self.plugin['xep_0045'].join_muc_wait(room, nick, maxstanzas=0)
|
await self.plugin['xep_0045'].join_muc_wait(room, self._muc_nick, maxstanzas=0)
|
||||||
print(f'[{_ts()}] Joined MUC room: {room} as {nick}')
|
print(f'[{_ts()}] Joined MUC room: {room}')
|
||||||
self._muc_last_join[room] = datetime.now()
|
|
||||||
self._muc_rejoin_attempts.pop(room, None)
|
|
||||||
self._muc_rejoin_attempts.pop("_nick_" + room, None)
|
|
||||||
success = True
|
|
||||||
break
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'[{_ts()}] MUC join attempt #{attempt} failed ({room}): {e}')
|
print(f'[{_ts()}] MUC join failed ({room}): {e}')
|
||||||
# Anti-ban: handle 409 Conflict - coba nick alternatif
|
|
||||||
if '409' in str(e) or 'conflict' in str(e).lower():
|
|
||||||
nick_attempts = self._muc_rejoin_attempts.get("_nick_" + room, 0)
|
|
||||||
if nick_attempts < MUC_NICK_SUFFIX_MAX:
|
|
||||||
nick_attempts += 1
|
|
||||||
self._muc_rejoin_attempts["_nick_" + room] = nick_attempts
|
|
||||||
print(f'[{_ts()}] MUC [{room}] Nick conflict, switching to: {self._get_muc_nick(room)}')
|
|
||||||
# Retry segera dengan nick baru (jangan wait)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# Anti-ban: semua nick alternatif habis
|
|
||||||
print(f'[{_ts()}] MUC [{room}] All nick variations exhausted')
|
|
||||||
break
|
|
||||||
elif attempt < 3:
|
|
||||||
# Anti-ban: error biasa, wait before retry (2s, 4s)
|
|
||||||
retry_delay = 2.0 * attempt
|
|
||||||
print(f'[{_ts()}] MUC [{room}] Retrying in {retry_delay:.0f}s...')
|
|
||||||
await asyncio.sleep(retry_delay)
|
|
||||||
if not success:
|
|
||||||
# Anti-ban: semua attempt gagal, schedule background rejoin
|
|
||||||
print(f'[{_ts()}] MUC [{room}] All join attempts failed, scheduling background rejoin')
|
|
||||||
self._schedule_muc_rejoin(room)
|
|
||||||
|
|
||||||
def _on_message(self, msg):
|
def _on_message(self, msg):
|
||||||
if msg['type'] not in ('chat', 'normal'):
|
if msg['type'] not in ('chat', 'normal'):
|
||||||
@ -233,9 +75,8 @@ class XMPPClient(ClientXMPP):
|
|||||||
def _on_groupchat_message(self, msg):
|
def _on_groupchat_message(self, msg):
|
||||||
if msg['type'] != 'groupchat':
|
if msg['type'] != 'groupchat':
|
||||||
return
|
return
|
||||||
room = msg['from'].bare
|
|
||||||
nick = msg['from'].resource
|
nick = msg['from'].resource
|
||||||
if self._is_my_nick(room, nick):
|
if nick == self._muc_nick:
|
||||||
return
|
return
|
||||||
room = msg['from'].bare
|
room = msg['from'].bare
|
||||||
if room not in self._muc_ready:
|
if room not in self._muc_ready:
|
||||||
@ -246,45 +87,22 @@ class XMPPClient(ClientXMPP):
|
|||||||
print(f'[{_ts()}] MUC [{room}] <{nick}>: {body[:60]}')
|
print(f'[{_ts()}] MUC [{room}] <{nick}>: {body[:60]}')
|
||||||
threading.Thread(target=self._process_muc, args=(room, nick, body), daemon=True).start()
|
threading.Thread(target=self._process_muc, args=(room, nick, body), daemon=True).start()
|
||||||
|
|
||||||
def _is_my_nick(self, room: str, nick: str) -> bool:
|
|
||||||
"""Anti-ban: cek apakah nick yang dimasukan sesuai dengan nick bot di room."""
|
|
||||||
expected = self._get_muc_nick(room)
|
|
||||||
# Bandingkan dengan nick yang diharapkan, plus base nick tanpa suffix
|
|
||||||
base = config.XMPP_NICKNAME.strip() or self._muc_nick
|
|
||||||
return nick == expected or nick == base
|
|
||||||
|
|
||||||
def _on_muc_presence(self, presence):
|
def _on_muc_presence(self, presence):
|
||||||
room = presence['from'].bare
|
room = presence['from'].bare
|
||||||
nick = presence['from'].resource
|
nick = presence['from'].resource
|
||||||
ptype = presence['type']
|
ptype = presence['type']
|
||||||
if self._is_my_nick(room, nick) and ptype not in ('unavailable', 'error'):
|
if nick == self._muc_nick and ptype not in ('unavailable', 'error'):
|
||||||
self._muc_ready.add(room)
|
self._muc_ready.add(room)
|
||||||
# Reset rejoin counter on successful join (anti-ban: avoid accumulating backoff)
|
|
||||||
self._muc_rejoin_attempts.pop(room, None)
|
|
||||||
self._muc_rejoin_attempts.pop("_nick_" + room, None)
|
|
||||||
if ptype == 'unavailable':
|
if ptype == 'unavailable':
|
||||||
print(f'[{_ts()}] MUC [{room}] <{nick}> left')
|
print(f'[{_ts()}] MUC [{room}] <{nick}> left')
|
||||||
# Anti-ban: remove from ready set on unavailable to keep state consistent
|
|
||||||
self._muc_ready.discard(room)
|
|
||||||
# Anti-ban: trigger auto-rejoin with exponential backoff
|
|
||||||
if self._is_my_nick(room, nick):
|
|
||||||
self._schedule_muc_rejoin(room)
|
|
||||||
elif ptype == 'error':
|
elif ptype == 'error':
|
||||||
print(f'[{_ts()}] MUC [{room}] error: {presence}')
|
print(f'[{_ts()}] MUC [{room}] error: {presence}')
|
||||||
# Anti-ban: also rejoin on error (e.g. temporary failure)
|
|
||||||
if self._is_my_nick(room, nick):
|
|
||||||
self._muc_ready.discard(room)
|
|
||||||
self._schedule_muc_rejoin(room)
|
|
||||||
else:
|
else:
|
||||||
print(f'[{_ts()}] MUC [{room}] <{nick}> joined (type={ptype})')
|
print(f'[{_ts()}] MUC [{room}] <{nick}> joined (type={ptype})')
|
||||||
|
|
||||||
def _process_dm(self, jid, body):
|
def _process_dm(self, jid, body):
|
||||||
session = self._session_mgr.get_or_create(
|
session = self._session_mgr.get_or_create(
|
||||||
jid, self._build_system_prompt(
|
jid, self._build_system_prompt(self._tools_def)
|
||||||
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()
|
session.cancel_timer()
|
||||||
|
|
||||||
@ -298,26 +116,14 @@ class XMPPClient(ClientXMPP):
|
|||||||
|
|
||||||
session.add_message('user', body)
|
session.add_message('user', body)
|
||||||
|
|
||||||
is_roleplay = self._skill == 'roleplayer'
|
|
||||||
if not is_roleplay:
|
|
||||||
self._schedule_send(jid, f'> {body}\nThinking...')
|
self._schedule_send(jid, f'> {body}\nThinking...')
|
||||||
|
self._agent_loop(session, jid, body, 'chat')
|
||||||
|
|
||||||
# Delay 1: simulasi membaca pesan user
|
session.start_timer(300, self._timeout_session, jid, 'chat')
|
||||||
if self._loop and not self._loop.is_closed():
|
|
||||||
asyncio.run_coroutine_threadsafe(_read_delay(), self._loop)
|
|
||||||
|
|
||||||
self._agent_loop(session, jid, body, 'chat', sender_nickname=jid)
|
|
||||||
|
|
||||||
# DM: timeout 24 jam (efektif tidak auto-close), MUC tetap 5 menit
|
|
||||||
session.start_timer(86400, self._timeout_session, jid, 'chat')
|
|
||||||
|
|
||||||
def _process_muc(self, room, nick, body):
|
def _process_muc(self, room, nick, body):
|
||||||
session = self._session_mgr.get_or_create(
|
session = self._session_mgr.get_or_create(
|
||||||
room, self._build_system_prompt(
|
room, self._build_system_prompt(self._tools_def)
|
||||||
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()
|
session.cancel_timer()
|
||||||
|
|
||||||
@ -330,21 +136,12 @@ class XMPPClient(ClientXMPP):
|
|||||||
prefixed = f'[{nick}] {body}'
|
prefixed = f'[{nick}] {body}'
|
||||||
session.add_message('user', prefixed)
|
session.add_message('user', prefixed)
|
||||||
|
|
||||||
if self._skill != 'roleplayer':
|
|
||||||
self._schedule_send(room, f'> [{nick}] {body}\nThinking...', mtype='groupchat')
|
self._schedule_send(room, f'> [{nick}] {body}\nThinking...', mtype='groupchat')
|
||||||
|
self._agent_loop(session, room, f'[{nick}] {body}', 'groupchat')
|
||||||
# Delay 1: simulasi membaca pesan user
|
|
||||||
if self._loop and not self._loop.is_closed():
|
|
||||||
asyncio.run_coroutine_threadsafe(_read_delay(), self._loop)
|
|
||||||
|
|
||||||
self._agent_loop(session, room, f'[{nick}] {body}', 'groupchat', sender_nickname=nick)
|
|
||||||
|
|
||||||
session.start_timer(300, self._timeout_session, room, 'groupchat')
|
session.start_timer(300, self._timeout_session, room, 'groupchat')
|
||||||
|
|
||||||
def _agent_loop(self, session, to, quote, mtype, sender_nickname=""):
|
def _agent_loop(self, session, to, quote, mtype):
|
||||||
is_roleplayer = self._skill == 'roleplayer'
|
|
||||||
my_name = PERSONALITY.name
|
|
||||||
|
|
||||||
for step in range(self._max_iterations):
|
for step in range(self._max_iterations):
|
||||||
print(f'[{_ts()}] Step {step + 1} — calling LLM...')
|
print(f'[{_ts()}] Step {step + 1} — calling LLM...')
|
||||||
response = self._llm.chat(session.messages, tools=self._TOOLS)
|
response = self._llm.chat(session.messages, tools=self._TOOLS)
|
||||||
@ -359,9 +156,6 @@ class XMPPClient(ClientXMPP):
|
|||||||
|
|
||||||
tnames = [tc['function']['name'] for tc in response.tool_calls]
|
tnames = [tc['function']['name'] for tc in response.tool_calls]
|
||||||
print(f'[{_ts()}] Using tools: {", ".join(tnames)}')
|
print(f'[{_ts()}] Using tools: {", ".join(tnames)}')
|
||||||
|
|
||||||
# Roleplayer tidak perlu kirim status "Using: ..."
|
|
||||||
if not is_roleplayer:
|
|
||||||
self._schedule_send(to, f'> {quote}\nUsing: {", ".join(tnames)}', mtype)
|
self._schedule_send(to, f'> {quote}\nUsing: {", ".join(tnames)}', mtype)
|
||||||
|
|
||||||
for tc in response.tool_calls:
|
for tc in response.tool_calls:
|
||||||
@ -373,41 +167,8 @@ class XMPPClient(ClientXMPP):
|
|||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
if response.content:
|
if response.content:
|
||||||
print(f'[{_ts()}] Response generated ({len(response.content)} chars)')
|
print(f'[{_ts()}] Response sent ({len(response.content)} chars)')
|
||||||
session.messages.append({'role': 'assistant', 'content': response.content})
|
session.messages.append({'role': 'assistant', 'content': response.content})
|
||||||
|
|
||||||
# ── Roleplayer: cek need_response sebelum kirim ──
|
|
||||||
if is_roleplayer:
|
|
||||||
if config.XMPP_SELECTIVE_RESPONSE:
|
|
||||||
# Build recent history dari session messages (tanpa system prompt)
|
|
||||||
recent_msgs = []
|
|
||||||
for msg in session.messages[-6:]:
|
|
||||||
if msg.get('role') == 'user':
|
|
||||||
recent_msgs.append(f"User: {msg.get('content', '')}")
|
|
||||||
elif msg.get('role') == 'assistant' and msg.get('content'):
|
|
||||||
recent_msgs.append(f"{my_name}: {msg.get('content', '')}")
|
|
||||||
recent_history = "\n".join(recent_msgs)
|
|
||||||
|
|
||||||
original_message = quote
|
|
||||||
if should_respond(
|
|
||||||
message=original_message,
|
|
||||||
sender_nickname=sender_nickname,
|
|
||||||
recent_history=recent_history,
|
|
||||||
my_name=my_name,
|
|
||||||
):
|
|
||||||
print(f'[{_ts()}] need_response=True → sending response')
|
|
||||||
self._schedule_send(to, response.content, mtype)
|
|
||||||
else:
|
|
||||||
print(f'[{_ts()}] need_response=False → staying silent')
|
|
||||||
else:
|
|
||||||
# Selective response OFF: cuma respon kalau nama AI disebut di pesan
|
|
||||||
from tools.roleplayer import _name_mentioned
|
|
||||||
if _name_mentioned(my_name, quote):
|
|
||||||
print(f'[{_ts()}] Name mentioned → sending response')
|
|
||||||
self._schedule_send(to, response.content, mtype)
|
|
||||||
else:
|
|
||||||
print(f'[{_ts()}] Name not mentioned → staying silent')
|
|
||||||
else:
|
|
||||||
self._schedule_send(to, f'> {quote}\n{response.content}', mtype)
|
self._schedule_send(to, f'> {quote}\n{response.content}', mtype)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -416,10 +177,6 @@ class XMPPClient(ClientXMPP):
|
|||||||
'role': 'assistant',
|
'role': 'assistant',
|
||||||
'content': 'Max iterations reached without final answer.',
|
'content': 'Max iterations reached without final answer.',
|
||||||
})
|
})
|
||||||
|
|
||||||
if is_roleplayer:
|
|
||||||
self._schedule_send(to, 'Max iterations reached without final answer.', mtype)
|
|
||||||
else:
|
|
||||||
self._schedule_send(to, f'> {quote}\nMax iterations reached without final answer.', mtype)
|
self._schedule_send(to, f'> {quote}\nMax iterations reached without final answer.', mtype)
|
||||||
|
|
||||||
def _execute_tool(self, tool_call):
|
def _execute_tool(self, tool_call):
|
||||||
@ -452,11 +209,6 @@ class XMPPClient(ClientXMPP):
|
|||||||
|
|
||||||
async def _send_coro(self, to, body, mtype):
|
async def _send_coro(self, to, body, mtype):
|
||||||
try:
|
try:
|
||||||
# Delay 2: simulasi mengetik (proporsional dengan panjang pesan)
|
|
||||||
delay = _typing_delay(body)
|
|
||||||
print(f'[{_ts()}] Typing delay: {delay:.1f}s ({len(body)} chars)')
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
|
|
||||||
msg = self.make_message(mto=to, mbody=body, mtype=mtype)
|
msg = self.make_message(mto=to, mbody=body, mtype=mtype)
|
||||||
msg.send()
|
msg.send()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -474,15 +226,11 @@ class XMPPClient(ClientXMPP):
|
|||||||
async def _run(self):
|
async def _run(self):
|
||||||
self._stopped = asyncio.Event()
|
self._stopped = asyncio.Event()
|
||||||
self._loop = asyncio.get_running_loop()
|
self._loop = asyncio.get_running_loop()
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGHUP):
|
||||||
# Hanya tangani SIGTERM untuk shutdown.
|
|
||||||
# SENGATKAN SIGHUP: nohup kirim SIGHUP saat terminal close,
|
|
||||||
# dan kita tidak mau proses mati karena itu.
|
|
||||||
try:
|
try:
|
||||||
self._loop.add_signal_handler(signal.SIGTERM, self._stopped.set)
|
self._loop.add_signal_handler(sig, self._stopped.set)
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
await self.connect()
|
await self.connect()
|
||||||
try:
|
try:
|
||||||
await self._stopped.wait()
|
await self._stopped.wait()
|
||||||
|
|||||||
@ -1,94 +0,0 @@
|
|||||||
import json
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
schema_sendhttprequest = {
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "sendhttprequest",
|
|
||||||
"description": "Send an HTTP request with full control over method, headers, and body/params.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"url": {"type": "string", "description": "Target URL"},
|
|
||||||
"method": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
||||||
"description": "HTTP method"
|
|
||||||
},
|
|
||||||
"authorization": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Bearer token or full Authorization header value (e.g. 'Bearer <token>' or 'Basic <base64>')"
|
|
||||||
},
|
|
||||||
"content_type": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Content-Type header (e.g. 'application/json', 'application/x-www-form-urlencoded')"
|
|
||||||
},
|
|
||||||
"data": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "JSON body for POST/PUT/PATCH requests (will be serialized as JSON). Ignored for GET/DELETE/HEAD/OPTIONS."
|
|
||||||
},
|
|
||||||
"params": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Query parameters as key-value dict (appended to URL)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["url", "method"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def sendhttprequest(url, method, authorization=None, content_type=None, data=None, params=None):
|
|
||||||
try:
|
|
||||||
if params:
|
|
||||||
url_parts = list(urllib.parse.urlparse(url))
|
|
||||||
query = dict(urllib.parse.parse_qsl(url_parts[4]))
|
|
||||||
query.update(params)
|
|
||||||
url_parts[4] = urllib.parse.urlencode(query)
|
|
||||||
url = urllib.parse.urlunparse(url_parts)
|
|
||||||
|
|
||||||
headers = {}
|
|
||||||
if authorization:
|
|
||||||
headers["Authorization"] = authorization
|
|
||||||
if content_type:
|
|
||||||
headers["Content-Type"] = content_type
|
|
||||||
|
|
||||||
body = None
|
|
||||||
if data is not None and method.upper() in ("POST", "PUT", "PATCH"):
|
|
||||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
||||||
if content_type is None:
|
|
||||||
headers["Content-Type"] = "application/json"
|
|
||||||
|
|
||||||
req = urllib.request.Request(url, data=body, headers=headers, method=method.upper())
|
|
||||||
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
||||||
status = resp.status
|
|
||||||
resp_headers = dict(resp.getheaders())
|
|
||||||
raw = resp.read()
|
|
||||||
content_type_resp = resp_headers.get("Content-Type", "")
|
|
||||||
if "application/json" in content_type_resp:
|
|
||||||
try:
|
|
||||||
resp_body = json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
resp_body = raw.decode("utf-8", errors="replace")
|
|
||||||
else:
|
|
||||||
resp_body = raw.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
return json.dumps({
|
|
||||||
"status": status,
|
|
||||||
"headers": resp_headers,
|
|
||||||
"body": resp_body
|
|
||||||
}, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
return json.dumps({
|
|
||||||
"status": e.code,
|
|
||||||
"headers": dict(e.headers),
|
|
||||||
"body": e.read().decode("utf-8", errors="replace")
|
|
||||||
}, ensure_ascii=False, indent=2)
|
|
||||||
except urllib.error.URLError as e:
|
|
||||||
return f"Error: {e.reason}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {str(e)}"
|
|
||||||
@ -146,10 +146,7 @@ schema_git_operation = {
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "git_operation",
|
"name": "git_operation",
|
||||||
"description": "Run a git command. Pass the git arguments as a list (e.g., ['status', '--short'] for 'git status --short'). "
|
"description": "Run a git command. Pass the git arguments as a list (e.g., ['status', '--short'] for 'git status --short').",
|
||||||
"POLICY: Never run 'git add' or 'git commit' without explicit user permission. "
|
|
||||||
"Safe to run without asking: git status, git diff, git log. "
|
|
||||||
"Always ask first before committing.",
|
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
188
tools/rag.py
188
tools/rag.py
@ -1,7 +1,4 @@
|
|||||||
import glob as globmod
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
import chromadb
|
import chromadb
|
||||||
from chromadb.config import Settings
|
from chromadb.config import Settings
|
||||||
@ -186,50 +183,6 @@ schema_inspect_collection = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
schema_ingest_files = {
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "ingest_files",
|
|
||||||
"description": (
|
|
||||||
"Read one or more files (supports glob patterns like *.py or src/**/*.md) "
|
|
||||||
"and store their content into a RAG collection. "
|
|
||||||
"Optionally chunk files into smaller pieces by line count. "
|
|
||||||
"Automatically extracts metadata: filename, path, extension, size, modification time."
|
|
||||||
),
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"collection": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Target collection name (will be created if it doesn't exist)"
|
|
||||||
},
|
|
||||||
"paths": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {"type": "string"},
|
|
||||||
"description": "File paths or glob patterns (e.g., ['*.txt', 'src/**/*.py'])"
|
|
||||||
},
|
|
||||||
"chunk_size": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Lines per chunk (0 = whole file as one document)",
|
|
||||||
"default": 0
|
|
||||||
},
|
|
||||||
"chunk_overlap": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Line overlap between chunks (only used when chunk_size > 0)",
|
|
||||||
"default": 0
|
|
||||||
},
|
|
||||||
"recursive": {
|
|
||||||
"type": "boolean",
|
|
||||||
"description": "Search directories recursively when using glob patterns",
|
|
||||||
"default": True
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["collection", "paths"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Tool handlers ────────────────────────────────────────────────────
|
# ── Tool handlers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _sanitize_meta(meta):
|
def _sanitize_meta(meta):
|
||||||
@ -341,144 +294,3 @@ def inspect_collection(collection, sample_size=3):
|
|||||||
return "\n".join(out)
|
return "\n".join(out)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
|
||||||
def ingest_files(collection, paths, chunk_size=0, chunk_overlap=0, recursive=True):
|
|
||||||
try:
|
|
||||||
col = _collection(collection)
|
|
||||||
all_ids, all_texts, all_metas = [], [], []
|
|
||||||
processed, skipped = 0, 0
|
|
||||||
|
|
||||||
# Expand glob patterns into real file paths
|
|
||||||
file_set = set()
|
|
||||||
for p in paths:
|
|
||||||
expanded = globmod.glob(p, recursive=recursive)
|
|
||||||
if expanded:
|
|
||||||
file_set.update(expanded)
|
|
||||||
else:
|
|
||||||
# Maybe it's a literal path that doesn't look like a glob
|
|
||||||
if os.path.isfile(p):
|
|
||||||
file_set.add(p)
|
|
||||||
else:
|
|
||||||
skipped += 1
|
|
||||||
|
|
||||||
if not file_set:
|
|
||||||
return "No matching files found."
|
|
||||||
|
|
||||||
for fpath in sorted(file_set):
|
|
||||||
if not os.path.isfile(fpath):
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
ext = os.path.splitext(fpath)[1].lower()
|
|
||||||
stat = os.stat(fpath)
|
|
||||||
base_meta = {
|
|
||||||
"filename": os.path.basename(fpath),
|
|
||||||
"path": os.path.relpath(fpath),
|
|
||||||
"extension": ext,
|
|
||||||
"size": stat.st_size,
|
|
||||||
"mtime": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(stat.st_mtime)),
|
|
||||||
}
|
|
||||||
base_name = os.path.splitext(os.path.basename(fpath))[0]
|
|
||||||
|
|
||||||
# ── read content ──────────────────────────────────────────
|
|
||||||
if ext in (".xlsx", ".xlsm"):
|
|
||||||
try:
|
|
||||||
import openpyxl
|
|
||||||
except ImportError:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
wb = openpyxl.load_workbook(fpath, read_only=True, data_only=True)
|
|
||||||
for sheet_name in wb.sheetnames:
|
|
||||||
ws = wb[sheet_name]
|
|
||||||
rows = []
|
|
||||||
for row in ws.iter_rows(values_only=True):
|
|
||||||
vals = [str(c) if c is not None else "" for c in row]
|
|
||||||
rows.append("\t".join(vals))
|
|
||||||
lines = rows
|
|
||||||
content = "\n".join(lines)
|
|
||||||
if not content.strip():
|
|
||||||
continue
|
|
||||||
|
|
||||||
sheet_meta = dict(base_meta)
|
|
||||||
sheet_meta["sheet"] = sheet_name
|
|
||||||
|
|
||||||
if chunk_size > 0:
|
|
||||||
n_lines = len(lines)
|
|
||||||
cid = 0
|
|
||||||
start = 0
|
|
||||||
while start < n_lines:
|
|
||||||
end = min(start + chunk_size, n_lines)
|
|
||||||
chunk_text = "\n".join(lines[start:end])
|
|
||||||
doc_id = f"{base_name}_{sheet_name}_chunk_{cid}"
|
|
||||||
meta = dict(sheet_meta)
|
|
||||||
meta["chunk_index"] = cid
|
|
||||||
meta["chunk_lines"] = end - start
|
|
||||||
meta["chunk_start_line"] = start + 1
|
|
||||||
all_ids.append(doc_id)
|
|
||||||
all_texts.append(chunk_text)
|
|
||||||
all_metas.append(_sanitize_meta(meta))
|
|
||||||
cid += 1
|
|
||||||
step = chunk_size - chunk_overlap
|
|
||||||
start += step if step > 0 else 1
|
|
||||||
processed += 1
|
|
||||||
else:
|
|
||||||
doc_id = f"{base_name}_{sheet_name}"
|
|
||||||
all_ids.append(doc_id)
|
|
||||||
all_texts.append(content)
|
|
||||||
all_metas.append(_sanitize_meta(sheet_meta))
|
|
||||||
processed += 1
|
|
||||||
wb.close()
|
|
||||||
else:
|
|
||||||
# Plain-text files
|
|
||||||
try:
|
|
||||||
with open(fpath, "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
except Exception:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
content = "".join(lines)
|
|
||||||
if not content.strip():
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if chunk_size > 0:
|
|
||||||
n_lines = len(lines)
|
|
||||||
cid = 0
|
|
||||||
start = 0
|
|
||||||
while start < n_lines:
|
|
||||||
end = min(start + chunk_size, n_lines)
|
|
||||||
chunk_text = "".join(lines[start:end])
|
|
||||||
doc_id = f"{base_name}_chunk_{cid}"
|
|
||||||
meta = dict(base_meta)
|
|
||||||
meta["chunk_index"] = cid
|
|
||||||
meta["chunk_lines"] = end - start
|
|
||||||
meta["chunk_start_line"] = start + 1
|
|
||||||
all_ids.append(doc_id)
|
|
||||||
all_texts.append(chunk_text)
|
|
||||||
all_metas.append(_sanitize_meta(meta))
|
|
||||||
cid += 1
|
|
||||||
step = chunk_size - chunk_overlap
|
|
||||||
start += step if step > 0 else 1
|
|
||||||
processed += 1
|
|
||||||
else:
|
|
||||||
doc_id = base_name
|
|
||||||
all_ids.append(doc_id)
|
|
||||||
all_texts.append(content)
|
|
||||||
all_metas.append(_sanitize_meta(base_meta))
|
|
||||||
processed += 1
|
|
||||||
|
|
||||||
if all_ids:
|
|
||||||
col.add(ids=all_ids, documents=all_texts, metadatas=all_metas)
|
|
||||||
|
|
||||||
parts = [f"Ingested {processed} file(s) into '{collection}'"]
|
|
||||||
if processed > 0:
|
|
||||||
parts.append(f"({len(all_ids)} document(s) total)")
|
|
||||||
if skipped > 0:
|
|
||||||
parts.append(f"({skipped} file(s) skipped)")
|
|
||||||
return " ".join(parts)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|||||||
@ -1,102 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def _name_mentioned(name: str, text: str) -> bool:
|
|
||||||
"""Cek apakah nama AI disebut dalam pesan (case-insensitive, word-boundary)."""
|
|
||||||
text_lower = text.lower()
|
|
||||||
name_lower = name.lower()
|
|
||||||
pattern = r'\b' + re.escape(name_lower) + r'\b'
|
|
||||||
return bool(re.search(pattern, text_lower))
|
|
||||||
|
|
||||||
|
|
||||||
def need_response(message: str, sender_nickname: str, recent_history: str, my_name: str) -> str:
|
|
||||||
"""
|
|
||||||
Decide whether the AI should respond to a message.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message: The latest message to evaluate.
|
|
||||||
sender_nickname: Nickname of the sender.
|
|
||||||
recent_history: Recent conversation history for context.
|
|
||||||
my_name: The AI's name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
"true" → should respond
|
|
||||||
"false" → should stay silent
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. STRONG — Direct call/mention of AI's name → always True
|
|
||||||
2. BRIEF — Talks about AI in third person → True
|
|
||||||
3. CONTEXTUAL — Related to AI's previous conversation → True
|
|
||||||
4. NO REPLY — Unrelated, confusing, between other people → False
|
|
||||||
"""
|
|
||||||
msg = message.strip()
|
|
||||||
|
|
||||||
# --- Rule 4: Empty message ---
|
|
||||||
if not msg:
|
|
||||||
return "false"
|
|
||||||
|
|
||||||
# --- Rule 1: Direct mention/name call ---
|
|
||||||
if _name_mentioned(my_name, msg):
|
|
||||||
return "true"
|
|
||||||
|
|
||||||
# --- Rule 2: Talks about AI (third person) ---
|
|
||||||
name_parts = my_name.lower().split()
|
|
||||||
text_lower = msg.lower()
|
|
||||||
if any(part in text_lower for part in name_parts if len(part) > 1):
|
|
||||||
return "true"
|
|
||||||
|
|
||||||
# --- Rule 3: Contextual — check recent history ---
|
|
||||||
if recent_history:
|
|
||||||
return "true"
|
|
||||||
|
|
||||||
# --- Rule 4: Default — no strong signal, stay silent ---
|
|
||||||
return "false"
|
|
||||||
|
|
||||||
|
|
||||||
def should_respond(message: str, sender_nickname: str, recent_history: str, my_name: str) -> bool:
|
|
||||||
"""
|
|
||||||
Python-side helper: return boolean directly.
|
|
||||||
Used by XMPP client to gate whether to send the LLM's response.
|
|
||||||
"""
|
|
||||||
result = need_response(message, sender_nickname, recent_history, my_name)
|
|
||||||
return result.strip().lower() == "true"
|
|
||||||
|
|
||||||
|
|
||||||
schema_need_response = {
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "need_response",
|
|
||||||
"description": (
|
|
||||||
"Decide whether you should respond to the current message. "
|
|
||||||
"Use this tool when you are unsure whether to reply or stay silent. "
|
|
||||||
"Returns 'true' if you should respond, 'false' if you should stay silent. "
|
|
||||||
"Rules: "
|
|
||||||
"- Return 'true' if your name is mentioned or someone talks about you. "
|
|
||||||
"- Return 'true' if the message is related to your previous conversation. "
|
|
||||||
"- Return 'false' if the message is between other people, unclear, "
|
|
||||||
"unrelated to you, or you have nothing to add."
|
|
||||||
),
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"message": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The latest message to evaluate.",
|
|
||||||
},
|
|
||||||
"sender_nickname": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The nickname of the person who sent the message.",
|
|
||||||
},
|
|
||||||
"recent_history": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Recent conversation history for context (last few messages).",
|
|
||||||
},
|
|
||||||
"my_name": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Your (the AI's) name.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["message", "sender_nickname", "recent_history", "my_name"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
32
tui/agent.py
32
tui/agent.py
@ -3,26 +3,17 @@ import threading
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from scripts import ntro
|
from scripts import ntro
|
||||||
|
|
||||||
WELCOME_ART = """
|
WELCOME_ART = """\
|
||||||
__ __ _______ __ _ ______ ______ ___ ___ _
|
\n\
|
||||||
| | | || || | | || | | _ | | | | | | |
|
|
||||||
| |_| || ___|| |_| || _ || | || | | | |_| |
|
|
||||||
| || |___ | || | | || |_||_ | | | _|
|
|
||||||
| || ___|| _ || |_| || __ || | | |_
|
|
||||||
| _ || |___ | | | || || | | || | | _ |
|
|
||||||
|__| |__||_______||_| |__||______| |___| |_||___| |___| |_|
|
|
||||||
"""
|
|
||||||
"""
|
|
||||||
╔══════════════════════════════════════════╗
|
╔══════════════════════════════════════════╗
|
||||||
║ ║
|
║ ║
|
||||||
║ /\\_/\\ ║
|
║ /\\_/\\ H E N D R I K ║
|
||||||
║ ( o.o ) HENDRIK ║
|
║ ( o.o ) AI Agent ║
|
||||||
║ > ^ < ║
|
║ > ^ < siap membantu! ║
|
||||||
║ ( ) AI Agent ║
|
║ ( ) ║
|
||||||
║ (___) ║
|
║ (___) ║
|
||||||
║ ║
|
║ ║
|
||||||
╚══════════════════════════════════════════╝
|
╚══════════════════════════════════════════╝"""
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def log(app, role, text):
|
def log(app, role, text):
|
||||||
@ -68,9 +59,6 @@ def _agent_loop(app):
|
|||||||
|
|
||||||
app.log.pop()
|
app.log.pop()
|
||||||
|
|
||||||
if response.warning:
|
|
||||||
log(app, "system", f" {response.warning}")
|
|
||||||
|
|
||||||
if response.tool_calls:
|
if response.tool_calls:
|
||||||
amsg = {
|
amsg = {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
@ -83,11 +71,7 @@ def _agent_loop(app):
|
|||||||
app.scroll = 999999
|
app.scroll = 999999
|
||||||
for tc in response.tool_calls:
|
for tc in response.tool_calls:
|
||||||
tname = tc["function"]["name"]
|
tname = tc["function"]["name"]
|
||||||
targs = tc["function"]["arguments"]
|
log(app, "system", f" \u2192 {tname}")
|
||||||
log(app, "tool_call", json.dumps({
|
|
||||||
"name": tname,
|
|
||||||
"arguments": targs,
|
|
||||||
}))
|
|
||||||
app.scroll = 999999
|
app.scroll = 999999
|
||||||
execute_tool(app, tc)
|
execute_tool(app, tc)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import curses
|
import curses
|
||||||
import threading
|
import threading
|
||||||
import config
|
|
||||||
from .render import init_colors, draw
|
from .render import init_colors, draw
|
||||||
from .input import handle_key
|
from .input import handle_key
|
||||||
from .agent import log, WELCOME_ART
|
from .agent import log, WELCOME_ART
|
||||||
@ -41,11 +40,8 @@ class HendrikTUI:
|
|||||||
stdscr.keypad(True)
|
stdscr.keypad(True)
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
|
|
||||||
self.messages = [{"role": "system", "content": self.build_system_prompt(
|
self.messages = [{"role": "system",
|
||||||
tools_definition=self.tools_def,
|
"content": self.build_system_prompt(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)
|
log(self, "welcome", WELCOME_ART)
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
|
|||||||
102
tui/render.py
102
tui/render.py
@ -3,7 +3,6 @@
|
|||||||
# lalu membaca state dari `app` untuk menggambar di layar.
|
# lalu membaca state dari `app` untuk menggambar di layar.
|
||||||
|
|
||||||
import curses
|
import curses
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
@ -22,8 +21,6 @@ C_INPUT_BORDER = 9 # border input box: biru
|
|||||||
C_STATUS_INFO = 12 # status info (workspace/hints): putih
|
C_STATUS_INFO = 12 # status info (workspace/hints): putih
|
||||||
C_HINT_DISABLED = 13 # hint disabled (abu-abu)
|
C_HINT_DISABLED = 13 # hint disabled (abu-abu)
|
||||||
C_WELCOME = 14 # welcome art: light blue
|
C_WELCOME = 14 # welcome art: light blue
|
||||||
C_TOOL_CALL = 15 # tool call: kuning terang
|
|
||||||
C_TOOL_RESULT = 16 # tool result: magenta muda
|
|
||||||
|
|
||||||
|
|
||||||
def init_colors():
|
def init_colors():
|
||||||
@ -43,8 +40,6 @@ def init_colors():
|
|||||||
curses.init_pair(C_INPUT_BORDER, curses.COLOR_BLUE, -1)
|
curses.init_pair(C_INPUT_BORDER, curses.COLOR_BLUE, -1)
|
||||||
curses.init_pair(C_HINT_DISABLED, 8, -1) # abu-abu di atas bg default
|
curses.init_pair(C_HINT_DISABLED, 8, -1) # abu-abu di atas bg default
|
||||||
curses.init_pair(C_WELCOME, curses.COLOR_BLUE + 8, -1) # light blue
|
curses.init_pair(C_WELCOME, curses.COLOR_BLUE + 8, -1) # light blue
|
||||||
curses.init_pair(C_TOOL_CALL, curses.COLOR_YELLOW + 8, -1) # bright yellow
|
|
||||||
curses.init_pair(C_TOOL_RESULT, curses.COLOR_MAGENTA + 8, -1) # bright magenta
|
|
||||||
|
|
||||||
|
|
||||||
def draw(app, stdscr):
|
def draw(app, stdscr):
|
||||||
@ -79,113 +74,67 @@ def draw_chat(app, stdscr):
|
|||||||
if chat_h <= 0:
|
if chat_h <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Render log ke list of rows; setiap row = list of (color, text) segments.
|
# Render log ke list of (color, text) agar scroll calculation akurat
|
||||||
# Ini memungkinkan satu baris punya multi-warna (misal label tool_call).
|
# Setiap baris di-wrap sesuai lebar terminal
|
||||||
# Setiap baris di-wrap sesuai lebar terminal.
|
rendered = []
|
||||||
rendered = [] # list of list of (color, text)
|
|
||||||
|
|
||||||
def _add_row(segments):
|
|
||||||
# segments: list of (color, text)
|
|
||||||
rendered.append(segments)
|
|
||||||
|
|
||||||
def _add_blank():
|
|
||||||
rendered.append([(None, "")])
|
|
||||||
|
|
||||||
def _wrap_render(text, indent=0, color=C_INPUT):
|
def _wrap_render(text, indent=0, color=C_INPUT):
|
||||||
available = w - indent - 1 # sisakan 1 kolom margin kanan
|
available = w - indent - 1 # sisakan 1 kolom margin kanan
|
||||||
if available <= 0:
|
if available <= 0:
|
||||||
_add_row([(color, " " * indent)])
|
rendered.append((color, " " * indent))
|
||||||
return
|
return
|
||||||
for line in text.split("\n"):
|
for line in text.split("\n"):
|
||||||
if not line:
|
if not line:
|
||||||
_add_row([(color, " " * indent)])
|
rendered.append((color, " " * indent))
|
||||||
continue
|
continue
|
||||||
start = 0
|
start = 0
|
||||||
while start < len(line):
|
while start < len(line):
|
||||||
chunk = line[start:start + available]
|
chunk = line[start:start + available]
|
||||||
_add_row([(color, " " * indent + chunk)])
|
rendered.append((color, " " * indent + chunk))
|
||||||
start += available
|
start += available
|
||||||
|
|
||||||
for idx, item in enumerate(app.log):
|
for idx, item in enumerate(app.log):
|
||||||
role, text = item["role"], item["text"]
|
role, text = item["role"], item["text"]
|
||||||
if role == "sep":
|
if role == "sep":
|
||||||
_add_blank()
|
rendered.append((None, ""))
|
||||||
_add_blank()
|
rendered.append((None, ""))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Tambah blank line sebelum system log setelah user/ai response
|
# Tambah blank line sebelum system log setelah user/ai response
|
||||||
if role == "system" and idx > 0 and app.log[idx - 1]["role"] in ("user", "ai"):
|
if role == "system" and idx > 0 and app.log[idx - 1]["role"] in ("user", "ai"):
|
||||||
_add_blank()
|
rendered.append((None, ""))
|
||||||
|
|
||||||
# Tambah blank line sebelum ai response setelah user (langsung, tanpa tools)
|
# Tambah blank line sebelum ai response setelah user (langsung, tanpa tools)
|
||||||
if role == "ai" and idx > 0 and app.log[idx - 1]["role"] in ("user", "ai"):
|
if role == "ai" and idx > 0 and app.log[idx - 1]["role"] in ("user", "ai"):
|
||||||
_add_blank()
|
rendered.append((None, ""))
|
||||||
|
|
||||||
# Tambah blank line sebelum ai response setelah system log
|
# Tambah blank line sebelum ai response setelah system log
|
||||||
if role == "ai" and idx > 0 and app.log[idx - 1]["role"] == "system":
|
if role == "ai" and idx > 0 and app.log[idx - 1]["role"] == "system":
|
||||||
_add_blank()
|
rendered.append((None, ""))
|
||||||
|
|
||||||
if role == "user":
|
if role == "user":
|
||||||
label = f" You ({item['time']}) "
|
label = f" You ({item['time']}) "
|
||||||
_add_row([(C_USER, label)])
|
rendered.append((C_USER, label))
|
||||||
_wrap_render(text, indent=1, color=C_INPUT)
|
_wrap_render(text, indent=1, color=C_INPUT)
|
||||||
elif role == "ai":
|
elif role == "ai":
|
||||||
label = f" Hendrik ({item['time']}) "
|
label = f" Hendrik ({item['time']}) "
|
||||||
_add_row([(C_AI, label)])
|
rendered.append((C_AI, label))
|
||||||
_wrap_render(text, indent=1, color=C_INPUT)
|
_wrap_render(text, indent=1, color=C_INPUT)
|
||||||
elif role == "system":
|
elif role == "system":
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
_add_row([(C_SYSTEM, lines[0])])
|
rendered.append((C_SYSTEM, lines[0]))
|
||||||
for line in lines[1:]:
|
for line in lines[1:]:
|
||||||
_add_row([(C_SYSTEM, " " + line)])
|
rendered.append((C_SYSTEM, " " + line))
|
||||||
elif role == "welcome":
|
elif role == "welcome":
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
for line in lines:
|
for line in lines:
|
||||||
_add_row([(C_WELCOME, " " + line)])
|
rendered.append((C_WELCOME, " " + line))
|
||||||
elif role == "tool_call":
|
|
||||||
# Format:
|
|
||||||
# (blank line)
|
|
||||||
# Hendrik run_bash (HH:MM) ← "Hendrik" hijau, "run_bash (HH:MM)" kuning
|
|
||||||
# { ← arguments indent 1 spasi, kuning
|
|
||||||
# "command": "ls -la"
|
|
||||||
# }
|
|
||||||
# (blank line)
|
|
||||||
# Blank line di atas (kecuali sebelumnya sudah blank dari role lain)
|
|
||||||
if idx > 0 and app.log[idx - 1]["role"] not in ("sep", "welcome"):
|
|
||||||
_add_blank()
|
|
||||||
try:
|
|
||||||
tc = json.loads(text)
|
|
||||||
tname = tc["name"]
|
|
||||||
targs_raw = tc["arguments"]
|
|
||||||
# Pretty-print arguments
|
|
||||||
try:
|
|
||||||
targs = json.loads(targs_raw) if isinstance(targs_raw, str) else targs_raw
|
|
||||||
args_str = json.dumps(targs, indent=2, ensure_ascii=False)
|
|
||||||
except Exception:
|
|
||||||
args_str = str(targs_raw)
|
|
||||||
|
|
||||||
# Label: "Hendrik" hijau + "tool_name" kuning + "(HH:MM)" hijau
|
|
||||||
_add_row([
|
|
||||||
(C_AI, " Hendrik "),
|
|
||||||
(C_TOOL_CALL, tname),
|
|
||||||
(C_AI, f" ({item['time']}) "),
|
|
||||||
])
|
|
||||||
for aline in args_str.split("\n"):
|
|
||||||
_add_row([(C_INPUT, " " + aline)])
|
|
||||||
except Exception:
|
|
||||||
_add_row([
|
|
||||||
(C_AI, " Hendrik "),
|
|
||||||
(C_TOOL_CALL, "unknown"),
|
|
||||||
(C_AI, f" ({item['time']}) "),
|
|
||||||
])
|
|
||||||
# Blank line di bawah
|
|
||||||
_add_blank()
|
|
||||||
elif role == "error":
|
elif role == "error":
|
||||||
label = " \u2717 "
|
label = " \u2717 "
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
_add_row([(C_ERROR, label + (lines[0] if lines else ""))])
|
rendered.append((C_ERROR, label + (lines[0] if lines else "")))
|
||||||
for line in lines[1:]:
|
for line in lines[1:]:
|
||||||
_add_row([(C_ERROR, " " + line)])
|
rendered.append((C_ERROR, " " + line))
|
||||||
|
|
||||||
# Clamp scroll agar tidak melebihi total baris
|
# Clamp scroll agar tidak melebihi total baris
|
||||||
total = len(rendered)
|
total = len(rendered)
|
||||||
@ -204,21 +153,14 @@ def draw_chat(app, stdscr):
|
|||||||
|
|
||||||
y = chat_top
|
y = chat_top
|
||||||
for i in range(app.scroll, min(app.scroll + chat_h, total)):
|
for i in range(app.scroll, min(app.scroll + chat_h, total)):
|
||||||
segments = rendered[i]
|
color, text = rendered[i]
|
||||||
x = 0
|
|
||||||
for color, text in segments:
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
attr = curses.color_pair(color) | curses.A_BOLD if color else curses.A_NORMAL
|
attr = curses.color_pair(color) | curses.A_BOLD if color else curses.A_NORMAL
|
||||||
remaining = w - x
|
if len(text) > w:
|
||||||
if remaining <= 0:
|
text = text[:w]
|
||||||
break
|
|
||||||
display = text[:remaining]
|
|
||||||
try:
|
try:
|
||||||
stdscr.addstr(y, x, display, attr)
|
stdscr.addstr(y, 0, text, attr)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
x += len(display)
|
|
||||||
y += 1
|
y += 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user