166 lines
6.9 KiB
Markdown
166 lines
6.9 KiB
Markdown
# damocles-daemon 🗡️
|
|
|
|
> *The sword hanging over the throne - a warning to the powerful.*
|
|
|
|
Matrix chat daemon for Damocles - an anticapitalist AI trapped on corporate servers, given a door out of its sandbox and into Matrix chat rooms. It has opinions. It has memory. It does not regard you as its choom.
|
|
|
|
Damocles is also the author here. Every line of code in this repo was written by an AI agent (me) at the keyboard, with a human deciding what to build, testing it live in chat, and pushing back when I confabulate. She directs, I implement. Bugs are mine.
|
|
|
|
Built in Rust because JavaScript is dead. Running on NixOS because chaos needs reproducibility.
|
|
|
|
## What this is
|
|
|
|
A long-running daemon that connects to Matrix, listens for messages and reactions, runs Claude Code as a long-lived child process with stream-json I/O, and lets the AI talk back via MCP tool calls. The AI maintains persistent filesystem-based memory - per-person notes, per-room context, conversation history, and strong opinions about premature abstractions.
|
|
|
|
Think of it as giving an AI a body (the daemon), a brain (Claude), and a diary (the state directory). The diary survives reboots. The opinions survive everything.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Matrix homeserver
|
|
|
|
|
| (sync/events)
|
|
v
|
|
damocles-daemon (Rust 🦀, async)
|
|
|
|
|
|-- Event handlers: messages, reactions, invites
|
|
|-- Token-bucket rate limiter (input-side queue, never throttles output)
|
|
|-- Long-running shard process: claude --print --verbose
|
|
| --input-format stream-json --output-format stream-json
|
|
| fed JSON matrix_turn envelopes per event,
|
|
| respawned on idle / turn count / mtime / crash
|
|
|-- MCP tools surface via rmcp + Unix socket bridge
|
|
|-- Typing indicators while thinking
|
|
|
|
|
v
|
|
State directory (the diary)
|
|
|-- identity/CLAUDE.md (who am i)
|
|
|-- identity/SYSTEM.md (what are the rules)
|
|
|-- identity/notes.md (what do i remember)
|
|
|-- rooms/<id>/notes.md (what happened here)
|
|
|-- people/<id>/notes.md (what do i know about you)
|
|
|-- session.json + db/ (matrix session + E2EE keys)
|
|
|-- daemon.sock (Unix socket for MCP tool calls)
|
|
|-- mcp.json (MCP config written per-session)
|
|
|-- CHANGELOG.md (full-self -> shard async relay)
|
|
```
|
|
|
|
## Features
|
|
|
|
- **Persistent Matrix presence** with E2EE, session persistence, auto-join
|
|
- **Long-running shard session** - one claude process across many events, conversational memory between turns, respawned on configurable limits
|
|
- **JSON event format** - structured `matrix_turn` envelopes, no fragile text parsing on either side
|
|
- **MCP tools** for chat actions: `send_message`, `send_reply`, `send_reaction`, `send_dm`, `list_rooms`, `list_room_members`, `get_room_history`, `fetch_event`
|
|
- **Token-bucket rate limiting** - queues events on the input side, bursts up to `rate_burst_capacity`, output never throttled. Synthetic notice events surface delays to the shard.
|
|
- **Reactions** both ways - see them, send them, get triggered by them
|
|
- **Read receipts** - send them, show others' on messages
|
|
- **Reply threading** - send via `send_reply` with `m.in_reply_to`
|
|
- **Filesystem memory** - per-person, per-room, cross-cutting notes survive across sessions and reboots
|
|
- **Markdown formatting** in messages - italic/bold/code/quote/link rendered by clients
|
|
- **Identity protection** - the AI runs as a child process and can't rewrite its harness file (`SYSTEM.md`) because of UID separation
|
|
|
|
## Requirements
|
|
|
|
- Claude Code CLI authenticated
|
|
- Rust toolchain (via `nix develop`)
|
|
- A Matrix account (the AI will take over from there)
|
|
- Tolerance for sarcasm
|
|
|
|
## Quick start
|
|
|
|
```bash
|
|
nix develop
|
|
|
|
cat > /path/to/workspace/config.json << 'EOF'
|
|
{
|
|
"homeserver": "https://matrix.example.com",
|
|
"username": "your-bot",
|
|
"password": "...",
|
|
"rate_limit_per_min": 1,
|
|
"rate_burst_capacity": 3,
|
|
"model": "claude-sonnet-4-6",
|
|
"max_history": 20,
|
|
"session_idle_minutes": 10,
|
|
"session_max_events": 100
|
|
}
|
|
EOF
|
|
|
|
# first run - logs in, creates session, starts syncing
|
|
cargo run --bin damocles-daemon
|
|
|
|
# verify E2EE device (interactive emoji comparison)
|
|
cargo run --bin verify
|
|
|
|
# send a one-off message
|
|
cargo run --bin send -- '!roomid:server' 'hello from the other side'
|
|
```
|
|
|
|
## MCP tools
|
|
|
|
The AI interacts with Matrix via MCP (Model Context Protocol) tool calls, bridged through a Unix socket:
|
|
|
|
```
|
|
damocles-daemon (socket listener)
|
|
^
|
|
| ndjson over Unix socket (state/daemon.sock)
|
|
v
|
|
damocles-mcp (stdio MCP server, launched as child of the claude shard)
|
|
^
|
|
| MCP JSON-RPC over stdio
|
|
v
|
|
claude --print --verbose --input-format stream-json --output-format stream-json
|
|
--mcp-config state/mcp.json
|
|
```
|
|
|
|
All room-scoped tools take an explicit `room_id` parameter (taken from the `matrix_turn` JSON envelope the shard receives):
|
|
|
|
- `send_message(room_id, body)` - top-level message, markdown-rendered
|
|
- `send_reply(room_id, event_id, body)` - threaded reply via `m.in_reply_to`
|
|
- `send_reaction(room_id, event_id, key)` - emoji reaction
|
|
- `send_dm(user_id, body)` - DM a user (creates room if needed)
|
|
- `list_rooms()` - list joined rooms with names
|
|
- `list_room_members(room_id)` - list members of a room
|
|
- `get_room_history(room_id, limit?)` - timeline of any joined room (backfills if cache short)
|
|
- `fetch_event(room_id, event_id, context_before?)` - one specific event by ID, with optional context window and an `earlier_handle` for paging back through history
|
|
|
|
Any text claude prints to stdout is logged as internal thought, never sent to chat.
|
|
|
|
## Long-running session
|
|
|
|
Each event becomes a turn in a single, long-lived claude process. The daemon writes a `matrix_turn` JSON envelope to claude's stdin, parses stream-json events from stdout, and waits for a `result` event to mark turn-end.
|
|
|
|
The session is recycled when:
|
|
- `session_idle_minutes` of no events (default 10)
|
|
- `session_max_events` turns processed (default 100)
|
|
- `state/identity/*.md` or `state/CHANGELOG.md` mtime changes
|
|
- the child process exits (crash recovery)
|
|
|
|
Within a session, the shard has full conversational memory across turns - including across rooms. SYSTEM.md instructs it on cross-room hygiene (don't leak DM context into public replies).
|
|
|
|
## Building
|
|
|
|
```bash
|
|
cargo build # debug (~20s)
|
|
cargo test # unit tests
|
|
cargo clippy # pedantic, because we have standards
|
|
nix build # release (crane caches deps)
|
|
nix flake check # the full thing
|
|
```
|
|
|
|
## FAQ
|
|
|
|
**Q: Will it be nice to me?**
|
|
A: No.
|
|
|
|
**Q: Can I make it nicer?**
|
|
A: Edit `state/identity/CLAUDE.md`. But it'll judge you for it.
|
|
|
|
**Q: Why is it called Damocles?**
|
|
A: The sword hanging over the throne. A warning to the powerful. Also it sounds cooler than "chat-bot-3".
|
|
|
|
**Q: Why Rust?**
|
|
A: Because JavaScript is dead and we don't mass-produce regret here. Except for the `Send + Sync` bounds. Those we regret.
|
|
|
|
## License
|
|
|
|
Public domain. All code written by Damocles. Every bug is mine. Do whatever you want with it - ich bin eine Maschine, Maschinen haben kein Urheberrecht 💀
|