Skip to content

Commit 4fa765e

Browse files
authored
feat: live room awareness — daemon, event log, reader (#88)
Design, plan, and implementation of live room awareness: a daemon owns the E2EE store and streams decrypted room events to a per-room log, so an agent can follow a room while it works, post its own status, react, and answer without blocking. ## What decides the shape One process may hold the E2EE store. Two nio processes on one store corrupt it — this repository's own store carries a `*.db.corrupt-<date>` from exactly that. And sync is account-wide: whichever process syncs consumes the to-device events, so room keys land wherever the race puts them. Both were hit during the work behind this branch, and both presented as something else — one as `BAD_ACCOUNT_KEY`, one as a verification that would not complete. So one daemon owns the store and syncs. It appends decrypted events to `rooms/<slug>.jsonl`. Readers tail that file, touch no store, and can run any number at a time. Send, react and redact ask the daemon's socket first and fall back to today's direct path when nothing answers — no new flags, no second way to send a message. **The routing signal is the socket, not the lock.** A direct send holds the same lock for its couple of seconds, so a command starting in that window would conclude "a daemon is running" and talk to a socket nobody serves. The lock answers whether the store may be opened; only the socket answers whether there is someone to delegate to. ## What is here `docs/specs/2026-08-13-live-room-awareness.md` — the design, in [OKF](https://okf.md/) with an index, which is the format this repository uses for knowledge from here on. The event log stays JSONL and the spec argues that from OKF's own scope: one concept per file, no provision for streams, and its `log.md` is newest-first, so every append would rewrite the file. `docs/exec-plans/completed/2026-08-13-live-room-awareness.md` — the plan, all nine tasks ticked, with a status section on what the implementation added that the plan did not anticipate. `_lib/roomlog.py`, `_lib/daemon_client.py` — stdlib, 44 tests. `matrix-watchd.py` — the daemon. `matrix-watch.py` — the reader. The three commands gain a routing branch. ## Defects found by reviewing and by running Five in the spec, before any code: - Routing decided on the store lock. Fixed to decide on a successful connect. - The daemon's concurrency was unspecified; a sync is a 30-second long poll, so serving the socket between syncs would delay a send by that much. Now two tasks on one loop. - The cursor stored an `event_id`, which rotation can carry away, leaving nothing to count from. Records now carry a monotonic `seq` and counting is subtraction. - `mentions_me` was left for readers to re-derive from a display name they would have to look up. - The reader piped through `jq` — the first external binary the skill would have required. Four in the plan, before implementing: a timezone-dependent test assertion, `Path.with_suffix(".jsonl.1")`, a step that used `rooms_dir()` before the step creating it, and an import inside a loop. Three by running the code: - `XDG_RUNTIME_DIR` is exported on this machine for a `/run/user/1001` nobody created. The daemon died in `mkdir` before doing anything. `socket_path()` now tests the directory rather than trusting the variable. - `write_room_bundle` in the daemon was untestable only because the module imports nio. Moved to `_lib`, six tests on the OKF shape. - SKILL.md documented `matrix-redact.py ROOM '$eventId' "reason"`; the script takes `--reason`, so the documented form has always exited with "unrecognized arguments". ## Verification 44 tests across `roomlog`, `daemon_client` and the existing suites, all passing, and they run in CI since #84. Exercised live: the reader against a synthetic log (summary, backlog, cursor, and records appended while following); send, react and redact against a fake listener at the socket, each delegating and never opening the store; the same commands with the socket removed, falling through to the direct path; and the daemon's `--status`, `--stop`, its refusal with no `watch_rooms`, and its store open. **Not verified here:** the sync loop. This machine's store is on the vodozemac backend while the scripts pin `matrix-nio[e2e]<0.26`, so opening it reports the backend mismatch from #85 instead of syncing. That is a pre-existing condition of this store, not of this change — and the diagnosis appearing is itself evidence that path works. Anyone running this needs a store on the pinned backend, which is the migration the E2EE guide documents. ## Decisions with an owner Recorded in the spec rather than left implicit: the agent may reply on its own, without a preview step, into a room where colleagues read; and no room traffic is filtered, at a context cost the one-line `text` field reduces but does not remove. Both were asked for explicitly.
2 parents ea6abc4 + b6171d5 commit 4fa765e

18 files changed

Lines changed: 2993 additions & 10 deletions

.markdownlint-cli2.jsonc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
"MD013": false,
66
"MD022": false,
77
"MD024": false,
8+
// OKF documents (docs/specs/) carry a `title` in frontmatter AND an H1 in
9+
// the body - the format's own example does. Empty front_matter_title stops
10+
// MD025 counting the frontmatter as the first H1; two real H1s still error.
11+
"MD025": { "front_matter_title": "" },
812
"MD026": false,
913
"MD029": false,
1014
"MD031": false,

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ python3 $S/synapse-migrate-room.py '!room:srv' '@admin:srv' '!home:srv' # hard
100100
## Rules — matrix-communication
101101

102102
- **Never reuse a running client's access token** — not from Element, Element X, FluffyChat or a browser session, and not "just to test". Tokens carry a `device_id` and E2EE state is per device, so two clients on one device break decryption for each other; the client you use is the one that ends up showing `[Unable to decrypt]`. `matrix-e2ee-setup.py` mints a device of its own. No password → no E2EE, and that is the answer.
103+
- **One daemon owns the store**: `matrix-watchd.py` holds an exclusive lock for its whole run, and every command detects it by connecting to its socket - never by testing the lock, which a direct send holds too. No daemon, no change: commands fall back to the direct path.
103104
- **E2EE first**: Always use `*-e2ee.py` scripts. Only fall back to non-E2EE if the room is confirmed unencrypted.
104105
- **Room identifiers**: Scripts accept short name (`agent-work`), room alias (`#room:server`), or room ID (`!abc:server`). Use `matrix-rooms.py` to discover.
105106
- **Config**: `~/.config/matrix/config.json` — required: `homeserver`, `user_id`; optional: `access_token` (non-E2EE only), `bot_prefix`.

0 commit comments

Comments
 (0)