764 lines
36 KiB
JavaScript
764 lines
36 KiB
JavaScript
import { ensureNativeBinary } from "./bootstrap-native.js";
|
|
// Ensure native binary is available before loading matrix-bot-sdk
|
|
// (opencode's npm doesn't run postinstall scripts)
|
|
ensureNativeBinary();
|
|
import fs from "fs";
|
|
import os from "os";
|
|
import path from "path";
|
|
import * as acorn from "acorn";
|
|
import * as acornWalk from "acorn-walk";
|
|
import { MatrixBotClient } from "./matrix-client.js";
|
|
import { SessionManager } from "./session-manager.js";
|
|
import { loadConfig } from "./config-loader.js";
|
|
import { setAppLog, log, logError } from "./logger.js";
|
|
// =============================================================================
|
|
// Plugin data directory — uses the same default as matrix-client
|
|
// =============================================================================
|
|
function ensureDataDir(dir) {
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
}
|
|
// =============================================================================
|
|
// Global mutex — prevents double startup (server + client both load plugins)
|
|
// =============================================================================
|
|
let LOCK_FILE = "";
|
|
function tryAcquireLock() {
|
|
try {
|
|
// Check if an existing lock is stale (process no longer running)
|
|
if (fs.existsSync(LOCK_FILE)) {
|
|
const existingPid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10);
|
|
if (existingPid && existingPid !== process.pid) {
|
|
try {
|
|
process.kill(existingPid, 0); // throws if process doesn't exist
|
|
}
|
|
catch {
|
|
// Process dead, remove stale lock
|
|
fs.unlinkSync(LOCK_FILE);
|
|
}
|
|
}
|
|
else if (existingPid === process.pid) {
|
|
// Same process re-entering (shouldn't happen, but be safe)
|
|
return false;
|
|
}
|
|
else {
|
|
return false; // Valid lock held by another process
|
|
}
|
|
}
|
|
fs.writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o644 });
|
|
return true;
|
|
}
|
|
catch {
|
|
return false;
|
|
}
|
|
}
|
|
function releaseLock() {
|
|
try {
|
|
fs.unlinkSync(LOCK_FILE);
|
|
}
|
|
catch {
|
|
// ignore
|
|
}
|
|
}
|
|
// =============================================================================
|
|
// Plugin Setup
|
|
// =============================================================================
|
|
async function runPlugin(ctx, options, dataDir) {
|
|
ensureDataDir(dataDir);
|
|
LOCK_FILE = `${dataDir}/lock`;
|
|
const directory = ctx?.location?.directory || process.cwd();
|
|
log(`Plugin setup started, directory=${directory}`);
|
|
// Acquire global lock — only one instance (server or client) starts the bot
|
|
const locked = tryAcquireLock();
|
|
if (!locked) {
|
|
log("Another instance already running (lock held), skipping bot startup");
|
|
return () => { };
|
|
}
|
|
const eventController = new AbortController();
|
|
let cleanup;
|
|
try {
|
|
log(`Configuration loaded`);
|
|
if (!options.homeserver) {
|
|
log("Plugin disabled (no homeserver configured)");
|
|
return () => { };
|
|
}
|
|
log(`Configuration loaded`);
|
|
const matrix = new MatrixBotClient(options);
|
|
const sessionManager = new SessionManager(options.rateLimitSeconds || 5);
|
|
// Track Matrix context per OpenCode session
|
|
const sessionContextMap = new Map();
|
|
// =============================================================================
|
|
// Pending prompt responses — maps sessionID -> { resolve, reject }
|
|
// =============================================================================
|
|
const pendingResponses = new Map();
|
|
const sessionsBySessionID = new Map();
|
|
/**
|
|
* Parse the CodeMode JS script and extract all tool calls.
|
|
* Uses acorn AST parser + walk — same approach as OpenCode's CodeMode interpreter.
|
|
* Does NOT execute the code, only parses and analyzes the AST.
|
|
*/
|
|
function parseCodeModeTools(code) {
|
|
const results = [];
|
|
const seen = new Set();
|
|
// Parse the code into an AST using acorn (same parser as CodeMode)
|
|
const ast = acorn.parse(code, {
|
|
ecmaVersion: "latest",
|
|
sourceType: "script",
|
|
allowReturnOutsideFunction: true,
|
|
allowAwaitOutsideFunction: true,
|
|
locations: true,
|
|
});
|
|
// Walk the AST and find all CallExpression nodes for tools.xxx.yyy(...)
|
|
acornWalk.simple(ast, {
|
|
CallExpression(node) {
|
|
// Check if callee is tools.<server>.<tool>(...)
|
|
const toolCall = extractToolCall(node);
|
|
if (!toolCall)
|
|
return;
|
|
const { path, args } = toolCall;
|
|
const name = path.join("_");
|
|
if (seen.has(name))
|
|
return;
|
|
seen.add(name);
|
|
// Evaluate the arguments to get their string representation
|
|
const argsStr = evaluateArgs(args);
|
|
results.push({ name, args: argsStr });
|
|
},
|
|
});
|
|
return results;
|
|
}
|
|
/**
|
|
* Check if a CallExpression is a tool call: tools.<server>.<tool>(...)
|
|
* Returns { path, args } or null.
|
|
*/
|
|
function extractToolCall(node) {
|
|
const callee = node.callee;
|
|
// Must be a MemberExpression: tools.xxx.yyy
|
|
if (callee.type !== "MemberExpression")
|
|
return null;
|
|
if (callee.computed)
|
|
return null;
|
|
const prop = callee.property;
|
|
if (prop.type !== "Identifier")
|
|
return null;
|
|
const toolName = prop.name;
|
|
// Object must be tools.xxx (nested MemberExpression) or just "tools"
|
|
let obj = callee.object;
|
|
const path = [];
|
|
// Walk up the nested MemberExpression chain
|
|
while (obj.type === "MemberExpression" && !obj.computed) {
|
|
const innerProp = obj.property;
|
|
if (innerProp.type !== "Identifier")
|
|
return null;
|
|
path.unshift(innerProp.name);
|
|
obj = obj.object;
|
|
}
|
|
// The base must be the identifier "tools"
|
|
if (obj.type !== "Identifier" || obj.name !== "tools")
|
|
return null;
|
|
path.unshift(toolName);
|
|
return { path, args: (node.arguments ?? []) };
|
|
}
|
|
/**
|
|
* Evaluate arguments to a string representation.
|
|
* Handles literals, identifiers, objects, arrays, and template literals.
|
|
* Matches how CodeMode's interpreter evaluates expression values.
|
|
*/
|
|
function evaluateArgs(args) {
|
|
if (!args || args.length === 0)
|
|
return "";
|
|
const values = [];
|
|
for (const arg of args) {
|
|
values.push(evaluateExpression(arg));
|
|
}
|
|
return values.join(", ");
|
|
}
|
|
/**
|
|
* Evaluate an AST expression node to its string value.
|
|
* Handles the expression types used in CodeMode tool calls.
|
|
*/
|
|
function evaluateExpression(node) {
|
|
switch (node.type) {
|
|
// Literals
|
|
case "Literal":
|
|
return String(node.value);
|
|
// String/number/boolean/null/regexp literals
|
|
case "Identifier": {
|
|
const name = node.name;
|
|
// Known globals with known values
|
|
if (name === "undefined")
|
|
return "undefined";
|
|
if (name === "true")
|
|
return "true";
|
|
if (name === "false")
|
|
return "false";
|
|
if (name === "null")
|
|
return "null";
|
|
// For variables, show the variable name
|
|
return name;
|
|
}
|
|
// Object literals: { key: value, ... }
|
|
case "ObjectExpression": {
|
|
const parts = [];
|
|
for (const prop of node.properties ?? []) {
|
|
if (prop.type === "Property") {
|
|
const key = prop.key.type === "Identifier" ? prop.key.name : String(prop.key.value);
|
|
const value = evaluateExpression(prop.value);
|
|
parts.push(`${key}: ${value}`);
|
|
}
|
|
}
|
|
return `{ ${parts.join(", ")} }`;
|
|
}
|
|
// Array literals: [value1, value2, ...]
|
|
case "ArrayExpression": {
|
|
const elements = (node.elements ?? []).map((el) => el ? evaluateExpression(el) : "");
|
|
return `[${elements.join(", ")}]`;
|
|
}
|
|
// Template literals: `text ${expr} text`
|
|
case "TemplateLiteral": {
|
|
let result = "`";
|
|
const quasis = node.quasis ?? [];
|
|
const expressions = node.expressions ?? [];
|
|
for (let i = 0; i < quasis.length; i++) {
|
|
result += quasis[i]?.value?.raw ?? "";
|
|
if (expressions[i]) {
|
|
result += "${" + evaluateExpression(expressions[i]) + "}";
|
|
}
|
|
}
|
|
result += "`";
|
|
return result;
|
|
}
|
|
// Binary expressions: a + b, a === b, etc.
|
|
case "BinaryExpression": {
|
|
const left = evaluateExpression(node.left);
|
|
const right = evaluateExpression(node.right);
|
|
return `${left} ${node.operator} ${right}`;
|
|
}
|
|
// Conditional: a ? b : c
|
|
case "ConditionalExpression": {
|
|
return `${evaluateExpression(node.test)} ? ${evaluateExpression(node.consequent)} : ${evaluateExpression(node.alternate)}`;
|
|
}
|
|
// Function expressions (arrow functions, etc.)
|
|
case "ArrowFunctionExpression":
|
|
case "FunctionExpression":
|
|
return "function";
|
|
// Call expressions: other().method()
|
|
case "CallExpression": {
|
|
if (node.callee?.type === "MemberExpression") {
|
|
const member = node.callee;
|
|
if (member.object?.type === "Identifier") {
|
|
return `${member.object.name}.${member.property?.name}(${evaluateArgs(node.arguments ?? [])})`;
|
|
}
|
|
}
|
|
return "call";
|
|
}
|
|
// Unary expressions: !a, -b, typeof c
|
|
case "UnaryExpression": {
|
|
const arg = evaluateExpression(node.argument);
|
|
return `${node.operator}${arg}`;
|
|
}
|
|
// Logical expressions: a && b, a || b
|
|
case "LogicalExpression": {
|
|
return `${evaluateExpression(node.left)} ${node.operator} ${evaluateExpression(node.right)}`;
|
|
}
|
|
// Await expressions
|
|
case "AwaitExpression": {
|
|
return `await ${evaluateExpression(node.argument)}`;
|
|
}
|
|
// Spread: ...arr
|
|
case "SpreadElement": {
|
|
return `...${evaluateExpression(node.argument)}`;
|
|
}
|
|
// Default: show "(expr)"
|
|
default:
|
|
return `(expr:${node.type})`;
|
|
}
|
|
}
|
|
// =============================================================================
|
|
// Event subscription — collects step data and sends messages per step
|
|
// =============================================================================
|
|
void (async () => {
|
|
try {
|
|
for await (const event of ctx.event.subscribe({ signal: eventController.signal })) {
|
|
let eventData = event;
|
|
if (typeof event === "string") {
|
|
try {
|
|
eventData = JSON.parse(event);
|
|
}
|
|
catch {
|
|
continue;
|
|
}
|
|
}
|
|
const eventType = eventData?.type || "";
|
|
const sessionId = eventData?.data?.sessionID || "";
|
|
const assistantMsgID = eventData?.data?.assistantMessageID || "";
|
|
// Skip all events after session is done
|
|
const state = sessionsBySessionID.get(sessionId);
|
|
if (state?.done)
|
|
continue;
|
|
// ── Step lifecycle ──────────────────────────────────────────────
|
|
if (eventType === "session.step.started") {
|
|
// SessionState created in message handler after prompt
|
|
if (!sessionsBySessionID.has(sessionId)) {
|
|
log(`step.started for unknown session ${sessionId}`);
|
|
continue;
|
|
}
|
|
const state = sessionsBySessionID.get(sessionId);
|
|
state.currentStepID = assistantMsgID;
|
|
state.steps.set(assistantMsgID, {
|
|
assistantMessageID: assistantMsgID,
|
|
reasoning: "",
|
|
tools: [],
|
|
text: "",
|
|
});
|
|
continue;
|
|
}
|
|
// ── Reasoning ───────────────────────────────────────────────────
|
|
if (eventType === "session.reasoning.delta" && assistantMsgID) {
|
|
const state = sessionsBySessionID.get(sessionId);
|
|
const step = state?.steps.get(assistantMsgID);
|
|
if (step)
|
|
step.reasoning += eventData?.data?.delta || "";
|
|
continue;
|
|
}
|
|
if (eventType === "session.reasoning.ended" && assistantMsgID) {
|
|
const state = sessionsBySessionID.get(sessionId);
|
|
const step = state?.steps.get(assistantMsgID);
|
|
if (step)
|
|
step.reasoning = eventData?.data?.text || "";
|
|
continue;
|
|
}
|
|
// ── Tool calls ──────────────────────────────────────────────────
|
|
if (eventType === "session.tool.input.started" && assistantMsgID) {
|
|
const toolID = eventData?.data?.id || "";
|
|
const name = eventData?.data?.name || "";
|
|
const state = sessionsBySessionID.get(sessionId);
|
|
const step = state?.steps.get(assistantMsgID);
|
|
if (step && toolID) {
|
|
step.tools.push({ toolID, name, args: "", result: "", failed: false });
|
|
}
|
|
continue;
|
|
}
|
|
if (eventType === "session.tool.input.ended" && assistantMsgID) {
|
|
const toolID = eventData?.data?.id || "";
|
|
const args = eventData?.data?.text || "";
|
|
const state = sessionsBySessionID.get(sessionId);
|
|
const step = state?.steps.get(assistantMsgID);
|
|
if (step && toolID) {
|
|
const tool = step.tools.find(t => t.toolID === toolID);
|
|
if (tool)
|
|
tool.args = args;
|
|
}
|
|
// Parse CodeMode execute tool — extract child tool calls from JS code
|
|
if (toolID && step && args) {
|
|
let parsedArgs;
|
|
try {
|
|
parsedArgs = JSON.parse(args);
|
|
}
|
|
catch {
|
|
continue;
|
|
}
|
|
const code = parsedArgs?.code || "";
|
|
if (code) {
|
|
const childTools = parseCodeModeTools(code);
|
|
for (const child of childTools) {
|
|
if (!step.tools.find(t => t.name === child.name)) {
|
|
step.tools.push({
|
|
toolID: `${toolID}/${child.name}`,
|
|
name: child.name,
|
|
args: child.args,
|
|
result: "",
|
|
failed: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if (eventType === "session.tool.success" && assistantMsgID) {
|
|
const toolID = eventData?.data?.id || "";
|
|
const content = eventData?.data?.content;
|
|
let result = "";
|
|
if (typeof content === "string") {
|
|
result = content;
|
|
}
|
|
else if (Array.isArray(content)) {
|
|
for (const part of content) {
|
|
if (part?.type === "text" && part?.text)
|
|
result += part.text;
|
|
}
|
|
}
|
|
const state = sessionsBySessionID.get(sessionId);
|
|
const step = state?.steps.get(assistantMsgID);
|
|
if (step && toolID) {
|
|
const tool = step.tools.find(t => t.toolID === toolID);
|
|
if (tool)
|
|
tool.result = result;
|
|
}
|
|
continue;
|
|
}
|
|
if (eventType === "session.tool.failed" && assistantMsgID) {
|
|
const toolID = eventData?.data?.id || "";
|
|
const state = sessionsBySessionID.get(sessionId);
|
|
const step = state?.steps.get(assistantMsgID);
|
|
if (step && toolID) {
|
|
const tool = step.tools.find(t => t.toolID === toolID);
|
|
if (tool) {
|
|
tool.failed = true;
|
|
tool.result = String(eventData?.data?.error || "Unknown error");
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
// ── Text output ────────────────────────────────────────────────
|
|
if (eventType === "session.text.ended" && assistantMsgID) {
|
|
const state = sessionsBySessionID.get(sessionId);
|
|
// Skip text events after final step
|
|
if (state?.done)
|
|
continue;
|
|
const step = state?.steps.get(assistantMsgID);
|
|
if (step) {
|
|
step.text = eventData?.data?.text || "";
|
|
if (state)
|
|
state.finalResponse = step.text;
|
|
}
|
|
continue;
|
|
}
|
|
// ── Step ended — send message for this step ────────────────────
|
|
if (eventType === "session.step.ended" && sessionId && assistantMsgID) {
|
|
const state = sessionsBySessionID.get(sessionId);
|
|
// Skip steps after final (finish:stop) — they are re-projections/duplicates
|
|
if (state?.done)
|
|
continue;
|
|
const step = state?.steps.get(assistantMsgID);
|
|
const finish = eventData?.data?.finish || "";
|
|
if (step && state) {
|
|
const msg = formatStepMessage(step);
|
|
await sendMessageToMatrix(state.context, msg);
|
|
log(`Step ${step.tools.length} tool(s), ${step.text.length} chars text → sent to ${state.context.roomId}`);
|
|
}
|
|
// If this is the final step, mark done and resolve the pending promise
|
|
if (finish === "stop" && state) {
|
|
state.done = true;
|
|
const pending = pendingResponses.get(sessionId);
|
|
if (pending && state.finalResponse) {
|
|
pendingResponses.delete(sessionId);
|
|
pending.resolve(state.finalResponse);
|
|
}
|
|
sessionsBySessionID.delete(sessionId);
|
|
}
|
|
continue;
|
|
}
|
|
// ── Execution errors ───────────────────────────────────────────
|
|
if (eventType === "session.execution.failed" && sessionId) {
|
|
const pending = pendingResponses.get(sessionId);
|
|
if (pending) {
|
|
pendingResponses.delete(sessionId);
|
|
pending.reject(new Error("Session execution failed"));
|
|
}
|
|
sessionsBySessionID.delete(sessionId);
|
|
continue;
|
|
}
|
|
if (eventType === "session.execution.interrupted" && sessionId) {
|
|
const pending = pendingResponses.get(sessionId);
|
|
if (pending) {
|
|
pendingResponses.delete(sessionId);
|
|
pending.reject(new Error("Session execution interrupted"));
|
|
}
|
|
sessionsBySessionID.delete(sessionId);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
catch (err) {
|
|
if (String(err).includes("AbortError") || err.name === "AbortError") {
|
|
// expected during cleanup
|
|
}
|
|
else {
|
|
log(`Event subscription error: ${String(err)}`);
|
|
}
|
|
}
|
|
})();
|
|
// =============================================================================
|
|
// Format step into Matrix message
|
|
// =============================================================================
|
|
function formatStepMessage(step) {
|
|
const parts = [];
|
|
// Reasoning in spoiler
|
|
if (step.reasoning) {
|
|
parts.push(`<details>\n<summary>Thinking</summary>\n\n${step.reasoning}\n\n</details>`);
|
|
}
|
|
// Tool calls
|
|
if (step.tools.length > 0) {
|
|
// Check if any tool is an execute with child tool calls
|
|
const executeTool = step.tools.find((t) => t.name === "execute" && t.toolID.includes("/"));
|
|
const childTools = executeTool
|
|
? step.tools.filter((t) => t.toolID.startsWith(executeTool.toolID + "/"))
|
|
: [];
|
|
if (childTools.length > 0) {
|
|
// Execute has child tool calls — show children, then execute result
|
|
for (const child of childTools) {
|
|
parts.push(`<details>\n<summary>🔧 ${child.name}</summary>\n\n`);
|
|
parts.push(`<b>Args:</b>\n\`\`\`\n${child.args}\n\`\`\`\n\n`);
|
|
if (child.failed) {
|
|
parts.push(`<b>Failed:</b> ${child.result}\n\n`);
|
|
}
|
|
else if (child.result) {
|
|
parts.push(`<b>Result:</b>\n\`\`\`\n${child.result}\n\`\`\`\n\n`);
|
|
}
|
|
parts.push(`</details>`);
|
|
}
|
|
// Execute result as a separate spoiler (like a regular tool result)
|
|
if (executeTool?.result) {
|
|
parts.push(`<details>\n<summary>🔧 execute</summary>\n\n`);
|
|
parts.push(`<b>Result:</b>\n\`\`\`\n${executeTool.result}\n\`\`\`\n\n`);
|
|
parts.push(`</details>`);
|
|
}
|
|
}
|
|
else {
|
|
// No child tool calls — show tools normally (including execute as-is)
|
|
for (const tool of step.tools) {
|
|
parts.push(`<details>\n<summary>🔧 ${tool.name}</summary>\n\n`);
|
|
parts.push(`<b>Args:</b>\n\`\`\`\n${tool.args}\n\`\`\`\n\n`);
|
|
if (tool.failed) {
|
|
parts.push(`<b>Failed:</b> ${tool.result}\n\n`);
|
|
}
|
|
else {
|
|
parts.push(`<b>Result:</b>\n\`\`\`\n${tool.result}\n\`\`\`\n\n`);
|
|
}
|
|
parts.push(`</details>`);
|
|
}
|
|
}
|
|
}
|
|
// Separator before text
|
|
if (step.text) {
|
|
if (parts.length > 0)
|
|
parts.push("\n---\n");
|
|
parts.push(step.text);
|
|
}
|
|
return parts.join("\n");
|
|
}
|
|
// =============================================================================
|
|
// Send message to Matrix (reuses existing sendReply/sendNotice)
|
|
// =============================================================================
|
|
async function sendMessageToMatrix(context, text) {
|
|
try {
|
|
await matrix.sendReply(context, text);
|
|
}
|
|
catch (err) {
|
|
log(`Failed to send step message: ${String(err)}`);
|
|
await matrix.sendNotice(context, `Error sending: ${String(err).slice(0, 200)}`);
|
|
}
|
|
}
|
|
// Message handler
|
|
matrix.on(async (data) => {
|
|
const { context, query, sender, roomId, eventId, timestamp } = data;
|
|
// Check rate limit
|
|
if (sessionManager.isRateLimited(sender)) {
|
|
await matrix.sendNotice(context, "Rate limited. Please wait a moment.");
|
|
return;
|
|
}
|
|
// Check for active query
|
|
if (sessionManager.hasActiveQuery(roomId, context.replyThreadRootId || context.eventId)) {
|
|
await matrix.sendNotice(context, "A request is already running in this thread. Please wait.");
|
|
return;
|
|
}
|
|
const abortFn = () => { };
|
|
const releaseQuery = sessionManager.markQueryActive(roomId, context.replyThreadRootId || context.eventId, abortFn);
|
|
const threadRootId = context.replyThreadRootId || context.eventId;
|
|
const session = sessionManager.getOrCreateSession(roomId, threadRootId, eventId);
|
|
session.isActive = true;
|
|
session.messageCount++;
|
|
session.inputChars += query.length;
|
|
session.lastEventIds.set(context.replyThreadRootId || context.eventId, eventId);
|
|
session.lastActivity = Date.now();
|
|
let opencodeSessionId = session.opencodeSessionId;
|
|
// If we have a session ID from the map, verify it exists in OpenCode
|
|
if (opencodeSessionId) {
|
|
try {
|
|
await ctx.session.get({ sessionID: opencodeSessionId });
|
|
log(`Using existing OpenCode session ${opencodeSessionId}`);
|
|
}
|
|
catch (getErr) {
|
|
log(`Session ${opencodeSessionId} not found in OpenCode, creating new one`);
|
|
opencodeSessionId = "";
|
|
}
|
|
}
|
|
// If no session ID yet, create one
|
|
if (!opencodeSessionId) {
|
|
log(`Creating new OpenCode session for ${roomId}:${threadRootId}`);
|
|
try {
|
|
const createResult = await ctx.session.create({ title: `Matrix: ${roomId.slice(0, 30)}...` });
|
|
opencodeSessionId = createResult?.id || createResult?.data?.id;
|
|
log(`Created OpenCode session ${opencodeSessionId} for ${roomId}:${threadRootId}`);
|
|
session.opencodeSessionId = opencodeSessionId;
|
|
sessionManager.saveSession(roomId, threadRootId, opencodeSessionId);
|
|
sessionContextMap.set(opencodeSessionId, { roomId, threadRootId, context });
|
|
}
|
|
catch (createErr) {
|
|
log(`Failed to create session: ${String(createErr)}`);
|
|
session.isActive = false;
|
|
session.lastActivity = Date.now();
|
|
releaseQuery();
|
|
await matrix.sendNotice(context, "Failed to create session. Check logs.");
|
|
return;
|
|
}
|
|
}
|
|
try {
|
|
// Trigger prompt — returns admission receipt, not response text
|
|
await ctx.session.prompt({
|
|
sessionID: opencodeSessionId,
|
|
text: query,
|
|
});
|
|
log(`Prompt sent to session ${opencodeSessionId}`);
|
|
// Create session state for step tracking
|
|
sessionsBySessionID.set(opencodeSessionId, {
|
|
steps: new Map(),
|
|
currentStepID: null,
|
|
context,
|
|
finalResponse: undefined,
|
|
done: false,
|
|
});
|
|
// Wait for response text from event subscription
|
|
// Resolved by: session.step.ended (finish: "stop")
|
|
// Rejected by: session.execution.failed / session.execution.interrupted
|
|
const responsePromise = new Promise((resolve, reject) => {
|
|
pendingResponses.set(opencodeSessionId, { resolve, reject });
|
|
});
|
|
const responseText = await responsePromise;
|
|
session.outputChars += responseText.length;
|
|
// Step messages already sent by event subscription — no double send
|
|
}
|
|
catch (err) {
|
|
log(`Error processing message: ${String(err)}`);
|
|
const errMsg = err?.response?.error?.message || String(err);
|
|
await matrix.sendNotice(context, `Error: ${errMsg.slice(0, 200)}`);
|
|
}
|
|
finally {
|
|
session.isActive = false;
|
|
session.lastActivity = Date.now();
|
|
sessionContextMap.delete(opencodeSessionId);
|
|
releaseQuery();
|
|
}
|
|
});
|
|
// Bridge commands
|
|
async function handleBridgeCommand(context, cmdName, sendFn) {
|
|
const threadRootId = context.threadRootId || context.replyThreadRootId || context.eventId;
|
|
const key = sessionManager.getSessionKey(context.roomId, threadRootId);
|
|
const sess = sessionManager.sessions.get(key);
|
|
switch (cmdName) {
|
|
case "status": {
|
|
if (!sess) {
|
|
await sendFn("No active session.");
|
|
return;
|
|
}
|
|
const age = Math.round((Date.now() - sess.lastActivity) / 60000);
|
|
const msg = `Session status:\n- Messages: ${sess.messageCount}\n- Age: ~${age} min ago\n- Input: ~${sess.inputChars} chars\n- Output: ~${sess.outputChars} chars`;
|
|
await sendFn(msg);
|
|
break;
|
|
}
|
|
case "clear":
|
|
case "reset": {
|
|
sessionManager.removeSession(context.roomId, threadRootId);
|
|
await sendFn("Session cleared. Next message will start a fresh session.");
|
|
break;
|
|
}
|
|
case "help":
|
|
case "h": {
|
|
const trigger = options.triggerPatterns?.[0] || "!oc ";
|
|
const botName = options.botName || "opencode";
|
|
const msg = [
|
|
`${botName} - OpenCode Matrix Plugin`,
|
|
"",
|
|
"Bridge commands:",
|
|
`- /h or /help - Show this help`,
|
|
`- /status - Show current chat session info`,
|
|
`- /clear or /reset - Delete current session history`,
|
|
"",
|
|
`Usage: ${trigger} <your question>`,
|
|
`Or @${botName} <your question>`,
|
|
`Or reply in a thread`,
|
|
].join("\n");
|
|
await sendFn(msg);
|
|
break;
|
|
}
|
|
default:
|
|
await sendFn(`Unknown command: ${cmdName}. Try /help`);
|
|
}
|
|
}
|
|
// Override the matrix.on handler to also process commands
|
|
const originalListener = matrix["listeners"][0];
|
|
matrix["listeners"] = [];
|
|
matrix.on(async (data) => {
|
|
const { context, query, sender, eventId } = data;
|
|
// Handle bridge commands
|
|
if (query.startsWith("/")) {
|
|
const cmdName = query.slice(1).split(" ")[0].toLowerCase();
|
|
const bridgeCommands = ["status", "clear", "reset", "help", "h"];
|
|
if (bridgeCommands.includes(cmdName)) {
|
|
const sendFn = (text) => matrix.sendNotice(context, text);
|
|
await handleBridgeCommand(context, cmdName, sendFn);
|
|
return;
|
|
}
|
|
}
|
|
// Forward to original handler
|
|
if (originalListener) {
|
|
await originalListener(data);
|
|
}
|
|
});
|
|
// V2 Storage
|
|
if (ctx?.storage) {
|
|
await ctx.storage.set("matrix/plugin_version", "1.0.0");
|
|
await ctx.storage.set("matrix/started_at", new Date().toISOString());
|
|
}
|
|
// Start Matrix client
|
|
await matrix.start();
|
|
log(`Plugin setup completed successfully`);
|
|
cleanup = () => {
|
|
log("Plugin cleaning up...");
|
|
eventController.abort();
|
|
matrix.stop();
|
|
// Don't release lock — keep it on disk so client always sees it
|
|
};
|
|
return cleanup;
|
|
}
|
|
catch (err) {
|
|
logError(`Setup error: ${String(err)}`);
|
|
releaseLock();
|
|
return () => { };
|
|
}
|
|
}
|
|
// =============================================================================
|
|
// V2 Setup Function
|
|
// =============================================================================
|
|
async function v2Setup(ctx) {
|
|
const rawStorage = ctx.options?.storagePath;
|
|
const dataDir = rawStorage
|
|
? path.join(rawStorage, "plugin-data")
|
|
: path.join(os.homedir(), ".local", "share", "opencode-matrix-bot", "plugin-data");
|
|
ensureDataDir(dataDir);
|
|
// Try to use OpenCode's logging API
|
|
if (ctx?.app?.log) {
|
|
setAppLog(ctx.app.log.bind(ctx.app));
|
|
}
|
|
try {
|
|
const { options } = await loadConfig(ctx.options);
|
|
return await runPlugin(ctx, options, dataDir);
|
|
}
|
|
catch (err) {
|
|
logError(`V2 setup error: ${String(err)}`);
|
|
return () => { };
|
|
}
|
|
}
|
|
// =============================================================================
|
|
// Export
|
|
// =============================================================================
|
|
export default {
|
|
id: "matrix-plugin",
|
|
async setup(ctx) {
|
|
log("Using V2 path (full context)");
|
|
return await v2Setup(ctx);
|
|
},
|
|
};
|