Add !ai Matrix command for querying other models via OpenRouter
build-agent / build-and-push (push) Successful in 7s
build-agent / build-and-push (push) Successful in 7s
This commit is contained in:
@@ -26,6 +26,10 @@ MATRIX_HOMESERVER_URL=https://matrix.apps.williamturner.eu
|
|||||||
MATRIX_BOT_TOKEN=
|
MATRIX_BOT_TOKEN=
|
||||||
MATRIX_CONTROL_ROOM_ID=
|
MATRIX_CONTROL_ROOM_ID=
|
||||||
|
|
||||||
|
# --- openrouter (the "!ai" chat command — other models, not the coding agent) ---
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
OPENROUTER_DEFAULT_MODEL=openai/gpt-4o-mini
|
||||||
|
|
||||||
# --- portainer (GitOps redeploy) ---
|
# --- portainer (GitOps redeploy) ---
|
||||||
PORTAINER_STACK_WEBHOOK_URL=
|
PORTAINER_STACK_WEBHOOK_URL=
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ Three things this gives you:
|
|||||||
(Gitea Actions) and redeploys the stack (Portainer webhook).
|
(Gitea Actions) and redeploys the stack (Portainer webhook).
|
||||||
- **Chat-driven coding agent**: `!claude owner/repo <instruction>` in the Matrix control
|
- **Chat-driven coding agent**: `!claude owner/repo <instruction>` in the Matrix control
|
||||||
room clones the repo, runs Claude Code, and opens a PR with the result.
|
room clones the repo, runs Claude Code, and opens a PR with the result.
|
||||||
|
- **Ask other models**: `!ai <prompt>` (default model) or `!ai provider/model <prompt>`
|
||||||
|
(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.
|
||||||
|
|
||||||
Nothing here auto-merges. Every path stops at a comment or an open PR — a human clicks merge.
|
Nothing here auto-merges. Every path stops at a comment or an open PR — a human clicks merge.
|
||||||
|
|
||||||
|
|||||||
+39
-5
@@ -1,19 +1,42 @@
|
|||||||
import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin } from "matrix-bot-sdk";
|
import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin } from "matrix-bot-sdk";
|
||||||
import { runChatTask } from "./runner.js";
|
import { runChatTask } from "./runner.js";
|
||||||
|
import { askOpenRouter, DEFAULT_MODEL } from "./openrouter.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;
|
||||||
|
|
||||||
|
const MAX_REPLY_LENGTH = 4000;
|
||||||
|
|
||||||
// "!claude owner/repo do the thing" — everything after the repo slug is the instruction.
|
// "!claude owner/repo do the thing" — everything after the repo slug is the instruction.
|
||||||
function parseCommand(text) {
|
function parseClaudeCommand(text) {
|
||||||
const match = text.match(/^!claude\s+([^\s/]+\/[^\s/]+)\s+(.+)$/s);
|
const match = text.match(/^!claude\s+([^\s/]+\/[^\s/]+)\s+(.+)$/s);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const [, repoFullName, instruction] = match;
|
const [, repoFullName, instruction] = match;
|
||||||
return { repoFullName, instruction: instruction.trim() };
|
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) {
|
||||||
|
if (text.length <= MAX_REPLY_LENGTH) return text;
|
||||||
|
return `${text.slice(0, MAX_REPLY_LENGTH)}\n\n[truncated]`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function startMatrixBot() {
|
export async function startMatrixBot() {
|
||||||
if (!HOMESERVER_URL || !ACCESS_TOKEN || !CONTROL_ROOM_ID) {
|
if (!HOMESERVER_URL || !ACCESS_TOKEN || !CONTROL_ROOM_ID) {
|
||||||
console.warn("Matrix env vars not set — skipping bot startup");
|
console.warn("Matrix env vars not set — skipping bot startup");
|
||||||
@@ -27,14 +50,25 @@ 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
|
// 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
|
// 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 ("Working on it...", "Opened PR:...",
|
// to filter out its own messages — its replies never match either command pattern
|
||||||
// "Failed:...") never match the !claude command pattern below, so they're ignored
|
// below, so they're ignored the same as any other non-command message.
|
||||||
// by parseCommand() returning null, same as any other non-command message.
|
|
||||||
if (roomId !== CONTROL_ROOM_ID) return;
|
if (roomId !== CONTROL_ROOM_ID) return;
|
||||||
const body = event.content?.body;
|
const body = event.content?.body;
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
|
|
||||||
const cmd = parseCommand(body);
|
const aiCmd = parseAiCommand(body);
|
||||||
|
if (aiCmd) {
|
||||||
|
try {
|
||||||
|
const reply = await askOpenRouter(aiCmd.model, aiCmd.prompt);
|
||||||
|
await client.sendText(roomId, `[${aiCmd.model}] ${truncate(reply)}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("openrouter query failed", err);
|
||||||
|
await client.sendText(roomId, `Failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd = parseClaudeCommand(body);
|
||||||
if (!cmd) return;
|
if (!cmd) return;
|
||||||
|
|
||||||
const [owner, repo] = cmd.repoFullName.split("/");
|
const [owner, repo] = cmd.repoFullName.split("/");
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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 }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -53,6 +53,8 @@ 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}
|
||||||
|
OPENROUTER_DEFAULT_MODEL: ${OPENROUTER_DEFAULT_MODEL:-openai/gpt-4o-mini}
|
||||||
volumes:
|
volumes:
|
||||||
- agent_workspace:/workspace
|
- agent_workspace:/workspace
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
Reference in New Issue
Block a user