Remove claude-agent's Matrix presence entirely — Hermes is the only agent in Matrix
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.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+8
-42
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user