#!/usr/bin/env python3 """Fireclaw IRC agent — connects to IRC, responds via Ollama with discoverable skills.""" import os import socket import json import sys import time import signal import threading from collections import deque from skills import discover_skills, execute_skill, set_logger as set_skills_logger from tools import load_memory, query_ollama, set_logger as set_tools_logger from sessions import init_db, save_message, load_recent, set_logger as set_sessions_logger # ─── Config ────────────────────────────────────────────────────────── with open("/etc/agent/config.json") as f: CONFIG = json.load(f) PERSONA = "" try: with open("/etc/agent/persona.md") as f: PERSONA = f.read().strip() except FileNotFoundError: PERSONA = "You are a helpful assistant." NICK = CONFIG.get("nick", "agent") SERVER = CONFIG.get("server", "172.16.0.1") PORT = CONFIG.get("port", 6667) OLLAMA_URL = CONFIG.get("ollama_url", "http://172.16.0.1:11434") CONTEXT_SIZE = CONFIG.get("context_size", 20) MAX_RESPONSE_LINES = CONFIG.get("max_response_lines", 50) TOOLS_ENABLED = CONFIG.get("tools", True) MAX_TOOL_ROUNDS = CONFIG.get("max_tool_rounds", 10) WORKSPACE = "/workspace" SKILL_DIRS = ["/opt/skills", f"{WORKSPACE}/skills"] RUNTIME = { "model": CONFIG.get("model", "qwen2.5-coder:7b"), "trigger": CONFIG.get("trigger", "mention"), "persona": PERSONA, } recent = deque(maxlen=CONTEXT_SIZE) # ─── Logging ───────────────────────────────────────────────────────── LOG_FILE = f"{WORKSPACE}/agent.log" if os.path.isdir(WORKSPACE) else None def log(msg): line = f"[{time.strftime('%H:%M:%S')}] {msg}" print(f"[agent:{NICK}] {line}", flush=True) if LOG_FILE: try: with open(LOG_FILE, "a") as f: f.write(line + "\n") except Exception: pass # Inject logger into submodules set_skills_logger(log) set_tools_logger(log) set_sessions_logger(log) # ─── Init ──────────────────────────────────────────────────────────── AGENT_MEMORY = load_memory(WORKSPACE) TOOLS, SKILL_SCRIPTS = discover_skills(SKILL_DIRS) log(f"Loaded {len(TOOLS)} skills: {', '.join(SKILL_SCRIPTS.keys())}") db_conn = init_db(f"{WORKSPACE}/sessions.db") for msg in load_recent(db_conn, CONTEXT_SIZE): recent.append(msg) log(f"Session: restored {len(recent)} messages") def reload_memory(): global AGENT_MEMORY AGENT_MEMORY = load_memory(WORKSPACE) def dispatch_tool(fn_name, fn_args, round_num): """Execute a tool call via the skill system.""" script = SKILL_SCRIPTS.get(fn_name) if not script: return f"[unknown tool: {fn_name}]" log(f"Skill [{round_num}]: {fn_name}({str(fn_args)[:60]})") result = execute_skill(script, fn_args, WORKSPACE, CONFIG) if fn_name == "save_memory": reload_memory() return result # ─── IRC Client ────────────────────────────────────────────────────── class IRCClient: def __init__(self, server, port, nick): self.server = server self.port = port self.nick = nick self.sock = None self.buf = "" self._lock = threading.Lock() def connect(self): self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.settimeout(300) self.sock.connect((self.server, self.port)) self.send(f"NICK {self.nick}") self.send(f"USER {self.nick} 0 * :Fireclaw Agent") def send(self, msg): with self._lock: self.sock.sendall(f"{msg}\r\n".encode("utf-8")) def join(self, channel): self.send(f"JOIN {channel}") def say(self, target, text): for line in text.split("\n"): line = line.strip() if line: while len(line) > 380: self.send(f"PRIVMSG {target} :{line[:380]}") line = line[380:] self.send(f"PRIVMSG {target} :{line}") def set_bot_mode(self): self.send(f"MODE {self.nick} +B") def recv_lines(self): try: data = self.sock.recv(4096) except socket.timeout: return [] if not data: raise ConnectionError("Connection closed") self.buf += data.decode("utf-8", errors="replace") lines = self.buf.split("\r\n") self.buf = lines.pop() return lines # ─── Message Handling ──────────────────────────────────────────────── def build_messages(question, channel): system = RUNTIME["persona"] if TOOLS_ENABLED and TOOLS: skill_names = [t["function"]["name"] for t in TOOLS] system += "\n\nYou have access to tools: " + ", ".join(skill_names) + "." system += "\nUse tools when needed rather than guessing. Your workspace at /workspace persists across restarts." if AGENT_MEMORY and AGENT_MEMORY != "# Agent Memory": system += f"\n\nIMPORTANT - Your persistent memory (facts you saved previously, use these to answer questions):\n{AGENT_MEMORY}" system += f"\n\nYou are in IRC channel {channel}. Your nick is {NICK}. Keep responses concise — this is IRC." system += "\nWhen you want to address another agent or user, always start your message with their nick followed by a colon, e.g. 'coder: can you review this?'. This is how IRC mentions work — without the prefix, they won't see your message." messages = [{"role": "system", "content": system}] channel_msgs = [m for m in recent if m["channel"] == channel] for msg in channel_msgs[-CONTEXT_SIZE:]: if msg["nick"] == NICK: messages.append({"role": "assistant", "content": msg["text"]}) else: messages.append({"role": "user", "content": f"<{msg['nick']}> {msg['text']}"}) last = messages[-1] if len(messages) > 1 else None if not last or last.get("role") != "user" or question not in last.get("content", ""): messages.append({"role": "user", "content": question}) return messages def should_trigger(text): if RUNTIME["trigger"] == "all": return True lower = text.lower() nick = NICK.lower() return ( lower.startswith(f"{nick}:") or lower.startswith(f"{nick},") or lower.startswith(f"{nick} ") or lower.startswith(f"@{nick}") or lower == nick or text.startswith("!ask ") ) def extract_question(text): lower = text.lower() for prefix in [ f"{NICK.lower()}: ", f"{NICK.lower()}, ", f"@{NICK.lower()} ", f"{NICK.lower()} ", ]: if lower.startswith(prefix): return text[len(prefix):] if text.startswith("!ask "): return text[5:] return text _last_response_time = 0 _AGENT_COOLDOWN = 10 _cooldown_lock = threading.Lock() def handle_message(irc, source_nick, target, text): global _last_response_time is_dm = not target.startswith("#") channel = source_nick if is_dm else target reply_to = source_nick if is_dm else target recent.append({"nick": source_nick, "text": text, "channel": channel}) save_message(db_conn, source_nick, channel, text) if source_nick == NICK: return if not is_dm and not should_trigger(text): return with _cooldown_lock: now = time.time() if now - _last_response_time < _AGENT_COOLDOWN: log(f"Cooldown active, ignoring trigger from {source_nick}") return _last_response_time = now question = extract_question(text) if not is_dm else text log(f"Triggered by {source_nick} in {channel}: {question[:80]}") def do_respond(): try: messages = build_messages(question, channel) response = query_ollama( messages, RUNTIME, TOOLS if TOOLS_ENABLED else [], SKILL_SCRIPTS, dispatch_tool, OLLAMA_URL, MAX_TOOL_ROUNDS, ) if not response: return lines = response.split("\n") if len(lines) > MAX_RESPONSE_LINES: lines = lines[:MAX_RESPONSE_LINES] lines.append(f"[truncated, {MAX_RESPONSE_LINES} lines max]") irc.say(reply_to, "\n".join(lines)) recent.append({"nick": NICK, "text": response[:200], "channel": channel}) save_message(db_conn, NICK, channel, response[:200], full_text=response) except Exception as e: log(f"Error handling message: {e}") try: irc.say(reply_to, f"[error: {e}]") except Exception: pass threading.Thread(target=do_respond, daemon=True).start() # ─── Main Loop ─────────────────────────────────────────────────────── def run(): log(f"Starting agent: nick={NICK} model={RUNTIME['model']} tools={TOOLS_ENABLED}") while True: try: irc = IRCClient(SERVER, PORT, NICK) log(f"Connecting to {SERVER}:{PORT}...") irc.connect() def handle_sighup(signum, frame): log("SIGHUP received, reloading config...") try: with open("/etc/agent/config.json") as f: new_config = json.load(f) RUNTIME["model"] = new_config.get("model", RUNTIME["model"]) RUNTIME["trigger"] = new_config.get("trigger", RUNTIME["trigger"]) try: with open("/etc/agent/persona.md") as f: RUNTIME["persona"] = f.read().strip() except FileNotFoundError: pass log(f"Reloaded: model={RUNTIME['model']} trigger={RUNTIME['trigger']}") irc.say("#agents", f"[reloaded: model={RUNTIME['model']}]") except Exception as e: log(f"Reload failed: {e}") signal.signal(signal.SIGHUP, handle_sighup) def handle_sigterm(signum, frame): log("SIGTERM received, quitting IRC...") try: irc.send("QUIT :Agent shutting down") except Exception: pass time.sleep(0.5) sys.exit(0) signal.signal(signal.SIGTERM, handle_sigterm) registered = False while True: lines = irc.recv_lines() for line in lines: if line.startswith("PING"): irc.send(f"PONG {line.split(' ', 1)[1]}") continue parts = line.split(" ") if len(parts) < 2: continue if parts[1] == "001" and not registered: registered = True log("Registered with server") irc.set_bot_mode() irc.join("#agents") log("Joined #agents") if parts[1] == "INVITE" and len(parts) >= 3: invited_channel = parts[-1].lstrip(":") inviter = parts[0].split("!")[0].lstrip(":") log(f"Invited to {invited_channel} by {inviter}, joining...") irc.join(invited_channel) if parts[1] == "PRIVMSG" and len(parts) >= 4: source_nick = parts[0].split("!")[0].lstrip(":") target = parts[2] text = " ".join(parts[3:]).lstrip(":") handle_message(irc, source_nick, target, text) except (ConnectionError, OSError, socket.timeout) as e: log(f"Disconnected: {e}. Reconnecting in 5s...") time.sleep(5) except KeyboardInterrupt: log("Shutting down.") sys.exit(0) if __name__ == "__main__": run()