Compare commits

...

4 Commits

Author SHA1 Message Date
8a0363b985 Persona 2026-06-10 15:12:50 +07:00
dc5fb67ac1 feat: add git policy - never auto commit without user permission
- Add git policy section in system prompt (gadget.py): no auto git add/commit
- Add policy warning in git_operation tool description (coder.py)
- LLM must ask user before running git add or git commit
- Safe commands (git status, git diff, git log) can run without asking
- When user asks to commit: show changes first, then wait for confirmation
2026-06-10 11:27:59 +07:00
41ec8287f7 feat: add custom MUC nickname & handle 409 Conflict error
- Add XMPP_NICKNAME config in .env for custom MUC nick (fallback to JID username)
- Add _get_muc_nick() helper: resolve nick with suffix fallback on conflict
- Add _is_my_nick() helper: compare presence nick with expected nick per room
- Handle 409 Conflict in _on_session_start: try nick alternatives (lily_, lily__)
- Handle 409 Conflict in _muc_rejoin_coro: try nick alternatives with max 3 attempts
- Stop retry when all nick variations exhausted (anti-ban: avoid infinite retry)
- Reset nick suffix counter on successful join
- Update _on_groupchat_message filter to use _is_my_nick()
2026-06-10 11:11:27 +07:00
93dd74d1b4 feat: add humanize delays & anti-ban MUC rejoin mechanism
- Add READ_DELAY_MIN/MAX config for reading delay (1-2s random)
- Add TYPING_SPEED/MAX config for proporsional typing delay
- Add reading delay before processing DM & MUC messages
- Add typing delay before sending any XMPP message (proporsional to msg length)
- Add auto-rejoin MUC on unavailable/error with exponential backoff
- Add retry join on session_start with incremental delay (3 attempts)
- Add cooldown between rejoin attempts to prevent join-spam
- Cancel pending rejoin tasks on disconnect
- Reset rejoin counter on successful join
2026-06-10 10:54:56 +07:00
8 changed files with 559 additions and 97 deletions

View File

@ -14,3 +14,16 @@ XMPP_USERNAME=
XMPP_PASSWORD= XMPP_PASSWORD=
# XMPP_MUC_ROOMS=room1@conference.server,room2@conference.server # 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

View File

@ -3,20 +3,54 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
# LLM Configuration
llm_baseurl = os.getenv("LLM_BASE_URL", default="http://localhost:11434/v1" ) llm_baseurl = os.getenv("LLM_BASE_URL", default="http://localhost:11434/v1" )
llm_model = os.getenv("LLM_MODEL", default="granite4.1:8b" ) llm_model = os.getenv("LLM_MODEL", default="granite4.1:8b" )
llm_api_key = os.getenv("LLM_API_KEY", default="ollama" ) llm_api_key = os.getenv("LLM_API_KEY", default="ollama" )
llm_timeout = int( os.getenv("LLM_TIMEOUT", default="600" ) ) llm_timeout = int( os.getenv("LLM_TIMEOUT", default="600" ) )
# Agent Configuration
AGENT_MAX_ITERATIONS = int( os.getenv("AGENT_MAX_ITERATIONS", default="10" ) ) AGENT_MAX_ITERATIONS = int( os.getenv("AGENT_MAX_ITERATIONS", default="30" ) )
# Tool Configuration (for future use)
MAX_TOOL_OUTPUT = int( os.getenv("MAX_TOOL_OUTPUT", default="4000" ) ) MAX_TOOL_OUTPUT = int( os.getenv("MAX_TOOL_OUTPUT", default="40000" ) )
# RAG Configuration
RAG_PERSIST_DIR = os.getenv("RAG_PERSIST_DIR", default="chroma_db" ) RAG_PERSIST_DIR = os.getenv("RAG_PERSIST_DIR", default="chroma_db" ) # Embedding: ChromaDB ONNX default (all-MiniLM-L6-v2, lokal, tidak perlu API call)
# Embedding: ChromaDB ONNX default (all-MiniLM-L6-v2, lokal, tidak perlu API call)
# XMPP Configuration
XMPP_ENABLED = os.getenv("XMPP_ENABLED", default="False" ).strip().lower() in ("true", "1", "yes") XMPP_ENABLED = os.getenv("XMPP_ENABLED", default="False" ).strip().lower() in ("true", "1", "yes")
XMPP_USERNAME = os.getenv("XMPP_USERNAME", default="" ) XMPP_USERNAME = os.getenv("XMPP_USERNAME", default="" )
XMPP_PASSWORD = os.getenv("XMPP_PASSWORD", default="" ) XMPP_PASSWORD = os.getenv("XMPP_PASSWORD", default="" )
XMPP_MUC_ROOMS = os.getenv("XMPP_MUC_ROOMS", 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)

View File

