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, type MatrixEventContext } 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: string): void { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }) } } // ============================================================================= // Global mutex — prevents double startup (server + client both load plugins) // ============================================================================= let LOCK_FILE = "" function tryAcquireLock(): boolean { 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(): void { try { fs.unlinkSync(LOCK_FILE) } catch { // ignore } } // ============================================================================= // Plugin Setup // ============================================================================= async function runPlugin(ctx: any, options: any, dataDir: string) { 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: (() => void) | undefined 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 void reject: (err: any) => void }>() // ============================================================================= // Step tracking — each step is one LLM turn, sent as a separate Matrix message // ============================================================================= interface ToolCall { toolID: string name: string args: string result: string failed: boolean } interface StepData { assistantMessageID: string reasoning: string tools: ToolCall[] text: string } interface SessionState { steps: Map // assistantMessageID → step currentStepID: string | null context: MatrixEventContext finalResponse?: string done: boolean // true после finish:stop } const sessionsBySessionID = new Map() // ============================================================================= // Parse tool calls from CodeMode JS script using AST (same as OpenCode) // CodeMode creates nested tools: tools..(...) // e.g. tools.duckduckgo.search({ query: "..." }) // ============================================================================= interface ChildToolInfo { name: string args: string } // Minimal AST node type (acorn doesn't export AST namespace in usable way) type AstNode = { type: string; [key: string]: any } /** * 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: string): ChildToolInfo[] { const results: ChildToolInfo[] = [] 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, }) as AstNode // Walk the AST and find all CallExpression nodes for tools.xxx.yyy(...) acornWalk.simple(ast as any, { CallExpression(node) { // Check if callee is tools..(...) 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..(...) * Returns { path, args } or null. */ function extractToolCall(node: AstNode): { path: string[]; args: AstNode[] } | null { const callee = node.callee as AstNode // Must be a MemberExpression: tools.xxx.yyy if (callee.type !== "MemberExpression") return null if (callee.computed) return null const prop = callee.property as AstNode if (prop.type !== "Identifier") return null const toolName = prop.name // Object must be tools.xxx (nested MemberExpression) or just "tools" let obj: AstNode = callee.object const path: string[] = [] // Walk up the nested MemberExpression chain while (obj.type === "MemberExpression" && !obj.computed) { const innerProp = obj.property as AstNode 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 ?? []) as AstNode[] } } /** * 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: AstNode[]): string { if (!args || args.length === 0) return "" const values: string[] = [] 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: AstNode): string { 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: string[] = [] for (const prop of node.properties ?? []) { if (prop.type === "Property") { const key = prop.key.type === "Identifier" ? prop.key.name : String((prop.key as AstNode).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: AstNode | null) => 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 as AstNode if (member.object?.type === "Identifier") { return `${(member.object as AstNode).name}.${(member.property as AstNode)?.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: any = 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: any 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 as { name?: string }).name === "AbortError") { // expected during cleanup } else { log(`Event subscription error: ${String(err)}`) } } })() // ============================================================================= // Format step into Matrix message // ============================================================================= function formatStepMessage(step: StepData): string { const parts: string[] = [] // Reasoning in spoiler if (step.reasoning) { parts.push(`
\nThinking\n\n${step.reasoning}\n\n
`) } // 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(`
\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(`
`) } // Execute result as a separate spoiler (like a regular tool result) if (executeTool?.result) { parts.push(`
\n🔧 execute\n\n`) parts.push(`Result:\n\`\`\`\n${executeTool.result}\n\`\`\`\n\n`) parts.push(`
`) } } else { // No child tool calls — show tools normally (including execute as-is) 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(`
`) } } } // 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: MatrixEventContext, text: string, ): Promise { 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: any) => { 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 = () => { /* placeholder for cancel */ } 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: any) { 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: any) { 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: any) { 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: { roomId: string; replyThreadRootId?: string; threadRootId?: string; eventId: string }, cmdName: string, sendFn: (text: string) => Promise, ): Promise { 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 to also process commands const originalListener = (matrix as any)["listeners"][0] ;(matrix as any)["listeners"] = [] matrix.on(async (data: any) => { 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: string) => 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: any) { const rawStorage = (ctx.options as any)?.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: any) { log("Using V2 path (full context)") return await v2Setup(ctx) }, }