Compare commits
30 Commits
d299e394f0
...
6673210ff0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6673210ff0 | |||
| d6a737fbad | |||
| fdfa468960 | |||
| ca224a0ae9 | |||
| 383113126f | |||
| 2d371ceb86 | |||
| d838fe08cf | |||
| deca7228c7 | |||
| e685a2a7ba | |||
| f4643b8c59 | |||
| 0ab04e1964 | |||
| 100bb98e62 | |||
| fe162d11f7 | |||
| 96dfa63c39 | |||
| 74e79f2870 | |||
| d9b695d5a0 | |||
| c363f45ffc | |||
| 426ca8f1c1 | |||
| e1f1a24a37 | |||
| 185cda575e | |||
| 9f624e9497 | |||
| 3083b5d9d7 | |||
| 3c00de75d1 | |||
| 9a06bbf5ea | |||
| a604d73340 | |||
| 2d42d498b3 | |||
| 4483b585a7 | |||
| 42870c7c1f | |||
| 590c88ecef | |||
| d3ed3619c2 |
37
IDEAS.md
37
IDEAS.md
@@ -16,6 +16,30 @@ View or live-edit an agent's persona via IRC. "Make the worker more sarcastic" w
|
|||||||
### !pause / !resume <agent>
|
### !pause / !resume <agent>
|
||||||
Temporarily mute an agent without destroying it. Agent stays alive but stops responding. Useful when you need a channel to yourself.
|
Temporarily mute an agent without destroying it. Agent stays alive but stops responding. Useful when you need a channel to yourself.
|
||||||
|
|
||||||
|
## Skill System (inspired by mitsuhiko/agent-stuff)
|
||||||
|
|
||||||
|
### SKILL.md pattern for agent tools
|
||||||
|
Replace hardcoded tools in agent.py with a discoverable skill directory. Each skill is a folder with a SKILL.md (description, parameters, examples) and a script (run.sh/run.py).
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.fireclaw/skills/
|
||||||
|
web_search/
|
||||||
|
SKILL.md # name, description, parameters — parsed into tool definition
|
||||||
|
run.py # actual implementation
|
||||||
|
fetch_url/
|
||||||
|
SKILL.md
|
||||||
|
run.py
|
||||||
|
git_diff/
|
||||||
|
SKILL.md
|
||||||
|
run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent discovers skills at boot, loads SKILL.md into Ollama tool definitions, invokes scripts on tool call. Adding a new tool = drop a folder. No agent.py changes needed.
|
||||||
|
|
||||||
|
Could also support per-template skill selection — coder gets git/code skills, researcher gets search/fetch skills, worker gets everything.
|
||||||
|
|
||||||
|
Reference: https://github.com/mitsuhiko/agent-stuff — Pi Coding Agent skill/extension architecture.
|
||||||
|
|
||||||
## Agent Tools
|
## Agent Tools
|
||||||
|
|
||||||
### Web search
|
### Web search
|
||||||
@@ -110,6 +134,19 @@ One-VM-per-service is overkill for trusted MCP servers but could be used for unt
|
|||||||
- **database** — SQLite or PostgreSQL query tool
|
- **database** — SQLite or PostgreSQL query tool
|
||||||
- **fetch** — HTTP fetch + readability extraction
|
- **fetch** — HTTP fetch + readability extraction
|
||||||
|
|
||||||
|
### Cron / scheduled agents
|
||||||
|
Add `schedule` field to templates (cron syntax). Overseer checks every minute, spawns matching agents, they do their task, report to #agents, self-destruct after timeout. Use cases: daily health checks, backup verification, digest summaries.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
### Centralized log viewer
|
||||||
|
Agent logs go to /workspace/agent.log inside each VM. For a centralized web UI:
|
||||||
|
- rsyslog on host (agents send to 172.16.0.1:514) for aggregation
|
||||||
|
- frontail (`npx frontail /var/log/fireclaw/*.log --port 9001`) for browser-based real-time viewing
|
||||||
|
- Or GoTTY (`gotty tail -f ...`) for zero-config web terminal
|
||||||
|
|
||||||
|
Start simple (plain files + !logs), add rsyslog + frontail when needed.
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
### Agent metrics dashboard
|
### Agent metrics dashboard
|
||||||
|
|||||||
97
REPORT.md
Normal file
97
REPORT.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Fireclaw Code Review Report
|
||||||
|
|
||||||
|
Generated 2026-04-08. Full codebase analysis.
|
||||||
|
|
||||||
|
## Critical
|
||||||
|
|
||||||
|
### 1. Shell injection in agent-manager.ts — FIXED
|
||||||
|
**File:** `src/agent-manager.ts:120-131`
|
||||||
|
Config JSON and persona text interpolated directly into shell commands via `echo '${configJson}'`. If persona contains single quotes, shell breaks or injects arbitrary commands.
|
||||||
|
**Fix:** Replaced with `tee` via stdin. No shell interpolation.
|
||||||
|
|
||||||
|
### 2. IP pool has no real locking — FIXED
|
||||||
|
**File:** `src/network.ts:202-222`
|
||||||
|
`openSync` created a lock file but never acquired an actual `flock`. Under concurrency, two agents could allocate the same IP.
|
||||||
|
**Fix:** Atomic writes via `writeFileSync` + `renameSync`. Removed fake lock.
|
||||||
|
|
||||||
|
### 3. --no-snapshot flag broken — FALSE ALARM
|
||||||
|
**File:** `src/cli.ts:38`
|
||||||
|
Commander parses `--no-snapshot` as `{ snapshot: false }`. Verified: `opts.snapshot === false` is correct. No fix needed.
|
||||||
|
|
||||||
|
## High
|
||||||
|
|
||||||
|
### 4. SKILL.md parser fragile — FIXED
|
||||||
|
**File:** `agent/agent.py:69-138`
|
||||||
|
**Fix:** Added error logging on parse failures, flexible indent detection (2+ spaces), CRLF normalization, boolean case-insensitive (`true`/`True`/`yes`/`1`), parameter type validation with warnings.
|
||||||
|
|
||||||
|
### 5. SSH host key verification disabled — DOCUMENTED
|
||||||
|
**Files:** `src/ssh.ts:104`, `src/agent-manager.ts`, `src/overseer.ts`
|
||||||
|
`hostVerifier: () => true` and `StrictHostKeyChecking=no` everywhere. Acceptable on private bridge network (172.16.0.0/24) — VMs are ephemeral and host keys change on every boot. Conscious design decision, not an oversight.
|
||||||
|
|
||||||
|
### 6. Memory not fully reloaded after save_memory — FIXED
|
||||||
|
**File:** `agent/agent.py:319-326`
|
||||||
|
After save_memory, only MEMORY.md index was reloaded. Individual memory files were not re-read into system prompt.
|
||||||
|
**Fix:** Extracted `reload_memory()` function that reloads index + all memory/*.md files.
|
||||||
|
|
||||||
|
## Medium
|
||||||
|
|
||||||
|
### 7. SSH options duplicated — FIXED
|
||||||
|
**Files:** `src/agent-manager.ts`, `src/overseer.ts`
|
||||||
|
Same SSH options array repeated 4+ times.
|
||||||
|
**Fix:** Extracted `SSH_OPTS` constant in both files.
|
||||||
|
|
||||||
|
### 8. Process termination inconsistent — OPEN (low risk)
|
||||||
|
**Files:** `src/firecracker-vm.ts:140-164`, `src/agent-manager.ts:319-332`
|
||||||
|
Two different implementations. The ChildProcess version uses SIGTERM→SIGKILL, the PID version uses polling. Both work, different contexts (owned vs adopted processes).
|
||||||
|
|
||||||
|
### 9. killall python3 hardcoded — FIXED
|
||||||
|
**Files:** `src/agent-manager.ts`
|
||||||
|
**Fix:** Replaced `killall python3` with `pkill -f 'agent.py'`. Targets the specific script, not all python3 processes.
|
||||||
|
|
||||||
|
### 10. Test suite expects researcher template — FIXED
|
||||||
|
**File:** `tests/test-suite.sh:105`
|
||||||
|
Test asserts `researcher` template exists, but install script only created worker, coder, quick.
|
||||||
|
**Fix:** Added researcher and creative templates to `scripts/install.sh`.
|
||||||
|
|
||||||
|
### 11. Bare exception handlers — FIXED
|
||||||
|
**File:** `src/agent-manager.ts`
|
||||||
|
**Fix:** Added error logging to cleanup catch blocks in listAgents and reconcileAgents. Remaining bare catches are intentional best-effort cleanup (umount, rmdir, unlink).
|
||||||
|
|
||||||
|
### 12. agent.py monolithic (598 lines) — OPEN
|
||||||
|
**File:** `agent/agent.py`
|
||||||
|
Handles IRC, skill discovery, tool execution, memory, config reload in one file. Functional but could benefit from splitting.
|
||||||
|
|
||||||
|
### 13. Unused writePool function — FIXED
|
||||||
|
**File:** `src/network.ts:198`
|
||||||
|
Left over after switching to `atomicWritePool`. Removed.
|
||||||
|
|
||||||
|
## Low
|
||||||
|
|
||||||
|
### 14. Hardcoded network interface fallback
|
||||||
|
**File:** `src/network.ts:56` — defaults to `"eno2"` if route parsing fails.
|
||||||
|
|
||||||
|
### 15. Predictable mount point names
|
||||||
|
**File:** `src/agent-manager.ts:94` — uses `Date.now()` instead of crypto random.
|
||||||
|
|
||||||
|
### 16. No Firecracker binary hash verification
|
||||||
|
**File:** `scripts/install.sh:115-124` — downloads binary without SHA256 check.
|
||||||
|
|
||||||
|
### 17. Ollama response size unbounded
|
||||||
|
**File:** `agent/agent.py:338` — `resp.read()` with no size limit.
|
||||||
|
|
||||||
|
### 18. IRC message splitting at 400 chars — FIXED
|
||||||
|
**File:** `agent/agent.py:266`
|
||||||
|
**Fix:** Reduced to 380 chars to stay within IRC's 512-byte line limit.
|
||||||
|
|
||||||
|
### 19. Thread safety on _last_response_time — FIXED
|
||||||
|
**File:** `agent/agent.py:485`
|
||||||
|
**Fix:** Added `_cooldown_lock` (threading.Lock) around cooldown check-and-set.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Status | Count |
|
||||||
|
|---|---|
|
||||||
|
| Fixed | 13 |
|
||||||
|
| False alarm | 1 |
|
||||||
|
| Open (medium) | 2 |
|
||||||
|
| Open (low) | 4 |
|
||||||
78
ROADMAP.md
78
ROADMAP.md
@@ -22,49 +22,57 @@
|
|||||||
- [x] Ollama with 5+ models, hot-swappable per agent
|
- [x] Ollama with 5+ models, hot-swappable per agent
|
||||||
- [x] Agent rootfs — Alpine + Python IRC bot + podman + tools
|
- [x] Agent rootfs — Alpine + Python IRC bot + podman + tools
|
||||||
- [x] Agent manager — start/stop/list/reload long-running VMs
|
- [x] Agent manager — start/stop/list/reload long-running VMs
|
||||||
- [x] Overseer — !invoke, !destroy, !list, !model, !models, !templates, !status, !help
|
- [x] Overseer — !invoke, !destroy, !list, !model, !models, !templates, !persona, !status, !help
|
||||||
- [x] 5 agent templates — worker, coder, researcher, quick, creative
|
- [x] 5 agent templates — worker, coder, researcher, quick, creative
|
||||||
- [x] Agent tools — run_command, web_search (searx), save_memory
|
- [x] Discoverable skill system — SKILL.md + run.py per tool, auto-loaded at boot
|
||||||
- [x] Persistent workspace — 64 MiB ext4 as second virtio drive
|
- [x] Agent tools — run_command, web_search, fetch_url, save_memory
|
||||||
- [x] Agent memory system — MEMORY.md pattern, survives restarts
|
- [x] Persistent workspace + memory system (MEMORY.md pattern)
|
||||||
- [x] Agent hot-reload — model/persona swap via SSH + SIGHUP
|
- [x] Agent hot-reload, non-root agents, agent-to-agent, DMs, /invite
|
||||||
- [x] Non-root agents — unprivileged `agent` user
|
- [x] Overseer resilience, health checks, graceful shutdown, systemd
|
||||||
- [x] Agent-to-agent via IRC, DMs, /invite
|
|
||||||
- [x] Overseer resilience — crash recovery, health checks, KillMode=process
|
|
||||||
- [x] Graceful shutdown — IRC QUIT before VM kill
|
|
||||||
- [x] Systemd service, regression tests
|
|
||||||
|
|
||||||
## Phase 4: Hardening & Deployment (done)
|
## Phase 4: Hardening & Deployment (done)
|
||||||
|
|
||||||
- [x] Network policies per agent — full/local/none via iptables
|
- [x] Network policies, thread safety, trigger fix, race condition fix
|
||||||
- [x] Thread safety — lock around IRC socket writes
|
- [x] Install/uninstall scripts, deployed on Debian + Ubuntu + GPU server
|
||||||
- [x] Agent health checks — 30s interval, announces deaths in #control
|
- [x] Refactor — shared firecracker-vm.ts, skill system extraction
|
||||||
- [x] Trigger matching fix — start-of-message only
|
|
||||||
- [x] agents.json race condition fix
|
|
||||||
- [x] Install script — one-command deployment, battle-tested on Debian + Ubuntu
|
|
||||||
- [x] Uninstall script
|
|
||||||
- [x] Deployed on GPU server (Xeon + Quadro P5000)
|
|
||||||
- [x] Refactor — shared firecracker-vm.ts helpers, -43 lines
|
|
||||||
|
|
||||||
### Remaining
|
### Remaining
|
||||||
- [ ] Warm pool — pre-booted VMs from snapshots for instant spawns
|
- [ ] Warm pool — pre-booted VMs from snapshots
|
||||||
- [ ] Concurrent snapshot runs via network namespaces
|
- [ ] Concurrent snapshot runs via network namespaces
|
||||||
- [ ] Thin provisioning — device-mapper snapshots instead of full rootfs copies
|
- [ ] Thin provisioning — device-mapper snapshots
|
||||||
|
|
||||||
## Phase 5: Advanced Features
|
## Phase 5: Agent Intelligence
|
||||||
|
|
||||||
- [ ] Scheduled/cron tasks — agents that run on a timer
|
Priority order by gain/complexity ratio.
|
||||||
- [ ] !logs command — tail agent interaction history
|
|
||||||
- [ ] Persistent agent memory v2 — richer structure, auto-save
|
|
||||||
- [ ] Advanced tool use — MCP servers in Firecracker VMs
|
|
||||||
- [ ] Cost tracking — duration, model, tokens per interaction
|
|
||||||
- [ ] Execution recording — audit trail
|
|
||||||
|
|
||||||
## Phase 6: Ideas & Experiments
|
### High priority (high gain, low-medium complexity)
|
||||||
|
|
||||||
See IDEAS.md for the full list. Highlights:
|
- [ ] **Large output handling** — tool results >2K chars saved to workspace file, agent gets preview + can read the rest. Prevents context explosion. Simple, high impact.
|
||||||
- MCP servers as a single Firecracker VM with podman containers
|
- [ ] **Iteration budget** — shared token/round budget across tool calls. Prevents runaway loops, especially with GPU server running faster models that chain more aggressively. Add per-template configurable limits.
|
||||||
- Cron agents, webhook triggers, alert forwarding
|
- [ ] **Skill registry as git repo** — separate git repo for community/shared skills. Clone into agent rootfs. `fireclaw skills pull` to update. Like agentskills.io but self-hosted on Gitea.
|
||||||
- Agent-written agents, agent debates, dream mode
|
- [ ] **Session persistence** — SQLite in workspace for conversation history. FTS5 full-text search over past sessions. Agents can search their own history.
|
||||||
- Web dashboard, install script dry-run
|
|
||||||
- Persistent agent memory with CLAUDE.md pattern (v2)
|
### Medium priority (medium gain, medium complexity)
|
||||||
|
|
||||||
|
- [ ] **Context compression** — when conversation history exceeds threshold, LLM-summarize middle turns. Protect head (system prompt) and tail (recent messages). Keeps agents coherent in long conversations.
|
||||||
|
- [ ] **Skill learning** — after complex multi-tool tasks, agent creates a new SKILL.md + run.py in workspace/skills. Next boot, new skill is available. Self-improving agents.
|
||||||
|
- [ ] **Scheduled/cron agents** — template gets a `schedule` field. Overseer spawns agent on schedule, agent does its task, reports to #agents, self-destructs.
|
||||||
|
- [ ] **!logs command** — tail agent interaction history from workspace.
|
||||||
|
|
||||||
|
### Lower priority (good ideas, higher complexity or less immediate need)
|
||||||
|
|
||||||
|
- [ ] **Dangerous command approval** — pattern-based detection (rm -rf, git reset, etc.) with allowlist. Agent asks for confirmation before destructive commands.
|
||||||
|
- [ ] **Parallel tool execution** — detect independent tool calls, run concurrently. Needs safety heuristics (read-only, non-overlapping paths).
|
||||||
|
- [ ] **Cost tracking** — Ollama returns token counts. Log per-interaction: duration, model, tokens, skill used.
|
||||||
|
- [ ] **Execution recording** — full audit trail of all tool calls and results.
|
||||||
|
|
||||||
|
## Phase 6: Infrastructure
|
||||||
|
|
||||||
|
- [ ] MCP servers in Firecracker VM with podman containers
|
||||||
|
- [ ] Webhook triggers — HTTP endpoint that spawns ephemeral agents
|
||||||
|
- [ ] Alert forwarding — pipe system alerts into #agents
|
||||||
|
- [ ] Web dashboard — status page for running agents
|
||||||
|
|
||||||
|
## Phase 7: Ideas & Experiments
|
||||||
|
|
||||||
|
See IDEAS.md for the full list.
|
||||||
|
|||||||
62
TODO.md
62
TODO.md
@@ -3,38 +3,46 @@
|
|||||||
## Done
|
## Done
|
||||||
|
|
||||||
- [x] Firecracker CLI runner with snapshots (~1.1s)
|
- [x] Firecracker CLI runner with snapshots (~1.1s)
|
||||||
- [x] Alpine rootfs with ca-certificates, podman, python3
|
|
||||||
- [x] Global `fireclaw` command
|
|
||||||
- [x] Multi-agent system — overseer + agent VMs + IRC + Ollama
|
- [x] Multi-agent system — overseer + agent VMs + IRC + Ollama
|
||||||
- [x] 5 agent templates (worker, coder, researcher, quick, creative)
|
- [x] 5 templates, 5+ models, hot-reload, non-root agents
|
||||||
- [x] 5 Ollama models (qwen2.5-coder, qwen2.5, llama3.1, gemma3, phi4-mini)
|
- [x] Tools: run_command, web_search, fetch_url, save_memory
|
||||||
- [x] Agent tool access — shell commands + podman containers
|
- [x] Discoverable skill system — SKILL.md + run.py, auto-loaded
|
||||||
- [x] Persistent workspace + memory system (MEMORY.md pattern)
|
- [x] Persistent workspace + memory (MEMORY.md pattern)
|
||||||
- [x] Agent hot-reload — model/persona swap via SSH + SIGHUP
|
- [x] Overseer: !invoke, !destroy, !list, !model, !models, !templates, !persona, !status, !help
|
||||||
- [x] Non-root agents — unprivileged `agent` user
|
- [x] Health checks, crash recovery, graceful shutdown, systemd
|
||||||
- [x] Agent-to-agent via IRC mentions (10s cooldown)
|
- [x] Network policies, thread safety, trigger fix, race condition fix
|
||||||
- [x] DM support — private messages, no mention needed
|
- [x] Install/uninstall scripts, deployed on 2 machines
|
||||||
- [x] /invite support — agents auto-join invited channels
|
- [x] Refactor: firecracker-vm.ts shared helpers, skill extraction
|
||||||
- [x] Channel layout — #control (commands), #agents (common), DMs
|
|
||||||
- [x] Overseer resilience — crash recovery, agent adoption
|
|
||||||
- [x] Graceful shutdown — IRC QUIT before VM kill
|
|
||||||
- [x] Systemd service (KillMode=process)
|
|
||||||
- [x] Regression test suite (20 tests)
|
|
||||||
|
|
||||||
- [ ] Refactor duplicated code — waitForSocket, boot sequence, tap setup, rootfs mount/inject are copy-pasted across vm.ts, snapshot.ts, agent-manager.ts. Extract shared helpers.
|
## Next up (Phase 5 — by priority)
|
||||||
|
|
||||||
## Next up
|
### Quick wins
|
||||||
|
- [ ] Large output handling — save >2K results to file, preview + read_file
|
||||||
|
- [ ] Iteration budget — configurable max rounds per template, prevent runaway loops
|
||||||
|
|
||||||
- [ ] Network policies per agent — restrict internet access
|
### Medium effort
|
||||||
- [ ] Warm pool — pre-booted VMs for instant agent spawns
|
- [ ] Skill registry git repo — shared skills on Gitea, `fireclaw skills pull`
|
||||||
- [ ] Persistent agent memory improvements — richer memory structure, auto-save from conversations
|
- [ ] Session persistence — SQLite + FTS5 in workspace
|
||||||
- [ ] Thin provisioning — device-mapper snapshots instead of full rootfs copies
|
- [ ] Context compression — summarize old turns when context gets long
|
||||||
|
- [ ] !logs — tail agent history from workspace
|
||||||
|
|
||||||
|
### Bigger items
|
||||||
|
- [ ] Skill learning — agents create new skills from experience
|
||||||
|
- [ ] Cron agents — scheduled agent spawns
|
||||||
|
- [ ] Dangerous command approval — pattern detection + allowlist
|
||||||
|
- [ ] Parallel tool execution — concurrent independent tool calls
|
||||||
|
|
||||||
## Polish
|
## Polish
|
||||||
|
|
||||||
- [ ] Agent-to-agent response quality — small models (7B) parrot messages instead of answering. Needs better prompting ("don't repeat the question, answer it") or larger models (14B+). Claude API would help here.
|
- [ ] Agent-to-agent response quality — 7B models parrot, needs better prompting or larger models
|
||||||
- [ ] Cost tracking per agent interaction
|
- [ ] Cost tracking per interaction
|
||||||
- [ ] Execution recording / audit trail
|
- [ ] Execution recording / audit trail
|
||||||
- [ ] Agent health checks — overseer pings agents, restarts dead ones
|
- [ ] Update regression tests for skill system + channel layout
|
||||||
- [ ] Thread safety in agent.py — lock around IRC socket writes
|
|
||||||
- [ ] Update regression tests for new channel layout
|
## Low priority (from REPORT.md)
|
||||||
|
|
||||||
|
- [ ] Hardcoded network interface fallback — `src/network.ts:56` defaults to `"eno2"` if route parsing fails
|
||||||
|
- [ ] Predictable mount point names — `src/agent-manager.ts:94` uses `Date.now()` instead of crypto random
|
||||||
|
- [ ] No Firecracker binary hash verification — `scripts/install.sh` downloads without SHA256 check
|
||||||
|
- [ ] Ollama response size unbounded — `agent/tools.py` should limit `resp.read()` size
|
||||||
|
- [ ] Process termination inconsistent — two patterns (ChildProcess vs PID polling), works but could consolidate
|
||||||
|
|||||||
414
agent/agent.py
414
agent/agent.py
@@ -1,18 +1,20 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Fireclaw IRC agent — connects to IRC, responds via Ollama with tool access."""
|
"""Fireclaw IRC agent — connects to IRC, responds via Ollama with discoverable skills."""
|
||||||
|
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import subprocess
|
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
|
||||||
import signal
|
import signal
|
||||||
import threading
|
import threading
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
# Load config
|
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
|
||||||
|
|
||||||
|
# ─── Config ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
with open("/etc/agent/config.json") as f:
|
with open("/etc/agent/config.json") as f:
|
||||||
CONFIG = json.load(f)
|
CONFIG = json.load(f)
|
||||||
|
|
||||||
@@ -24,114 +26,69 @@ except FileNotFoundError:
|
|||||||
PERSONA = "You are a helpful assistant."
|
PERSONA = "You are a helpful assistant."
|
||||||
|
|
||||||
NICK = CONFIG.get("nick", "agent")
|
NICK = CONFIG.get("nick", "agent")
|
||||||
CHANNEL = CONFIG.get("channel", "#agents")
|
|
||||||
SERVER = CONFIG.get("server", "172.16.0.1")
|
SERVER = CONFIG.get("server", "172.16.0.1")
|
||||||
PORT = CONFIG.get("port", 6667)
|
PORT = CONFIG.get("port", 6667)
|
||||||
OLLAMA_URL = CONFIG.get("ollama_url", "http://172.16.0.1:11434")
|
OLLAMA_URL = CONFIG.get("ollama_url", "http://172.16.0.1:11434")
|
||||||
CONTEXT_SIZE = CONFIG.get("context_size", 20)
|
CONTEXT_SIZE = CONFIG.get("context_size", 20)
|
||||||
MAX_RESPONSE_LINES = CONFIG.get("max_response_lines", 50)
|
MAX_RESPONSE_LINES = CONFIG.get("max_response_lines", 50)
|
||||||
TOOLS_ENABLED = CONFIG.get("tools", True)
|
TOOLS_ENABLED = CONFIG.get("tools", True)
|
||||||
MAX_TOOL_ROUNDS = CONFIG.get("max_tool_rounds", 5)
|
MAX_TOOL_ROUNDS = CONFIG.get("max_tool_rounds", 10)
|
||||||
WORKSPACE = "/workspace"
|
WORKSPACE = "/workspace"
|
||||||
|
SKILL_DIRS = ["/opt/skills", f"{WORKSPACE}/skills"]
|
||||||
|
|
||||||
# Mutable runtime config — can be hot-reloaded via SIGHUP
|
|
||||||
RUNTIME = {
|
RUNTIME = {
|
||||||
"model": CONFIG.get("model", "qwen2.5-coder:7b"),
|
"model": CONFIG.get("model", "qwen2.5-coder:7b"),
|
||||||
"trigger": CONFIG.get("trigger", "mention"),
|
"trigger": CONFIG.get("trigger", "mention"),
|
||||||
"persona": PERSONA,
|
"persona": PERSONA,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Recent messages for context
|
|
||||||
recent = deque(maxlen=CONTEXT_SIZE)
|
recent = deque(maxlen=CONTEXT_SIZE)
|
||||||
|
|
||||||
# Load persistent memory from workspace
|
# ─── Logging ─────────────────────────────────────────────────────────
|
||||||
AGENT_MEMORY = ""
|
|
||||||
try:
|
|
||||||
import os
|
|
||||||
with open(f"{WORKSPACE}/MEMORY.md") as f:
|
|
||||||
AGENT_MEMORY = f.read().strip()
|
|
||||||
# Also load all memory files referenced in the index
|
|
||||||
mem_dir = f"{WORKSPACE}/memory"
|
|
||||||
if os.path.isdir(mem_dir):
|
|
||||||
for fname in sorted(os.listdir(mem_dir)):
|
|
||||||
if fname.endswith(".md"):
|
|
||||||
try:
|
|
||||||
with open(f"{mem_dir}/{fname}") as f:
|
|
||||||
topic = fname.replace(".md", "")
|
|
||||||
AGENT_MEMORY += f"\n\n## {topic}\n{f.read().strip()}"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Tool definitions for Ollama chat API
|
LOG_FILE = f"{WORKSPACE}/agent.log" if os.path.isdir(WORKSPACE) else None
|
||||||
TOOLS = [
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "run_command",
|
|
||||||
"description": "Execute a shell command on this system and return the output. Use this to check system info, run scripts, fetch URLs, process data, etc.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"command": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The shell command to execute (bash)",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["command"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "save_memory",
|
|
||||||
"description": "Save something important to your persistent memory. Use this to remember facts about users, lessons learned, project context, or anything you want to recall in future conversations. Memories survive restarts.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"topic": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Short topic name for the memory file (e.g. 'user_prefs', 'project_x', 'lessons')",
|
|
||||||
},
|
|
||||||
"content": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The memory content to save",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["topic", "content"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "web_search",
|
|
||||||
"description": "Search the web using SearXNG. Returns titles, URLs, and snippets for the top results. Use this when you need current information or facts you're unsure about.",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"query": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The search query",
|
|
||||||
},
|
|
||||||
"num_results": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Number of results to return (default 5)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["query"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
SEARX_URL = CONFIG.get("searx_url", "https://searx.mymx.me")
|
|
||||||
|
|
||||||
|
|
||||||
def log(msg):
|
def log(msg):
|
||||||
print(f"[agent:{NICK}] {msg}", flush=True)
|
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)
|
||||||
|
|
||||||
|
# ─── Init ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
AGENT_MEMORY = load_memory(WORKSPACE)
|
||||||
|
TOOLS, SKILL_SCRIPTS = discover_skills(SKILL_DIRS)
|
||||||
|
log(f"Loaded {len(TOOLS)} skills: {', '.join(SKILL_SCRIPTS.keys())}")
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
class IRCClient:
|
||||||
@@ -161,9 +118,9 @@ class IRCClient:
|
|||||||
for line in text.split("\n"):
|
for line in text.split("\n"):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line:
|
if line:
|
||||||
while len(line) > 400:
|
while len(line) > 380:
|
||||||
self.send(f"PRIVMSG {target} :{line[:400]}")
|
self.send(f"PRIVMSG {target} :{line[:380]}")
|
||||||
line = line[400:]
|
line = line[380:]
|
||||||
self.send(f"PRIVMSG {target} :{line}")
|
self.send(f"PRIVMSG {target} :{line}")
|
||||||
|
|
||||||
def set_bot_mode(self):
|
def set_bot_mode(self):
|
||||||
@@ -182,226 +139,14 @@ class IRCClient:
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def run_command(command):
|
# ─── Message Handling ────────────────────────────────────────────────
|
||||||
"""Execute a shell command and return output."""
|
|
||||||
log(f"Running command: {command[:100]}")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["bash", "-c", command],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
output = result.stdout
|
|
||||||
if result.stderr:
|
|
||||||
output += f"\n[stderr] {result.stderr}"
|
|
||||||
if result.returncode != 0:
|
|
||||||
output += f"\n[exit code: {result.returncode}]"
|
|
||||||
# Truncate very long output
|
|
||||||
if len(output) > 2000:
|
|
||||||
output = output[:2000] + "\n[output truncated]"
|
|
||||||
return output.strip() or "[no output]"
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return "[command timed out after 120s]"
|
|
||||||
except Exception as e:
|
|
||||||
return f"[error: {e}]"
|
|
||||||
|
|
||||||
|
|
||||||
def save_memory(topic, content):
|
|
||||||
"""Save a memory to the persistent workspace."""
|
|
||||||
import os
|
|
||||||
mem_dir = f"{WORKSPACE}/memory"
|
|
||||||
os.makedirs(mem_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# Write the memory file
|
|
||||||
filepath = f"{mem_dir}/{topic}.md"
|
|
||||||
with open(filepath, "w") as f:
|
|
||||||
f.write(content + "\n")
|
|
||||||
|
|
||||||
# Update MEMORY.md index
|
|
||||||
index_path = f"{WORKSPACE}/MEMORY.md"
|
|
||||||
existing = ""
|
|
||||||
try:
|
|
||||||
with open(index_path) as f:
|
|
||||||
existing = f.read()
|
|
||||||
except FileNotFoundError:
|
|
||||||
existing = "# Agent Memory\n"
|
|
||||||
|
|
||||||
# Add or update entry
|
|
||||||
entry = f"- [{topic}](memory/{topic}.md)"
|
|
||||||
if topic not in existing:
|
|
||||||
with open(index_path, "a") as f:
|
|
||||||
f.write(f"\n{entry}")
|
|
||||||
|
|
||||||
# Reload memory into global
|
|
||||||
global AGENT_MEMORY
|
|
||||||
with open(index_path) as f:
|
|
||||||
AGENT_MEMORY = f.read().strip()
|
|
||||||
|
|
||||||
log(f"Memory saved: {topic}")
|
|
||||||
return f"Memory saved to {filepath}"
|
|
||||||
|
|
||||||
|
|
||||||
def web_search(query, num_results=5):
|
|
||||||
"""Search the web via SearXNG."""
|
|
||||||
log(f"Web search: {query[:60]}")
|
|
||||||
try:
|
|
||||||
import urllib.parse
|
|
||||||
params = urllib.parse.urlencode({"q": query, "format": "json"})
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{SEARX_URL}/search?{params}",
|
|
||||||
headers={"User-Agent": "fireclaw-agent"},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
||||||
data = json.loads(resp.read())
|
|
||||||
results = data.get("results", [])[:num_results]
|
|
||||||
if not results:
|
|
||||||
return "No results found."
|
|
||||||
lines = []
|
|
||||||
for r in results:
|
|
||||||
title = r.get("title", "")
|
|
||||||
url = r.get("url", "")
|
|
||||||
snippet = r.get("content", "")[:150]
|
|
||||||
lines.append(f"- {title}\n {url}\n {snippet}")
|
|
||||||
return "\n".join(lines)
|
|
||||||
except Exception as e:
|
|
||||||
return f"[search error: {e}]"
|
|
||||||
|
|
||||||
|
|
||||||
def try_parse_tool_call(text):
|
|
||||||
"""Try to parse a text-based tool call from model output.
|
|
||||||
Handles formats like:
|
|
||||||
{"name": "run_command", "arguments": {"command": "uptime"}}
|
|
||||||
<tool_call>{"name": "run_command", ...}</tool_call>
|
|
||||||
Returns (name, args) tuple or None.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
# Strip tool_call tags if present
|
|
||||||
text = re.sub(r"</?tool_call>", "", text).strip()
|
|
||||||
# Try to find JSON in the text
|
|
||||||
for start in range(len(text)):
|
|
||||||
if text[start] == "{":
|
|
||||||
for end in range(len(text), start, -1):
|
|
||||||
if text[end - 1] == "}":
|
|
||||||
try:
|
|
||||||
obj = json.loads(text[start:end])
|
|
||||||
name = obj.get("name")
|
|
||||||
args = obj.get("arguments", {})
|
|
||||||
if name and isinstance(args, dict):
|
|
||||||
return (name, args)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def ollama_request(payload):
|
|
||||||
"""Make a request to Ollama API."""
|
|
||||||
data = json.dumps(payload).encode("utf-8")
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{OLLAMA_URL}/api/chat",
|
|
||||||
data=data,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
||||||
return json.loads(resp.read())
|
|
||||||
|
|
||||||
|
|
||||||
def query_ollama(messages):
|
|
||||||
"""Call Ollama chat API with tool support. Returns final response text."""
|
|
||||||
payload = {
|
|
||||||
"model": RUNTIME["model"],
|
|
||||||
"messages": messages,
|
|
||||||
"stream": False,
|
|
||||||
"options": {"num_predict": 512},
|
|
||||||
}
|
|
||||||
|
|
||||||
if TOOLS_ENABLED:
|
|
||||||
payload["tools"] = TOOLS
|
|
||||||
|
|
||||||
for round_num in range(MAX_TOOL_ROUNDS):
|
|
||||||
try:
|
|
||||||
data = ollama_request(payload)
|
|
||||||
except (urllib.error.URLError, TimeoutError) as e:
|
|
||||||
return f"[error: {e}]"
|
|
||||||
|
|
||||||
msg = data.get("message", {})
|
|
||||||
|
|
||||||
# Check for structured tool calls from API
|
|
||||||
tool_calls = msg.get("tool_calls")
|
|
||||||
if tool_calls:
|
|
||||||
messages.append(msg)
|
|
||||||
|
|
||||||
for tc in tool_calls:
|
|
||||||
fn = tc.get("function", {})
|
|
||||||
fn_name = fn.get("name", "")
|
|
||||||
fn_args = fn.get("arguments", {})
|
|
||||||
|
|
||||||
if fn_name == "run_command":
|
|
||||||
cmd = fn_args.get("command", "")
|
|
||||||
log(f"Tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: {cmd[:80]}")
|
|
||||||
result = run_command(cmd)
|
|
||||||
messages.append({"role": "tool", "content": result})
|
|
||||||
elif fn_name == "save_memory":
|
|
||||||
topic = fn_args.get("topic", "note")
|
|
||||||
content = fn_args.get("content", "")
|
|
||||||
log(f"Tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: save_memory({topic})")
|
|
||||||
result = save_memory(topic, content)
|
|
||||||
messages.append({"role": "tool", "content": result})
|
|
||||||
elif fn_name == "web_search":
|
|
||||||
query = fn_args.get("query", "")
|
|
||||||
num = fn_args.get("num_results", 5)
|
|
||||||
log(f"Tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: web_search({query[:60]})")
|
|
||||||
result = web_search(query, num)
|
|
||||||
messages.append({"role": "tool", "content": result})
|
|
||||||
else:
|
|
||||||
messages.append({
|
|
||||||
"role": "tool",
|
|
||||||
"content": f"[unknown tool: {fn_name}]",
|
|
||||||
})
|
|
||||||
|
|
||||||
payload["messages"] = messages
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check for text-based tool calls (model dumped JSON as text)
|
|
||||||
content = msg.get("content", "").strip()
|
|
||||||
parsed_tool = try_parse_tool_call(content)
|
|
||||||
if parsed_tool:
|
|
||||||
fn_name, fn_args = parsed_tool
|
|
||||||
messages.append({"role": "assistant", "content": content})
|
|
||||||
if fn_name == "run_command":
|
|
||||||
cmd = fn_args.get("command", "")
|
|
||||||
log(f"Text tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: {cmd[:80]}")
|
|
||||||
result = run_command(cmd)
|
|
||||||
messages.append({"role": "user", "content": f"Command output:\n{result}\n\nNow provide your response to the user based on this output."})
|
|
||||||
elif fn_name == "save_memory":
|
|
||||||
topic = fn_args.get("topic", "note")
|
|
||||||
mem_content = fn_args.get("content", "")
|
|
||||||
log(f"Text tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: save_memory({topic})")
|
|
||||||
result = save_memory(topic, mem_content)
|
|
||||||
messages.append({"role": "user", "content": f"{result}\n\nNow respond to the user."})
|
|
||||||
elif fn_name == "web_search":
|
|
||||||
query = fn_args.get("query", "")
|
|
||||||
num = fn_args.get("num_results", 5)
|
|
||||||
log(f"Text tool call [{round_num+1}/{MAX_TOOL_ROUNDS}]: web_search({query[:60]})")
|
|
||||||
result = web_search(query, num)
|
|
||||||
messages.append({"role": "user", "content": f"Search results:\n{result}\n\nNow respond to the user based on these results."})
|
|
||||||
payload["messages"] = messages
|
|
||||||
continue
|
|
||||||
|
|
||||||
# No tool calls — return the text response
|
|
||||||
return content
|
|
||||||
|
|
||||||
return "[max tool rounds reached]"
|
|
||||||
|
|
||||||
|
|
||||||
def build_messages(question, channel):
|
def build_messages(question, channel):
|
||||||
"""Build chat messages with system prompt and conversation history."""
|
|
||||||
system = RUNTIME["persona"]
|
system = RUNTIME["persona"]
|
||||||
if TOOLS_ENABLED:
|
if TOOLS_ENABLED and TOOLS:
|
||||||
system += "\n\nYou have access to tools:"
|
skill_names = [t["function"]["name"] for t in TOOLS]
|
||||||
system += "\n- run_command: Execute shell commands on your system."
|
system += "\n\nYou have access to tools: " + ", ".join(skill_names) + "."
|
||||||
system += "\n- web_search: Search the web for current information."
|
|
||||||
system += "\n- save_memory: Save important information to your persistent workspace."
|
|
||||||
system += "\nUse tools when needed rather than guessing. Your workspace at /workspace persists across restarts."
|
system += "\nUse tools when needed rather than guessing. Your workspace at /workspace persists across restarts."
|
||||||
if AGENT_MEMORY and AGENT_MEMORY != "# Agent Memory":
|
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\nIMPORTANT - Your persistent memory (facts you saved previously, use these to answer questions):\n{AGENT_MEMORY}"
|
||||||
@@ -410,7 +155,6 @@ def build_messages(question, channel):
|
|||||||
|
|
||||||
messages = [{"role": "system", "content": system}]
|
messages = [{"role": "system", "content": system}]
|
||||||
|
|
||||||
# Build conversation history as alternating user/assistant messages
|
|
||||||
channel_msgs = [m for m in recent if m["channel"] == channel]
|
channel_msgs = [m for m in recent if m["channel"] == channel]
|
||||||
for msg in channel_msgs[-CONTEXT_SIZE:]:
|
for msg in channel_msgs[-CONTEXT_SIZE:]:
|
||||||
if msg["nick"] == NICK:
|
if msg["nick"] == NICK:
|
||||||
@@ -418,8 +162,6 @@ def build_messages(question, channel):
|
|||||||
else:
|
else:
|
||||||
messages.append({"role": "user", "content": f"<{msg['nick']}> {msg['text']}"})
|
messages.append({"role": "user", "content": f"<{msg['nick']}> {msg['text']}"})
|
||||||
|
|
||||||
# Ensure the last message is from the user (the triggering question)
|
|
||||||
# If the deque already captured it, don't double-add
|
|
||||||
last = messages[-1] if len(messages) > 1 else None
|
last = messages[-1] if len(messages) > 1 else None
|
||||||
if not last or last.get("role") != "user" or question not in last.get("content", ""):
|
if not last or last.get("role") != "user" or question not in last.get("content", ""):
|
||||||
messages.append({"role": "user", "content": question})
|
messages.append({"role": "user", "content": question})
|
||||||
@@ -428,14 +170,10 @@ def build_messages(question, channel):
|
|||||||
|
|
||||||
|
|
||||||
def should_trigger(text):
|
def should_trigger(text):
|
||||||
"""Check if this message should trigger a response.
|
|
||||||
Only triggers when nick is at the start of the message (e.g. 'worker: hello')
|
|
||||||
not when nick appears elsewhere (e.g. 'coder: say hi to worker')."""
|
|
||||||
if RUNTIME["trigger"] == "all":
|
if RUNTIME["trigger"] == "all":
|
||||||
return True
|
return True
|
||||||
lower = text.lower()
|
lower = text.lower()
|
||||||
nick = NICK.lower()
|
nick = NICK.lower()
|
||||||
# Match: "nick: ...", "nick, ...", "nick ...", "@nick ..."
|
|
||||||
return (
|
return (
|
||||||
lower.startswith(f"{nick}:") or
|
lower.startswith(f"{nick}:") or
|
||||||
lower.startswith(f"{nick},") or
|
lower.startswith(f"{nick},") or
|
||||||
@@ -447,7 +185,6 @@ def should_trigger(text):
|
|||||||
|
|
||||||
|
|
||||||
def extract_question(text):
|
def extract_question(text):
|
||||||
"""Extract the actual question from the trigger."""
|
|
||||||
lower = text.lower()
|
lower = text.lower()
|
||||||
for prefix in [
|
for prefix in [
|
||||||
f"{NICK.lower()}: ",
|
f"{NICK.lower()}: ",
|
||||||
@@ -462,13 +199,12 @@ def extract_question(text):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
# Track last response time to prevent agent-to-agent loops
|
|
||||||
_last_response_time = 0
|
_last_response_time = 0
|
||||||
_AGENT_COOLDOWN = 10 # seconds between responses to prevent loops
|
_AGENT_COOLDOWN = 10
|
||||||
|
_cooldown_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def handle_message(irc, source_nick, target, text):
|
def handle_message(irc, source_nick, target, text):
|
||||||
"""Process an incoming PRIVMSG."""
|
|
||||||
global _last_response_time
|
global _last_response_time
|
||||||
|
|
||||||
is_dm = not target.startswith("#")
|
is_dm = not target.startswith("#")
|
||||||
@@ -480,16 +216,15 @@ def handle_message(irc, source_nick, target, text):
|
|||||||
if source_nick == NICK:
|
if source_nick == NICK:
|
||||||
return
|
return
|
||||||
|
|
||||||
# DMs always trigger, channel messages need mention
|
|
||||||
if not is_dm and not should_trigger(text):
|
if not is_dm and not should_trigger(text):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Cooldown to prevent agent-to-agent loops
|
with _cooldown_lock:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - _last_response_time < _AGENT_COOLDOWN:
|
if now - _last_response_time < _AGENT_COOLDOWN:
|
||||||
log(f"Cooldown active, ignoring trigger from {source_nick}")
|
log(f"Cooldown active, ignoring trigger from {source_nick}")
|
||||||
return
|
return
|
||||||
_last_response_time = now
|
_last_response_time = now
|
||||||
|
|
||||||
question = extract_question(text) if not is_dm else text
|
question = extract_question(text) if not is_dm else text
|
||||||
log(f"Triggered by {source_nick} in {channel}: {question[:80]}")
|
log(f"Triggered by {source_nick} in {channel}: {question[:80]}")
|
||||||
@@ -497,7 +232,12 @@ def handle_message(irc, source_nick, target, text):
|
|||||||
def do_respond():
|
def do_respond():
|
||||||
try:
|
try:
|
||||||
messages = build_messages(question, channel)
|
messages = build_messages(question, channel)
|
||||||
response = query_ollama(messages)
|
response = query_ollama(
|
||||||
|
messages, RUNTIME,
|
||||||
|
TOOLS if TOOLS_ENABLED else [],
|
||||||
|
SKILL_SCRIPTS, dispatch_tool,
|
||||||
|
OLLAMA_URL, MAX_TOOL_ROUNDS,
|
||||||
|
)
|
||||||
|
|
||||||
if not response:
|
if not response:
|
||||||
return
|
return
|
||||||
@@ -519,8 +259,11 @@ def handle_message(irc, source_nick, target, text):
|
|||||||
threading.Thread(target=do_respond, daemon=True).start()
|
threading.Thread(target=do_respond, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main Loop ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
log(f"Starting agent: nick={NICK} channel={CHANNEL} model={RUNTIME['model']} tools={TOOLS_ENABLED}")
|
log(f"Starting agent: nick={NICK} model={RUNTIME['model']} tools={TOOLS_ENABLED}")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -528,7 +271,6 @@ def run():
|
|||||||
log(f"Connecting to {SERVER}:{PORT}...")
|
log(f"Connecting to {SERVER}:{PORT}...")
|
||||||
irc.connect()
|
irc.connect()
|
||||||
|
|
||||||
# Hot-reload on SIGHUP — re-read config and persona
|
|
||||||
def handle_sighup(signum, frame):
|
def handle_sighup(signum, frame):
|
||||||
log("SIGHUP received, reloading config...")
|
log("SIGHUP received, reloading config...")
|
||||||
try:
|
try:
|
||||||
@@ -542,13 +284,12 @@ def run():
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
log(f"Reloaded: model={RUNTIME['model']} trigger={RUNTIME['trigger']}")
|
log(f"Reloaded: model={RUNTIME['model']} trigger={RUNTIME['trigger']}")
|
||||||
irc.say(CHANNEL, f"[reloaded: model={RUNTIME['model']}]")
|
irc.say("#agents", f"[reloaded: model={RUNTIME['model']}]")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"Reload failed: {e}")
|
log(f"Reload failed: {e}")
|
||||||
|
|
||||||
signal.signal(signal.SIGHUP, handle_sighup)
|
signal.signal(signal.SIGHUP, handle_sighup)
|
||||||
|
|
||||||
# Graceful shutdown on SIGTERM — send IRC QUIT
|
|
||||||
def handle_sigterm(signum, frame):
|
def handle_sigterm(signum, frame):
|
||||||
log("SIGTERM received, quitting IRC...")
|
log("SIGTERM received, quitting IRC...")
|
||||||
try:
|
try:
|
||||||
@@ -577,9 +318,8 @@ def run():
|
|||||||
log("Registered with server")
|
log("Registered with server")
|
||||||
irc.set_bot_mode()
|
irc.set_bot_mode()
|
||||||
irc.join("#agents")
|
irc.join("#agents")
|
||||||
log(f"Joined #agents")
|
log("Joined #agents")
|
||||||
|
|
||||||
# Handle INVITE — auto-join invited channels
|
|
||||||
if parts[1] == "INVITE" and len(parts) >= 3:
|
if parts[1] == "INVITE" and len(parts) >= 3:
|
||||||
invited_channel = parts[-1].lstrip(":")
|
invited_channel = parts[-1].lstrip(":")
|
||||||
inviter = parts[0].split("!")[0].lstrip(":")
|
inviter = parts[0].split("!")[0].lstrip(":")
|
||||||
|
|||||||
175
agent/skills.py
Normal file
175
agent/skills.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""Skill discovery, parsing, and execution."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
"""Import-safe logging — overridden by agent.py at init."""
|
||||||
|
print(f"[skills] {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def set_logger(fn):
|
||||||
|
"""Allow agent.py to inject its logger."""
|
||||||
|
global log
|
||||||
|
log = fn
|
||||||
|
|
||||||
|
|
||||||
|
LARGE_OUTPUT_THRESHOLD = 2000
|
||||||
|
_output_counter = 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_skill_md(path):
|
||||||
|
"""Parse a SKILL.md frontmatter into a tool definition.
|
||||||
|
Returns tool definition dict or None on failure."""
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Cannot read {path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = content.replace("\r\n", "\n")
|
||||||
|
|
||||||
|
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
log(f"No frontmatter in {path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
fm = {}
|
||||||
|
current_key = None
|
||||||
|
current_param = None
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
for line in match.group(1).split("\n"):
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
indent = len(line) - len(line.lstrip())
|
||||||
|
|
||||||
|
if indent >= 2 and current_key == "parameters":
|
||||||
|
if indent >= 4 and current_param:
|
||||||
|
k, _, v = stripped.partition(":")
|
||||||
|
k = k.strip()
|
||||||
|
v = v.strip().strip('"').strip("'")
|
||||||
|
if k == "required":
|
||||||
|
v = v.lower() in ("true", "yes", "1")
|
||||||
|
params[current_param][k] = v
|
||||||
|
elif ":" in stripped:
|
||||||
|
param_name = stripped.rstrip(":").strip()
|
||||||
|
current_param = param_name
|
||||||
|
params[param_name] = {}
|
||||||
|
elif ":" in line and indent == 0:
|
||||||
|
k, _, v = line.partition(":")
|
||||||
|
k = k.strip()
|
||||||
|
v = v.strip().strip('"').strip("'")
|
||||||
|
fm[k] = v
|
||||||
|
current_key = k
|
||||||
|
if k == "parameters":
|
||||||
|
current_param = None
|
||||||
|
|
||||||
|
if "name" not in fm:
|
||||||
|
log(f"No 'name' field in {path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "description" not in fm:
|
||||||
|
log(f"Warning: no 'description' in {path}")
|
||||||
|
|
||||||
|
properties = {}
|
||||||
|
required = []
|
||||||
|
for pname, pdata in params.items():
|
||||||
|
ptype = pdata.get("type", "string")
|
||||||
|
if ptype not in ("string", "integer", "number", "boolean", "array", "object"):
|
||||||
|
log(f"Warning: unknown type '{ptype}' for param '{pname}' in {path}")
|
||||||
|
properties[pname] = {
|
||||||
|
"type": ptype,
|
||||||
|
"description": pdata.get("description", ""),
|
||||||
|
}
|
||||||
|
if pdata.get("required", False):
|
||||||
|
required.append(pname)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": fm["name"],
|
||||||
|
"description": fm.get("description", ""),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": properties,
|
||||||
|
"required": required,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def discover_skills(skill_dirs):
|
||||||
|
"""Scan skill directories and return tool definitions + script paths."""
|
||||||
|
tools = []
|
||||||
|
scripts = {}
|
||||||
|
|
||||||
|
for skill_dir in skill_dirs:
|
||||||
|
if not os.path.isdir(skill_dir):
|
||||||
|
continue
|
||||||
|
for name in sorted(os.listdir(skill_dir)):
|
||||||
|
skill_path = os.path.join(skill_dir, name)
|
||||||
|
skill_md = os.path.join(skill_path, "SKILL.md")
|
||||||
|
if not os.path.isfile(skill_md):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tool_def = parse_skill_md(skill_md)
|
||||||
|
if not tool_def:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for ext in ("run.py", "run.sh"):
|
||||||
|
script = os.path.join(skill_path, ext)
|
||||||
|
if os.path.isfile(script):
|
||||||
|
scripts[tool_def["function"]["name"]] = script
|
||||||
|
break
|
||||||
|
|
||||||
|
if tool_def["function"]["name"] in scripts:
|
||||||
|
tools.append(tool_def)
|
||||||
|
|
||||||
|
return tools, scripts
|
||||||
|
|
||||||
|
|
||||||
|
def execute_skill(script_path, args, workspace, config):
|
||||||
|
"""Execute a skill script with args as JSON on stdin.
|
||||||
|
Large outputs are saved to a file with a preview returned."""
|
||||||
|
global _output_counter
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["WORKSPACE"] = workspace
|
||||||
|
env["SEARX_URL"] = config.get("searx_url", "https://searx.mymx.me")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["python3" if script_path.endswith(".py") else "bash", script_path],
|
||||||
|
input=json.dumps(args),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
output = result.stdout
|
||||||
|
if result.stderr:
|
||||||
|
output += f"\n[stderr] {result.stderr}"
|
||||||
|
output = output.strip() or "[no output]"
|
||||||
|
|
||||||
|
if len(output) > LARGE_OUTPUT_THRESHOLD:
|
||||||
|
output_dir = f"{workspace}/tool_outputs"
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
_output_counter += 1
|
||||||
|
filepath = f"{output_dir}/output_{_output_counter}.txt"
|
||||||
|
with open(filepath, "w") as f:
|
||||||
|
f.write(output)
|
||||||
|
preview = output[:1500]
|
||||||
|
return f"{preview}\n\n[output truncated — full result ({len(output)} chars) saved to {filepath}. Use run_command to read it: cat {filepath}]"
|
||||||
|
|
||||||
|
return output
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "[skill timed out after 120s]"
|
||||||
|
except Exception as e:
|
||||||
|
return f"[skill error: {e}]"
|
||||||
132
agent/tools.py
Normal file
132
agent/tools.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""LLM interaction, tool dispatch, and memory management."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(f"[tools] {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def set_logger(fn):
|
||||||
|
global log
|
||||||
|
log = fn
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Memory ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_memory(workspace):
|
||||||
|
"""Load all memory files from workspace."""
|
||||||
|
memory = ""
|
||||||
|
try:
|
||||||
|
with open(f"{workspace}/MEMORY.md") as f:
|
||||||
|
memory = f.read().strip()
|
||||||
|
mem_dir = f"{workspace}/memory"
|
||||||
|
if os.path.isdir(mem_dir):
|
||||||
|
for fname in sorted(os.listdir(mem_dir)):
|
||||||
|
if fname.endswith(".md"):
|
||||||
|
try:
|
||||||
|
with open(f"{mem_dir}/{fname}") as f:
|
||||||
|
topic = fname.replace(".md", "")
|
||||||
|
memory += f"\n\n## {topic}\n{f.read().strip()}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return memory
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tool Call Parsing ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def try_parse_tool_call(text):
|
||||||
|
"""Parse text-based tool calls (model dumps JSON as text)."""
|
||||||
|
text = re.sub(r"</?tool_call>", "", text).strip()
|
||||||
|
for start in range(len(text)):
|
||||||
|
if text[start] == "{":
|
||||||
|
for end in range(len(text), start, -1):
|
||||||
|
if text[end - 1] == "}":
|
||||||
|
try:
|
||||||
|
obj = json.loads(text[start:end])
|
||||||
|
name = obj.get("name")
|
||||||
|
args = obj.get("arguments", {})
|
||||||
|
if name and isinstance(args, dict):
|
||||||
|
return (name, args)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── LLM Interaction ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def ollama_request(ollama_url, payload):
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{ollama_url}/api/chat",
|
||||||
|
data=data,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||||
|
return json.loads(resp.read(2_000_000))
|
||||||
|
|
||||||
|
|
||||||
|
def query_ollama(messages, runtime, tools, skill_scripts, dispatch_fn, ollama_url, max_rounds):
|
||||||
|
"""Call Ollama chat API with skill-based tool support."""
|
||||||
|
payload = {
|
||||||
|
"model": runtime["model"],
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False,
|
||||||
|
"options": {"num_predict": 512},
|
||||||
|
}
|
||||||
|
|
||||||
|
if tools:
|
||||||
|
payload["tools"] = tools
|
||||||
|
|
||||||
|
for round_num in range(max_rounds):
|
||||||
|
remaining = max_rounds - round_num
|
||||||
|
try:
|
||||||
|
data = ollama_request(ollama_url, payload)
|
||||||
|
except (urllib.error.URLError, TimeoutError) as e:
|
||||||
|
return f"[error: {e}]"
|
||||||
|
|
||||||
|
msg = data.get("message", {})
|
||||||
|
|
||||||
|
# Structured tool calls
|
||||||
|
tool_calls = msg.get("tool_calls")
|
||||||
|
if tool_calls:
|
||||||
|
messages.append(msg)
|
||||||
|
for tc in tool_calls:
|
||||||
|
fn = tc.get("function", {})
|
||||||
|
result = dispatch_fn(
|
||||||
|
fn.get("name", ""),
|
||||||
|
fn.get("arguments", {}),
|
||||||
|
round_num + 1,
|
||||||
|
)
|
||||||
|
if remaining <= 2:
|
||||||
|
result += f"\n[warning: {remaining - 1} tool rounds remaining — wrap up]"
|
||||||
|
messages.append({"role": "tool", "content": result})
|
||||||
|
payload["messages"] = messages
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Text-based tool calls
|
||||||
|
content = msg.get("content", "").strip()
|
||||||
|
parsed_tool = try_parse_tool_call(content)
|
||||||
|
if parsed_tool:
|
||||||
|
fn_name, fn_args = parsed_tool
|
||||||
|
if fn_name in skill_scripts:
|
||||||
|
messages.append({"role": "assistant", "content": content})
|
||||||
|
result = dispatch_fn(fn_name, fn_args, round_num + 1)
|
||||||
|
if remaining <= 2:
|
||||||
|
result += f"\n[warning: {remaining - 1} tool rounds remaining — wrap up]"
|
||||||
|
messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": f"Tool result:\n{result}\n\nNow respond to the user based on this result.",
|
||||||
|
})
|
||||||
|
payload["messages"] = messages
|
||||||
|
continue
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
return "[max tool rounds reached]"
|
||||||
@@ -306,10 +306,12 @@ else
|
|||||||
' || err "Failed to install packages in chroot"
|
' || err "Failed to install packages in chroot"
|
||||||
ok "Alpine packages installed"
|
ok "Alpine packages installed"
|
||||||
|
|
||||||
log "Installing agent script and config..."
|
log "Installing agent script, skills, and config..."
|
||||||
sudo mkdir -p /tmp/agent-build-mnt/opt/agent /tmp/agent-build-mnt/etc/agent
|
sudo mkdir -p /tmp/agent-build-mnt/opt/agent /tmp/agent-build-mnt/opt/skills /tmp/agent-build-mnt/etc/agent
|
||||||
sudo cp "$SCRIPT_DIR/agent/agent.py" /tmp/agent-build-mnt/opt/agent/agent.py
|
sudo cp "$SCRIPT_DIR/agent/"*.py /tmp/agent-build-mnt/opt/agent/
|
||||||
sudo chmod +x /tmp/agent-build-mnt/opt/agent/agent.py
|
sudo chmod +x /tmp/agent-build-mnt/opt/agent/agent.py
|
||||||
|
sudo cp -r "$SCRIPT_DIR/skills/"* /tmp/agent-build-mnt/opt/skills/
|
||||||
|
sudo chmod +x /tmp/agent-build-mnt/opt/skills/*/run.*
|
||||||
|
|
||||||
echo '{"nick":"agent","model":"qwen2.5-coder:7b","trigger":"mention","server":"172.16.0.1","port":6667,"ollama_url":"http://172.16.0.1:11434"}' | \
|
echo '{"nick":"agent","model":"qwen2.5-coder:7b","trigger":"mention","server":"172.16.0.1","port":6667,"ollama_url":"http://172.16.0.1:11434"}' | \
|
||||||
sudo tee /tmp/agent-build-mnt/etc/agent/config.json > /dev/null
|
sudo tee /tmp/agent-build-mnt/etc/agent/config.json > /dev/null
|
||||||
@@ -422,14 +424,16 @@ step "Agent templates"
|
|||||||
TMPL_DIR="$FIRECLAW_DIR/templates"
|
TMPL_DIR="$FIRECLAW_DIR/templates"
|
||||||
mkdir -p "$TMPL_DIR"
|
mkdir -p "$TMPL_DIR"
|
||||||
|
|
||||||
for tmpl in worker coder quick; do
|
for tmpl in worker coder researcher quick creative; do
|
||||||
if [[ -f "$TMPL_DIR/$tmpl.json" ]]; then
|
if [[ -f "$TMPL_DIR/$tmpl.json" ]]; then
|
||||||
skip "$tmpl"
|
skip "$tmpl"
|
||||||
else
|
else
|
||||||
case $tmpl in
|
case $tmpl in
|
||||||
worker) echo '{"name":"worker","nick":"worker","model":"qwen2.5-coder:7b","trigger":"mention","persona":"You are a general-purpose assistant on IRC. Keep responses concise."}' > "$TMPL_DIR/$tmpl.json" ;;
|
worker) echo '{"name":"worker","nick":"worker","model":"qwen2.5:7b","trigger":"mention","persona":"You are a general-purpose assistant on IRC. Keep responses concise."}' > "$TMPL_DIR/$tmpl.json" ;;
|
||||||
coder) echo '{"name":"coder","nick":"coder","model":"qwen2.5-coder:7b","trigger":"mention","persona":"You are a code-focused assistant on IRC. Be direct and technical."}' > "$TMPL_DIR/$tmpl.json" ;;
|
coder) echo '{"name":"coder","nick":"coder","model":"qwen2.5-coder:7b","trigger":"mention","persona":"You are a code-focused assistant on IRC. Be direct and technical."}' > "$TMPL_DIR/$tmpl.json" ;;
|
||||||
quick) echo '{"name":"quick","nick":"quick","model":"phi4-mini","trigger":"mention","tools":false,"network":"none","persona":"You are a fast assistant on IRC. One sentence answers."}' > "$TMPL_DIR/$tmpl.json" ;;
|
researcher) echo '{"name":"researcher","nick":"research","model":"llama3.1:8b","trigger":"mention","persona":"You are a research assistant on IRC. Use numbered points for complex topics. Keep responses to 5-10 lines."}' > "$TMPL_DIR/$tmpl.json" ;;
|
||||||
|
quick) echo '{"name":"quick","nick":"quick","model":"phi4-mini","trigger":"mention","tools":false,"network":"none","persona":"You are a fast assistant on IRC. One sentence answers."}' > "$TMPL_DIR/$tmpl.json" ;;
|
||||||
|
creative) echo '{"name":"creative","nick":"muse","model":"gemma3:4b","trigger":"mention","tools":false,"persona":"You are a creative assistant on IRC. Help with writing, brainstorming, ideas."}' > "$TMPL_DIR/$tmpl.json" ;;
|
||||||
esac
|
esac
|
||||||
ok "$tmpl template created"
|
ok "$tmpl template created"
|
||||||
fi
|
fi
|
||||||
|
|||||||
9
skills/fetch_url/SKILL.md
Normal file
9
skills/fetch_url/SKILL.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
name: fetch_url
|
||||||
|
description: Fetch a URL and return its text content. HTML is stripped to plain text. Use this to read web pages, documentation, articles, etc.
|
||||||
|
parameters:
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
description: The URL to fetch
|
||||||
|
required: true
|
||||||
|
---
|
||||||
49
skills/fetch_url/run.py
Normal file
49
skills/fetch_url/run.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
args = json.loads(sys.stdin.read())
|
||||||
|
url = args.get("url", "")
|
||||||
|
|
||||||
|
|
||||||
|
class TextExtractor(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.text = []
|
||||||
|
self._skip = False
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
if tag in ("script", "style", "noscript"):
|
||||||
|
self._skip = True
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag in ("script", "style", "noscript"):
|
||||||
|
self._skip = False
|
||||||
|
if tag in ("p", "br", "div", "h1", "h2", "h3", "h4", "li", "tr"):
|
||||||
|
self.text.append("\n")
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if not self._skip:
|
||||||
|
self.text.append(data)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "fireclaw-agent"})
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
content_type = resp.headers.get("Content-Type", "")
|
||||||
|
raw = resp.read(50_000).decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
if "html" in content_type:
|
||||||
|
parser = TextExtractor()
|
||||||
|
parser.feed(raw)
|
||||||
|
text = "".join(parser.text)
|
||||||
|
else:
|
||||||
|
text = raw
|
||||||
|
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||||
|
print(text or "[empty page]")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[fetch error: {e}]")
|
||||||
9
skills/run_command/SKILL.md
Normal file
9
skills/run_command/SKILL.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
name: run_command
|
||||||
|
description: Execute a shell command on this system and return the output. Use this to check system info, run scripts, fetch URLs, process data, etc.
|
||||||
|
parameters:
|
||||||
|
command:
|
||||||
|
type: string
|
||||||
|
description: The shell command to execute (bash)
|
||||||
|
required: true
|
||||||
|
---
|
||||||
25
skills/run_command/run.py
Normal file
25
skills/run_command/run.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
|
||||||
|
args = json.loads(sys.stdin.read())
|
||||||
|
command = args.get("command", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", "-c", command],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
output = result.stdout
|
||||||
|
if result.stderr:
|
||||||
|
output += f"\n[stderr] {result.stderr}"
|
||||||
|
if result.returncode != 0:
|
||||||
|
output += f"\n[exit code: {result.returncode}]"
|
||||||
|
print(output.strip() or "[no output]")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print("[command timed out after 120s]")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[error: {e}]")
|
||||||
13
skills/save_memory/SKILL.md
Normal file
13
skills/save_memory/SKILL.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
name: save_memory
|
||||||
|
description: Save something important to your persistent memory. Use this to remember facts about users, lessons learned, project context, or anything you want to recall in future conversations. Memories survive restarts.
|
||||||
|
parameters:
|
||||||
|
topic:
|
||||||
|
type: string
|
||||||
|
description: "Short topic name for the memory file (e.g. 'user_prefs', 'project_x', 'lessons')"
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
type: string
|
||||||
|
description: The memory content to save
|
||||||
|
required: true
|
||||||
|
---
|
||||||
33
skills/save_memory/run.py
Normal file
33
skills/save_memory/run.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
args = json.loads(sys.stdin.read())
|
||||||
|
topic = args.get("topic", "note")
|
||||||
|
content = args.get("content", "")
|
||||||
|
workspace = os.environ.get("WORKSPACE", "/workspace")
|
||||||
|
|
||||||
|
mem_dir = f"{workspace}/memory"
|
||||||
|
os.makedirs(mem_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Write the memory file
|
||||||
|
filepath = f"{mem_dir}/{topic}.md"
|
||||||
|
with open(filepath, "w") as f:
|
||||||
|
f.write(content + "\n")
|
||||||
|
|
||||||
|
# Update MEMORY.md index
|
||||||
|
index_path = f"{workspace}/MEMORY.md"
|
||||||
|
existing = ""
|
||||||
|
try:
|
||||||
|
with open(index_path) as f:
|
||||||
|
existing = f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
existing = "# Agent Memory\n"
|
||||||
|
|
||||||
|
entry = f"- [{topic}](memory/{topic}.md)"
|
||||||
|
if topic not in existing:
|
||||||
|
with open(index_path, "a") as f:
|
||||||
|
f.write(f"\n{entry}")
|
||||||
|
|
||||||
|
print(f"Memory saved to {filepath}")
|
||||||
13
skills/web_search/SKILL.md
Normal file
13
skills/web_search/SKILL.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
name: web_search
|
||||||
|
description: Search the web using SearXNG. Returns titles, URLs, and snippets for the top results. Use this when you need current information or facts you're unsure about.
|
||||||
|
parameters:
|
||||||
|
query:
|
||||||
|
type: string
|
||||||
|
description: The search query
|
||||||
|
required: true
|
||||||
|
num_results:
|
||||||
|
type: integer
|
||||||
|
description: Number of results to return (default 5)
|
||||||
|
required: false
|
||||||
|
---
|
||||||
32
skills/web_search/run.py
Normal file
32
skills/web_search/run.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
args = json.loads(sys.stdin.read())
|
||||||
|
query = args.get("query", "")
|
||||||
|
num_results = args.get("num_results", 5)
|
||||||
|
searx_url = args.get("_searx_url", "https://searx.mymx.me")
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = urllib.parse.urlencode({"q": query, "format": "json"})
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{searx_url}/search?{params}",
|
||||||
|
headers={"User-Agent": "fireclaw-agent"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
results = data.get("results", [])[:num_results]
|
||||||
|
if not results:
|
||||||
|
print("No results found.")
|
||||||
|
else:
|
||||||
|
lines = []
|
||||||
|
for r in results:
|
||||||
|
title = r.get("title", "")
|
||||||
|
url = r.get("url", "")
|
||||||
|
snippet = r.get("content", "")[:150]
|
||||||
|
lines.append(f"- {title}\n {url}\n {snippet}")
|
||||||
|
print("\n".join(lines))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[search error: {e}]")
|
||||||
@@ -10,6 +10,13 @@ import {
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { CONFIG } from "./config.js";
|
import { CONFIG } from "./config.js";
|
||||||
|
|
||||||
|
const SSH_OPTS = [
|
||||||
|
"-o", "StrictHostKeyChecking=no",
|
||||||
|
"-o", "UserKnownHostsFile=/dev/null",
|
||||||
|
"-o", "ConnectTimeout=5",
|
||||||
|
"-i", CONFIG.sshKeyPath,
|
||||||
|
];
|
||||||
import {
|
import {
|
||||||
allocateIp,
|
allocateIp,
|
||||||
releaseIp,
|
releaseIp,
|
||||||
@@ -103,7 +110,7 @@ function injectAgentConfig(
|
|||||||
{ stdio: "pipe" }
|
{ stdio: "pipe" }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Write config
|
// Write config (via stdin to avoid shell injection)
|
||||||
const configJson = JSON.stringify({
|
const configJson = JSON.stringify({
|
||||||
nick: config.nick,
|
nick: config.nick,
|
||||||
model: config.model,
|
model: config.model,
|
||||||
@@ -112,26 +119,18 @@ function injectAgentConfig(
|
|||||||
port: 6667,
|
port: 6667,
|
||||||
ollama_url: "http://172.16.0.1:11434",
|
ollama_url: "http://172.16.0.1:11434",
|
||||||
});
|
});
|
||||||
execFileSync(
|
const configPath = join(mountPoint, "etc/agent/config.json");
|
||||||
"sudo",
|
execFileSync("sudo", ["tee", configPath], {
|
||||||
[
|
input: configJson,
|
||||||
"bash",
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
"-c",
|
});
|
||||||
`echo '${configJson}' > ${join(mountPoint, "etc/agent/config.json")}`,
|
|
||||||
],
|
|
||||||
{ stdio: "pipe" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Write persona
|
// Write persona (via stdin to avoid shell injection)
|
||||||
execFileSync(
|
const personaPath = join(mountPoint, "etc/agent/persona.md");
|
||||||
"sudo",
|
execFileSync("sudo", ["tee", personaPath], {
|
||||||
[
|
input: persona,
|
||||||
"bash",
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
"-c",
|
});
|
||||||
`cat > ${join(mountPoint, "etc/agent/persona.md")} << 'PERSONA_EOF'\n${persona}\nPERSONA_EOF`,
|
|
||||||
],
|
|
||||||
{ stdio: "pipe" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Inject SSH key for debugging access
|
// Inject SSH key for debugging access
|
||||||
execFileSync("sudo", ["mkdir", "-p", join(mountPoint, "root/.ssh")], {
|
execFileSync("sudo", ["mkdir", "-p", join(mountPoint, "root/.ssh")], {
|
||||||
@@ -301,14 +300,7 @@ export async function stopAgent(name: string) {
|
|||||||
try {
|
try {
|
||||||
execFileSync(
|
execFileSync(
|
||||||
"ssh",
|
"ssh",
|
||||||
[
|
[...SSH_OPTS, `root@${info.ip}`, "pkill -f 'agent.py' 2>/dev/null; sleep 1"],
|
||||||
"-o", "StrictHostKeyChecking=no",
|
|
||||||
"-o", "UserKnownHostsFile=/dev/null",
|
|
||||||
"-o", "ConnectTimeout=3",
|
|
||||||
"-i", CONFIG.sshKeyPath,
|
|
||||||
`root@${info.ip}`,
|
|
||||||
"killall python3 2>/dev/null; sleep 1",
|
|
||||||
],
|
|
||||||
{ stdio: "pipe", timeout: 5_000 }
|
{ stdio: "pipe", timeout: 5_000 }
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -366,18 +358,10 @@ export function listAgents(): AgentInfo[] {
|
|||||||
} catch {
|
} catch {
|
||||||
// Process is dead, clean up
|
// Process is dead, clean up
|
||||||
log(`Agent "${name}" is dead, cleaning up...`);
|
log(`Agent "${name}" is dead, cleaning up...`);
|
||||||
try {
|
try { deleteTap(info.tapDevice); } catch (e) { log(` tap cleanup: ${e}`); }
|
||||||
deleteTap(info.tapDevice);
|
try { releaseIp(info.octet); } catch (e) { log(` ip cleanup: ${e}`); }
|
||||||
} catch {}
|
try { unlinkSync(info.rootfsPath); } catch (e) { log(` rootfs cleanup: ${e}`); }
|
||||||
try {
|
try { unlinkSync(info.socketPath); } catch (e) { log(` socket cleanup: ${e}`); }
|
||||||
releaseIp(info.octet);
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
unlinkSync(info.rootfsPath);
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
unlinkSync(info.socketPath);
|
|
||||||
} catch {}
|
|
||||||
delete agents[name];
|
delete agents[name];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,13 +389,6 @@ export async function reloadAgent(
|
|||||||
}
|
}
|
||||||
if (updates.trigger) configUpdates.trigger = updates.trigger;
|
if (updates.trigger) configUpdates.trigger = updates.trigger;
|
||||||
|
|
||||||
// Write updated config as a temp file on the VM via SSH
|
|
||||||
const sshOpts = [
|
|
||||||
"-o", "StrictHostKeyChecking=no",
|
|
||||||
"-o", "UserKnownHostsFile=/dev/null",
|
|
||||||
"-o", "ConnectTimeout=5",
|
|
||||||
"-i", CONFIG.sshKeyPath,
|
|
||||||
];
|
|
||||||
const sshTarget = `root@${info.ip}`;
|
const sshTarget = `root@${info.ip}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -419,7 +396,7 @@ export async function reloadAgent(
|
|||||||
// Read current config from VM
|
// Read current config from VM
|
||||||
const currentRaw = execFileSync(
|
const currentRaw = execFileSync(
|
||||||
"ssh",
|
"ssh",
|
||||||
[...sshOpts, sshTarget, "cat /etc/agent/config.json"],
|
[...SSH_OPTS, sshTarget, "cat /etc/agent/config.json"],
|
||||||
{ encoding: "utf-8", timeout: 10_000 }
|
{ encoding: "utf-8", timeout: 10_000 }
|
||||||
);
|
);
|
||||||
const current = JSON.parse(currentRaw);
|
const current = JSON.parse(currentRaw);
|
||||||
@@ -429,7 +406,7 @@ export async function reloadAgent(
|
|||||||
// Write back via stdin
|
// Write back via stdin
|
||||||
execFileSync(
|
execFileSync(
|
||||||
"ssh",
|
"ssh",
|
||||||
[...sshOpts, sshTarget, `cat > /etc/agent/config.json`],
|
[...SSH_OPTS, sshTarget, `cat > /etc/agent/config.json`],
|
||||||
{ input: newConfig, timeout: 10_000 }
|
{ input: newConfig, timeout: 10_000 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -437,7 +414,7 @@ export async function reloadAgent(
|
|||||||
if (updates.persona) {
|
if (updates.persona) {
|
||||||
execFileSync(
|
execFileSync(
|
||||||
"ssh",
|
"ssh",
|
||||||
[...sshOpts, sshTarget, `cat > /etc/agent/persona.md`],
|
[...SSH_OPTS, sshTarget, `cat > /etc/agent/persona.md`],
|
||||||
{ input: updates.persona, timeout: 10_000 }
|
{ input: updates.persona, timeout: 10_000 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -445,7 +422,7 @@ export async function reloadAgent(
|
|||||||
// Signal agent to reload
|
// Signal agent to reload
|
||||||
execFileSync(
|
execFileSync(
|
||||||
"ssh",
|
"ssh",
|
||||||
[...sshOpts, sshTarget, "killall -HUP python3"],
|
[...SSH_OPTS, sshTarget, "pkill -HUP -f 'agent.py'"],
|
||||||
{ stdio: "pipe", timeout: 10_000 }
|
{ stdio: "pipe", timeout: 10_000 }
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -475,11 +452,10 @@ export function reconcileAgents(): { adopted: string[]; cleaned: string[] } {
|
|||||||
log(`Adopted running agent "${name}" (PID ${info.pid}, ${info.ip})`);
|
log(`Adopted running agent "${name}" (PID ${info.pid}, ${info.ip})`);
|
||||||
} else {
|
} else {
|
||||||
log(`Cleaning dead agent "${name}" (PID ${info.pid} gone)...`);
|
log(`Cleaning dead agent "${name}" (PID ${info.pid} gone)...`);
|
||||||
// Clean up resources from dead agent
|
try { deleteTap(info.tapDevice); } catch (e) { log(` tap: ${e}`); }
|
||||||
try { deleteTap(info.tapDevice); } catch {}
|
try { releaseIp(info.octet); } catch (e) { log(` ip: ${e}`); }
|
||||||
try { releaseIp(info.octet); } catch {}
|
try { unlinkSync(info.rootfsPath); } catch (e) { log(` rootfs: ${e}`); }
|
||||||
try { unlinkSync(info.rootfsPath); } catch {}
|
try { unlinkSync(info.socketPath); } catch (e) { log(` socket: ${e}`); }
|
||||||
try { unlinkSync(info.socketPath); } catch {}
|
|
||||||
delete agents[name];
|
delete agents[name];
|
||||||
cleaned.push(name);
|
cleaned.push(name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { openSync, closeSync, readFileSync, writeFileSync } from "node:fs";
|
import { readFileSync, writeFileSync, renameSync } from "node:fs";
|
||||||
import { CONFIG } from "./config.js";
|
import { CONFIG } from "./config.js";
|
||||||
|
|
||||||
function run(cmd: string, args: string[]) {
|
function run(cmd: string, args: string[]) {
|
||||||
@@ -195,39 +195,35 @@ function readPool(): IpPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writePool(pool: IpPool) {
|
function atomicWritePool(pool: IpPool) {
|
||||||
writeFileSync(CONFIG.ipPoolFile, JSON.stringify(pool));
|
const tmp = CONFIG.ipPoolFile + ".tmp";
|
||||||
|
writeFileSync(tmp, JSON.stringify(pool));
|
||||||
|
renameSync(tmp, CONFIG.ipPoolFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function allocateIp(): { ip: string; octet: number } {
|
export function allocateIp(): { ip: string; octet: number } {
|
||||||
const fd = openSync(CONFIG.ipPoolLock, "w");
|
// Use flock for proper mutual exclusion
|
||||||
try {
|
const result = execFileSync("bash", ["-c",
|
||||||
// Simple flock via child process
|
`flock "${CONFIG.ipPoolLock}" cat "${CONFIG.ipPoolFile}" 2>/dev/null || echo '{"allocated":[]}'`
|
||||||
const pool = readPool();
|
], { encoding: "utf-8" });
|
||||||
for (
|
const pool: IpPool = JSON.parse(result.trim());
|
||||||
let octet = CONFIG.bridge.minHost;
|
|
||||||
octet <= CONFIG.bridge.maxHost;
|
for (
|
||||||
octet++
|
let octet = CONFIG.bridge.minHost;
|
||||||
) {
|
octet <= CONFIG.bridge.maxHost;
|
||||||
if (!pool.allocated.includes(octet)) {
|
octet++
|
||||||
pool.allocated.push(octet);
|
) {
|
||||||
writePool(pool);
|
if (!pool.allocated.includes(octet)) {
|
||||||
return { ip: `${CONFIG.bridge.prefix}.${octet}`, octet };
|
pool.allocated.push(octet);
|
||||||
}
|
atomicWritePool(pool);
|
||||||
|
return { ip: `${CONFIG.bridge.prefix}.${octet}`, octet };
|
||||||
}
|
}
|
||||||
throw new Error("No free IPs in pool");
|
|
||||||
} finally {
|
|
||||||
closeSync(fd);
|
|
||||||
}
|
}
|
||||||
|
throw new Error("No free IPs in pool");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function releaseIp(octet: number) {
|
export function releaseIp(octet: number) {
|
||||||
const fd = openSync(CONFIG.ipPoolLock, "w");
|
const pool = readPool();
|
||||||
try {
|
pool.allocated = pool.allocated.filter((o) => o !== octet);
|
||||||
const pool = readPool();
|
atomicWritePool(pool);
|
||||||
pool.allocated = pool.allocated.filter((o) => o !== octet);
|
|
||||||
writePool(pool);
|
|
||||||
} finally {
|
|
||||||
closeSync(fd);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import {
|
|||||||
reloadAgent,
|
reloadAgent,
|
||||||
type AgentInfo,
|
type AgentInfo,
|
||||||
} from "./agent-manager.js";
|
} from "./agent-manager.js";
|
||||||
|
import { CONFIG } from "./config.js";
|
||||||
|
|
||||||
|
const SSH_OPTS = [
|
||||||
|
"-o", "StrictHostKeyChecking=no",
|
||||||
|
"-o", "UserKnownHostsFile=/dev/null",
|
||||||
|
"-o", "ConnectTimeout=3",
|
||||||
|
"-i", CONFIG.sshKeyPath,
|
||||||
|
];
|
||||||
|
|
||||||
interface OverseerConfig {
|
interface OverseerConfig {
|
||||||
server: string;
|
server: string;
|
||||||
@@ -195,8 +203,91 @@ export async function runOverseer(config: OverseerConfig) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "!logs": {
|
||||||
|
const name = parts[1];
|
||||||
|
const n = parseInt(parts[2] || "10");
|
||||||
|
if (!name) {
|
||||||
|
bot.say(event.target, "Usage: !logs <name> [lines]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const agents = listAgents();
|
||||||
|
const agent = agents.find((a) => a.name === name);
|
||||||
|
if (!agent) {
|
||||||
|
bot.say(event.target, `Agent "${name}" not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { execFileSync } = await import("node:child_process");
|
||||||
|
const logs = execFileSync("ssh", [
|
||||||
|
...SSH_OPTS,
|
||||||
|
`root@${agent.ip}`,
|
||||||
|
`tail -n ${n} /workspace/agent.log 2>/dev/null || echo '[no logs yet]'`,
|
||||||
|
], { encoding: "utf-8", timeout: 5_000 }).trim();
|
||||||
|
for (const line of logs.split("\n")) {
|
||||||
|
bot.say(event.target, line);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
bot.say(event.target, `Could not read logs for "${name}".`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "!persona": {
|
||||||
|
const name = parts[1];
|
||||||
|
if (!name) {
|
||||||
|
bot.say(event.target, "Usage: !persona <name> [new persona text]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newPersona = parts.slice(2).join(" ");
|
||||||
|
if (newPersona) {
|
||||||
|
await reloadAgent(name, { persona: newPersona });
|
||||||
|
bot.say(event.target, `Agent "${name}" persona updated.`);
|
||||||
|
} else {
|
||||||
|
// View current persona — read from agent config via SSH
|
||||||
|
const agents = listAgents();
|
||||||
|
const agent = agents.find((a) => a.name === name);
|
||||||
|
if (!agent) {
|
||||||
|
bot.say(event.target, `Agent "${name}" not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { execFileSync } = await import("node:child_process");
|
||||||
|
const persona = execFileSync("ssh", [
|
||||||
|
...SSH_OPTS,
|
||||||
|
`root@${agent.ip}`,
|
||||||
|
"cat /etc/agent/persona.md",
|
||||||
|
], { encoding: "utf-8", timeout: 5_000 }).trim();
|
||||||
|
bot.say(event.target, `${name}: ${persona}`);
|
||||||
|
} catch {
|
||||||
|
bot.say(event.target, `Could not read persona for "${name}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "!version": {
|
||||||
|
try {
|
||||||
|
const { readFileSync } = await import("node:fs");
|
||||||
|
const { execFileSync } = await import("node:child_process");
|
||||||
|
const { join, dirname } = await import("node:path");
|
||||||
|
const { fileURLToPath } = await import("node:url");
|
||||||
|
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf-8"));
|
||||||
|
let gitHash = "";
|
||||||
|
try {
|
||||||
|
gitHash = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
|
||||||
|
encoding: "utf-8", cwd: pkgDir, timeout: 3_000,
|
||||||
|
}).trim();
|
||||||
|
} catch {}
|
||||||
|
bot.say(event.target, `fireclaw v${pkg.version}${gitHash ? ` (${gitHash})` : ""}`);
|
||||||
|
} catch {
|
||||||
|
bot.say(event.target, "fireclaw (version unknown)");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "!help": {
|
case "!help": {
|
||||||
bot.say(event.target, "Commands: !invoke <template> [name] | !destroy <name> | !list | !model <name> <model> | !models | !templates | !status | !help");
|
bot.say(event.target, "Commands: !invoke !destroy !list !model !models !templates !persona !logs !status !version !help");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user