@ -1,39 +1,42 @@
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, carrack
from scripts import gadget from scripts import gadget
from scripts.persona import build_system_prompt, PERSONALITY, MODE
# 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_write_file, handler = coder.write_file ), gadget.tools_mapping( schema = coder.schema_read_file, handler = coder.read_file ),
gadget.tools_mapping( schema = coder.schema_edit_file, handler = coder.edit_file ), gadget.tools_mapping( schema = coder.schema_write_file, handler = coder.write_file ),
gadget.tools_mapping( schema = coder.schema_run_bash, handler = coder.run_bash ), gadget.tools_mapping( schema = coder.schema_edit_file, handler = coder.edit_file ),
gadget.tools_mapping( schema = coder.schema_search_code, handler = coder.search_code ), gadget.tools_mapping( schema = coder.schema_run_bash, handler = coder.run_bash ),
gadget.tools_mapping( schema = coder.schema_git_operation, handler = coder.git_operation ), gadget.tools_mapping( schema = coder.schema_search_code, handler = coder.search_code ),
gadget.tools_mapping( schema = rag.schema_ingest_files, handler = rag.ingest_files ), gadget.tools_mapping( schema = coder.schema_git_operation, handler = coder.git_operation ),
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_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 ),
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 ), 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):
if sys.argv[i] in ('-w', '--workspace') and i + 1 < len(sys.argv): if sys.argv[i] in ('-w', '--workspace') and i + 1 < len(sys.argv):
workspace = sys.argv[i + 1] workspace = sys.argv[i + 1]
@ -41,7 +44,6 @@ 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):
@ -50,24 +52,21 @@ 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,
llm_client = llm_client, llm_client = llm_client,
tools_definition = tools_definition, tools_definition = tools_definition,
TOOLS = TOOLS, TOOLS = TOOLS,
TOOL_HANDLERS = TOOL_HANDLERS, TOOL_HANDLERS = TOOL_HANDLERS,
build_system_prompt = gadget.build_system_prompt, build_system_prompt = 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() # blocking, headless client.start()
else: else:
from tui import HendrikTUI from tui import HendrikTUI
HendrikTUI( HendrikTUI(
@ -75,10 +74,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 = gadget.build_system_prompt, build_system_prompt = 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()

View File

@ -1,50 +1,12 @@
import os from .persona import build_system_prompt
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.",
"- 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
View 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'"
)

View File

@ -1,5 +1,6 @@
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
@ -7,11 +8,34 @@ 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 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(): 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,
@ -24,14 +48,22 @@ 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._mode = PERSONA_MODE
self._muc_rooms = muc_rooms or [] 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._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')
@ -45,22 +77,147 @@ 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:
try: # Anti-ban: retry join dengan incremental delay & nick fallback
await self.plugin['xep_0045'].join_muc_wait(room, self._muc_nick, maxstanzas=0) success = False
print(f'[{_ts()}] Joined MUC room: {room}') for attempt in range(1, 4):
except Exception as e: nick = self._get_muc_nick(room)
print(f'[{_ts()}] MUC join failed ({room}): {e}') try:
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 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): def _on_message(self, msg):
if msg['type'] not in ('chat', 'normal'): if msg['type'] not in ('chat', 'normal'):
@ -75,8 +232,9 @@ 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 nick == self._muc_nick: if self._is_my_nick(room, 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:
@ -87,22 +245,41 @@ 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 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) 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(self._tools_def) jid, self._build_system_prompt(tools_definition=self._tools_def)
) )
session.cancel_timer() session.cancel_timer()
@ -116,14 +293,20 @@ class XMPPClient(ClientXMPP):
session.add_message('user', body) session.add_message('user', body)
self._schedule_send(jid, f'> {body}\nThinking...') 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') self._agent_loop(session, jid, body, 'chat')
session.start_timer(300, self._timeout_session, jid, 'chat') session.start_timer(300, 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(self._tools_def) room, self._build_system_prompt(tools_definition=self._tools_def)
) )
session.cancel_timer() session.cancel_timer()
@ -136,12 +319,20 @@ class XMPPClient(ClientXMPP):
prefixed = f'[{nick}] {body}' prefixed = f'[{nick}] {body}'
session.add_message('user', prefixed) session.add_message('user', prefixed)
self._schedule_send(room, f'> [{nick}] {body}\nThinking...', mtype='groupchat') 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') self._agent_loop(session, room, f'[{nick}] {body}', 'groupchat')
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): def _agent_loop(self, session, to, quote, mtype):
is_roleplayer = self._mode == 'roleplayer'
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)
@ -156,7 +347,10 @@ 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)}')
self._schedule_send(to, f'> {quote}\nUsing: {", ".join(tnames)}', mtype)
# 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: for tc in response.tool_calls:
result = self._execute_tool(tc) result = self._execute_tool(tc)
@ -169,7 +363,12 @@ class XMPPClient(ClientXMPP):
if response.content: if response.content:
print(f'[{_ts()}] Response sent ({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})
self._schedule_send(to, f'> {quote}\n{response.content}', mtype)
# 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 return
print(f'[{_ts()}] Max iterations ({self._max_iterations}) reached') print(f'[{_ts()}] Max iterations ({self._max_iterations}) reached')
@ -177,7 +376,11 @@ class XMPPClient(ClientXMPP):
'role': 'assistant', 'role': 'assistant',
'content': 'Max iterations reached without final answer.', 'content': 'Max iterations reached without final answer.',
}) })
self._schedule_send(to, f'> {quote}\nMax iterations reached without final answer.', mtype)
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): def _execute_tool(self, tool_call):
tname = tool_call['function']['name'] tname = tool_call['function']['name']
@ -209,6 +412,11 @@ 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:

View File

@ -146,7 +146,10 @@ 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": {

View File

@ -40,8 +40,7 @@ class HendrikTUI:
stdscr.keypad(True) stdscr.keypad(True)
stdscr.refresh() stdscr.refresh()
self.messages = [{"role": "system", self.messages = [{"role": "system", "content": self.build_system_prompt(tools_definition=self.tools_def)}]
"content": self.build_system_prompt(self.tools_def)}]
log(self, "welcome", WELCOME_ART) log(self, "welcome", WELCOME_ART)
while self.running: while self.running: