- New read_file skill: paginated file reading with line ranges, path restricted to /workspace, binary detection, directory listing - Session persistence via SQLite + FTS5: conversation history survives agent restarts, last N messages restored into deque on boot, auto-prune to 1000 messages - Update truncation hint to reference read_file instead of run_command - New scripts/update.sh for patching rootfs + rebuilding snapshot Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
354 lines
12 KiB
Python
354 lines
12 KiB
Python
#!/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()
|