From 34f90534d237dae47421d0027bd08e3d41f808b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D0=BE=D1=80=D0=BE=D0=B4=D0=B8=D0=BD=20=D0=A0=D0=BE?= =?UTF-8?q?=D0=BC=D0=B0=D0=BD?= Date: Sat, 26 Sep 2026 12:48:43 +0300 Subject: [PATCH] feat: use acorn AST parser (same as CodeMode) to extract tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - acorn.parse() парсит JS в AST (тот же парсер, что в CodeMode) - acorn-walk.simple() обходит AST и находит CallExpression для tools.xxx.yyy - evaluateExpression() извлекает аргументы из AST (literals, identifiers, objects, arrays, template literals, binary, conditional, await, spread) - НЕ выполняет код — только парсит и анализирует AST --- index.ts | 237 ++++++++++++++++++++++++++++++++++++---------- package-lock.json | 166 ++++++++++++++++++++++++++++++-- package.json | 12 ++- 3 files changed, 352 insertions(+), 63 deletions(-) diff --git a/index.ts b/index.ts index a2a3bca..0f0a0be 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,8 @@ import fs from "fs" import os from "os" import path from "path" +import * as acorn from "acorn" +import * as acornWalk from "acorn-walk" import { MatrixBotClient, type MatrixEventContext } from "./matrix-client.js" import { SessionManager } from "./session-manager.js" import { loadConfig } from "./config-loader.js" @@ -132,7 +134,7 @@ async function runPlugin(ctx: any, options: any, dataDir: string) { const sessionsBySessionID = new Map() // ============================================================================= - // Parse tool calls from CodeMode JS script + // Parse tool calls from CodeMode JS script using AST (same as OpenCode) // CodeMode creates nested tools: tools..(...) // e.g. tools.duckduckgo.search({ query: "..." }) // ============================================================================= @@ -142,72 +144,209 @@ 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[] { const results: ChildToolInfo[] = [] const seen = new Set() - const regex = /tools\.(\w+)\.(\w+)\s*\(/g - let match - while ((match = regex.exec(code)) !== null) { - const toolName = `${match[1]}_${match[2]}` - if (seen.has(toolName)) continue - seen.add(toolName) + // Parse the code into an AST using acorn (same parser as CodeMode) + const ast = acorn.parse(code, { + ecmaVersion: "latest", + sourceType: "script", + allowReturnOutsideFunction: true, + allowAwaitOutsideFunction: true, + locations: true, + }) as AstNode - // Extract the full argument block starting from the ( - const openParenPos = match.index + match[0].indexOf("(") - const args = extractArgumentBlock(code, openParenPos) - results.push({ name: toolName, args }) - } + // Walk the AST and find all CallExpression nodes for tools.xxx.yyy(...) + acornWalk.simple(ast as any, { + CallExpression(node) { + // Check if callee is tools..(...) + const toolCall = extractToolCall(node) + if (!toolCall) return + + const { path, args } = toolCall + const name = path.join("_") + if (seen.has(name)) return + seen.add(name) + + // Evaluate the arguments to get their string representation + const argsStr = evaluateArgs(args) + results.push({ name, args: argsStr }) + }, + }) return results } /** - * Extract argument block from code starting at the opening parenthesis. - * Handles nested parentheses, brackets, and braces. - * e.g. for tools.duckduckgo.fetch_content({ url: url, max_length: 5000 }) - * returns "{ url: url, max_length: 5000 }" + * Check if a CallExpression is a tool call: tools..(...) + * Returns { path, args } or null. */ - function extractArgumentBlock(code: string, openParenPos: number): string { - let depth = 0 - let start = openParenPos - let end = openParenPos + function extractToolCall(node: AstNode): { path: string[]; args: AstNode[] } | null { + const callee = node.callee as AstNode - // Find the opening character: (, {, [, or " - while (start < code.length) { - const ch = code[start] - if (ch === "(" || ch === "{" || ch === "[" || ch === '"') break - start++ + // Must be a MemberExpression: tools.xxx.yyy + if (callee.type !== "MemberExpression") return null + if (callee.computed) return null + + const prop = callee.property as AstNode + if (prop.type !== "Identifier") return null + const toolName = prop.name + + // Object must be tools.xxx (nested MemberExpression) or just "tools" + let obj: AstNode = callee.object + const path: string[] = [] + + // Walk up the nested MemberExpression chain + while (obj.type === "MemberExpression" && !obj.computed) { + const innerProp = obj.property as AstNode + if (innerProp.type !== "Identifier") return null + path.unshift(innerProp.name) + obj = obj.object } - if (start >= code.length) return "(variable)" + // The base must be the identifier "tools" + if (obj.type !== "Identifier" || obj.name !== "tools") return null - const openCh = code[start] - const closeCh = openCh === "(" ? ")" : openCh === "{" ? "}" : openCh === "[" ? "]" : '"' + path.unshift(toolName) + return { path, args: (node.arguments ?? []) as AstNode[] } + } - for (let i = start; i < code.length; i++) { - const ch = code[i] - if (ch === openCh) depth++ - else if (ch === closeCh) { - depth-- - if (depth === 0) { - end = i + 1 - break - } - } - // Skip string literals to avoid counting quotes inside strings - if (ch === '"' || ch === "'") { - let j = i + 1 - while (j < code.length && code[j] !== ch) { - if (code[j] === "\\") j++ - j++ - } - i = j - } + /** + * Evaluate arguments to a string representation. + * Handles literals, identifiers, objects, arrays, and template literals. + * Matches how CodeMode's interpreter evaluates expression values. + */ + function evaluateArgs(args: AstNode[]): string { + if (!args || args.length === 0) return "" + + const values: string[] = [] + for (const arg of args) { + values.push(evaluateExpression(arg)) } - const result = code.slice(start, end).trim() - return result || "(variable)" + return values.join(", ") + } + + /** + * Evaluate an AST expression node to its string value. + * Handles the expression types used in CodeMode tool calls. + */ + function evaluateExpression(node: AstNode): string { + switch (node.type) { + // Literals + case "Literal": + return String(node.value) + + // String/number/boolean/null/regexp literals + case "Identifier": { + const name = node.name + // Known globals with known values + if (name === "undefined") return "undefined" + if (name === "true") return "true" + if (name === "false") return "false" + if (name === "null") return "null" + // For variables, show the variable name + return name + } + + // Object literals: { key: value, ... } + case "ObjectExpression": { + const parts: string[] = [] + for (const prop of node.properties ?? []) { + if (prop.type === "Property") { + const key = prop.key.type === "Identifier" ? prop.key.name : String((prop.key as AstNode).value) + const value = evaluateExpression(prop.value) + parts.push(`${key}: ${value}`) + } + } + return `{ ${parts.join(", ")} }` + } + + // Array literals: [value1, value2, ...] + case "ArrayExpression": { + const elements = (node.elements ?? []).map((el: AstNode | null) => + el ? evaluateExpression(el) : "", + ) + return `[${elements.join(", ")}]` + } + + // Template literals: `text ${expr} text` + case "TemplateLiteral": { + let result = "`" + const quasis = node.quasis ?? [] + const expressions = node.expressions ?? [] + for (let i = 0; i < quasis.length; i++) { + result += quasis[i]?.value?.raw ?? "" + if (expressions[i]) { + result += "${" + evaluateExpression(expressions[i]) + "}" + } + } + result += "`" + return result + } + + // Binary expressions: a + b, a === b, etc. + case "BinaryExpression": { + const left = evaluateExpression(node.left) + const right = evaluateExpression(node.right) + return `${left} ${node.operator} ${right}` + } + + // Conditional: a ? b : c + case "ConditionalExpression": { + return `${evaluateExpression(node.test)} ? ${evaluateExpression(node.consequent)} : ${evaluateExpression(node.alternate)}` + } + + // Function expressions (arrow functions, etc.) + case "ArrowFunctionExpression": + case "FunctionExpression": + return "function" + + // Call expressions: other().method() + case "CallExpression": { + if (node.callee?.type === "MemberExpression") { + const member = node.callee as AstNode + if (member.object?.type === "Identifier") { + return `${(member.object as AstNode).name}.${(member.property as AstNode)?.name}(${evaluateArgs(node.arguments ?? [])})` + } + } + return "call" + } + + // Unary expressions: !a, -b, typeof c + case "UnaryExpression": { + const arg = evaluateExpression(node.argument) + return `${node.operator}${arg}` + } + + // Logical expressions: a && b, a || b + case "LogicalExpression": { + return `${evaluateExpression(node.left)} ${node.operator} ${evaluateExpression(node.right)}` + } + + // Await expressions + case "AwaitExpression": { + return `await ${evaluateExpression(node.argument)}` + } + + // Spread: ...arr + case "SpreadElement": { + return `...${evaluateExpression(node.argument)}` + } + + // Default: show "(expr)" + default: + return `(expr:${node.type})` + } } // ============================================================================= diff --git a/package-lock.json b/package-lock.json index 010df29..0cd8697 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,23 @@ { - "name": "opencode-matrix-plugin", + "name": "matrix-plugin", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "opencode-matrix-plugin", + "name": "matrix-plugin", "version": "1.0.0", "dependencies": { - "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", + "@opencode-ai/codemode": "^1.18.4", "@opencode/plugin": "^2.0.16", + "acorn": "^8.18.0", + "acorn-walk": "^8.3.5", + "effect": "^4.0.0-rc.112", "marked": "^15.0.0", "matrix-bot-sdk": "^0.8.0" }, "devDependencies": { + "@types/node": "^20.0.0", "typescript": "^5.7.0" } }, @@ -936,6 +940,82 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/@opencode-ai/codemode": { + "version": "1.18.4", + "resolved": "https://registry.npmjs.org/@opencode-ai/codemode/-/codemode-1.18.4.tgz", + "integrity": "sha512-Bbwj30vHydJ0znhHj9O+Pc3+FD+sPcDHN6QWHhmo8DjbzQYhGUKHWkq3lWlKw07c+RKhvdGeN8RiW2L5imzu1A==", + "license": "MIT", + "dependencies": { + "acorn": "8.15.0", + "effect": "4.0.0-beta.101", + "typescript": "5.8.2" + } + }, + "node_modules/@opencode-ai/codemode/node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/@opencode-ai/codemode/node_modules/effect": { + "version": "4.0.0-beta.101", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.101.tgz", + "integrity": "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.9.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.4", + "multipasta": "^0.2.8", + "toml": "^4.1.2", + "uuid": "^14.0.1", + "yaml": "^2.9.0" + } + }, + "node_modules/@opencode-ai/codemode/node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@opencode-ai/codemode/node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@opencode-ai/codemode/node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/@opencode/ai": { "version": "2.0.16", "resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.0.16.tgz", @@ -1660,12 +1740,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", - "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "license": "MIT", "dependencies": { - "undici-types": "~8.9.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/qs": { @@ -1762,6 +1842,30 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2762,6 +2866,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3463,6 +3573,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, "node_modules/launder": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", @@ -3934,6 +4050,12 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -5414,6 +5536,15 @@ "node": ">=0.6" } }, + "node_modules/toml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", @@ -5532,9 +5663,9 @@ } }, "node_modules/undici-types": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", - "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, "node_modules/unpipe": { @@ -5775,6 +5906,21 @@ "node": ">=18" } }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", diff --git a/package.json b/package.json index ed7fb77..bf4339e 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,16 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@opencode-ai/codemode": "^1.18.4", "@opencode/plugin": "^2.0.16", - "matrix-bot-sdk": "^0.8.0", - "marked": "^15.0.0" + "acorn": "^8.18.0", + "acorn-walk": "^8.3.5", + "effect": "^4.0.0-rc.112", + "marked": "^15.0.0", + "matrix-bot-sdk": "^0.8.0" }, "devDependencies": { - "typescript": "^5.7.0", - "@types/node": "^20.0.0" + "@types/node": "^20.0.0", + "typescript": "^5.7.0" } }