diff --git a/dist/index.js b/dist/index.js index 6af7823..94b3d13 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,46 +1,37 @@ -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 { loadConfig } from "./config-loader.js"; -import { setAppLog, log, logError } from "./logger.js"; +// 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 — uses the same default as matrix-client +// Plugin data directory // ============================================================================= function ensureDataDir(dir) { + const fs = require("fs"); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } // ============================================================================= -// Global mutex — prevents double startup (server + client both load plugins) +// Global mutex // ============================================================================= let LOCK_FILE = ""; function tryAcquireLock() { + const fs = require("fs"); 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 + process.kill(existingPid, 0); } 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 + return false; } } fs.writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o644 }); @@ -51,6 +42,7 @@ function tryAcquireLock() { } } function releaseLock() { + const fs = require("fs"); try { fs.unlinkSync(LOCK_FILE); } @@ -61,16 +53,16 @@ function releaseLock() { // ============================================================================= // Plugin Setup // ============================================================================= -async function runPlugin(ctx, options, dataDir) { +async function runPlugin(ctx, options, dataDir, log, logError) { // Dynamic imports — loaded AFTER ensureNativeBinary() completes - // (static imports would be hoisted and evaluated before ensureNativeBinary()) 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 — only one instance (server or client) starts the bot + // Acquire global lock const locked = tryAcquireLock(); if (!locked) { log("Another instance already running (lock held), skipping bot startup"); @@ -90,19 +82,15 @@ async function runPlugin(ctx, options, dataDir) { // Track Matrix context per OpenCode session const sessionContextMap = new Map(); // ============================================================================= - // Pending prompt responses — maps sessionID -> { resolve, reject } + // Pending prompt responses // ============================================================================= 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) { + async function parseCodeModeTools(code) { const results = []; const seen = new Set(); - // Parse the code into an AST using acorn (same parser as CodeMode) + const acorn = await import("acorn"); + const acornWalk = await import("acorn-walk"); const ast = acorn.parse(code, { ecmaVersion: "latest", sourceType: "script", @@ -110,10 +98,8 @@ async function runPlugin(ctx, options, dataDir) { 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..(...) const toolCall = extractToolCall(node); if (!toolCall) return; @@ -122,20 +108,14 @@ async function runPlugin(ctx, options, dataDir) { 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) { const callee = node.callee; - // Must be a MemberExpression: tools.xxx.yyy if (callee.type !== "MemberExpression") return null; if (callee.computed) @@ -144,10 +124,8 @@ async function runPlugin(ctx, options, dataDir) { 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") @@ -155,17 +133,11 @@ async function runPlugin(ctx, options, dataDir) { 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 ""; @@ -175,19 +147,12 @@ async function runPlugin(ctx, options, dataDir) { } 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") @@ -196,10 +161,8 @@ async function runPlugin(ctx, options, dataDir) { 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 ?? []) { @@ -211,12 +174,10 @@ async function runPlugin(ctx, options, dataDir) { } 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 ?? []; @@ -230,21 +191,17 @@ async function runPlugin(ctx, options, dataDir) { 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; @@ -254,30 +211,25 @@ async function runPlugin(ctx, options, dataDir) { } 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 + // Event subscription // ============================================================================= void (async () => { try { @@ -294,13 +246,10 @@ async function runPlugin(ctx, options, dataDir) { 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; @@ -315,7 +264,6 @@ async function runPlugin(ctx, options, dataDir) { }); continue; } - // ── Reasoning ─────────────────────────────────────────────────── if (eventType === "session.reasoning.delta" && assistantMsgID) { const state = sessionsBySessionID.get(sessionId); const step = state?.steps.get(assistantMsgID); @@ -330,7 +278,6 @@ async function runPlugin(ctx, options, dataDir) { 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 || ""; @@ -351,7 +298,6 @@ async function runPlugin(ctx, options, dataDir) { if (tool) tool.args = args; } - // Parse CodeMode execute tool — extract child tool calls from JS code if (toolID && step && args) { let parsedArgs; try { @@ -362,7 +308,7 @@ async function runPlugin(ctx, options, dataDir) { } const code = parsedArgs?.code || ""; if (code) { - const childTools = parseCodeModeTools(code); + const childTools = await parseCodeModeTools(code); for (const child of childTools) { if (!step.tools.find(t => t.name === child.name)) { step.tools.push({ @@ -413,10 +359,8 @@ async function runPlugin(ctx, options, dataDir) { } 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); @@ -427,10 +371,8 @@ async function runPlugin(ctx, options, dataDir) { } 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); @@ -440,7 +382,6 @@ async function runPlugin(ctx, options, dataDir) { 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); @@ -452,7 +393,6 @@ async function runPlugin(ctx, options, dataDir) { } continue; } - // ── Execution errors ─────────────────────────────────────────── if (eventType === "session.execution.failed" && sessionId) { const pending = pendingResponses.get(sessionId); if (pending) { @@ -487,19 +427,15 @@ async function runPlugin(ctx, options, dataDir) { // ============================================================================= function formatStepMessage(step) { const parts = []; - // 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`); @@ -511,7 +447,6 @@ async function runPlugin(ctx, options, dataDir) { } 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`); @@ -519,7 +454,6 @@ async function runPlugin(ctx, options, dataDir) { } } 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`); @@ -533,7 +467,6 @@ async function runPlugin(ctx, options, dataDir) { } } } - // Separator before text if (step.text) { if (parts.length > 0) parts.push("\n---\n"); @@ -542,7 +475,7 @@ async function runPlugin(ctx, options, dataDir) { return parts.join("\n"); } // ============================================================================= - // Send message to Matrix (reuses existing sendReply/sendNotice) + // Send message to Matrix // ============================================================================= async function sendMessageToMatrix(context, text) { try { @@ -556,12 +489,10 @@ async function runPlugin(ctx, options, dataDir) { // 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; @@ -576,7 +507,6 @@ async function runPlugin(ctx, options, dataDir) { 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 }); @@ -587,7 +517,6 @@ async function runPlugin(ctx, options, dataDir) { opencodeSessionId = ""; } } - // If no session ID yet, create one if (!opencodeSessionId) { log(`Creating new OpenCode session for ${roomId}:${threadRootId}`); try { @@ -608,13 +537,11 @@ async function runPlugin(ctx, options, dataDir) { } } 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, @@ -622,15 +549,11 @@ async function runPlugin(ctx, options, dataDir) { 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)}`); @@ -689,12 +612,11 @@ async function runPlugin(ctx, options, dataDir) { await sendFn(`Unknown command: ${cmdName}. Try /help`); } } - // Override the matrix.on handler to also process commands + // Override the matrix.on handler 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"]; @@ -704,7 +626,6 @@ async function runPlugin(ctx, options, dataDir) { return; } } - // Forward to original handler if (originalListener) { await originalListener(data); } @@ -721,7 +642,6 @@ async function runPlugin(ctx, options, dataDir) { log("Plugin cleaning up..."); eventController.abort(); matrix.stop(); - // Don't release lock — keep it on disk so client always sees it }; return cleanup; } @@ -735,23 +655,26 @@ async function runPlugin(ctx, options, dataDir) { // 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 + // 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)); } - try { - const { options } = await loadConfig(ctx.options); - return await runPlugin(ctx, options, dataDir); - } - catch (err) { - logError(`V2 setup error: ${String(err)}`); - return () => { }; - } + 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 @@ -759,7 +682,13 @@ async function v2Setup(ctx) { export default { id: "matrix-plugin", async setup(ctx) { - log("Using V2 path (full context)"); - return await v2Setup(ctx); + try { + return await v2Setup(ctx); + } + catch (err) { + const { logError } = await import("./logger.js"); + logError(`V2 setup error: ${String(err)}`); + return () => { }; + } }, }; diff --git a/index.ts b/index.ts index baf51db..334ed5a 100644 --- a/index.ts +++ b/index.ts @@ -1,53 +1,42 @@ -import { ensureNativeBinary } from "./bootstrap-native.js" +// 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 -// Type-only import — doesn't execute any code at runtime +// Type-only import — erased at compile time, no runtime effect import type { MatrixEventContext } from "./matrix-client.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 { loadConfig } from "./config-loader.js" -import { setAppLog, log, logError } from "./logger.js" - // ============================================================================= -// Plugin data directory — uses the same default as matrix-client +// Plugin data directory // ============================================================================= function ensureDataDir(dir: string): void { + const fs = require("fs") if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }) } } // ============================================================================= -// Global mutex — prevents double startup (server + client both load plugins) +// Global mutex // ============================================================================= let LOCK_FILE = "" function tryAcquireLock(): boolean { + const fs = require("fs") 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 + process.kill(existingPid, 0) } 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 + return false } } fs.writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o644 }) @@ -58,6 +47,7 @@ function tryAcquireLock(): boolean { } function releaseLock(): void { + const fs = require("fs") try { fs.unlinkSync(LOCK_FILE) } catch { @@ -69,11 +59,17 @@ function releaseLock(): void { // Plugin Setup // ============================================================================= -async function runPlugin(ctx: any, options: any, dataDir: string) { +async function runPlugin( + ctx: any, + options: any, + dataDir: string, + log: (msg: string) => void, + logError: (msg: string) => void, +) { // Dynamic imports — loaded AFTER ensureNativeBinary() completes - // (static imports would be hoisted and evaluated before ensureNativeBinary()) 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` @@ -81,7 +77,7 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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 + // Acquire global lock const locked = tryAcquireLock() if (!locked) { log("Another instance already running (lock held), skipping bot startup") @@ -108,7 +104,7 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { const sessionContextMap = new Map() // ============================================================================= - // Pending prompt responses — maps sessionID -> { resolve, reject } + // Pending prompt responses // ============================================================================= const pendingResponses = new Map() // ============================================================================= - // Step tracking — each step is one LLM turn, sent as a separate Matrix message + // Step tracking // ============================================================================= interface ToolCall { @@ -136,19 +132,17 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { } interface SessionState { - steps: Map // assistantMessageID → step + steps: Map currentStepID: string | null context: MatrixEventContext finalResponse?: string - done: boolean // true после finish:stop + done: boolean } 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: "..." }) + // Parse tool calls from CodeMode JS script using AST // ============================================================================= interface ChildToolInfo { @@ -156,19 +150,15 @@ async function runPlugin(ctx: any, options: any, dataDir: 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[] { + async function parseCodeModeTools(code: string): Promise { const results: ChildToolInfo[] = [] const seen = new Set() - // Parse the code into an AST using acorn (same parser as CodeMode) + const acorn = await import("acorn") + const acornWalk = await import("acorn-walk") + const ast = acorn.parse(code, { ecmaVersion: "latest", sourceType: "script", @@ -177,10 +167,8 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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..(...) + CallExpression(node: AstNode) { const toolCall = extractToolCall(node) if (!toolCall) return @@ -189,7 +177,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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 }) }, @@ -198,14 +185,9 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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 @@ -213,11 +195,9 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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 @@ -225,18 +205,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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 "" @@ -248,29 +222,20 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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 ?? []) { @@ -283,7 +248,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { return `{ ${parts.join(", ")} }` } - // Array literals: [value1, value2, ...] case "ArrayExpression": { const elements = (node.elements ?? []).map((el: AstNode | null) => el ? evaluateExpression(el) : "", @@ -291,7 +255,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { return `[${elements.join(", ")}]` } - // Template literals: `text ${expr} text` case "TemplateLiteral": { let result = "`" const quasis = node.quasis ?? [] @@ -306,24 +269,20 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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 @@ -334,35 +293,30 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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 + // Event subscription // ============================================================================= void (async () => { @@ -377,14 +331,10 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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 @@ -400,8 +350,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { continue } - // ── Reasoning ─────────────────────────────────────────────────── - if (eventType === "session.reasoning.delta" && assistantMsgID) { const state = sessionsBySessionID.get(sessionId) const step = state?.steps.get(assistantMsgID) @@ -416,8 +364,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { continue } - // ── Tool calls ────────────────────────────────────────────────── - if (eventType === "session.tool.input.started" && assistantMsgID) { const toolID = eventData?.data?.id || "" const name = eventData?.data?.name || "" @@ -438,13 +384,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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) + const childTools = await parseCodeModeTools(code) for (const child of childTools) { if (!step.tools.find(t => t.name === child.name)) { step.tools.push({ @@ -492,11 +437,8 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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) { @@ -506,12 +448,9 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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) @@ -523,7 +462,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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) @@ -537,8 +475,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { continue } - // ── Execution errors ─────────────────────────────────────────── - if (eventType === "session.execution.failed" && sessionId) { const pending = pendingResponses.get(sessionId) if (pending) { @@ -575,14 +511,11 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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("/"), ) @@ -591,7 +524,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { : [] 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`) @@ -602,14 +534,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { } 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`) @@ -623,7 +553,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { } } - // Separator before text if (step.text) { if (parts.length > 0) parts.push("\n---\n") parts.push(step.text) @@ -633,7 +562,7 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { } // ============================================================================= - // Send message to Matrix (reuses existing sendReply/sendNotice) + // Send message to Matrix // ============================================================================= async function sendMessageToMatrix( @@ -652,13 +581,11 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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 @@ -677,7 +604,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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 }) @@ -688,7 +614,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { } } - // If no session ID yet, create one if (!opencodeSessionId) { log(`Creating new OpenCode session for ${roomId}:${threadRootId}`) try { @@ -709,14 +634,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { } 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, @@ -725,16 +648,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { 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) @@ -798,13 +717,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { } } - // Override the matrix.on handler to also process commands + // Override the matrix.on handler 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"] @@ -815,7 +733,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { } } - // Forward to original handler if (originalListener) { await originalListener(data) } @@ -836,7 +753,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { log("Plugin cleaning up...") eventController.abort() matrix.stop() - // Don't release lock — keep it on disk so client always sees it } return cleanup @@ -852,24 +768,32 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { // ============================================================================= 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) + // 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)") - // 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 () => {} - } + const rawStorage = (ctx.options as any)?.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) } // ============================================================================= @@ -879,7 +803,12 @@ async function v2Setup(ctx: any) { export default { id: "matrix-plugin", async setup(ctx: any) { - log("Using V2 path (full context)") - return await v2Setup(ctx) + try { + return await v2Setup(ctx) + } catch (err) { + const { logError } = await import("./logger.js") + logError(`V2 setup error: ${String(err)}`) + return () => {} + } }, }