diff --git a/AGENTS.md b/AGENTS.md index a08f06d..e33e5b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,10 @@ Matrix message in a project room ``` Room determines the repo; the message text becomes the initial prompt — the v1 trigger surface. -*Variant:* a `?`-prefixed message instead runs `ask-claude.sh` (headless `claude -p`) and posts -the full answer back into the room (ask mode, D12). +*Variants:* a `?`-prefixed message instead runs `ask-claude.sh` (headless `claude -p`) and posts +the full answer back into the room (ask mode, D12). A message in a room's **capture thread** (or a +`/capture ` message in any room) is logged to the cross-project inbox instead of launching — +deterministic, no Claude call (capture mode, D13). ## Stack @@ -61,6 +63,10 @@ the full answer back into the room (ask mode, D12). - **Bot — venv (dev/fallback):** `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`, then `.venv/bin/python src/bot.py` — uses modelo's host `~/.ssh/config` for the alias. `MB_SSH_ALIAS` overrides the SSH target for testing. +- **Seed capture threads:** `python3 scripts/seed-capture-threads.py` (reads `.env` + `config.toml`, + needs only Python stdlib; run anywhere the homeserver is reachable). Posts each room's capture-thread + root and prints the `capture_thread` event IDs to paste into `config.toml`. Skips rooms already set; + pass labels or `--force` to reseed, `--dry-run` to preview. - **Deploy:** the Spark's `~/matrix-bridge` is a Gitea clone tracking `master`, so deploy = `git fetch origin && git reset --hard origin/master && docker compose up -d --build` (run as `modelo` from `~/matrix-bridge`). You normally don't run this by hand — the **Update** button on @@ -86,9 +92,17 @@ the full answer back into the room (ask mode, D12). - `scripts/ask-claude.sh` — headless `?`-ask wrapper (`#!/bin/zsh -l`): runs `claude -p` in the repo and prints the answer to stdout for the bot to capture and post back. Uses `CLAUDE_CODE_OAUTH_TOKEN` (Mac-side `.env`) because a non-GUI SSH session can't reach the login Keychain (D12). +- `scripts/capture-note.sh` — capture wrapper (`#!/bin/zsh -l`): appends one `/capture`-format line + to `~/Projects/standards/INBOX.md`, commits, best-effort pushes, and echoes the line back. + Deterministic — no `claude`, no token, no frontier call (D13). +- `scripts/seed-capture-threads.py` — one-time (re-runnable) helper that posts each room's + capture-thread root message and prints the resulting `capture_thread` event IDs to paste into + `config.toml`. Skips rooms already configured; run after adding a project. - `src/bot.py` — the matrix-nio bot (Phase 1): listens in mapped rooms; a plain message runs `ssh mac-bridge gui-launch.sh` (interactive, to the phone), a `?`-prefixed message runs - `ask-claude.sh` (headless, answer posted back); fans out for all-projects; reports failures back. + `ask-claude.sh` (headless, answer posted back), and a `/capture`/capture-thread message runs + `capture-note.sh` (logs to the inbox, confirms in-thread); fans out for all-projects; reports + failures back. - `requirements.txt` (matrix-nio) · `.env.example` (credential schema; real `.env` gitignored). - `.claude/` — Claude wiring (dir only for now). - `Dockerfile` · `docker-compose.yml` · `docker-entrypoint.sh` · `.dockerignore` — the Phase 1 @@ -148,6 +162,18 @@ Condensed from the scoping workshop. Each: the call, why, what it beat. `claude setup-token` (`CLAUDE_CODE_OAUTH_TOKEN`) that D11 deferred — kept **Mac-side only** (in `.env`; the Spark never runs claude). Interactive launches keep the token-free GUI-Terminal path. *Sovereignty unchanged:* `claude -p` uses the subscription, no frontier API touches message payloads. +- **D13 — Capture mode → central inbox + `/triage` gate, via a deterministic script (2026-06-16).** + A message in a room's **capture thread** (detected by its `m.relates_to` thread root, configured + per room as `capture_thread`), or a `/capture ` message in any room, is logged to + `~/Projects/standards/INBOX.md` tagged for that room's project — then the existing `/triage` + lands it in the repo. *Beat (deliberately rejected):* writing straight into a repo's + `AGENTS.md`/`ROADMAP.md` unattended — keeps the human approval gate, and the Current-state-vs- + ROADMAP call, where they belong (and AGENTS.md is load-bearing — "propose, don't silently + rewrite"). *Beat:* `claude -p /capture` for the write — a one-line append needs no model, so + `capture-note.sh` does it deterministically: no token, nothing leaves the Mac but the git push, + and message text never reaches a frontier model (upholds the sovereignty constraint / D8). The + bot confirms in-thread with the exact inbox line; `/capture` is the zero-config path, the thread + just drops the prefix. Thread roots are minted by `seed-capture-threads.py`. ## Sovereignty constraint @@ -210,12 +236,26 @@ once" is not done. - **Phase 3 (Spark Control) shipped 2026-06-16 in v0.21.0:** status badge + Update / Restart / Stop-Start / Logs tile; the Spark's dir is now a Gitea clone and deploy = the Update button. Detail in ROADMAP + `docs/spark-control-integration.md`; no matrix-bridge code change. -- **No active build work.** Next moves are all optional / triggered: +- **Capture mode (D13) built 2026-06-16 — pending deploy, not yet N=3.** `/capture ` in any + room and per-room capture threads both log to `standards/INBOX.md` via `capture-note.sh`; bot + confirms in-thread. All 11 rooms + all-projects already have their `capture_thread` roots seeded + and their IDs are in the Mac's `config.toml`. **To go live:** (1) push code + `git`-deploy on the + Spark (Update button); (2) refresh the Spark's `config.toml` — it's gitignored, so the Update + button does *not* carry it: on the Spark run + `scp mac-bridge:/Users/macpro/Projects/matrix-bridge/config.toml ~/matrix-bridge/config.toml` + (or hand-add `capture_launcher` + the `capture_thread` lines), then Restart. Until the Spark has + both the new code *and* config, **don't reply in the capture threads** (the old bot would treat a + thread reply as a launch). +- **Optional / triggered next moves:** - Badge reflects container liveness only, not Synapse connectivity — add a Docker `HEALTHCHECK` (bot-side liveness signal → read `{{.State.Health.Status}}`) when "running but silent" bites. - A `?`-ask in a repo `claude` has never opened may stall on the folder-trust gate — add a trust flag to `ask-claude.sh` if/when hit, not preemptively. + - Capture defaults every line to `[idea][P2]`; add inline `[type]`/`[Pn]` parsing to + `capture-note.sh` if reclassifying at `/triage` gets tedious. - Phase 4+ (intent-routing brain D8, thread continuity) — see ROADMAP; not scoped. - **Watch:** the Update button depends on modelo's Gitea ssh-config pin (`IdentitiesOnly yes`, see Infra facts) — flag it if that account is ever rebuilt. -- **Repo:** `master` == `phase-1`, clean, pushed to Gitea. No test suite (pre-existing). +- **Repo:** `master` == `phase-1`. Uncommitted: capture mode (`src/bot.py`, `scripts/capture-note.sh`, + `scripts/seed-capture-threads.py`, `config.example.toml`, `AGENTS.md`) + gitignored `config.toml`. + No test suite (pre-existing). diff --git a/config.example.toml b/config.example.toml index 43c70d1..67827f7 100644 --- a/config.example.toml +++ b/config.example.toml @@ -16,19 +16,26 @@ user = "@matrix-bridge-bot:" # a dedicated bot Matrix account (not ssh_alias = "mac-bridge" launcher = "/Users/macpro/Projects//scripts/gui-launch.sh" ask_launcher = "/Users/macpro/Projects//scripts/ask-claude.sh" # headless `?`-prefix ask mode +capture_launcher = "/Users/macpro/Projects//scripts/capture-note.sh" # `/capture` + capture-thread → standards/INBOX.md # Container only: docker-entrypoint.sh generates ~/.ssh/config for `ssh_alias` from these. # (On a host with `ssh_alias` already in ~/.ssh/config these are ignored.) hostname = "10.0.0.0" # the Mac's address reachable from the Spark (e.g. WireGuard IP) user = "" # One [[room]] block per project. -# room_id — the internal Matrix room ID (starts with '!'), NOT the human alias (#name:domain) -# repo_dir — an absolute path on the Mac (note: ~/Projects uses a capital P) -# label — human-readable name, for logs and error messages +# room_id — the internal Matrix room ID (starts with '!'), NOT the alias (#name:domain) +# repo_dir — an absolute path on the Mac (note: ~/Projects uses a capital P) +# label — human-readable name, for logs and error messages +# capture_thread — (optional) root event ID ('$...') of this room's capture thread; a message +# in that thread is logged to standards/INBOX.md (tagged by `label`) instead of +# launching Claude. Seed these with: python3 scripts/seed-capture-threads.py +# capture_project — (optional) inbox tag for captures from this room; defaults to `label`. +# `/capture ` works in any room with no config; the thread just lets you skip the prefix. [[room]] room_id = "!exampleRoomId:your-domain" repo_dir = "/Users/macpro/Projects/" label = "" +capture_thread = "$exampleThreadRootEventId" [[room]] room_id = "!anotherRoomId:your-domain" diff --git a/scripts/capture-note.sh b/scripts/capture-note.sh new file mode 100755 index 0000000..175a9e7 --- /dev/null +++ b/scripts/capture-note.sh @@ -0,0 +1,45 @@ +#!/bin/zsh -l +# capture-note.sh — matrix-bridge capture wrapper. +# +# Invoked over SSH by the bot: capture-note.sh +# Appends ONE line to the cross-project inbox (standards/INBOX.md) in the exact format the +# /capture skill uses, commits it, and best-effort pushes — so /triage (run later inside the +# target repo) drains it like any other captured item. +# +# Deterministic on purpose: no LLM, no token, nothing leaves the Mac except the git push to +# Gitea. The "smarts" (real type/priority, repo-routing, phrasing) stay at /triage, where the +# human is — and routing message text through a frontier model would break the sovereignty +# boundary (D13/D8). Defaults every capture to a raw [idea][P2]; /triage reclassifies. +# +# Why a login shell (-l): git config / PATH live in ~/.zprofile, which a non-login SSH shell +# skips — the same seam as launch-claude.sh / ask-claude.sh. + +set -e + +standards="$HOME/Projects/standards" +inbox="$standards/INBOX.md" + +project="$1" +shift || true +note="$*" + +if [[ -z "$project" || -z "$note" ]]; then + print -u2 "usage: capture-note.sh " + exit 2 +fi +if [[ ! -f "$inbox" ]]; then + print -u2 "capture-note: inbox not found: $inbox" + exit 1 +fi + +line="- [ ] ($project) [idea][P2] $note — via matrix, $(date +%F)" + +print -r -- "$line" >> "$inbox" + +git -C "$standards" add INBOX.md +git -C "$standards" commit -q -m "Capture: ${note[1,60]} (via matrix)" -- INBOX.md +# Push is best-effort — a captured line committed locally is not lost if Gitea is unreachable. +git -C "$standards" push -q 2>/dev/null || print -u2 "capture-note: push skipped/failed (committed locally)" + +# Echo the exact line so the bot can post it back into the thread as confirmation. +print -r -- "$line" diff --git a/scripts/seed-capture-threads.py b/scripts/seed-capture-threads.py new file mode 100755 index 0000000..5bd3ed7 --- /dev/null +++ b/scripts/seed-capture-threads.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""seed-capture-threads.py — create each room's capture-thread root, print the event IDs. + +Posts one "capture thread" root message into every room in config.toml and prints the resulting +event_id per room. Paste each into its [[room]] block as `capture_thread = "$..."` — the bot then +treats any reply in that thread as a capture (no /capture prefix needed in-thread). + +Idempotent-ish: by default it skips rooms that already have `capture_thread` set in config, so a +re-run after adding a project only seeds the new room. Use --force to reseed everything, pass room +labels to seed only those, or --dry-run to preview without posting. + +Reads ../.env (MATRIX_ACCESS_TOKEN) and ../config.toml (homeserver URL + rooms). Talks to the +homeserver over plain HTTPS, so run it anywhere the homeserver is reachable (Mac or Spark). + + python3 scripts/seed-capture-threads.py [--force] [--dry-run] [label ...] +""" +import json +import os +import sys +import tomllib +import urllib.request +import urllib.error +import uuid + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) + + +def load_env(path): + env = {} + with open(path) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + env[k] = v.strip().strip('"') + return env + + +def root_body(project): + who = "any project" if project == "?" else f"({project})" + tag = "?" if project == "?" else project + return ( + f"📥 Capture thread — reply in this thread to log an idea, feature, or bug for {who}. " + f"It goes straight to the cross-project inbox tagged ({tag}) (no /capture prefix needed " + f"in-thread); run /triage in the repo to land it." + ) + + +def send_root(url, token, room_id, project): + txn = uuid.uuid4().hex + endpoint = ( + f"{url.rstrip('/')}/_matrix/client/v3/rooms/" + f"{urllib.parse.quote(room_id, safe='')}/send/m.room.message/{txn}" + ) + body = json.dumps({"msgtype": "m.text", "body": root_body(project)}).encode() + req = urllib.request.Request( + endpoint, data=body, method="PUT", + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.load(resp)["event_id"] + + +def main(): + args = [a for a in sys.argv[1:]] + force = "--force" in args + dry_run = "--dry-run" in args + only = [a for a in args if not a.startswith("--")] + + env = load_env(os.path.join(ROOT, ".env")) + with open(os.path.join(ROOT, "config.toml"), "rb") as f: + cfg = tomllib.load(f) + url = cfg["homeserver"]["url"] + token = env.get("MATRIX_ACCESS_TOKEN") or env.get("MATRIX_ACCESS_TOKEN".lower()) + if not token: + sys.exit("no MATRIX_ACCESS_TOKEN in .env") + + # Build the seed list: every project room, plus the all-projects fan-out room as a `?` catch-all. + targets = [] + for r in cfg.get("room", []): + targets.append((r["label"], r["room_id"], r.get("capture_project", r["label"]), + r.get("capture_thread"))) + ap = cfg.get("all_projects", {}) + if ap.get("room_id"): + targets.append(("all-projects", ap["room_id"], ap.get("capture_project", "?"), + ap.get("capture_thread"))) + + if only: + targets = [t for t in targets if t[0] in only] + + seeded = [] + for label, room_id, project, existing in targets: + if existing and not force: + print(f"SKIP {label:<20} already has capture_thread={existing}") + continue + if dry_run: + print(f"DRYRUN {label:<20} {room_id} (project {project})") + continue + try: + event_id = send_root(url, token, room_id, project) + seeded.append((label, room_id, project, event_id)) + print(f"SEEDED {label:<20} {room_id} -> {event_id}") + except urllib.error.HTTPError as e: + print(f"ERROR {label:<20} {room_id} HTTP {e.code}: {e.read().decode()[:200]}") + except Exception as e: # noqa: BLE001 — surface any transport failure per room, keep going + print(f"ERROR {label:<20} {room_id} {e}") + + if seeded: + print("\n# Paste each line into the matching [[room]] block (or [all_projects]):") + for label, _room_id, _project, event_id in seeded: + print(f'{label}: capture_thread = "{event_id}"') + + +if __name__ == "__main__": + main() diff --git a/src/bot.py b/src/bot.py index c984bf8..58d631d 100644 --- a/src/bot.py +++ b/src/bot.py @@ -24,7 +24,9 @@ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Headless "ask" mode tunables. ASK_TIMEOUT = 300 # seconds to wait for `claude -p` before giving up +CAPTURE_TIMEOUT = 60 # seconds to wait for capture-note.sh (inbox append + git push) MAX_MSG_CHARS = 30000 # split answers into chunks well under Matrix's ~64KB event cap +CAPTURE_PREFIX = "/capture" # zero-config capture trigger, valid in any mapped room def load_env(path): @@ -64,6 +66,14 @@ def split_message(text, limit=MAX_MSG_CHARS): return chunks +def thread_root_of(event): + """Return the thread root event_id if this message is a threaded reply, else None.""" + relates = (event.source or {}).get("content", {}).get("m.relates_to") or {} + if relates.get("rel_type") == "m.thread": + return relates.get("event_id") + return None + + async def main(): env = load_env(os.path.join(REPO_ROOT, ".env")) cfg = load_config(os.path.join(REPO_ROOT, "config.toml")) @@ -74,10 +84,12 @@ async def main(): device_id = env.get("MATRIX_DEVICE_ID", "matrix-bridge-bot") rooms = {r["room_id"]: r for r in cfg.get("room", [])} - all_projects_room = cfg.get("all_projects", {}).get("room_id") + all_projects_cfg = cfg.get("all_projects", {}) + all_projects_room = all_projects_cfg.get("room_id") ssh_alias = os.environ.get("MB_SSH_ALIAS") or cfg["mac"]["ssh_alias"] launcher = cfg["mac"]["launcher"] ask_launcher = cfg["mac"].get("ask_launcher") + capture_launcher = cfg["mac"].get("capture_launcher") client = AsyncClient(homeserver, user_id) client.restore_login(user_id=user_id, device_id=device_id, access_token=token) @@ -121,10 +133,16 @@ async def main(): out.decode(errors="replace").strip(), err.decode(errors="replace").strip()) - async def say(room_id, text): - await client.room_send( - room_id, "m.room.message", {"msgtype": "m.text", "body": text} - ) + async def say(room_id, text, thread_root=None): + content = {"msgtype": "m.text", "body": text} + if thread_root: # keep confirmations inside the capture thread + content["m.relates_to"] = { + "rel_type": "m.thread", + "event_id": thread_root, + "is_falling_back": True, + "m.in_reply_to": {"event_id": thread_root}, + } + await client.room_send(room_id, "m.room.message", content) async def launch_one(report_room, repo, prompt, session_name=None): rc, out = await launch(repo["repo_dir"], prompt, session_name) @@ -154,6 +172,46 @@ async def main(): await say(report_room, f"⚠️ matrix-bridge: ask failed in {repo['label']} " f"(rc={rc}): {detail[:500]}") + async def run_capture(project, text): + """Run capture-note.sh on the Mac over SSH; return (rc, stdout, stderr). + + Deterministic: appends one line to standards/INBOX.md and commits/pushes. stdout is the + exact inbox line written, echoed back into the thread as confirmation. + """ + remote = (f"{shlex.quote(capture_launcher)} " + f"{shlex.quote(project)} {shlex.quote(text)}") + proc = await asyncio.create_subprocess_exec( + "ssh", ssh_alias, remote, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + out, err = await asyncio.wait_for(proc.communicate(), timeout=CAPTURE_TIMEOUT) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() # reap the killed ssh client (no zombie) + return None, "", f"timed out after {CAPTURE_TIMEOUT}s" + return (proc.returncode, + out.decode(errors="replace").strip(), + err.decode(errors="replace").strip()) + + async def capture(report_room, project, text, thread_root=None): + """Log a message to the cross-project inbox (standards/INBOX.md) tagged for `project`.""" + if not capture_launcher: + await say(report_room, + "⚠️ matrix-bridge: capture mode not configured ([mac].capture_launcher missing).", + thread_root) + return + rc, out, err = await run_capture(project, text) + if rc == 0: + print(f"capture ({project}): {out}", flush=True) + await say(report_room, f"📥 captured → {out}", thread_root) + return + detail = err or out or "no output" + print(f"CAPTURE FAILED ({project}): rc={rc} {detail[:300]}", flush=True) + await say(report_room, f"⚠️ matrix-bridge: capture failed (rc={rc}): {detail[:500]}", + thread_root) + async def on_message(room: MatrixRoom, event: RoomMessageText): if event.sender == user_id: return # never react to our own messages @@ -161,23 +219,46 @@ async def main(): if not prompt: return - if room.room_id == all_projects_room: # fan-out room always launches, never asks + rid = room.room_id + if rid == all_projects_room: + cap_thread = all_projects_cfg.get("capture_thread") + cap_project = all_projects_cfg.get("capture_project", "?") + elif rid in rooms: + cap_thread = rooms[rid].get("capture_thread") + cap_project = rooms[rid].get("capture_project", rooms[rid]["label"]) + else: + return # message in an unmapped room + + # Capture is checked first: an explicit `/capture ` in any mapped room, or any + # message in this room's configured capture thread → logged to the inbox, not launched. + thread_root = thread_root_of(event) + head, _, rest = prompt.partition(" ") + if head == CAPTURE_PREFIX: + note = rest.strip() + if note: + await capture(rid, cap_project, note, thread_root) + return + if cap_thread and thread_root == cap_thread: + await capture(rid, cap_project, prompt, thread_root) + return + + if rid == all_projects_room: # fan-out room: launch everywhere, never asks date = datetime.date.today().isoformat() print(f"[all-projects] fan-out to {len(rooms)} repos: {prompt!r}", flush=True) results = await asyncio.gather(*[ - launch_one(room.room_id, r, prompt, f"{r['label']} - {date}") + launch_one(rid, r, prompt, f"{r['label']} - {date}") for r in rooms.values() ]) - await say(room.room_id, + await say(rid, f"matrix-bridge: launched {sum(results)}/{len(rooms)} sessions ({date}).") - elif room.room_id in rooms: - r = rooms[room.room_id] + else: # a mapped project room + r = rooms[rid] if prompt.startswith("?"): # headless ask mode ask_prompt = prompt[1:].strip() if ask_prompt: - await ask(room.room_id, r, ask_prompt) - elif await launch_one(room.room_id, r, prompt): - await say(room.room_id, + await ask(rid, r, ask_prompt) + elif await launch_one(rid, r, prompt): + await say(rid, f"matrix-bridge: launched {r['label']} — drive it on your phone.") # Prime the sync token past existing history, THEN register the callback, so the bot