fix: move all imports to dynamic to defer matrix-bot-sdk loading
ES modules evaluate ALL static imports before any code executes. ensureNativeBinary() was called but matrix-bot-sdk was already loaded by the time it ran. Solution: all imports are now dynamic (await import()). The setup function calls ensureNativeBinary() first, then dynamically loads everything else including matrix-bot-sdk transitively. Changes: - Remove all static imports from index.ts - Move ensureNativeBinary() into async setup() - Pass log/logError as parameters to runPlugin - Make parseCodeModeTools async for dynamic acorn imports - Add import type for MatrixEventContext (erased at compile time)
This commit is contained in:
parent
8dae9656b8
commit
4294e30f76
|
|
@ -1,46 +1,37 @@
|
||||||
import { ensureNativeBinary } from "./bootstrap-native.js";
|
// All imports are dynamic — loaded AFTER ensureNativeBinary() completes
|
||||||
// Ensure native binary is available before loading matrix-bot-sdk
|
// ES modules evaluate ALL static imports before executing any code,
|
||||||
// (opencode's npm doesn't run postinstall scripts)
|
// so we cannot have any static imports that transitively load matrix-bot-sdk
|
||||||
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) {
|
function ensureDataDir(dir) {
|
||||||
|
const fs = require("fs");
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Global mutex — prevents double startup (server + client both load plugins)
|
// Global mutex
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
let LOCK_FILE = "";
|
let LOCK_FILE = "";
|
||||||
function tryAcquireLock() {
|
function tryAcquireLock() {
|
||||||
|
const fs = require("fs");
|
||||||
try {
|
try {
|
||||||
// Check if an existing lock is stale (process no longer running)
|
|
||||||
if (fs.existsSync(LOCK_FILE)) {
|
if (fs.existsSync(LOCK_FILE)) {
|
||||||
const existingPid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10);
|
const existingPid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10);
|
||||||
if (existingPid && existingPid !== process.pid) {
|
if (existingPid && existingPid !== process.pid) {
|
||||||
try {
|
try {
|
||||||
process.kill(existingPid, 0); // throws if process doesn't exist
|
process.kill(existingPid, 0);
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
// Process dead, remove stale lock
|
|
||||||
fs.unlinkSync(LOCK_FILE);
|
fs.unlinkSync(LOCK_FILE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (existingPid === process.pid) {
|
else if (existingPid === process.pid) {
|
||||||
// Same process re-entering (shouldn't happen, but be safe)
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return false; // Valid lock held by another process
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fs.writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o644 });
|
fs.writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o644 });
|
||||||
|
|
@ -51,6 +42,7 @@ function tryAcquireLock() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function releaseLock() {
|
function releaseLock() {
|
||||||
|
const fs = require("fs");
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(LOCK_FILE);
|
fs.unlinkSync(LOCK_FILE);
|
||||||
}
|
}
|
||||||
|
|
@ -61,16 +53,16 @@ function releaseLock() {
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Plugin Setup
|
// Plugin Setup
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
async function runPlugin(ctx, options, dataDir) {
|
async function runPlugin(ctx, options, dataDir, log, logError) {
|
||||||
// Dynamic imports — loaded AFTER ensureNativeBinary() completes
|
// Dynamic imports — loaded AFTER ensureNativeBinary() completes
|
||||||
// (static imports would be hoisted and evaluated before ensureNativeBinary())
|
|
||||||
const { MatrixBotClient } = await import("./matrix-client.js");
|
const { MatrixBotClient } = await import("./matrix-client.js");
|
||||||
const { SessionManager } = await import("./session-manager.js");
|
const { SessionManager } = await import("./session-manager.js");
|
||||||
|
const { loadConfig } = await import("./config-loader.js");
|
||||||
ensureDataDir(dataDir);
|
ensureDataDir(dataDir);
|
||||||
LOCK_FILE = `${dataDir}/lock`;
|
LOCK_FILE = `${dataDir}/lock`;
|
||||||
const directory = ctx?.location?.directory || process.cwd();
|
const directory = ctx?.location?.directory || process.cwd();
|
||||||
log(`Plugin setup started, directory=${directory}`);
|
log(`Plugin setup started, directory=${directory}`);
|
||||||
// Acquire global lock — only one instance (server or client) starts the bot
|
// Acquire global lock
|
||||||
const locked = tryAcquireLock();
|
const locked = tryAcquireLock();
|
||||||
if (!locked) {
|
if (!locked) {
|
||||||
log("Another instance already running (lock held), skipping bot startup");
|
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
|
// Track Matrix context per OpenCode session
|
||||||
const sessionContextMap = new Map();
|
const sessionContextMap = new Map();
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Pending prompt responses — maps sessionID -> { resolve, reject }
|
// Pending prompt responses
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
const pendingResponses = new Map();
|
const pendingResponses = new Map();
|
||||||
const sessionsBySessionID = new Map();
|
const sessionsBySessionID = new Map();
|
||||||
/**
|
async function parseCodeModeTools(code) {
|
||||||
* 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 results = [];
|
||||||
const seen = new Set();
|
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, {
|
const ast = acorn.parse(code, {
|
||||||
ecmaVersion: "latest",
|
ecmaVersion: "latest",
|
||||||
sourceType: "script",
|
sourceType: "script",
|
||||||
|
|
@ -110,10 +98,8 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
allowAwaitOutsideFunction: true,
|
allowAwaitOutsideFunction: true,
|
||||||
locations: true,
|
locations: true,
|
||||||
});
|
});
|
||||||
// Walk the AST and find all CallExpression nodes for tools.xxx.yyy(...)
|
|
||||||
acornWalk.simple(ast, {
|
acornWalk.simple(ast, {
|
||||||
CallExpression(node) {
|
CallExpression(node) {
|
||||||
// Check if callee is tools.<server>.<tool>(...)
|
|
||||||
const toolCall = extractToolCall(node);
|
const toolCall = extractToolCall(node);
|
||||||
if (!toolCall)
|
if (!toolCall)
|
||||||
return;
|
return;
|
||||||
|
|
@ -122,20 +108,14 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
if (seen.has(name))
|
if (seen.has(name))
|
||||||
return;
|
return;
|
||||||
seen.add(name);
|
seen.add(name);
|
||||||
// Evaluate the arguments to get their string representation
|
|
||||||
const argsStr = evaluateArgs(args);
|
const argsStr = evaluateArgs(args);
|
||||||
results.push({ name, args: argsStr });
|
results.push({ name, args: argsStr });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Check if a CallExpression is a tool call: tools.<server>.<tool>(...)
|
|
||||||
* Returns { path, args } or null.
|
|
||||||
*/
|
|
||||||
function extractToolCall(node) {
|
function extractToolCall(node) {
|
||||||
const callee = node.callee;
|
const callee = node.callee;
|
||||||
// Must be a MemberExpression: tools.xxx.yyy
|
|
||||||
if (callee.type !== "MemberExpression")
|
if (callee.type !== "MemberExpression")
|
||||||
return null;
|
return null;
|
||||||
if (callee.computed)
|
if (callee.computed)
|
||||||
|
|
@ -144,10 +124,8 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
if (prop.type !== "Identifier")
|
if (prop.type !== "Identifier")
|
||||||
return null;
|
return null;
|
||||||
const toolName = prop.name;
|
const toolName = prop.name;
|
||||||
// Object must be tools.xxx (nested MemberExpression) or just "tools"
|
|
||||||
let obj = callee.object;
|
let obj = callee.object;
|
||||||
const path = [];
|
const path = [];
|
||||||
// Walk up the nested MemberExpression chain
|
|
||||||
while (obj.type === "MemberExpression" && !obj.computed) {
|
while (obj.type === "MemberExpression" && !obj.computed) {
|
||||||
const innerProp = obj.property;
|
const innerProp = obj.property;
|
||||||
if (innerProp.type !== "Identifier")
|
if (innerProp.type !== "Identifier")
|
||||||
|
|
@ -155,17 +133,11 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
path.unshift(innerProp.name);
|
path.unshift(innerProp.name);
|
||||||
obj = obj.object;
|
obj = obj.object;
|
||||||
}
|
}
|
||||||
// The base must be the identifier "tools"
|
|
||||||
if (obj.type !== "Identifier" || obj.name !== "tools")
|
if (obj.type !== "Identifier" || obj.name !== "tools")
|
||||||
return null;
|
return null;
|
||||||
path.unshift(toolName);
|
path.unshift(toolName);
|
||||||
return { path, args: (node.arguments ?? []) };
|
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) {
|
function evaluateArgs(args) {
|
||||||
if (!args || args.length === 0)
|
if (!args || args.length === 0)
|
||||||
return "";
|
return "";
|
||||||
|
|
@ -175,19 +147,12 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
}
|
}
|
||||||
return values.join(", ");
|
return values.join(", ");
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Evaluate an AST expression node to its string value.
|
|
||||||
* Handles the expression types used in CodeMode tool calls.
|
|
||||||
*/
|
|
||||||
function evaluateExpression(node) {
|
function evaluateExpression(node) {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
// Literals
|
|
||||||
case "Literal":
|
case "Literal":
|
||||||
return String(node.value);
|
return String(node.value);
|
||||||
// String/number/boolean/null/regexp literals
|
|
||||||
case "Identifier": {
|
case "Identifier": {
|
||||||
const name = node.name;
|
const name = node.name;
|
||||||
// Known globals with known values
|
|
||||||
if (name === "undefined")
|
if (name === "undefined")
|
||||||
return "undefined";
|
return "undefined";
|
||||||
if (name === "true")
|
if (name === "true")
|
||||||
|
|
@ -196,10 +161,8 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
return "false";
|
return "false";
|
||||||
if (name === "null")
|
if (name === "null")
|
||||||
return "null";
|
return "null";
|
||||||
// For variables, show the variable name
|
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
// Object literals: { key: value, ... }
|
|
||||||
case "ObjectExpression": {
|
case "ObjectExpression": {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
for (const prop of node.properties ?? []) {
|
for (const prop of node.properties ?? []) {
|
||||||
|
|
@ -211,12 +174,10 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
}
|
}
|
||||||
return `{ ${parts.join(", ")} }`;
|
return `{ ${parts.join(", ")} }`;
|
||||||
}
|
}
|
||||||
// Array literals: [value1, value2, ...]
|
|
||||||
case "ArrayExpression": {
|
case "ArrayExpression": {
|
||||||
const elements = (node.elements ?? []).map((el) => el ? evaluateExpression(el) : "");
|
const elements = (node.elements ?? []).map((el) => el ? evaluateExpression(el) : "");
|
||||||
return `[${elements.join(", ")}]`;
|
return `[${elements.join(", ")}]`;
|
||||||
}
|
}
|
||||||
// Template literals: `text ${expr} text`
|
|
||||||
case "TemplateLiteral": {
|
case "TemplateLiteral": {
|
||||||
let result = "`";
|
let result = "`";
|
||||||
const quasis = node.quasis ?? [];
|
const quasis = node.quasis ?? [];
|
||||||
|
|
@ -230,21 +191,17 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
result += "`";
|
result += "`";
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
// Binary expressions: a + b, a === b, etc.
|
|
||||||
case "BinaryExpression": {
|
case "BinaryExpression": {
|
||||||
const left = evaluateExpression(node.left);
|
const left = evaluateExpression(node.left);
|
||||||
const right = evaluateExpression(node.right);
|
const right = evaluateExpression(node.right);
|
||||||
return `${left} ${node.operator} ${right}`;
|
return `${left} ${node.operator} ${right}`;
|
||||||
}
|
}
|
||||||
// Conditional: a ? b : c
|
|
||||||
case "ConditionalExpression": {
|
case "ConditionalExpression": {
|
||||||
return `${evaluateExpression(node.test)} ? ${evaluateExpression(node.consequent)} : ${evaluateExpression(node.alternate)}`;
|
return `${evaluateExpression(node.test)} ? ${evaluateExpression(node.consequent)} : ${evaluateExpression(node.alternate)}`;
|
||||||
}
|
}
|
||||||
// Function expressions (arrow functions, etc.)
|
|
||||||
case "ArrowFunctionExpression":
|
case "ArrowFunctionExpression":
|
||||||
case "FunctionExpression":
|
case "FunctionExpression":
|
||||||
return "function";
|
return "function";
|
||||||
// Call expressions: other().method()
|
|
||||||
case "CallExpression": {
|
case "CallExpression": {
|
||||||
if (node.callee?.type === "MemberExpression") {
|
if (node.callee?.type === "MemberExpression") {
|
||||||
const member = node.callee;
|
const member = node.callee;
|
||||||
|
|
@ -254,30 +211,25 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
}
|
}
|
||||||
return "call";
|
return "call";
|
||||||
}
|
}
|
||||||
// Unary expressions: !a, -b, typeof c
|
|
||||||
case "UnaryExpression": {
|
case "UnaryExpression": {
|
||||||
const arg = evaluateExpression(node.argument);
|
const arg = evaluateExpression(node.argument);
|
||||||
return `${node.operator}${arg}`;
|
return `${node.operator}${arg}`;
|
||||||
}
|
}
|
||||||
// Logical expressions: a && b, a || b
|
|
||||||
case "LogicalExpression": {
|
case "LogicalExpression": {
|
||||||
return `${evaluateExpression(node.left)} ${node.operator} ${evaluateExpression(node.right)}`;
|
return `${evaluateExpression(node.left)} ${node.operator} ${evaluateExpression(node.right)}`;
|
||||||
}
|
}
|
||||||
// Await expressions
|
|
||||||
case "AwaitExpression": {
|
case "AwaitExpression": {
|
||||||
return `await ${evaluateExpression(node.argument)}`;
|
return `await ${evaluateExpression(node.argument)}`;
|
||||||
}
|
}
|
||||||
// Spread: ...arr
|
|
||||||
case "SpreadElement": {
|
case "SpreadElement": {
|
||||||
return `...${evaluateExpression(node.argument)}`;
|
return `...${evaluateExpression(node.argument)}`;
|
||||||
}
|
}
|
||||||
// Default: show "(expr)"
|
|
||||||
default:
|
default:
|
||||||
return `(expr:${node.type})`;
|
return `(expr:${node.type})`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Event subscription — collects step data and sends messages per step
|
// Event subscription
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -294,13 +246,10 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
const eventType = eventData?.type || "";
|
const eventType = eventData?.type || "";
|
||||||
const sessionId = eventData?.data?.sessionID || "";
|
const sessionId = eventData?.data?.sessionID || "";
|
||||||
const assistantMsgID = eventData?.data?.assistantMessageID || "";
|
const assistantMsgID = eventData?.data?.assistantMessageID || "";
|
||||||
// Skip all events after session is done
|
|
||||||
const state = sessionsBySessionID.get(sessionId);
|
const state = sessionsBySessionID.get(sessionId);
|
||||||
if (state?.done)
|
if (state?.done)
|
||||||
continue;
|
continue;
|
||||||
// ── Step lifecycle ──────────────────────────────────────────────
|
|
||||||
if (eventType === "session.step.started") {
|
if (eventType === "session.step.started") {
|
||||||
// SessionState created in message handler after prompt
|
|
||||||
if (!sessionsBySessionID.has(sessionId)) {
|
if (!sessionsBySessionID.has(sessionId)) {
|
||||||
log(`step.started for unknown session ${sessionId}`);
|
log(`step.started for unknown session ${sessionId}`);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -315,7 +264,6 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// ── Reasoning ───────────────────────────────────────────────────
|
|
||||||
if (eventType === "session.reasoning.delta" && assistantMsgID) {
|
if (eventType === "session.reasoning.delta" && assistantMsgID) {
|
||||||
const state = sessionsBySessionID.get(sessionId);
|
const state = sessionsBySessionID.get(sessionId);
|
||||||
const step = state?.steps.get(assistantMsgID);
|
const step = state?.steps.get(assistantMsgID);
|
||||||
|
|
@ -330,7 +278,6 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
step.reasoning = eventData?.data?.text || "";
|
step.reasoning = eventData?.data?.text || "";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// ── Tool calls ──────────────────────────────────────────────────
|
|
||||||
if (eventType === "session.tool.input.started" && assistantMsgID) {
|
if (eventType === "session.tool.input.started" && assistantMsgID) {
|
||||||
const toolID = eventData?.data?.id || "";
|
const toolID = eventData?.data?.id || "";
|
||||||
const name = eventData?.data?.name || "";
|
const name = eventData?.data?.name || "";
|
||||||
|
|
@ -351,7 +298,6 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
if (tool)
|
if (tool)
|
||||||
tool.args = args;
|
tool.args = args;
|
||||||
}
|
}
|
||||||
// Parse CodeMode execute tool — extract child tool calls from JS code
|
|
||||||
if (toolID && step && args) {
|
if (toolID && step && args) {
|
||||||
let parsedArgs;
|
let parsedArgs;
|
||||||
try {
|
try {
|
||||||
|
|
@ -362,7 +308,7 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
}
|
}
|
||||||
const code = parsedArgs?.code || "";
|
const code = parsedArgs?.code || "";
|
||||||
if (code) {
|
if (code) {
|
||||||
const childTools = parseCodeModeTools(code);
|
const childTools = await parseCodeModeTools(code);
|
||||||
for (const child of childTools) {
|
for (const child of childTools) {
|
||||||
if (!step.tools.find(t => t.name === child.name)) {
|
if (!step.tools.find(t => t.name === child.name)) {
|
||||||
step.tools.push({
|
step.tools.push({
|
||||||
|
|
@ -413,10 +359,8 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// ── Text output ────────────────────────────────────────────────
|
|
||||||
if (eventType === "session.text.ended" && assistantMsgID) {
|
if (eventType === "session.text.ended" && assistantMsgID) {
|
||||||
const state = sessionsBySessionID.get(sessionId);
|
const state = sessionsBySessionID.get(sessionId);
|
||||||
// Skip text events after final step
|
|
||||||
if (state?.done)
|
if (state?.done)
|
||||||
continue;
|
continue;
|
||||||
const step = state?.steps.get(assistantMsgID);
|
const step = state?.steps.get(assistantMsgID);
|
||||||
|
|
@ -427,10 +371,8 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// ── Step ended — send message for this step ────────────────────
|
|
||||||
if (eventType === "session.step.ended" && sessionId && assistantMsgID) {
|
if (eventType === "session.step.ended" && sessionId && assistantMsgID) {
|
||||||
const state = sessionsBySessionID.get(sessionId);
|
const state = sessionsBySessionID.get(sessionId);
|
||||||
// Skip steps after final (finish:stop) — they are re-projections/duplicates
|
|
||||||
if (state?.done)
|
if (state?.done)
|
||||||
continue;
|
continue;
|
||||||
const step = state?.steps.get(assistantMsgID);
|
const step = state?.steps.get(assistantMsgID);
|
||||||
|
|
@ -440,7 +382,6 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
await sendMessageToMatrix(state.context, msg);
|
await sendMessageToMatrix(state.context, msg);
|
||||||
log(`Step ${step.tools.length} tool(s), ${step.text.length} chars text → sent to ${state.context.roomId}`);
|
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) {
|
if (finish === "stop" && state) {
|
||||||
state.done = true;
|
state.done = true;
|
||||||
const pending = pendingResponses.get(sessionId);
|
const pending = pendingResponses.get(sessionId);
|
||||||
|
|
@ -452,7 +393,6 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// ── Execution errors ───────────────────────────────────────────
|
|
||||||
if (eventType === "session.execution.failed" && sessionId) {
|
if (eventType === "session.execution.failed" && sessionId) {
|
||||||
const pending = pendingResponses.get(sessionId);
|
const pending = pendingResponses.get(sessionId);
|
||||||
if (pending) {
|
if (pending) {
|
||||||
|
|
@ -487,19 +427,15 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
function formatStepMessage(step) {
|
function formatStepMessage(step) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
// Reasoning in spoiler
|
|
||||||
if (step.reasoning) {
|
if (step.reasoning) {
|
||||||
parts.push(`<details>\n<summary>Thinking</summary>\n\n${step.reasoning}\n\n</details>`);
|
parts.push(`<details>\n<summary>Thinking</summary>\n\n${step.reasoning}\n\n</details>`);
|
||||||
}
|
}
|
||||||
// Tool calls
|
|
||||||
if (step.tools.length > 0) {
|
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 executeTool = step.tools.find((t) => t.name === "execute" && t.toolID.includes("/"));
|
||||||
const childTools = executeTool
|
const childTools = executeTool
|
||||||
? step.tools.filter((t) => t.toolID.startsWith(executeTool.toolID + "/"))
|
? step.tools.filter((t) => t.toolID.startsWith(executeTool.toolID + "/"))
|
||||||
: [];
|
: [];
|
||||||
if (childTools.length > 0) {
|
if (childTools.length > 0) {
|
||||||
// Execute has child tool calls — show children, then execute result
|
|
||||||
for (const child of childTools) {
|
for (const child of childTools) {
|
||||||
parts.push(`<details>\n<summary>🔧 ${child.name}</summary>\n\n`);
|
parts.push(`<details>\n<summary>🔧 ${child.name}</summary>\n\n`);
|
||||||
parts.push(`<b>Args:</b>\n\`\`\`\n${child.args}\n\`\`\`\n\n`);
|
parts.push(`<b>Args:</b>\n\`\`\`\n${child.args}\n\`\`\`\n\n`);
|
||||||
|
|
@ -511,7 +447,6 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
}
|
}
|
||||||
parts.push(`</details>`);
|
parts.push(`</details>`);
|
||||||
}
|
}
|
||||||
// Execute result as a separate spoiler (like a regular tool result)
|
|
||||||
if (executeTool?.result) {
|
if (executeTool?.result) {
|
||||||
parts.push(`<details>\n<summary>🔧 execute</summary>\n\n`);
|
parts.push(`<details>\n<summary>🔧 execute</summary>\n\n`);
|
||||||
parts.push(`<b>Result:</b>\n\`\`\`\n${executeTool.result}\n\`\`\`\n\n`);
|
parts.push(`<b>Result:</b>\n\`\`\`\n${executeTool.result}\n\`\`\`\n\n`);
|
||||||
|
|
@ -519,7 +454,6 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// No child tool calls — show tools normally (including execute as-is)
|
|
||||||
for (const tool of step.tools) {
|
for (const tool of step.tools) {
|
||||||
parts.push(`<details>\n<summary>🔧 ${tool.name}</summary>\n\n`);
|
parts.push(`<details>\n<summary>🔧 ${tool.name}</summary>\n\n`);
|
||||||
parts.push(`<b>Args:</b>\n\`\`\`\n${tool.args}\n\`\`\`\n\n`);
|
parts.push(`<b>Args:</b>\n\`\`\`\n${tool.args}\n\`\`\`\n\n`);
|
||||||
|
|
@ -533,7 +467,6 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Separator before text
|
|
||||||
if (step.text) {
|
if (step.text) {
|
||||||
if (parts.length > 0)
|
if (parts.length > 0)
|
||||||
parts.push("\n---\n");
|
parts.push("\n---\n");
|
||||||
|
|
@ -542,7 +475,7 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Send message to Matrix (reuses existing sendReply/sendNotice)
|
// Send message to Matrix
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
async function sendMessageToMatrix(context, text) {
|
async function sendMessageToMatrix(context, text) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -556,12 +489,10 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
// Message handler
|
// Message handler
|
||||||
matrix.on(async (data) => {
|
matrix.on(async (data) => {
|
||||||
const { context, query, sender, roomId, eventId, timestamp } = data;
|
const { context, query, sender, roomId, eventId, timestamp } = data;
|
||||||
// Check rate limit
|
|
||||||
if (sessionManager.isRateLimited(sender)) {
|
if (sessionManager.isRateLimited(sender)) {
|
||||||
await matrix.sendNotice(context, "Rate limited. Please wait a moment.");
|
await matrix.sendNotice(context, "Rate limited. Please wait a moment.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Check for active query
|
|
||||||
if (sessionManager.hasActiveQuery(roomId, context.replyThreadRootId || context.eventId)) {
|
if (sessionManager.hasActiveQuery(roomId, context.replyThreadRootId || context.eventId)) {
|
||||||
await matrix.sendNotice(context, "A request is already running in this thread. Please wait.");
|
await matrix.sendNotice(context, "A request is already running in this thread. Please wait.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -576,7 +507,6 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
session.lastEventIds.set(context.replyThreadRootId || context.eventId, eventId);
|
session.lastEventIds.set(context.replyThreadRootId || context.eventId, eventId);
|
||||||
session.lastActivity = Date.now();
|
session.lastActivity = Date.now();
|
||||||
let opencodeSessionId = session.opencodeSessionId;
|
let opencodeSessionId = session.opencodeSessionId;
|
||||||
// If we have a session ID from the map, verify it exists in OpenCode
|
|
||||||
if (opencodeSessionId) {
|
if (opencodeSessionId) {
|
||||||
try {
|
try {
|
||||||
await ctx.session.get({ sessionID: opencodeSessionId });
|
await ctx.session.get({ sessionID: opencodeSessionId });
|
||||||
|
|
@ -587,7 +517,6 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
opencodeSessionId = "";
|
opencodeSessionId = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If no session ID yet, create one
|
|
||||||
if (!opencodeSessionId) {
|
if (!opencodeSessionId) {
|
||||||
log(`Creating new OpenCode session for ${roomId}:${threadRootId}`);
|
log(`Creating new OpenCode session for ${roomId}:${threadRootId}`);
|
||||||
try {
|
try {
|
||||||
|
|
@ -608,13 +537,11 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Trigger prompt — returns admission receipt, not response text
|
|
||||||
await ctx.session.prompt({
|
await ctx.session.prompt({
|
||||||
sessionID: opencodeSessionId,
|
sessionID: opencodeSessionId,
|
||||||
text: query,
|
text: query,
|
||||||
});
|
});
|
||||||
log(`Prompt sent to session ${opencodeSessionId}`);
|
log(`Prompt sent to session ${opencodeSessionId}`);
|
||||||
// Create session state for step tracking
|
|
||||||
sessionsBySessionID.set(opencodeSessionId, {
|
sessionsBySessionID.set(opencodeSessionId, {
|
||||||
steps: new Map(),
|
steps: new Map(),
|
||||||
currentStepID: null,
|
currentStepID: null,
|
||||||
|
|
@ -622,15 +549,11 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
finalResponse: undefined,
|
finalResponse: undefined,
|
||||||
done: false,
|
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) => {
|
const responsePromise = new Promise((resolve, reject) => {
|
||||||
pendingResponses.set(opencodeSessionId, { resolve, reject });
|
pendingResponses.set(opencodeSessionId, { resolve, reject });
|
||||||
});
|
});
|
||||||
const responseText = await responsePromise;
|
const responseText = await responsePromise;
|
||||||
session.outputChars += responseText.length;
|
session.outputChars += responseText.length;
|
||||||
// Step messages already sent by event subscription — no double send
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
log(`Error processing message: ${String(err)}`);
|
log(`Error processing message: ${String(err)}`);
|
||||||
|
|
@ -689,12 +612,11 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
await sendFn(`Unknown command: ${cmdName}. Try /help`);
|
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];
|
const originalListener = matrix["listeners"][0];
|
||||||
matrix["listeners"] = [];
|
matrix["listeners"] = [];
|
||||||
matrix.on(async (data) => {
|
matrix.on(async (data) => {
|
||||||
const { context, query, sender, eventId } = data;
|
const { context, query, sender, eventId } = data;
|
||||||
// Handle bridge commands
|
|
||||||
if (query.startsWith("/")) {
|
if (query.startsWith("/")) {
|
||||||
const cmdName = query.slice(1).split(" ")[0].toLowerCase();
|
const cmdName = query.slice(1).split(" ")[0].toLowerCase();
|
||||||
const bridgeCommands = ["status", "clear", "reset", "help", "h"];
|
const bridgeCommands = ["status", "clear", "reset", "help", "h"];
|
||||||
|
|
@ -704,7 +626,6 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Forward to original handler
|
|
||||||
if (originalListener) {
|
if (originalListener) {
|
||||||
await originalListener(data);
|
await originalListener(data);
|
||||||
}
|
}
|
||||||
|
|
@ -721,7 +642,6 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
log("Plugin cleaning up...");
|
log("Plugin cleaning up...");
|
||||||
eventController.abort();
|
eventController.abort();
|
||||||
matrix.stop();
|
matrix.stop();
|
||||||
// Don't release lock — keep it on disk so client always sees it
|
|
||||||
};
|
};
|
||||||
return cleanup;
|
return cleanup;
|
||||||
}
|
}
|
||||||
|
|
@ -735,23 +655,26 @@ async function runPlugin(ctx, options, dataDir) {
|
||||||
// V2 Setup Function
|
// V2 Setup Function
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
async function v2Setup(ctx) {
|
async function v2Setup(ctx) {
|
||||||
const rawStorage = ctx.options?.storagePath;
|
// 1. Download native binary BEFORE any matrix imports
|
||||||
const dataDir = rawStorage
|
const { ensureNativeBinary } = await import("./bootstrap-native.js");
|
||||||
? path.join(rawStorage, "plugin-data")
|
ensureNativeBinary();
|
||||||
: path.join(os.homedir(), ".local", "share", "opencode-matrix-bot", "plugin-data");
|
// 2. Load logger
|
||||||
ensureDataDir(dataDir);
|
const { log, logError, setAppLog } = await import("./logger.js");
|
||||||
// Try to use OpenCode's logging API
|
log("Using V2 path (full context)");
|
||||||
if (ctx?.app?.log) {
|
if (ctx?.app?.log) {
|
||||||
setAppLog(ctx.app.log.bind(ctx.app));
|
setAppLog(ctx.app.log.bind(ctx.app));
|
||||||
}
|
}
|
||||||
try {
|
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);
|
const { options } = await loadConfig(ctx.options);
|
||||||
return await runPlugin(ctx, options, dataDir);
|
// 4. Run plugin (handles all matrix imports dynamically)
|
||||||
}
|
return await runPlugin(ctx, options, dataDir, log, logError);
|
||||||
catch (err) {
|
|
||||||
logError(`V2 setup error: ${String(err)}`);
|
|
||||||
return () => { };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Export
|
// Export
|
||||||
|
|
@ -759,7 +682,13 @@ async function v2Setup(ctx) {
|
||||||
export default {
|
export default {
|
||||||
id: "matrix-plugin",
|
id: "matrix-plugin",
|
||||||
async setup(ctx) {
|
async setup(ctx) {
|
||||||
log("Using V2 path (full context)");
|
try {
|
||||||
return await v2Setup(ctx);
|
return await v2Setup(ctx);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const { logError } = await import("./logger.js");
|
||||||
|
logError(`V2 setup error: ${String(err)}`);
|
||||||
|
return () => { };
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
191
index.ts
191
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"
|
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 {
|
function ensureDataDir(dir: string): void {
|
||||||
|
const fs = require("fs")
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Global mutex — prevents double startup (server + client both load plugins)
|
// Global mutex
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
let LOCK_FILE = ""
|
let LOCK_FILE = ""
|
||||||
|
|
||||||
function tryAcquireLock(): boolean {
|
function tryAcquireLock(): boolean {
|
||||||
|
const fs = require("fs")
|
||||||
try {
|
try {
|
||||||
// Check if an existing lock is stale (process no longer running)
|
|
||||||
if (fs.existsSync(LOCK_FILE)) {
|
if (fs.existsSync(LOCK_FILE)) {
|
||||||
const existingPid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10)
|
const existingPid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10)
|
||||||
if (existingPid && existingPid !== process.pid) {
|
if (existingPid && existingPid !== process.pid) {
|
||||||
try {
|
try {
|
||||||
process.kill(existingPid, 0) // throws if process doesn't exist
|
process.kill(existingPid, 0)
|
||||||
} catch {
|
} catch {
|
||||||
// Process dead, remove stale lock
|
|
||||||
fs.unlinkSync(LOCK_FILE)
|
fs.unlinkSync(LOCK_FILE)
|
||||||
}
|
}
|
||||||
} else if (existingPid === process.pid) {
|
} else if (existingPid === process.pid) {
|
||||||
// Same process re-entering (shouldn't happen, but be safe)
|
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
return false // Valid lock held by another process
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fs.writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o644 })
|
fs.writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o644 })
|
||||||
|
|
@ -58,6 +47,7 @@ function tryAcquireLock(): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseLock(): void {
|
function releaseLock(): void {
|
||||||
|
const fs = require("fs")
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(LOCK_FILE)
|
fs.unlinkSync(LOCK_FILE)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -69,11 +59,17 @@ function releaseLock(): void {
|
||||||
// Plugin Setup
|
// 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
|
// Dynamic imports — loaded AFTER ensureNativeBinary() completes
|
||||||
// (static imports would be hoisted and evaluated before ensureNativeBinary())
|
|
||||||
const { MatrixBotClient } = await import("./matrix-client.js")
|
const { MatrixBotClient } = await import("./matrix-client.js")
|
||||||
const { SessionManager } = await import("./session-manager.js")
|
const { SessionManager } = await import("./session-manager.js")
|
||||||
|
const { loadConfig } = await import("./config-loader.js")
|
||||||
|
|
||||||
ensureDataDir(dataDir)
|
ensureDataDir(dataDir)
|
||||||
LOCK_FILE = `${dataDir}/lock`
|
LOCK_FILE = `${dataDir}/lock`
|
||||||
|
|
@ -81,7 +77,7 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
const directory = ctx?.location?.directory || process.cwd()
|
const directory = ctx?.location?.directory || process.cwd()
|
||||||
log(`Plugin setup started, directory=${directory}`)
|
log(`Plugin setup started, directory=${directory}`)
|
||||||
|
|
||||||
// Acquire global lock — only one instance (server or client) starts the bot
|
// Acquire global lock
|
||||||
const locked = tryAcquireLock()
|
const locked = tryAcquireLock()
|
||||||
if (!locked) {
|
if (!locked) {
|
||||||
log("Another instance already running (lock held), skipping bot startup")
|
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<string, { roomId: string; threadRootId: string; context: any }>()
|
const sessionContextMap = new Map<string, { roomId: string; threadRootId: string; context: any }>()
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Pending prompt responses — maps sessionID -> { resolve, reject }
|
// Pending prompt responses
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const pendingResponses = new Map<string, {
|
const pendingResponses = new Map<string, {
|
||||||
|
|
@ -117,7 +113,7 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Step tracking — each step is one LLM turn, sent as a separate Matrix message
|
// Step tracking
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
interface ToolCall {
|
interface ToolCall {
|
||||||
|
|
@ -136,19 +132,17 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
steps: Map<string, StepData> // assistantMessageID → step
|
steps: Map<string, StepData>
|
||||||
currentStepID: string | null
|
currentStepID: string | null
|
||||||
context: MatrixEventContext
|
context: MatrixEventContext
|
||||||
finalResponse?: string
|
finalResponse?: string
|
||||||
done: boolean // true после finish:stop
|
done: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionsBySessionID = new Map<string, SessionState>()
|
const sessionsBySessionID = new Map<string, SessionState>()
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Parse tool calls from CodeMode JS script using AST (same as OpenCode)
|
// Parse tool calls from CodeMode JS script using AST
|
||||||
// CodeMode creates nested tools: tools.<server>.<tool>(...)
|
|
||||||
// e.g. tools.duckduckgo.search({ query: "..." })
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
interface ChildToolInfo {
|
interface ChildToolInfo {
|
||||||
|
|
@ -156,19 +150,15 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
args: string
|
args: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal AST node type (acorn doesn't export AST namespace in usable way)
|
|
||||||
type AstNode = { type: string; [key: string]: any }
|
type AstNode = { type: string; [key: string]: any }
|
||||||
|
|
||||||
/**
|
async function parseCodeModeTools(code: string): Promise<ChildToolInfo[]> {
|
||||||
* 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 results: ChildToolInfo[] = []
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
|
|
||||||
// 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, {
|
const ast = acorn.parse(code, {
|
||||||
ecmaVersion: "latest",
|
ecmaVersion: "latest",
|
||||||
sourceType: "script",
|
sourceType: "script",
|
||||||
|
|
@ -177,10 +167,8 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
locations: true,
|
locations: true,
|
||||||
}) as AstNode
|
}) as AstNode
|
||||||
|
|
||||||
// Walk the AST and find all CallExpression nodes for tools.xxx.yyy(...)
|
|
||||||
acornWalk.simple(ast as any, {
|
acornWalk.simple(ast as any, {
|
||||||
CallExpression(node) {
|
CallExpression(node: AstNode) {
|
||||||
// Check if callee is tools.<server>.<tool>(...)
|
|
||||||
const toolCall = extractToolCall(node)
|
const toolCall = extractToolCall(node)
|
||||||
if (!toolCall) return
|
if (!toolCall) return
|
||||||
|
|
||||||
|
|
@ -189,7 +177,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
if (seen.has(name)) return
|
if (seen.has(name)) return
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
|
|
||||||
// Evaluate the arguments to get their string representation
|
|
||||||
const argsStr = evaluateArgs(args)
|
const argsStr = evaluateArgs(args)
|
||||||
results.push({ name, args: argsStr })
|
results.push({ name, args: argsStr })
|
||||||
},
|
},
|
||||||
|
|
@ -198,14 +185,9 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a CallExpression is a tool call: tools.<server>.<tool>(...)
|
|
||||||
* Returns { path, args } or null.
|
|
||||||
*/
|
|
||||||
function extractToolCall(node: AstNode): { path: string[]; args: AstNode[] } | null {
|
function extractToolCall(node: AstNode): { path: string[]; args: AstNode[] } | null {
|
||||||
const callee = node.callee as AstNode
|
const callee = node.callee as AstNode
|
||||||
|
|
||||||
// Must be a MemberExpression: tools.xxx.yyy
|
|
||||||
if (callee.type !== "MemberExpression") return null
|
if (callee.type !== "MemberExpression") return null
|
||||||
if (callee.computed) 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
|
if (prop.type !== "Identifier") return null
|
||||||
const toolName = prop.name
|
const toolName = prop.name
|
||||||
|
|
||||||
// Object must be tools.xxx (nested MemberExpression) or just "tools"
|
|
||||||
let obj: AstNode = callee.object
|
let obj: AstNode = callee.object
|
||||||
const path: string[] = []
|
const path: string[] = []
|
||||||
|
|
||||||
// Walk up the nested MemberExpression chain
|
|
||||||
while (obj.type === "MemberExpression" && !obj.computed) {
|
while (obj.type === "MemberExpression" && !obj.computed) {
|
||||||
const innerProp = obj.property as AstNode
|
const innerProp = obj.property as AstNode
|
||||||
if (innerProp.type !== "Identifier") return null
|
if (innerProp.type !== "Identifier") return null
|
||||||
|
|
@ -225,18 +205,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
obj = obj.object
|
obj = obj.object
|
||||||
}
|
}
|
||||||
|
|
||||||
// The base must be the identifier "tools"
|
|
||||||
if (obj.type !== "Identifier" || obj.name !== "tools") return null
|
if (obj.type !== "Identifier" || obj.name !== "tools") return null
|
||||||
|
|
||||||
path.unshift(toolName)
|
path.unshift(toolName)
|
||||||
return { path, args: (node.arguments ?? []) as AstNode[] }
|
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 {
|
function evaluateArgs(args: AstNode[]): string {
|
||||||
if (!args || args.length === 0) return ""
|
if (!args || args.length === 0) return ""
|
||||||
|
|
||||||
|
|
@ -248,29 +222,20 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
return values.join(", ")
|
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 {
|
function evaluateExpression(node: AstNode): string {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
// Literals
|
|
||||||
case "Literal":
|
case "Literal":
|
||||||
return String(node.value)
|
return String(node.value)
|
||||||
|
|
||||||
// String/number/boolean/null/regexp literals
|
|
||||||
case "Identifier": {
|
case "Identifier": {
|
||||||
const name = node.name
|
const name = node.name
|
||||||
// Known globals with known values
|
|
||||||
if (name === "undefined") return "undefined"
|
if (name === "undefined") return "undefined"
|
||||||
if (name === "true") return "true"
|
if (name === "true") return "true"
|
||||||
if (name === "false") return "false"
|
if (name === "false") return "false"
|
||||||
if (name === "null") return "null"
|
if (name === "null") return "null"
|
||||||
// For variables, show the variable name
|
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Object literals: { key: value, ... }
|
|
||||||
case "ObjectExpression": {
|
case "ObjectExpression": {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
for (const prop of node.properties ?? []) {
|
for (const prop of node.properties ?? []) {
|
||||||
|
|
@ -283,7 +248,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
return `{ ${parts.join(", ")} }`
|
return `{ ${parts.join(", ")} }`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Array literals: [value1, value2, ...]
|
|
||||||
case "ArrayExpression": {
|
case "ArrayExpression": {
|
||||||
const elements = (node.elements ?? []).map((el: AstNode | null) =>
|
const elements = (node.elements ?? []).map((el: AstNode | null) =>
|
||||||
el ? evaluateExpression(el) : "",
|
el ? evaluateExpression(el) : "",
|
||||||
|
|
@ -291,7 +255,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
return `[${elements.join(", ")}]`
|
return `[${elements.join(", ")}]`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Template literals: `text ${expr} text`
|
|
||||||
case "TemplateLiteral": {
|
case "TemplateLiteral": {
|
||||||
let result = "`"
|
let result = "`"
|
||||||
const quasis = node.quasis ?? []
|
const quasis = node.quasis ?? []
|
||||||
|
|
@ -306,24 +269,20 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binary expressions: a + b, a === b, etc.
|
|
||||||
case "BinaryExpression": {
|
case "BinaryExpression": {
|
||||||
const left = evaluateExpression(node.left)
|
const left = evaluateExpression(node.left)
|
||||||
const right = evaluateExpression(node.right)
|
const right = evaluateExpression(node.right)
|
||||||
return `${left} ${node.operator} ${right}`
|
return `${left} ${node.operator} ${right}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conditional: a ? b : c
|
|
||||||
case "ConditionalExpression": {
|
case "ConditionalExpression": {
|
||||||
return `${evaluateExpression(node.test)} ? ${evaluateExpression(node.consequent)} : ${evaluateExpression(node.alternate)}`
|
return `${evaluateExpression(node.test)} ? ${evaluateExpression(node.consequent)} : ${evaluateExpression(node.alternate)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function expressions (arrow functions, etc.)
|
|
||||||
case "ArrowFunctionExpression":
|
case "ArrowFunctionExpression":
|
||||||
case "FunctionExpression":
|
case "FunctionExpression":
|
||||||
return "function"
|
return "function"
|
||||||
|
|
||||||
// Call expressions: other().method()
|
|
||||||
case "CallExpression": {
|
case "CallExpression": {
|
||||||
if (node.callee?.type === "MemberExpression") {
|
if (node.callee?.type === "MemberExpression") {
|
||||||
const member = node.callee as AstNode
|
const member = node.callee as AstNode
|
||||||
|
|
@ -334,35 +293,30 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
return "call"
|
return "call"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unary expressions: !a, -b, typeof c
|
|
||||||
case "UnaryExpression": {
|
case "UnaryExpression": {
|
||||||
const arg = evaluateExpression(node.argument)
|
const arg = evaluateExpression(node.argument)
|
||||||
return `${node.operator}${arg}`
|
return `${node.operator}${arg}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logical expressions: a && b, a || b
|
|
||||||
case "LogicalExpression": {
|
case "LogicalExpression": {
|
||||||
return `${evaluateExpression(node.left)} ${node.operator} ${evaluateExpression(node.right)}`
|
return `${evaluateExpression(node.left)} ${node.operator} ${evaluateExpression(node.right)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Await expressions
|
|
||||||
case "AwaitExpression": {
|
case "AwaitExpression": {
|
||||||
return `await ${evaluateExpression(node.argument)}`
|
return `await ${evaluateExpression(node.argument)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spread: ...arr
|
|
||||||
case "SpreadElement": {
|
case "SpreadElement": {
|
||||||
return `...${evaluateExpression(node.argument)}`
|
return `...${evaluateExpression(node.argument)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: show "(expr)"
|
|
||||||
default:
|
default:
|
||||||
return `(expr:${node.type})`
|
return `(expr:${node.type})`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Event subscription — collects step data and sends messages per step
|
// Event subscription
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
@ -377,14 +331,10 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
const sessionId = eventData?.data?.sessionID || ""
|
const sessionId = eventData?.data?.sessionID || ""
|
||||||
const assistantMsgID = eventData?.data?.assistantMessageID || ""
|
const assistantMsgID = eventData?.data?.assistantMessageID || ""
|
||||||
|
|
||||||
// Skip all events after session is done
|
|
||||||
const state = sessionsBySessionID.get(sessionId)
|
const state = sessionsBySessionID.get(sessionId)
|
||||||
if (state?.done) continue
|
if (state?.done) continue
|
||||||
|
|
||||||
// ── Step lifecycle ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
if (eventType === "session.step.started") {
|
if (eventType === "session.step.started") {
|
||||||
// SessionState created in message handler after prompt
|
|
||||||
if (!sessionsBySessionID.has(sessionId)) {
|
if (!sessionsBySessionID.has(sessionId)) {
|
||||||
log(`step.started for unknown session ${sessionId}`)
|
log(`step.started for unknown session ${sessionId}`)
|
||||||
continue
|
continue
|
||||||
|
|
@ -400,8 +350,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reasoning ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if (eventType === "session.reasoning.delta" && assistantMsgID) {
|
if (eventType === "session.reasoning.delta" && assistantMsgID) {
|
||||||
const state = sessionsBySessionID.get(sessionId)
|
const state = sessionsBySessionID.get(sessionId)
|
||||||
const step = state?.steps.get(assistantMsgID)
|
const step = state?.steps.get(assistantMsgID)
|
||||||
|
|
@ -416,8 +364,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tool calls ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if (eventType === "session.tool.input.started" && assistantMsgID) {
|
if (eventType === "session.tool.input.started" && assistantMsgID) {
|
||||||
const toolID = eventData?.data?.id || ""
|
const toolID = eventData?.data?.id || ""
|
||||||
const name = eventData?.data?.name || ""
|
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)
|
const tool = step.tools.find(t => t.toolID === toolID)
|
||||||
if (tool) tool.args = args
|
if (tool) tool.args = args
|
||||||
}
|
}
|
||||||
// Parse CodeMode execute tool — extract child tool calls from JS code
|
|
||||||
if (toolID && step && args) {
|
if (toolID && step && args) {
|
||||||
let parsedArgs: any
|
let parsedArgs: any
|
||||||
try { parsedArgs = JSON.parse(args) } catch { continue }
|
try { parsedArgs = JSON.parse(args) } catch { continue }
|
||||||
const code = parsedArgs?.code || ""
|
const code = parsedArgs?.code || ""
|
||||||
if (code) {
|
if (code) {
|
||||||
const childTools = parseCodeModeTools(code)
|
const childTools = await parseCodeModeTools(code)
|
||||||
for (const child of childTools) {
|
for (const child of childTools) {
|
||||||
if (!step.tools.find(t => t.name === child.name)) {
|
if (!step.tools.find(t => t.name === child.name)) {
|
||||||
step.tools.push({
|
step.tools.push({
|
||||||
|
|
@ -492,11 +437,8 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Text output ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if (eventType === "session.text.ended" && assistantMsgID) {
|
if (eventType === "session.text.ended" && assistantMsgID) {
|
||||||
const state = sessionsBySessionID.get(sessionId)
|
const state = sessionsBySessionID.get(sessionId)
|
||||||
// Skip text events after final step
|
|
||||||
if (state?.done) continue
|
if (state?.done) continue
|
||||||
const step = state?.steps.get(assistantMsgID)
|
const step = state?.steps.get(assistantMsgID)
|
||||||
if (step) {
|
if (step) {
|
||||||
|
|
@ -506,12 +448,9 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step ended — send message for this step ────────────────────
|
|
||||||
|
|
||||||
if (eventType === "session.step.ended" && sessionId && assistantMsgID) {
|
if (eventType === "session.step.ended" && sessionId && assistantMsgID) {
|
||||||
const state = sessionsBySessionID.get(sessionId)
|
const state = sessionsBySessionID.get(sessionId)
|
||||||
|
|
||||||
// Skip steps after final (finish:stop) — they are re-projections/duplicates
|
|
||||||
if (state?.done) continue
|
if (state?.done) continue
|
||||||
|
|
||||||
const step = state?.steps.get(assistantMsgID)
|
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}`)
|
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) {
|
if (finish === "stop" && state) {
|
||||||
state.done = true
|
state.done = true
|
||||||
const pending = pendingResponses.get(sessionId)
|
const pending = pendingResponses.get(sessionId)
|
||||||
|
|
@ -537,8 +475,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Execution errors ───────────────────────────────────────────
|
|
||||||
|
|
||||||
if (eventType === "session.execution.failed" && sessionId) {
|
if (eventType === "session.execution.failed" && sessionId) {
|
||||||
const pending = pendingResponses.get(sessionId)
|
const pending = pendingResponses.get(sessionId)
|
||||||
if (pending) {
|
if (pending) {
|
||||||
|
|
@ -575,14 +511,11 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
function formatStepMessage(step: StepData): string {
|
function formatStepMessage(step: StepData): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|
||||||
// Reasoning in spoiler
|
|
||||||
if (step.reasoning) {
|
if (step.reasoning) {
|
||||||
parts.push(`<details>\n<summary>Thinking</summary>\n\n${step.reasoning}\n\n</details>`)
|
parts.push(`<details>\n<summary>Thinking</summary>\n\n${step.reasoning}\n\n</details>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool calls
|
|
||||||
if (step.tools.length > 0) {
|
if (step.tools.length > 0) {
|
||||||
// Check if any tool is an execute with child tool calls
|
|
||||||
const executeTool = step.tools.find(
|
const executeTool = step.tools.find(
|
||||||
(t) => t.name === "execute" && t.toolID.includes("/"),
|
(t) => t.name === "execute" && t.toolID.includes("/"),
|
||||||
)
|
)
|
||||||
|
|
@ -591,7 +524,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
: []
|
: []
|
||||||
|
|
||||||
if (childTools.length > 0) {
|
if (childTools.length > 0) {
|
||||||
// Execute has child tool calls — show children, then execute result
|
|
||||||
for (const child of childTools) {
|
for (const child of childTools) {
|
||||||
parts.push(`<details>\n<summary>🔧 ${child.name}</summary>\n\n`)
|
parts.push(`<details>\n<summary>🔧 ${child.name}</summary>\n\n`)
|
||||||
parts.push(`<b>Args:</b>\n\`\`\`\n${child.args}\n\`\`\`\n\n`)
|
parts.push(`<b>Args:</b>\n\`\`\`\n${child.args}\n\`\`\`\n\n`)
|
||||||
|
|
@ -602,14 +534,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
}
|
}
|
||||||
parts.push(`</details>`)
|
parts.push(`</details>`)
|
||||||
}
|
}
|
||||||
// Execute result as a separate spoiler (like a regular tool result)
|
|
||||||
if (executeTool?.result) {
|
if (executeTool?.result) {
|
||||||
parts.push(`<details>\n<summary>🔧 execute</summary>\n\n`)
|
parts.push(`<details>\n<summary>🔧 execute</summary>\n\n`)
|
||||||
parts.push(`<b>Result:</b>\n\`\`\`\n${executeTool.result}\n\`\`\`\n\n`)
|
parts.push(`<b>Result:</b>\n\`\`\`\n${executeTool.result}\n\`\`\`\n\n`)
|
||||||
parts.push(`</details>`)
|
parts.push(`</details>`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No child tool calls — show tools normally (including execute as-is)
|
|
||||||
for (const tool of step.tools) {
|
for (const tool of step.tools) {
|
||||||
parts.push(`<details>\n<summary>🔧 ${tool.name}</summary>\n\n`)
|
parts.push(`<details>\n<summary>🔧 ${tool.name}</summary>\n\n`)
|
||||||
parts.push(`<b>Args:</b>\n\`\`\`\n${tool.args}\n\`\`\`\n\n`)
|
parts.push(`<b>Args:</b>\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 (step.text) {
|
||||||
if (parts.length > 0) parts.push("\n---\n")
|
if (parts.length > 0) parts.push("\n---\n")
|
||||||
parts.push(step.text)
|
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(
|
async function sendMessageToMatrix(
|
||||||
|
|
@ -652,13 +581,11 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
matrix.on(async (data: any) => {
|
matrix.on(async (data: any) => {
|
||||||
const { context, query, sender, roomId, eventId, timestamp } = data
|
const { context, query, sender, roomId, eventId, timestamp } = data
|
||||||
|
|
||||||
// Check rate limit
|
|
||||||
if (sessionManager.isRateLimited(sender)) {
|
if (sessionManager.isRateLimited(sender)) {
|
||||||
await matrix.sendNotice(context, "Rate limited. Please wait a moment.")
|
await matrix.sendNotice(context, "Rate limited. Please wait a moment.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for active query
|
|
||||||
if (sessionManager.hasActiveQuery(roomId, context.replyThreadRootId || context.eventId)) {
|
if (sessionManager.hasActiveQuery(roomId, context.replyThreadRootId || context.eventId)) {
|
||||||
await matrix.sendNotice(context, "A request is already running in this thread. Please wait.")
|
await matrix.sendNotice(context, "A request is already running in this thread. Please wait.")
|
||||||
return
|
return
|
||||||
|
|
@ -677,7 +604,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
|
|
||||||
let opencodeSessionId = session.opencodeSessionId
|
let opencodeSessionId = session.opencodeSessionId
|
||||||
|
|
||||||
// If we have a session ID from the map, verify it exists in OpenCode
|
|
||||||
if (opencodeSessionId) {
|
if (opencodeSessionId) {
|
||||||
try {
|
try {
|
||||||
await ctx.session.get({ sessionID: opencodeSessionId })
|
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) {
|
if (!opencodeSessionId) {
|
||||||
log(`Creating new OpenCode session for ${roomId}:${threadRootId}`)
|
log(`Creating new OpenCode session for ${roomId}:${threadRootId}`)
|
||||||
try {
|
try {
|
||||||
|
|
@ -709,14 +634,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Trigger prompt — returns admission receipt, not response text
|
|
||||||
await ctx.session.prompt({
|
await ctx.session.prompt({
|
||||||
sessionID: opencodeSessionId,
|
sessionID: opencodeSessionId,
|
||||||
text: query,
|
text: query,
|
||||||
})
|
})
|
||||||
log(`Prompt sent to session ${opencodeSessionId}`)
|
log(`Prompt sent to session ${opencodeSessionId}`)
|
||||||
|
|
||||||
// Create session state for step tracking
|
|
||||||
sessionsBySessionID.set(opencodeSessionId, {
|
sessionsBySessionID.set(opencodeSessionId, {
|
||||||
steps: new Map(),
|
steps: new Map(),
|
||||||
currentStepID: null,
|
currentStepID: null,
|
||||||
|
|
@ -725,16 +648,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
done: false,
|
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<string>((resolve, reject) => {
|
const responsePromise = new Promise<string>((resolve, reject) => {
|
||||||
pendingResponses.set(opencodeSessionId, { resolve, reject })
|
pendingResponses.set(opencodeSessionId, { resolve, reject })
|
||||||
})
|
})
|
||||||
|
|
||||||
const responseText = await responsePromise
|
const responseText = await responsePromise
|
||||||
session.outputChars += responseText.length
|
session.outputChars += responseText.length
|
||||||
// Step messages already sent by event subscription — no double send
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
log(`Error processing message: ${String(err)}`)
|
log(`Error processing message: ${String(err)}`)
|
||||||
const errMsg = err?.response?.error?.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]
|
const originalListener = (matrix as any)["listeners"][0]
|
||||||
;(matrix as any)["listeners"] = []
|
;(matrix as any)["listeners"] = []
|
||||||
matrix.on(async (data: any) => {
|
matrix.on(async (data: any) => {
|
||||||
const { context, query, sender, eventId } = data
|
const { context, query, sender, eventId } = data
|
||||||
|
|
||||||
// Handle bridge commands
|
|
||||||
if (query.startsWith("/")) {
|
if (query.startsWith("/")) {
|
||||||
const cmdName = query.slice(1).split(" ")[0].toLowerCase()
|
const cmdName = query.slice(1).split(" ")[0].toLowerCase()
|
||||||
const bridgeCommands = ["status", "clear", "reset", "help", "h"]
|
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) {
|
if (originalListener) {
|
||||||
await originalListener(data)
|
await originalListener(data)
|
||||||
}
|
}
|
||||||
|
|
@ -836,7 +753,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
log("Plugin cleaning up...")
|
log("Plugin cleaning up...")
|
||||||
eventController.abort()
|
eventController.abort()
|
||||||
matrix.stop()
|
matrix.stop()
|
||||||
// Don't release lock — keep it on disk so client always sees it
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanup
|
return cleanup
|
||||||
|
|
@ -852,24 +768,32 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
async function v2Setup(ctx: any) {
|
async function v2Setup(ctx: any) {
|
||||||
const rawStorage = (ctx.options as any)?.storagePath
|
// 1. Download native binary BEFORE any matrix imports
|
||||||
const dataDir = rawStorage
|
const { ensureNativeBinary } = await import("./bootstrap-native.js")
|
||||||
? path.join(rawStorage, "plugin-data")
|
ensureNativeBinary()
|
||||||
: path.join(os.homedir(), ".local", "share", "opencode-matrix-bot", "plugin-data")
|
|
||||||
ensureDataDir(dataDir)
|
// 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) {
|
if (ctx?.app?.log) {
|
||||||
setAppLog(ctx.app.log.bind(ctx.app))
|
setAppLog(ctx.app.log.bind(ctx.app))
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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)
|
const { options } = await loadConfig(ctx.options)
|
||||||
return await runPlugin(ctx, options, dataDir)
|
|
||||||
} catch (err) {
|
// 4. Run plugin (handles all matrix imports dynamically)
|
||||||
logError(`V2 setup error: ${String(err)}`)
|
return await runPlugin(ctx, options, dataDir, log, logError)
|
||||||
return () => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -879,7 +803,12 @@ async function v2Setup(ctx: any) {
|
||||||
export default {
|
export default {
|
||||||
id: "matrix-plugin",
|
id: "matrix-plugin",
|
||||||
async setup(ctx: any) {
|
async setup(ctx: any) {
|
||||||
log("Using V2 path (full context)")
|
try {
|
||||||
return await v2Setup(ctx)
|
return await v2Setup(ctx)
|
||||||
|
} catch (err) {
|
||||||
|
const { logError } = await import("./logger.js")
|
||||||
|
logError(`V2 setup error: ${String(err)}`)
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue