From 6bc862e051faf0450f1e3d325cfdeceed182f189 Mon Sep 17 00:00:00 2001 From: William Turner Date: Sun, 23 Aug 2026 16:16:19 +0000 Subject: [PATCH] =?UTF-8?q?Remove=20claude-agent's=20Matrix=20presence=20e?= =?UTF-8?q?ntirely=20=E2=80=94=20Hermes=20is=20the=20only=20agent=20in=20M?= =?UTF-8?q?atrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By request: one agent in Matrix, not several. Removes matrixBot.js, router.js (chat-vs-code-task classifier), litellm.js (claude-agent's own LiteLLM client), the matrix-bot-sdk dependency, runChatTask() and its gitea.js branch/PR helpers (createBranch/createPullRequest — only ever called from the now-removed chat flow), and every Matrix/LiteLLM env var from claude-agent's compose service. claude-agent already left the control room manually before this merge. It keeps its Gitea-webhook-triggered PR review, which never touched Matrix or LiteLLM to begin with. Makes PR #12 (the claude-bot/Hermes cross-reply cascade fix) moot — the bug can't happen once claude-agent has no Matrix client at all. Close #12 without merging once this lands. --- .env.example | 24 +++---------- README.md | 46 +++++++++++++----------- agent/package.json | 3 +- agent/src/gitea.js | 21 ----------- agent/src/litellm.js | 37 -------------------- agent/src/matrixBot.js | 79 ------------------------------------------ agent/src/router.js | 45 ------------------------ agent/src/runner.js | 50 +++++--------------------- agent/src/server.js | 5 --- docker-compose.yml | 15 ++------ 10 files changed, 42 insertions(+), 283 deletions(-) delete mode 100644 agent/src/litellm.js delete mode 100644 agent/src/matrixBot.js delete mode 100644 agent/src/router.js diff --git a/.env.example b/.env.example index b32ad6a..9e6c232 100644 --- a/.env.example +++ b/.env.example @@ -21,31 +21,17 @@ GITEA_REGISTRY_IMAGE=gitea.apps.williamturner.eu//" in the control room). +# in a room (free-response in DMs). MATRIX_HUMAN_USER_ID=@william:matrix.apps.williamturner.eu -# Access token for the @hermes bot account (register it the same way as claude-bot — -# see README — then log in as it via /_matrix/client/v3/login to get this token). +# Access token for the @hermes bot account — register it on the homeserver, then log +# in as it via /_matrix/client/v3/login to get this token (see README). HERMES_MATRIX_ACCESS_TOKEN= # Any random string — bearer key for Hermes's own OpenAI-compatible API server # (internal network only, not published anywhere). diff --git a/README.md b/README.md index dce7000..819ccad 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,25 @@ # gitops-automation -Claude Code automation wired into Gitea + Portainer + Matrix on this VPS. See -`~/.claude/plans/cozy-honking-lantern.md` on the host for the full design rationale. +Claude Code + Hermes automation wired into Gitea + Portainer + Matrix on this VPS. See +`~/.claude/plans/cozy-honking-lantern.md` on the host for the original design rationale +(some of it — the Matrix chat bot on claude-agent — has since been superseded, see below). -Three things this gives you: +What this gives you today: - **PR review**: opening/updating a PR in a watched Gitea repo gets a Claude-authored - review comment. + review comment (`claude-agent`, triggered by a Gitea webhook — nothing to do with Matrix). - **GitOps redeploy**: pushing to `main` on this repo rebuilds the `claude-agent` image - (Gitea Actions) and redeploys the stack (Portainer webhook). -- **Chat-driven coding agent**: `!claude owner/repo ` in the Matrix control - room clones the repo, runs Claude Code, and opens a PR with the result. -- **Ask other models**: `!ai ` (default model) or `!ai provider/model ` - (e.g. `!ai google/gemini-2.0-flash-001 explain this error`) queries any model on - OpenRouter and replies in the room. No repo/file access — just a chat reply, unlike - `!claude` which is the only command that can edit files and open PRs. + (Gitea Actions) and redeploys the stack, chained as the last step of that same workflow. +- **Matrix**: **Hermes is the only agent present in Matrix.** `claude-agent` used to also + run a Matrix bot (`!claude`/`!ai` commands, then no-prefix auto-routing) — that's been + removed entirely (by request: one agent in Matrix, not several). Hermes has its own + native Matrix connection, responds to `@hermes ` in shared rooms (no mention + needed in DMs), and has its own tools (terminal, code execution, web search, etc.) — see + its docs at https://hermes-agent.nousresearch.com for what it can do. It does not (yet) + have the old branch/PR-opening workflow the Matrix bot used to have; that logic still + exists in git history if it's worth reviving as a Hermes tool/skill later. -Nothing here auto-merges. Every path stops at a comment or an open PR — a human clicks merge. +Nothing here auto-merges PRs. Every path stops at a comment or an open PR — a human clicks +merge (enforced by branch protection on `main`, not just by convention — see below). ## Prerequisites (one-time, on the VPS) @@ -125,17 +129,17 @@ docker network create web ## Smoke test - Open a throwaway PR on a repo with the PR webhook set → expect a Claude review comment. -- In the Matrix control room: `!claude owner/repo add a comment to the README` → expect a - "working on it" reply, then a PR link. -- `git push` to `main` on this repo → expect a Gitea Actions run, then a Portainer redeploy. +- In the Matrix control room: `@hermes hello` → expect a reply from Hermes. +- `git push` to `main` on this repo (touching `agent/**`) → expect a Gitea Actions run, + then a chained Portainer redeploy at the end of that same workflow. ## Notes -- `agent/src/runner.js` is the only thing that ever runs `git commit`/`git push`/`git - checkout` — Claude Code itself is explicitly denied those tools (`--disallowedTools`), - so even a misbehaving prompt can't push directly or touch `main`. -- The Matrix bot only reacts inside `MATRIX_CONTROL_ROOM_ID`; keep that room invite-only. +- `agent/src/runner.js` only ever runs read-only `git clone`/`fetch`/`checkout` for the PR + diff it reviews — Claude Code itself is denied `Edit`/`Write`/commit/push tools + (`--disallowedTools`), so this path can never modify a repo, only comment on it. - `.gitea/workflows/build.yml` assumes the act_runner label `docker` — check `GITEA_RUNNER_LABELS` in `docker-compose.yml` matches what you actually registered. - - +- Hermes's own config/memory/skills live in `/home/william/hermes-data` on the host + (bind-mounted, not in this repo) — back that up separately if it accumulates anything + worth keeping. diff --git a/agent/package.json b/agent/package.json index e90b8fa..77efbda 100644 --- a/agent/package.json +++ b/agent/package.json @@ -8,7 +8,6 @@ "start": "node src/server.js" }, "dependencies": { - "express": "^4.19.2", - "matrix-bot-sdk": "^0.7.1" + "express": "^4.19.2" } } diff --git a/agent/src/gitea.js b/agent/src/gitea.js index 406e3ee..2206b04 100644 --- a/agent/src/gitea.js +++ b/agent/src/gitea.js @@ -24,27 +24,6 @@ export async function postPRComment(owner, repo, index, body) { await assertOk(res, "post PR comment"); } -export async function createBranch(owner, repo, newBranch, oldBranch = "main") { - const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/branches`; - const res = await fetch(url, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ new_branch_name: newBranch, old_branch_name: oldBranch }), - }); - await assertOk(res, "create branch"); -} - -export async function createPullRequest(owner, repo, { head, base = "main", title, body }) { - const url = `${GITEA_URL}/api/v1/repos/${owner}/${repo}/pulls`; - const res = await fetch(url, { - method: "POST", - headers: authHeaders(), - body: JSON.stringify({ head, base, title, body }), - }); - await assertOk(res, "create PR"); - return res.json(); -} - // Injects the agent's token into a Gitea clone URL so git operations don't need SSH keys. export function authenticatedCloneUrl(cloneUrl) { const u = new URL(cloneUrl); diff --git a/agent/src/litellm.js b/agent/src/litellm.js deleted file mode 100644 index 26d95c5..0000000 --- a/agent/src/litellm.js +++ /dev/null @@ -1,37 +0,0 @@ -const LITELLM_BASE_URL = process.env.LITELLM_BASE_URL || "http://litellm:4000"; -const LITELLM_MASTER_KEY = process.env.LITELLM_MASTER_KEY; - -// OpenAI-compatible chat completion, for OpenRouter-backed models routed through the -// local LiteLLM gateway (e.g. "auto" — OpenRouter's own prompt-aware auto-router). -export async function chatCompletion(model, prompt) { - if (!LITELLM_MASTER_KEY) { - throw new Error("LITELLM_MASTER_KEY is not set"); - } - - const res = await fetch(`${LITELLM_BASE_URL}/v1/chat/completions`, { - method: "POST", - headers: { - Authorization: `Bearer ${LITELLM_MASTER_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model, - messages: [{ role: "user", content: prompt }], - // Some models default max_tokens to their full context window (e.g. 65536), which - // can exceed available credit balance before a single token is generated. This is a - // quick chat reply, not a long-form task — cap it. - max_tokens: 1024, - }), - }); - - if (!res.ok) { - throw new Error(`LiteLLM request failed: ${res.status} ${await res.text()}`); - } - - const data = await res.json(); - const content = data.choices?.[0]?.message?.content; - if (!content) { - throw new Error(`LiteLLM returned no content: ${JSON.stringify(data)}`); - } - return content; -} diff --git a/agent/src/matrixBot.js b/agent/src/matrixBot.js deleted file mode 100644 index b60243b..0000000 --- a/agent/src/matrixBot.js +++ /dev/null @@ -1,79 +0,0 @@ -import { MatrixClient, SimpleFsStorageProvider } from "matrix-bot-sdk"; -import { runChatTask } from "./runner.js"; -import { routeMessage, chatReply } from "./router.js"; - -const HOMESERVER_URL = process.env.MATRIX_HOMESERVER_URL; -const ACCESS_TOKEN = process.env.MATRIX_BOT_TOKEN; -const CONTROL_ROOM_ID = process.env.MATRIX_CONTROL_ROOM_ID; -const GITEA_URL = process.env.GITEA_URL; -// Set explicitly rather than fetched via client.getUserId() — that call hits /whoami, -// which (like /joined_rooms before it) 404s against Continuwuity for reasons unrelated -// to the endpoint itself. This is also the only reliable way to filter the bot's own -// messages now that there's no command prefix to naturally exclude them by. -const BOT_USER_ID = process.env.MATRIX_BOT_USER_ID; -const KNOWN_REPOS = (process.env.KNOWN_REPOS || "") - .split(",") - .map((r) => r.trim()) - .filter(Boolean); - -const MAX_REPLY_LENGTH = 4000; - -function truncate(text) { - if (text.length <= MAX_REPLY_LENGTH) return text; - return `${text.slice(0, MAX_REPLY_LENGTH)}\n\n[truncated]`; -} - -export async function startMatrixBot() { - if (!HOMESERVER_URL || !ACCESS_TOKEN || !CONTROL_ROOM_ID) { - console.warn("Matrix env vars not set — skipping bot startup"); - return; - } - if (!BOT_USER_ID) { - console.warn("MATRIX_BOT_USER_ID not set — bot could reply to its own messages, skipping startup"); - return; - } - - const storage = new SimpleFsStorageProvider("/workspace/matrix-bot-storage.json"); - const client = new MatrixClient(HOMESERVER_URL, ACCESS_TOKEN, storage); - - client.on("room.invite", async (roomId) => { - try { - await client.joinRoom(roomId); - } catch (err) { - console.error("failed to join invited room", roomId, err.message); - } - }); - - client.on("room.message", async (roomId, event) => { - if (roomId !== CONTROL_ROOM_ID) return; - if (event.sender === BOT_USER_ID) return; - const body = event.content?.body; - if (!body) return; - // Messages explicitly addressed to another agent in this room (currently just - // @hermes) are that agent's to answer — without this, claude-bot's classifier would - // also see and reply to them, since it otherwise treats every message as its own. - if (/^@hermes\b/i.test(body.trim())) return; - - try { - const decision = await routeMessage(body, KNOWN_REPOS); - - if (decision.type === "code_task") { - const [owner, repo] = decision.repo.split("/"); - await client.sendText(roomId, `Working on it: ${decision.repo} — ${decision.instruction}`); - const cloneUrl = `${GITEA_URL}/${owner}/${repo}.git`; - const pr = await runChatTask({ owner, repo, cloneUrl, instruction: decision.instruction }); - await client.sendText(roomId, `Opened PR: ${pr.html_url}`); - return; - } - - const reply = await chatReply(body); - await client.sendText(roomId, truncate(reply)); - } catch (err) { - console.error("message handling failed", err); - await client.sendText(roomId, `Failed: ${err.message}`); - } - }); - - await client.start(); - console.log("Matrix bot started, room:", CONTROL_ROOM_ID); -} diff --git a/agent/src/router.js b/agent/src/router.js deleted file mode 100644 index 757cd8a..0000000 --- a/agent/src/router.js +++ /dev/null @@ -1,45 +0,0 @@ -import { chatCompletion } from "./litellm.js"; - -const ROUTER_MODEL = "router-classifier"; -const CHAT_MODEL = "auto"; - -function systemPrompt(knownRepos) { - return [ - "You are a routing classifier for a chat bot. Given a user message, decide whether it is:", - '- "chat": a question, discussion, or anything that just needs a text reply.', - '- "code_task": a request to change a specific code repository (add/edit/fix something)', - " where the repository is clearly one of the known repositories below.", - "", - `Known repositories: ${knownRepos.join(", ") || "(none configured)"}`, - "", - "Reply with ONLY a JSON object, nothing else:", - '{"type":"chat"}', - 'or', - '{"type":"code_task","repo":"owner/repo","instruction":"clear imperative instruction"}', - "", - "If it sounds like a code change but you can't confidently match it to one of the known", - 'repositories, reply {"type":"chat"} instead of guessing.', - ].join("\n"); -} - -function parseDecision(raw) { - try { - const cleaned = raw.trim().replace(/^```(?:json)?\n?/, "").replace(/```$/, ""); - const parsed = JSON.parse(cleaned); - if (parsed.type === "code_task" && parsed.repo && parsed.instruction) { - return parsed; - } - } catch { - // fall through to chat — an unparseable classification is not a reason to edit a repo - } - return { type: "chat" }; -} - -export async function routeMessage(text, knownRepos) { - const raw = await chatCompletion(ROUTER_MODEL, `${systemPrompt(knownRepos)}\n\nMessage: ${text}`); - return parseDecision(raw); -} - -export async function chatReply(text) { - return chatCompletion(CHAT_MODEL, text); -} diff --git a/agent/src/runner.js b/agent/src/runner.js index 12bc766..db59425 100644 --- a/agent/src/runner.js +++ b/agent/src/runner.js @@ -2,8 +2,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { mkdtemp, rm, mkdir } from "node:fs/promises"; import path from "node:path"; -import crypto from "node:crypto"; -import { authenticatedCloneUrl, createBranch, createPullRequest } from "./gitea.js"; +import { authenticatedCloneUrl } from "./gitea.js"; const execFileAsync = promisify(execFile); const WORKSPACE_ROOT = "/workspace"; @@ -29,10 +28,12 @@ async function withWorkspace(fn) { // Runs Claude Code headless. Unattended containers have no TTY to answer permission // prompts, so this trusts the sandboxing of the throwaway clone dir instead: // bypassPermissions to avoid hanging, plus --disallowedTools as defense in depth so -// Claude can never push/commit/checkout itself — this script owns those steps. -async function runClaude(cwd, prompt, { allowEdits }) { - const disallowed = ["Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)"]; - if (!allowEdits) disallowed.push("Edit", "Write", "NotebookEdit"); +// Claude can never push/commit/checkout, or edit files — this is read-only review. +async function runClaude(cwd, prompt) { + const disallowed = [ + "Bash(git push:*)", "Bash(git commit:*)", "Bash(git checkout:*)", + "Edit", "Write", "NotebookEdit", + ]; const args = [ "-p", prompt, @@ -59,41 +60,6 @@ export async function reviewPullRequest({ owner, repo, ref, cloneUrl, prTitle, p `PR description:\n${prBody}`, ].join("\n"); - return runClaude(dir, prompt, { allowEdits: false }); - }); -} - -export async function runChatTask({ owner, repo, cloneUrl, instruction }) { - return withWorkspace(async (dir) => { - const authedUrl = authenticatedCloneUrl(cloneUrl); - await run("git", ["clone", "--quiet", authedUrl, dir]); - - const branch = `claude/${crypto.randomBytes(4).toString("hex")}`; - await createBranch(owner, repo, branch); - await run("git", ["fetch", "--quiet", "origin", branch], { cwd: dir }); - await run("git", ["checkout", "--quiet", branch], { cwd: dir }); - - const prompt = [ - "Implement the change described below in this repository. Make the smallest", - "correct change that satisfies it. Do not run git commit, git push, or git", - "checkout yourself — just edit files; committing and pushing happens separately.", - `Instruction: ${instruction}`, - ].join("\n"); - - await runClaude(dir, prompt, { allowEdits: true }); - - await run("git", ["add", "-A"], { cwd: dir }); - const status = await run("git", ["status", "--porcelain"], { cwd: dir }); - if (!status.trim()) { - throw new Error("Claude made no changes for this instruction"); - } - await run("git", ["commit", "-m", `claude: ${instruction}`.slice(0, 200)], { cwd: dir }); - await run("git", ["push", "--quiet", "origin", branch], { cwd: dir }); - - return createPullRequest(owner, repo, { - head: branch, - title: `claude: ${instruction}`.slice(0, 200), - body: `Requested via Matrix:\n\n> ${instruction}`, - }); + return runClaude(dir, prompt); }); } diff --git a/agent/src/server.js b/agent/src/server.js index 968bb2e..34eaaa1 100644 --- a/agent/src/server.js +++ b/agent/src/server.js @@ -2,7 +2,6 @@ import express from "express"; import crypto from "node:crypto"; import { postPRComment } from "./gitea.js"; import { reviewPullRequest } from "./runner.js"; -import { startMatrixBot } from "./matrixBot.js"; const app = express(); app.use( @@ -60,7 +59,3 @@ app.post("/webhooks/gitea", async (req, res) => { app.listen(PORT, () => { console.log(`claude-agent listening on :${PORT}`); }); - -startMatrixBot().catch((err) => { - console.error("matrix bot failed to start:", err); -}); diff --git a/docker-compose.yml b/docker-compose.yml index 525be44..bfed4a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -94,9 +94,10 @@ services: command: ["gateway", "run"] claude-agent: + # Gitea PR-review only now — no Matrix presence (see hermes above; only one agent + # is meant to be in Matrix). Still triggered by Gitea's pull_request webhook and + # posts review comments there, entirely independent of Matrix/LiteLLM. image: ${GITEA_REGISTRY_IMAGE} - depends_on: - - litellm container_name: claude-agent restart: unless-stopped # Explicit vars, not env_file: .env — Portainer's git-based stack deploy clones the @@ -109,16 +110,6 @@ services: # Claude subscription (Pro/Max) auth via `claude setup-token`, not API billing — # Claude Code reads this in preference to ANTHROPIC_API_KEY when both could apply. CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN} - MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL} - MATRIX_BOT_TOKEN: ${MATRIX_BOT_TOKEN} - MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID} - MATRIX_BOT_USER_ID: ${MATRIX_BOT_USER_ID} - KNOWN_REPOS: ${KNOWN_REPOS} - # All model calls now go through the local litellm service, not OpenRouter directly — - # one gateway for OpenRouter's models (incl. its auto-router) and, for the - # claude-subscription route, Anthropic itself via the forwarded OAuth token above. - LITELLM_BASE_URL: http://litellm:4000 - LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY} volumes: - agent_workspace:/workspace networks: -- 2.54.0