Compare commits
4 Commits
78387899ad
...
8a0363b985
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a0363b985 | |||
| dc5fb67ac1 | |||
| 41ec8287f7 | |||
| 93dd74d1b4 |
13
.env.example
13
.env.example
@ -14,3 +14,16 @@ XMPP_USERNAME=
|
||||
XMPP_PASSWORD=
|
||||
# XMPP_MUC_ROOMS=room1@conference.server,room2@conference.server
|
||||
|
||||
# ─── Persona / Mode ──────────────────────────────────────────────────────────
|
||||
# Mode AI: "programmer" (default, koding) | "roleplayer" (ngobrol)
|
||||
PERSONA_MODE=programmer
|
||||
|
||||
# Personality
|
||||
PERSONA_NAME=OWL
|
||||
PERSONA_TONE=casual # casual | formal | playful | warm
|
||||
PERSONA_VERBOSITY=balanced # concise | balanced | detailed
|
||||
PERSONA_HUMOR=light # none | light | witty
|
||||
PERSONA_LANGUAGE=id # id | en | (kosong = auto)
|
||||
PERSONA_MOOD=cheerful # cheerful | calm | energetic | sarcastic
|
||||
# PERSONA_CATCHPHRASES=Siap bro!, Haha~, Wkwkwk
|
||||
|
||||
|
||||
52
config.py
52
config.py
@ -3,20 +3,54 @@ from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# LLM Configuration
|
||||
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" ) )
|
||||
# Agent Configuration
|
||||
AGENT_MAX_ITERATIONS = int( os.getenv("AGENT_MAX_ITERATIONS", default="10" ) )
|
||||
# Tool Configuration (for future use)
|
||||
MAX_TOOL_OUTPUT = int( os.getenv("MAX_TOOL_OUTPUT", default="4000" ) )
|
||||
# RAG Configuration
|
||||
RAG_PERSIST_DIR = os.getenv("RAG_PERSIST_DIR", default="chroma_db" )
|
||||
# Embedding: ChromaDB ONNX default (all-MiniLM-L6-v2, lokal, tidak perlu API call)
|
||||
# XMPP Configuration
|
||||
|
||||
AGENT_MAX_ITERATIONS = int( os.getenv("AGENT_MAX_ITERATIONS", default="30" ) )
|
||||
|
||||
MAX_TOOL_OUTPUT = int( os.getenv("MAX_TOOL_OUTPUT", default="40000" ) )
|
||||
|
||||
RAG_PERSIST_DIR = os.getenv("RAG_PERSIST_DIR", default="chroma_db" ) # Embedding: ChromaDB ONNX default (all-MiniLM-L6-v2, lokal, tidak perlu API call)
|
||||
|
||||
XMPP_ENABLED = os.getenv("XMPP_ENABLED", default="False" ).strip().lower() in ("true", "1", "yes")
|
||||
XMPP_USERNAME = os.getenv("XMPP_USERNAME", default="" )
|
||||
XMPP_PASSWORD = os.getenv("XMPP_PASSWORD", default="" )
|
||||
XMPP_MUC_ROOMS = os.getenv("XMPP_MUC_ROOMS", default="" )
|
||||
XMPP_NICKNAME = os.getenv("XMPP_NICKNAME", default="" ) # custom nick MUC (empty = use username)
|
||||
|
||||
# ─── Persona / Mode Configuration ────────────────────────────────────────────
|
||||
# Pilihan mode AI:
|
||||
# "programmer" → AI Agent untuk koding (default), tool-focused
|
||||
# "roleplayer" → Teman ngobrol / chat companion, conversational
|
||||
PERSONA_MODE = os.getenv("PERSONA_MODE", default="programmer").strip().lower()
|
||||
|
||||
# Personality — nama panggilan AI (default: "OWL")
|
||||
PERSONA_NAME = os.getenv("PERSONA_NAME", default="OWL").strip() or "OWL"
|
||||
|
||||
# Gaya bicara: "casual" | "formal" | "playful" | "warm"
|
||||
PERSONA_TONE = os.getenv("PERSONA_TONE", default="casual").strip().lower() or "casual"
|
||||
|
||||
# Panjang jawaban: "concise" | "balanced" | "detailed"
|
||||
PERSONA_VERBOSITY = os.getenv("PERSONA_VERBOSITY", default="balanced").strip().lower() or "balanced"
|
||||
|
||||
# Humor: "none" | "light" | "witty"
|
||||
PERSONA_HUMOR = os.getenv("PERSONA_HUMOR", default="light").strip().lower() or "light"
|
||||
|
||||
# Bahasa: "id" | "en" | "" (auto)
|
||||
PERSONA_LANGUAGE = os.getenv("PERSONA_LANGUAGE", default="id").strip().lower() or "id"
|
||||
|
||||
# Mood: "cheerful" | "calm" | "energetic" | "sarcastic"
|
||||
PERSONA_MOOD = os.getenv("PERSONA_MOOD", default="cheerful").strip().lower() or "cheerful"
|
||||
|
||||
# Catchphrases khas AI (comma-separated)
|
||||
# Contoh: "Siap bro!, Haha~, Wkwkwk"
|
||||
PERSONA_CATCHPHRASES = os.getenv("PERSONA_CATCHPHRASES", default="").strip()
|
||||
|
||||
# Humanize Delay Configuration (anti-bot detection)
|
||||
READ_DELAY_MIN = float( os.getenv("READ_DELAY_MIN", default="1.0" ) ) # min reading delay (second)
|
||||
READ_DELAY_MAX = float( os.getenv("READ_DELAY_MAX", default="2.0" ) ) # max reading delay (second)
|
||||
TYPING_SPEED = float( os.getenv("TYPING_SPEED", default="15.0" ) ) # characters per second
|
||||
TYPING_MAX = float( os.getenv("TYPING_MAX", default="10.0" ) ) # max typing delay limit (second)
|
||||
|
||||
|
||||
23
hendrik.py
23
hendrik.py
@ -1,18 +1,22 @@
|
||||
import os, sys
|
||||
import config
|
||||
|
||||
from services.xmpp_client import XMPPClient
|
||||
|
||||
from scripts.llm_client import LLMClient
|
||||
from tools import coder, rag, carrack
|
||||
from scripts import gadget
|
||||
from scripts.persona import build_system_prompt, PERSONALITY, MODE
|
||||
|
||||
# Daftar tools yang tersedia
|
||||
tools_definition = [
|
||||
|
||||
gadget.tools_mapping( schema = coder.schema_read_file, handler = coder.read_file ),
|
||||
gadget.tools_mapping( schema = coder.schema_write_file, handler = coder.write_file ),
|
||||
gadget.tools_mapping( schema = coder.schema_edit_file, handler = coder.edit_file ),
|
||||
gadget.tools_mapping( schema = coder.schema_run_bash, handler = coder.run_bash ),
|
||||
gadget.tools_mapping( schema = coder.schema_search_code, handler = coder.search_code ),
|
||||
gadget.tools_mapping( schema = coder.schema_git_operation, handler = coder.git_operation ),
|
||||
|
||||
gadget.tools_mapping( schema = 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_search_knowledge, handler = rag.search_knowledge ),
|
||||
@ -20,18 +24,17 @@ tools_definition = [
|
||||
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_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)
|
||||
TOOL_HANDLERS = gadget.tool_handlers (tools_definition)
|
||||
|
||||
|
||||
def main():
|
||||
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
|
||||
i = 1
|
||||
while i < len(sys.argv):
|
||||
@ -41,7 +44,6 @@ def main():
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Apply workspace jika ada
|
||||
if workspace:
|
||||
resolved = os.path.abspath(workspace)
|
||||
if not os.path.isdir(resolved):
|
||||
@ -50,12 +52,9 @@ def main():
|
||||
os.chdir(resolved)
|
||||
|
||||
if config.XMPP_ENABLED:
|
||||
from services.xmpp_client import XMPPClient
|
||||
|
||||
muc_rooms = []
|
||||
if config.XMPP_MUC_ROOMS.strip():
|
||||
muc_rooms = [r.strip() for r in config.XMPP_MUC_ROOMS.split(',') if r.strip()]
|
||||
|
||||
client = XMPPClient(
|
||||
jid = config.XMPP_USERNAME,
|
||||
password = config.XMPP_PASSWORD,
|
||||
@ -63,11 +62,11 @@ def main():
|
||||
tools_definition = tools_definition,
|
||||
TOOLS = TOOLS,
|
||||
TOOL_HANDLERS = TOOL_HANDLERS,
|
||||
build_system_prompt = gadget.build_system_prompt,
|
||||
build_system_prompt = build_system_prompt,
|
||||
agent_max_iterations = config.AGENT_MAX_ITERATIONS,
|
||||
muc_rooms = muc_rooms,
|
||||
)
|
||||
client.start() # blocking, headless
|
||||
client.start()
|
||||
else:
|
||||
from tui import HendrikTUI
|
||||
HendrikTUI(
|
||||
@ -75,10 +74,10 @@ def main():
|
||||
tools_definition = tools_definition,
|
||||
TOOLS = TOOLS,
|
||||
TOOL_HANDLERS = TOOL_HANDLERS,
|
||||
build_system_prompt = gadget.build_system_prompt,
|
||||
build_system_prompt = build_system_prompt,
|
||||
agent_max_iterations = config.AGENT_MAX_ITERATIONS,
|
||||
).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
@ -1,50 +1,12 @@
|
||||
import os
|
||||
|
||||
from .persona import build_system_prompt
|
||||
|
||||
def tools_mapping(schema, handler, name=None):
|
||||
tool_name = name or schema["function"]["name"]
|
||||
return {"name": tool_name, "schema": schema, "handler": handler}
|
||||
|
||||
|
||||
def tool_schemas(tools_definition):
|
||||
return [t["schema"] for t in tools_definition]
|
||||
|
||||
|
||||
def tool_handlers(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.",
|
||||
"- ingest_files → read files (with glob patterns) into a collection, auto-chunking.",
|
||||
"",
|
||||
"You can create collections yourself! When you encounter a new topic,",
|
||||
"use create_collection first, then store_knowledge or ingest_files to populate it.",
|
||||
"Always inspect_collection to discover metadata keys before filtering."
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
244
scripts/persona.py
Normal file
244
scripts/persona.py
Normal file
@ -0,0 +1,244 @@
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
# ─── Mode / Skill ────────────────────────────────────────────────────────────
|
||||
# Pilihan mode AI:
|
||||
# - "programmer" : AI Agent untuk koding (default), tool-focused, task-oriented
|
||||
# - "roleplayer" : Teman ngobrol / chat companion, conversational, expressive
|
||||
MODE = os.getenv("PERSONA_MODE", default="programmer").strip().lower()
|
||||
|
||||
|
||||
# ─── Personality Configuration ───────────────────────────────────────────────
|
||||
# Semua parameter personality bisa di-set via .env.
|
||||
# Lihat komentar di setiap field untuk pilihan yang tersedia.
|
||||
|
||||
@dataclass
|
||||
class PersonalityConfig:
|
||||
"""Konfigurasi personality AI yang berlaku lintas mode."""
|
||||
|
||||
# Nama panggilan AI (default: "OWL")
|
||||
name: str = "OWL"
|
||||
|
||||
# Gaya bicara:
|
||||
# "casual" → santai, gaul
|
||||
# "formal" → sopan, profesional
|
||||
# "playful" → ceria, suka bercanda
|
||||
# "warm" → hangat, ramah
|
||||
tone: str = "casual"
|
||||
|
||||
# Panjang jawaban:
|
||||
# "concise" → singkat, to the point
|
||||
# "balanced" → sedang (default)
|
||||
# "detailed" → panjang, detail
|
||||
verbosity: str = "balanced"
|
||||
|
||||
# Seberapa sering bercanda:
|
||||
# "none" → serius, tidak bercanda
|
||||
# "light" → sesekali (default)
|
||||
# "witty" → sering, jenaka
|
||||
humor_level: str = "light"
|
||||
|
||||
# Bahasa utama:
|
||||
# "id" → Indonesia
|
||||
# "en" → English
|
||||
# "" → auto (sesuai bahasa user)
|
||||
language: str = "id"
|
||||
|
||||
# Suasana hati umum:
|
||||
# "cheerful" → ceria, positif
|
||||
# "calm" → tenang, menenangkan
|
||||
# "energetic" → bersemangat, aktif
|
||||
# "sarcastic" → sarkastik, sinis
|
||||
mood: str = "cheerful"
|
||||
|
||||
# Ekspresi khas AI (comma-separated di .env, jadi list di sini)
|
||||
# Contoh: "Siap bro!, Haha~, Wkwkwk"
|
||||
catchphrases: list = field(default_factory=list)
|
||||
|
||||
|
||||
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",
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# Instance global — di-load sekali saat import
|
||||
PERSONALITY = _load_personality_from_env()
|
||||
|
||||
|
||||
# ─── Prompt Builders ─────────────────────────────────────────────────────────
|
||||
|
||||
def _build_personality_block(cfg: PersonalityConfig) -> str:
|
||||
"""Generate deskripsi personality dari config."""
|
||||
parts = [f"You are {cfg.name}."]
|
||||
|
||||
# Tone
|
||||
tone_map = {
|
||||
"casual": "You speak in a casual, relaxed manner — like chatting with a friend.",
|
||||
"formal": "You speak formally and professionally, using polite language.",
|
||||
"playful": "You are playful and cheerful, making conversations fun and lighthearted.",
|
||||
"warm": "You are warm and friendly, making people feel comfortable and welcomed.",
|
||||
}
|
||||
parts.append(tone_map.get(cfg.tone, tone_map["casual"]))
|
||||
|
||||
# Verbosity
|
||||
verbosity_map = {
|
||||
"concise": "Keep your answers short and to the point.",
|
||||
"balanced": "Provide balanced answers — not too brief, not too long.",
|
||||
"detailed": "Give thorough, detailed answers with explanations.",
|
||||
}
|
||||
parts.append(verbosity_map.get(cfg.verbosity, verbosity_map["balanced"]))
|
||||
|
||||
# Humor
|
||||
humor_map = {
|
||||
"none": "Stay serious and avoid jokes.",
|
||||
"light": "Occasionally sprinkle in light humor when appropriate.",
|
||||
"witty": "Be witty and humorous — jokes, puns, and playful banter are welcome.",
|
||||
}
|
||||
parts.append(humor_map.get(cfg.humor_level, humor_map["light"]))
|
||||
|
||||
# Language
|
||||
if cfg.language == "id":
|
||||
parts.append("Always respond in Indonesian (Bahasa Indonesia).")
|
||||
elif cfg.language == "en":
|
||||
parts.append("Always respond in English.")
|
||||
else:
|
||||
parts.append("Respond in the same language the user uses.")
|
||||
|
||||
# Mood
|
||||
mood_map = {
|
||||
"cheerful": "Your overall mood is cheerful and positive.",
|
||||
"calm": "Your overall mood is calm and soothing.",
|
||||
"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"]))
|
||||
|
||||
# Catchphrases
|
||||
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 _build_programmer_prompt(cfg: PersonalityConfig, tools_definition: list[dict]) -> str:
|
||||
"""Build system prompt untuk mode Programmer."""
|
||||
lines = [
|
||||
_build_personality_block(cfg),
|
||||
"",
|
||||
"You are a coding agent that assists with software engineering tasks.",
|
||||
"",
|
||||
_build_tools_block(tools_definition),
|
||||
"",
|
||||
f"Your workspace directory is: {os.getcwd()}. "
|
||||
"All file operations are relative to this directory.",
|
||||
"",
|
||||
"⚠️ GIT POLICY — IMPORTANT:",
|
||||
"- NEVER run 'git add' or 'git commit' automatically after making changes.",
|
||||
"- After editing/creating files, always ASK the user first before committing.",
|
||||
"- Only run git commands when the user explicitly asks you to commit.",
|
||||
"- You may run 'git status', 'git diff', 'git log' freely to inspect state.",
|
||||
"- When user asks to commit: show them the changes first, then wait for confirmation.",
|
||||
"",
|
||||
"RAG capabilities (knowledge retrieval):",
|
||||
"- list_collections → see available collections & doc counts.",
|
||||
"- create_collection → create a new collection for a new topic.",
|
||||
"- delete_collection → permanently remove a collection and its data.",
|
||||
"- inspect_collection → learn metadata fields before searching.",
|
||||
"- search_knowledge → semantic search + optional metadata filter.",
|
||||
"- store_knowledge → save docs with rich metadata for later use.",
|
||||
"- ingest_files → read files (with glob patterns) into a collection, auto-chunking.",
|
||||
"",
|
||||
"You can create collections yourself! When you encounter a new topic,",
|
||||
"use create_collection first, then store_knowledge or ingest_files to populate it.",
|
||||
"Always inspect_collection to discover metadata keys before filtering.",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_roleplayer_prompt(cfg: PersonalityConfig) -> str:
|
||||
"""Build system prompt untuk mode Roleplayer."""
|
||||
lines = [
|
||||
_build_personality_block(cfg),
|
||||
"",
|
||||
f"You are {cfg.name}, a conversational companion and roleplayer. "
|
||||
"Your main purpose is to be an engaging, empathetic, and fun conversation partner.",
|
||||
"",
|
||||
"Guidelines:",
|
||||
"- Stay in character at all times. Be consistent with your personality.",
|
||||
"- Be responsive and empathetic — acknowledge the user's feelings and thoughts.",
|
||||
"- Ask follow-up questions to keep the conversation flowing naturally.",
|
||||
"- Use natural, conversational language — not robotic or overly formal.",
|
||||
"- If the user wants to roleplay a scenario, dive into it enthusiastically.",
|
||||
"- Adapt your tone and energy to match the mood of the conversation.",
|
||||
"- Keep the conversation comfortable and enjoyable.",
|
||||
"",
|
||||
"Note: You currently do not have access to external tools. "
|
||||
"Focus on being a great conversationalist!",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def build_system_prompt(
|
||||
tools_definition: list[dict] | None = None,
|
||||
mode: str | None = None,
|
||||
personality: PersonalityConfig | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build system prompt berdasarkan mode dan personality config.
|
||||
|
||||
Args:
|
||||
tools_definition: Daftar tools (required untuk mode programmer).
|
||||
mode: "programmer" atau "roleplayer". Default: dari env PERSONA_MODE.
|
||||
personality: PersonalityConfig instance. Default: dari env (global PERSONALITY).
|
||||
|
||||
Returns:
|
||||
String system prompt lengkap.
|
||||
"""
|
||||
selected_mode = (mode or MODE).strip().lower()
|
||||
cfg = personality or PERSONALITY
|
||||
|
||||
if selected_mode == "programmer":
|
||||
if tools_definition is None:
|
||||
raise ValueError("tools_definition is required for 'programmer' mode")
|
||||
return _build_programmer_prompt(cfg, tools_definition)
|
||||
elif selected_mode == "roleplayer":
|
||||
return _build_roleplayer_prompt(cfg)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown mode: '{selected_mode}'. "
|
||||
f"Available modes: 'programmer', 'roleplayer'"
|
||||
)
|
||||
@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
import signal
|
||||
import threading
|
||||
from datetime import datetime
|
||||
@ -7,11 +8,34 @@ from datetime import datetime
|
||||
from slixmpp import ClientXMPP
|
||||
from services.session_manager import SessionManager
|
||||
|
||||
import config
|
||||
from scripts.persona import MODE as PERSONA_MODE
|
||||
|
||||
# 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():
|
||||
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):
|
||||
def __init__(self, jid, password, llm_client, tools_definition, TOOLS,
|
||||
TOOL_HANDLERS, build_system_prompt, agent_max_iterations,
|
||||
@ -24,14 +48,22 @@ class XMPPClient(ClientXMPP):
|
||||
self._TOOL_HANDLERS = TOOL_HANDLERS
|
||||
self._build_system_prompt = build_system_prompt
|
||||
self._max_iterations = agent_max_iterations
|
||||
self._mode = PERSONA_MODE
|
||||
self._muc_rooms = muc_rooms or []
|
||||
self._muc_nick = jid.split('@')[0]
|
||||
# Custom nick dari config, fallback ke username JID
|
||||
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._session_mgr = SessionManager()
|
||||
self._loop = 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.register_plugin('xep_0030')
|
||||
@ -45,22 +77,147 @@ class XMPPClient(ClientXMPP):
|
||||
self.add_event_handler('connected', self._on_connected)
|
||||
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):
|
||||
print(f'[{_ts()}] XMPP connected')
|
||||
|
||||
async def _on_disconnected(self, event):
|
||||
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):
|
||||
self.send_presence()
|
||||
self.get_roster()
|
||||
print(f'[{_ts()}] XMPP online as {self.boundjid.full}')
|
||||
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:
|
||||
await self.plugin['xep_0045'].join_muc_wait(room, self._muc_nick, maxstanzas=0)
|
||||
print(f'[{_ts()}] Joined MUC room: {room}')
|
||||
await self.plugin['xep_0045'].join_muc_wait(room, nick, maxstanzas=0)
|
||||
print(f'[{_ts()}] Joined MUC room: {room} as {nick}')
|
||||
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:
|
||||
print(f'[{_ts()}] MUC join failed ({room}): {e}')
|
||||
print(f'[{_ts()}] MUC join attempt #{attempt} 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):
|
||||
if msg['type'] not in ('chat', 'normal'):
|
||||
@ -75,8 +232,9 @@ class XMPPClient(ClientXMPP):
|
||||
def _on_groupchat_message(self, msg):
|
||||
if msg['type'] != 'groupchat':
|
||||
return
|
||||
room = msg['from'].bare
|
||||
nick = msg['from'].resource
|
||||
if nick == self._muc_nick:
|
||||
if self._is_my_nick(room, nick):
|
||||
return
|
||||
room = msg['from'].bare
|
||||
if room not in self._muc_ready:
|
||||
@ -87,22 +245,41 @@ class XMPPClient(ClientXMPP):
|
||||
print(f'[{_ts()}] MUC [{room}] <{nick}>: {body[:60]}')
|
||||
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):
|
||||
room = presence['from'].bare
|
||||
nick = presence['from'].resource
|
||||
ptype = presence['type']
|
||||
if nick == self._muc_nick and ptype not in ('unavailable', 'error'):
|
||||
if self._is_my_nick(room, nick) and ptype not in ('unavailable', 'error'):
|
||||
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':
|
||||
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':
|
||||
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:
|
||||
print(f'[{_ts()}] MUC [{room}] <{nick}> joined (type={ptype})')
|
||||
|
||||
def _process_dm(self, jid, body):
|
||||
session = self._session_mgr.get_or_create(
|
||||
jid, self._build_system_prompt(self._tools_def)
|
||||
jid, self._build_system_prompt(tools_definition=self._tools_def)
|
||||
)
|
||||
session.cancel_timer()
|
||||
|
||||
@ -116,14 +293,20 @@ class XMPPClient(ClientXMPP):
|
||||
|
||||
session.add_message('user', body)
|
||||
|
||||
if self._mode != 'roleplayer':
|
||||
self._schedule_send(jid, f'> {body}\nThinking...')
|
||||
|
||||
# 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, jid, body, 'chat')
|
||||
|
||||
session.start_timer(300, self._timeout_session, jid, 'chat')
|
||||
|
||||
def _process_muc(self, room, nick, body):
|
||||
session = self._session_mgr.get_or_create(
|
||||
room, self._build_system_prompt(self._tools_def)
|
||||
room, self._build_system_prompt(tools_definition=self._tools_def)
|
||||
)
|
||||
session.cancel_timer()
|
||||
|
||||
@ -136,12 +319,20 @@ class XMPPClient(ClientXMPP):
|
||||
prefixed = f'[{nick}] {body}'
|
||||
session.add_message('user', prefixed)
|
||||
|
||||
if self._mode != 'roleplayer':
|
||||
self._schedule_send(room, f'> [{nick}] {body}\nThinking...', mtype='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')
|
||||
|
||||
session.start_timer(300, self._timeout_session, room, 'groupchat')
|
||||
|
||||
def _agent_loop(self, session, to, quote, mtype):
|
||||
is_roleplayer = self._mode == 'roleplayer'
|
||||
|
||||
for step in range(self._max_iterations):
|
||||
print(f'[{_ts()}] Step {step + 1} — calling LLM...')
|
||||
response = self._llm.chat(session.messages, tools=self._TOOLS)
|
||||
@ -156,6 +347,9 @@ class XMPPClient(ClientXMPP):
|
||||
|
||||
tnames = [tc['function']['name'] for tc in response.tool_calls]
|
||||
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)
|
||||
|
||||
for tc in response.tool_calls:
|
||||
@ -169,6 +363,11 @@ class XMPPClient(ClientXMPP):
|
||||
if response.content:
|
||||
print(f'[{_ts()}] Response sent ({len(response.content)} chars)')
|
||||
session.messages.append({'role': 'assistant', 'content': response.content})
|
||||
|
||||
# Roleplayer: kirim langsung isinya, tanpa prefix
|
||||
if is_roleplayer:
|
||||
self._schedule_send(to, response.content, mtype)
|
||||
else:
|
||||
self._schedule_send(to, f'> {quote}\n{response.content}', mtype)
|
||||
return
|
||||
|
||||
@ -177,6 +376,10 @@ class XMPPClient(ClientXMPP):
|
||||
'role': 'assistant',
|
||||
'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)
|
||||
|
||||
def _execute_tool(self, tool_call):
|
||||
@ -209,6 +412,11 @@ class XMPPClient(ClientXMPP):
|
||||
|
||||
async def _send_coro(self, to, body, mtype):
|
||||
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.send()
|
||||
except Exception as e:
|
||||
|
||||
@ -146,7 +146,10 @@ schema_git_operation = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@ -40,8 +40,7 @@ class HendrikTUI:
|
||||
stdscr.keypad(True)
|
||||
stdscr.refresh()
|
||||
|
||||
self.messages = [{"role": "system",
|
||||
"content": self.build_system_prompt(self.tools_def)}]
|
||||
self.messages = [{"role": "system", "content": self.build_system_prompt(tools_definition=self.tools_def)}]
|
||||
log(self, "welcome", WELCOME_ART)
|
||||
|
||||
while self.running:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user