// All imports are dynamic — loaded AFTER ensureNativeBinary() completes // ES modules evaluate ALL static imports before executing any code, // so we cannot have any static imports that transitively load matrix-bot-sdk // ============================================================================= // Plugin data directory // ============================================================================= function ensureDataDir(dir) { const fs = require("fs"); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } // ============================================================================= // Global mutex // ============================================================================= let LOCK_FILE = ""; function tryAcquireLock() { const fs = require("fs"); try { 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); } catch { fs.unlinkSync(LOCK_FILE); } } else if (existingPid === process.pid) { return false; } else { return false; } } fs.writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o644 }); return true; } catch { return false; } } function releaseLock() { const fs = require("fs"); try { fs.unlinkSync(LOCK_FILE); } catch { // ignore } } // ============================================================================= // Plugin Setup // ============================================================================= async function runPlugin(ctx, options, dataDir, log, logError) { // Dynamic imports — loaded AFTER ensureNativeBinary() completes const { MatrixBotClient } = await import("./matrix-client.js"); const { SessionManager } = await import("./session-manager.js"); const { loadConfig } = await import("./config-loader.js"); ensureDataDir(dataDir); LOCK_FILE = `${dataDir}/lock`; const directory = ctx?.location?.directory || process.cwd(); log(`Plugin setup started, directory=${directory}`); // Acquire global lock 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 // ============================================================================= const pendingResponses = new Map(); const sessionsBySessionID = new Map(); async function parseCodeModeTools(code) { const results = []; const seen = new Set(); const acorn = await import("acorn"); const acornWalk = await import("acorn-walk"); const ast = acorn.parse(code, { ecmaVersion: "latest", sourceType: "script", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true, locations: true, }); acornWalk.simple(ast, { CallExpression(node) { const toolCall = extractToolCall(node); if (!toolCall) return; const { path, args } = toolCall; const name = path.join("_"); if (seen.has(name)) return; seen.add(name); const argsStr = evaluateArgs(args); results.push({ name, args: argsStr }); }, }); return results; } function extractToolCall(node) { const callee = node.callee; 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; let obj = callee.object; const path = []; while (obj.type === "MemberExpression" && !obj.computed) { const innerProp = obj.property; if (innerProp.type !== "Identifier") return null; path.unshift(innerProp.name); obj = obj.object; } if (obj.type !== "Identifier" || obj.name !== "tools") return null; path.unshift(toolName); return { path, args: (node.arguments ?? []) }; } function evaluateArgs(args) { if (!args || args.length === 0) return ""; const values = []; for (const arg of args) { values.push(evaluateExpression(arg)); } return values.join(", "); } function evaluateExpression(node) { switch (node.type) { case "Literal": return String(node.value); case "Identifier": { const name = node.name; if (name === "undefined") return "undefined"; if (name === "true") return "true"; if (name === "false") return "false"; if (name === "null") return "null"; return name; } 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(", ")} }`; } case "ArrayExpression": { const elements = (node.elements ?? []).map((el) => el ? evaluateExpression(el) : ""); return `[${elements.join(", ")}]`; } 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; } case "BinaryExpression": { const left = evaluateExpression(node.left); const right = evaluateExpression(node.right); return `${left} ${node.operator} ${right}`; } case "ConditionalExpression": { return `${evaluateExpression(node.test)} ? ${evaluateExpression(node.consequent)} : ${evaluateExpression(node.alternate)}`; } case "ArrowFunctionExpression": case "FunctionExpression": return "function"; 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"; } case "UnaryExpression": { const arg = evaluateExpression(node.argument); return `${node.operator}${arg}`; } case "LogicalExpression": { return `${evaluateExpression(node.left)} ${node.operator} ${evaluateExpression(node.right)}`; } case "AwaitExpression": { return `await ${evaluateExpression(node.argument)}`; } case "SpreadElement": { return `...${evaluateExpression(node.argument)}`; } default: return `(expr:${node.type})`; } } // ============================================================================= // Event subscription // ============================================================================= 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 || ""; const state = sessionsBySessionID.get(sessionId); if (state?.done) continue; if (eventType === "session.step.started") { 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; } 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; } 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; } if (toolID && step && args) { let parsedArgs; try { parsedArgs = JSON.parse(args); } catch { continue; } const code = parsedArgs?.code || ""; if (code) { const childTools = await 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; } if (eventType === "session.text.ended" && assistantMsgID) { const state = sessionsBySessionID.get(sessionId); if (state?.done) continue; const step = state?.steps.get(assistantMsgID); if (step) { step.text = eventData?.data?.text || ""; if (state) state.finalResponse = step.text; } continue; } if (eventType === "session.step.ended" && sessionId && assistantMsgID) { const state = sessionsBySessionID.get(sessionId); 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 (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; } 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 = []; if (step.reasoning) { parts.push(`
\nThinking\n\n${step.reasoning}\n\n
`); } if (step.tools.length > 0) { 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) { for (const child of childTools) { parts.push(`
\n🔧 ${child.name}\n\n`); parts.push(`Args:\n\`\`\`\n${child.args}\n\`\`\`\n\n`); if (child.failed) { parts.push(`Failed: ${child.result}\n\n`); } else if (child.result) { parts.push(`Result:\n\`\`\`\n${child.result}\n\`\`\`\n\n`); } parts.push(`
`); } if (executeTool?.result) { parts.push(`
\n🔧 execute\n\n`); parts.push(`Result:\n\`\`\`\n${executeTool.result}\n\`\`\`\n\n`); parts.push(`
`); } } else { for (const tool of step.tools) { parts.push(`
\n🔧 ${tool.name}\n\n`); parts.push(`Args:\n\`\`\`\n${tool.args}\n\`\`\`\n\n`); if (tool.failed) { parts.push(`Failed: ${tool.result}\n\n`); } else { parts.push(`Result:\n\`\`\`\n${tool.result}\n\`\`\`\n\n`); } parts.push(`
`); } } } if (step.text) { if (parts.length > 0) parts.push("\n---\n"); parts.push(step.text); } return parts.join("\n"); } // ============================================================================= // Send message to Matrix // ============================================================================= 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; if (sessionManager.isRateLimited(sender)) { await matrix.sendNotice(context, "Rate limited. Please wait a moment."); return; } 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 (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 (!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 { await ctx.session.prompt({ sessionID: opencodeSessionId, text: query, }); log(`Prompt sent to session ${opencodeSessionId}`); sessionsBySessionID.set(opencodeSessionId, { steps: new Map(), currentStepID: null, context, finalResponse: undefined, done: false, }); const responsePromise = new Promise((resolve, reject) => { pendingResponses.set(opencodeSessionId, { resolve, reject }); }); const responseText = await responsePromise; session.outputChars += responseText.length; } 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} `, `Or @${botName} `, `Or reply in a thread`, ].join("\n"); await sendFn(msg); break; } default: await sendFn(`Unknown command: ${cmdName}. Try /help`); } } // Override the matrix.on handler const originalListener = matrix["listeners"][0]; matrix["listeners"] = []; matrix.on(async (data) => { const { context, query, sender, eventId } = data; 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; } } 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(); }; return cleanup; } catch (err) { logError(`Setup error: ${String(err)}`); releaseLock(); return () => { }; } } // ============================================================================= // V2 Setup Function // ============================================================================= async function v2Setup(ctx) { // 1. Download native binary BEFORE any matrix imports const { ensureNativeBinary } = await import("./bootstrap-native.js"); ensureNativeBinary(); // 2. Load logger const { log, logError, setAppLog } = await import("./logger.js"); log("Using V2 path (full context)"); if (ctx?.app?.log) { setAppLog(ctx.app.log.bind(ctx.app)); } const rawStorage = ctx.options?.storagePath; const os = require("os"); const path = require("path"); const dataDir = rawStorage ? path.join(rawStorage, "plugin-data") : path.join(os.homedir(), ".local", "share", "opencode-matrix-bot", "plugin-data"); // 3. Load config const { loadConfig } = await import("./config-loader.js"); const { options } = await loadConfig(ctx.options); // 4. Run plugin (handles all matrix imports dynamically) return await runPlugin(ctx, options, dataDir, log, logError); } // ============================================================================= // Export // ============================================================================= export default { id: "matrix-plugin", async setup(ctx) { try { return await v2Setup(ctx); } catch (err) { const { logError } = await import("./logger.js"); logError(`V2 setup error: ${String(err)}`); return () => { }; } }, };