Compare commits

..
Author SHA1 Message Date
william 9fa025f7d5 Add Hermes Agent as a second native Matrix presence
Deployed as its own service (pinned nousresearch/hermes-agent:v2026.8.19),
own Matrix bot account (@hermes), own OpenRouter-backed model config, and
its own OpenAI-compatible API server (internal network only, for possible
future use by claude-agent). Joins the same control room but only responds
when explicitly @mentioned, restricted to the human user — no conflict with
claude-bot's default no-prefix chat routing. claude-agent's router now
ignores messages addressed to @hermes so both bots don't answer the same
message.

Bridge networking (the 'web' network), not the image's default host mode —
no reason for an agent container to share the host's network namespace when
everything it needs (the homeserver, OpenRouter) is reachable over the
existing bridge.
2026-08-23 15:46:58 +00:00
william 5461754f23 Merge pull request 'Route chat through local LiteLLM gateway, drop !claude/!ai prefixes' (#8) from feat/litellm-router into main
build-agent / build-and-push (push) Successful in 7s
Reviewed-on: #8
2026-08-23 15:34:29 +00:00
william f93bfb25a2 Route all chat through a local LiteLLM gateway, drop command prefixes
- New litellm service (pinned v1.98.0 — litellm 1.82.7/1.82.8 on PyPI were
  compromised with credential-stealing malware in March 2026; internal-only,
  no Traefik route, no reason to expose an LLM gateway with a master key
  publicly).
- Replaces !claude/!ai command prefixes with automatic routing: every plain
  message in the control room goes through a classifier (router.js) that
  decides chat vs code_task. Chat replies use OpenRouter's own auto-router
  (openrouter/auto) via LiteLLM; code_task requests go through the existing
  runChatTask() flow (Claude Code CLI, unchanged, still using the
  subscription token directly).
- Investigated routing Claude itself through LiteLLM via OAuth token
  forwarding (general_settings.forward_client_headers_to_llm_api) so the
  Pro/Max subscription could be one of the auto-routable options. Confirmed
  non-functional: Anthropic returns a generic rate_limit_error for any
  direct API call using this token type outside the real Claude Code CLI,
  reproduced with plain curl straight to api.anthropic.com. Not included.
- MATRIX_BOT_USER_ID now required and set explicitly (self-message filtering
  can no longer rely on a command-prefix mismatch once there isn't one).
2026-08-23 15:32:55 +00:00
william 4fc4433833 Merge pull request 'Fix GitOps redeploy race: chain to end of build, not a parallel webhook' (#7) from fix/chain-portainer-redeploy-after-build into main
build-agent / build-and-push (push) Successful in 3s
Reviewed-on: #7
2026-08-23 15:12:03 +00:00
william 8db8347df0 Chain Portainer redeploy to the end of the build workflow, not a parallel webhook
The separate Gitea push webhook to Portainer fired in parallel with this
build workflow on the same push event, so it could redeploy before the
new image finished pushing — Portainer would then pull the still-current
:latest tag and silently keep running old code. Removed that webhook;
redeploy now only happens as this workflow's last step, after the image
push actually completes.
2026-08-23 15:11:31 +00:00
9 changed files with 229 additions and 104 deletions
+23 -2
View File
@@ -25,10 +25,31 @@ CLAUDE_CODE_OAUTH_TOKEN=
MATRIX_HOMESERVER_URL=https://matrix.apps.williamturner.eu MATRIX_HOMESERVER_URL=https://matrix.apps.williamturner.eu
MATRIX_BOT_TOKEN= MATRIX_BOT_TOKEN=
MATRIX_CONTROL_ROOM_ID= MATRIX_CONTROL_ROOM_ID=
# The bot's own Matrix ID (@username:server), e.g. @claude-bot:matrix.apps.williamturner.eu
# — set explicitly rather than fetched via the API (that call 404s against Continuwuity).
# Required: without it the bot can't tell its own messages apart from real ones and would
# reply to itself in a loop, so it refuses to start.
MATRIX_BOT_USER_ID=
# Comma-separated "owner/repo" list the chat router is allowed to open code-change PRs
# against. A plain chat message mentioning a repo NOT in this list is treated as chat,
# never as a code task — the router only matches confidently against known repos.
KNOWN_REPOS=william/gitops-automation
# --- openrouter (the "!ai" chat command — other models, not the coding agent) --- # --- litellm (local LLM gateway — see litellm-config.yaml) ---
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
OPENROUTER_DEFAULT_MODEL=openai/gpt-4o-mini # Any random string; also used as litellm's general_settings.master_key.
LITELLM_MASTER_KEY=
# --- hermes (autonomous agent with its own native Matrix presence) ---
# Your own Matrix ID — Hermes only responds to this user, and only when @mentioned
# in a shared room (e.g. "@hermes <task>" in the control room).
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).
HERMES_MATRIX_ACCESS_TOKEN=
# Any random string — bearer key for Hermes's own OpenAI-compatible API server
# (internal network only, not published anywhere).
HERMES_API_SERVER_KEY=
# --- portainer (GitOps redeploy) --- # --- portainer (GitOps redeploy) ---
PORTAINER_STACK_WEBHOOK_URL= PORTAINER_STACK_WEBHOOK_URL=
+7
View File
@@ -31,3 +31,10 @@ jobs:
IMAGE="${{ vars.REGISTRY_HOST }}/${{ gitea.repository }}/claude-agent:latest" IMAGE="${{ vars.REGISTRY_HOST }}/${{ gitea.repository }}/claude-agent:latest"
docker build -t "$IMAGE" ./agent docker build -t "$IMAGE" ./agent
docker push "$IMAGE" docker push "$IMAGE"
- name: Trigger Portainer redeploy
# Deliberately NOT a separate Gitea repo webhook firing in parallel on the same
# push — that raced with this build and could redeploy before the new image was
# actually pushed, silently keeping the old code running. Chaining it here as the
# last step guarantees the image exists before Portainer goes to pull it.
run: curl -f -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}"
-8
View File
@@ -1,8 +0,0 @@
# Contributing
- Changes land via pull request — no direct pushes to `main`. Branch protection enforces
this (see README.md, "Branch protection on `main`").
- This applies to the Claude bot too: it only ever opens PRs, never pushes or merges
directly. `claude-bot`'s account is deliberately excluded from the merge whitelist, so a
human always reviews and merges.
- Open a PR against `main` and wait for review before merging.
+37
View File
@@ -0,0 +1,37 @@
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;
}
+30 -52
View File
@@ -1,37 +1,23 @@
import { MatrixClient, SimpleFsStorageProvider } from "matrix-bot-sdk"; import { MatrixClient, SimpleFsStorageProvider } from "matrix-bot-sdk";
import { runChatTask } from "./runner.js"; import { runChatTask } from "./runner.js";
import { askOpenRouter, DEFAULT_MODEL } from "./openrouter.js"; import { routeMessage, chatReply } from "./router.js";
const HOMESERVER_URL = process.env.MATRIX_HOMESERVER_URL; const HOMESERVER_URL = process.env.MATRIX_HOMESERVER_URL;
const ACCESS_TOKEN = process.env.MATRIX_BOT_TOKEN; const ACCESS_TOKEN = process.env.MATRIX_BOT_TOKEN;
const CONTROL_ROOM_ID = process.env.MATRIX_CONTROL_ROOM_ID; const CONTROL_ROOM_ID = process.env.MATRIX_CONTROL_ROOM_ID;
const GITEA_URL = process.env.GITEA_URL; 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; const MAX_REPLY_LENGTH = 4000;
// "!claude owner/repo do the thing" — everything after the repo slug is the instruction.
function parseClaudeCommand(text) {
const match = text.match(/^!claude\s+([^\s/]+\/[^\s/]+)\s+(.+)$/s);
if (!match) return null;
const [, repoFullName, instruction] = match;
return { repoFullName, instruction: instruction.trim() };
}
// "!ai <prompt>" uses the default model. "!ai provider/model <prompt>" (first token
// contains a "/") picks a specific OpenRouter model, e.g. "!ai google/gemini-2.0-flash-001
// summarize this repo's README".
function parseAiCommand(text) {
const match = text.match(/^!ai\s+(.+)$/s);
if (!match) return null;
const rest = match[1].trim();
const firstSpace = rest.search(/\s/);
const firstToken = firstSpace === -1 ? rest : rest.slice(0, firstSpace);
if (firstToken.includes("/") && firstSpace !== -1) {
return { model: firstToken, prompt: rest.slice(firstSpace + 1).trim() };
}
return { model: DEFAULT_MODEL, prompt: rest };
}
function truncate(text) { function truncate(text) {
if (text.length <= MAX_REPLY_LENGTH) return text; if (text.length <= MAX_REPLY_LENGTH) return text;
return `${text.slice(0, MAX_REPLY_LENGTH)}\n\n[truncated]`; return `${text.slice(0, MAX_REPLY_LENGTH)}\n\n[truncated]`;
@@ -42,15 +28,14 @@ export async function startMatrixBot() {
console.warn("Matrix env vars not set — skipping bot startup"); console.warn("Matrix env vars not set — skipping bot startup");
return; 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 storage = new SimpleFsStorageProvider("/workspace/matrix-bot-storage.json");
const client = new MatrixClient(HOMESERVER_URL, ACCESS_TOKEN, storage); const client = new MatrixClient(HOMESERVER_URL, ACCESS_TOKEN, storage);
// Not using AutojoinRoomsMixin: it calls /joined_rooms at startup to build its initial
// state, which — like the /whoami call removed earlier — 404s against Continuwuity for
// reasons unrelated to the endpoint itself (curling it directly works fine). This
// simpler handler does the one thing we actually need — auto-join on invite — without
// that startup scan.
client.on("room.invite", async (roomId) => { client.on("room.invite", async (roomId) => {
try { try {
await client.joinRoom(roomId); await client.joinRoom(roomId);
@@ -60,38 +45,31 @@ export async function startMatrixBot() {
}); });
client.on("room.message", async (roomId, event) => { client.on("room.message", async (roomId, event) => {
// Invite-only control room enforces who can reach the bot at all; this just scopes
// command handling to that one room. No need to fetch/compare the bot's own user ID
// to filter out its own messages — its replies never match either command pattern
// below, so they're ignored the same as any other non-command message.
if (roomId !== CONTROL_ROOM_ID) return; if (roomId !== CONTROL_ROOM_ID) return;
if (event.sender === BOT_USER_ID) return;
const body = event.content?.body; const body = event.content?.body;
if (!body) return; 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;
const aiCmd = parseAiCommand(body);
if (aiCmd) {
try { try {
const reply = await askOpenRouter(aiCmd.model, aiCmd.prompt); const decision = await routeMessage(body, KNOWN_REPOS);
await client.sendText(roomId, `[${aiCmd.model}] ${truncate(reply)}`);
} catch (err) { if (decision.type === "code_task") {
console.error("openrouter query failed", err); const [owner, repo] = decision.repo.split("/");
await client.sendText(roomId, `Failed: ${err.message}`); 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; return;
} }
const cmd = parseClaudeCommand(body); const reply = await chatReply(body);
if (!cmd) return; await client.sendText(roomId, truncate(reply));
const [owner, repo] = cmd.repoFullName.split("/");
await client.sendText(roomId, `Working on it: ${cmd.repoFullName}${cmd.instruction}`);
try {
const cloneUrl = `${GITEA_URL}/${owner}/${repo}.git`;
const pr = await runChatTask({ owner, repo, cloneUrl, instruction: cmd.instruction });
await client.sendText(roomId, `Opened PR: ${pr.html_url}`);
} catch (err) { } catch (err) {
console.error("chat task failed", err); console.error("message handling failed", err);
await client.sendText(roomId, `Failed: ${err.message}`); await client.sendText(roomId, `Failed: ${err.message}`);
} }
}); });
-37
View File
@@ -1,37 +0,0 @@
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
const DEFAULT_MODEL = process.env.OPENROUTER_DEFAULT_MODEL || "openai/gpt-4o-mini";
export async function askOpenRouter(model, prompt) {
if (!OPENROUTER_API_KEY) {
throw new Error("OPENROUTER_API_KEY is not set");
}
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: model || DEFAULT_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(`OpenRouter request failed: ${res.status} ${await res.text()}`);
}
const data = await res.json();
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error(`OpenRouter returned no content: ${JSON.stringify(data)}`);
}
return content;
}
export { DEFAULT_MODEL };
+45
View File
@@ -0,0 +1,45 @@
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);
}
+61 -2
View File
@@ -36,8 +36,62 @@ services:
- "traefik.http.routers.matrix.tls.certresolver=letsencrypt" - "traefik.http.routers.matrix.tls.certresolver=letsencrypt"
- "traefik.http.services.matrix.loadbalancer.server.port=8008" - "traefik.http.services.matrix.loadbalancer.server.port=8008"
litellm:
# Pinned deliberately, not :latest or :main-latest — litellm==1.82.7/1.82.8 on PyPI
# were compromised with credential-stealing malware in March 2026 (fixed within the
# hour, but a floating tag could still land on a bad release in the future). v1.98.0
# verified clean as of this writing.
image: ghcr.io/berriai/litellm:v1.98.0
container_name: litellm
restart: unless-stopped
environment:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
volumes:
# Absolute host path, NOT a repo-relative one — Portainer's git-stack deploy clones
# into its own directory (/data/compose/N/) whose checkout doesn't reliably persist
# for the container's runtime (see the act_runner config comment below for the same
# failure mode). An absolute path on the actual host filesystem always resolves the
# same way regardless of which tool ran `docker compose up`. Keep this local clone
# (/home/william/gitops-automation) pulled to latest when the config changes.
- /home/william/gitops-automation/litellm-config.yaml:/app/config.yaml:ro
command: ["--config", "/app/config.yaml", "--port", "4000"]
networks:
- web
# Internal only — no Traefik labels. No reason to expose an LLM gateway holding a
# master key and OAuth-forwarding config to the public internet.
hermes:
# Pinned to a specific dated release, not :latest — same rationale as litellm above.
image: nousresearch/hermes-agent:v2026.8.19
container_name: hermes
restart: unless-stopped
environment:
HERMES_UID: "1000"
HERMES_GID: "1000"
# Internal container address, not the public HTTPS one — same docker network as
# matrix-homeserver, no reason to round-trip through Traefik/TLS for this.
MATRIX_HOMESERVER: http://matrix-homeserver:8008
MATRIX_ACCESS_TOKEN: ${HERMES_MATRIX_ACCESS_TOKEN}
# Only you can trigger it; and only with an explicit @hermes mention in shared
# rooms (DMs to it would respond unprompted, per Hermes's own default behavior).
MATRIX_ALLOWED_USERS: ${MATRIX_HUMAN_USER_ID}
MATRIX_REQUIRE_MENTION: "true"
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
# Exposed on the internal network only (see claude-agent's LITELLM_BASE_URL-style
# usage pattern) — nothing publishes this port externally.
API_SERVER_ENABLED: "true"
API_SERVER_HOST: 0.0.0.0
API_SERVER_KEY: ${HERMES_API_SERVER_KEY}
volumes:
- /home/william/hermes-data:/opt/data
networks:
- web
claude-agent: claude-agent:
image: ${GITEA_REGISTRY_IMAGE} image: ${GITEA_REGISTRY_IMAGE}
depends_on:
- litellm
container_name: claude-agent container_name: claude-agent
restart: unless-stopped restart: unless-stopped
# Explicit vars, not env_file: .env — Portainer's git-based stack deploy clones the # Explicit vars, not env_file: .env — Portainer's git-based stack deploy clones the
@@ -53,8 +107,13 @@ services:
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL} MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL}
MATRIX_BOT_TOKEN: ${MATRIX_BOT_TOKEN} MATRIX_BOT_TOKEN: ${MATRIX_BOT_TOKEN}
MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID} MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} MATRIX_BOT_USER_ID: ${MATRIX_BOT_USER_ID}
OPENROUTER_DEFAULT_MODEL: ${OPENROUTER_DEFAULT_MODEL:-openai/gpt-4o-mini} 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: volumes:
- agent_workspace:/workspace - agent_workspace:/workspace
networks: networks:
+23
View File
@@ -0,0 +1,23 @@
model_list:
# General chat — OpenRouter's own auto-router picks the best underlying model per prompt.
- model_name: auto
litellm_params:
model: openrouter/openrouter/auto
api_key: os.environ/OPENROUTER_API_KEY
# Cheap/fast model used by the agent's own chat-vs-code-task classifier, not by users directly.
- model_name: router-classifier
litellm_params:
model: openrouter/openai/gpt-4o-mini
api_key: os.environ/OPENROUTER_API_KEY
# NOT included: a "claude-subscription" route forwarding the Claude Pro/Max OAuth token
# (from `claude setup-token`) through to Anthropic's raw API. Tested and confirmed
# non-functional — Anthropic returns a generic rate_limit_error for ANY direct API call
# using this token type outside the real Claude Code CLI client (reproduced with plain
# curl straight to api.anthropic.com, bypassing LiteLLM entirely, same result). The
# subscription token only works through the actual Claude Code CLI, which is what
# claude-agent already uses directly for code tasks — it was never routed through here.
general_settings:
master_key: os.environ/LITELLM_MASTER_KEY