feat: use acorn AST parser (same as CodeMode) to extract tool calls

- 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
This commit is contained in:
Бородин Роман 2026-09-26 12:48:43 +03:00
parent a829e0b114
commit 34f90534d2
3 changed files with 352 additions and 63 deletions

239
index.ts
View File

@ -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<string, SessionState>()
// =============================================================================
// Parse tool calls from CodeMode JS script
// Parse tool calls from CodeMode JS script using AST (same as OpenCode)
// CodeMode creates nested tools: tools.<server>.<tool>(...)
// 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<string>()
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.<server>.<tool>(...)
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.<server>.<tool>(...)
* 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 === "[" ? "]" : '"'
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
}
path.unshift(toolName)
return { path, args: (node.arguments ?? []) as AstNode[] }
}
const result = code.slice(start, end).trim()
return result || "(variable)"
/**
* Evaluate arguments to a string representation.
* Handles literals, identifiers, objects, arrays, and template literals.
* Matches how CodeMode's interpreter evaluates expression values.
*/
function evaluateArgs(args: AstNode[]): string {
if (!args || args.length === 0) return ""
const values: string[] = []
for (const arg of args) {
values.push(evaluateExpression(arg))
}
return values.join(", ")
}
/**
* Evaluate an AST expression node to its string value.
* Handles the expression types used in CodeMode tool calls.
*/
function evaluateExpression(node: AstNode): string {
switch (node.type) {
// Literals
case "Literal":
return String(node.value)
// String/number/boolean/null/regexp literals
case "Identifier": {
const name = node.name
// Known globals with known values
if (name === "undefined") return "undefined"
if (name === "true") return "true"
if (name === "false") return "false"
if (name === "null") return "null"
// For variables, show the variable name
return name
}
// Object literals: { key: value, ... }
case "ObjectExpression": {
const parts: string[] = []
for (const prop of node.properties ?? []) {
if (prop.type === "Property") {
const key = prop.key.type === "Identifier" ? prop.key.name : String((prop.key as AstNode).value)
const value = evaluateExpression(prop.value)
parts.push(`${key}: ${value}`)
}
}
return `{ ${parts.join(", ")} }`
}
// Array literals: [value1, value2, ...]
case "ArrayExpression": {
const elements = (node.elements ?? []).map((el: AstNode | null) =>
el ? evaluateExpression(el) : "",
)
return `[${elements.join(", ")}]`
}
// Template literals: `text ${expr} text`
case "TemplateLiteral": {
let result = "`"
const quasis = node.quasis ?? []
const expressions = node.expressions ?? []
for (let i = 0; i < quasis.length; i++) {
result += quasis[i]?.value?.raw ?? ""
if (expressions[i]) {
result += "${" + evaluateExpression(expressions[i]) + "}"
}
}
result += "`"
return result
}
// Binary expressions: a + b, a === b, etc.
case "BinaryExpression": {
const left = evaluateExpression(node.left)
const right = evaluateExpression(node.right)
return `${left} ${node.operator} ${right}`
}
// Conditional: a ? b : c
case "ConditionalExpression": {
return `${evaluateExpression(node.test)} ? ${evaluateExpression(node.consequent)} : ${evaluateExpression(node.alternate)}`
}
// Function expressions (arrow functions, etc.)
case "ArrowFunctionExpression":
case "FunctionExpression":
return "function"
// Call expressions: other().method()
case "CallExpression": {
if (node.callee?.type === "MemberExpression") {
const member = node.callee as AstNode
if (member.object?.type === "Identifier") {
return `${(member.object as AstNode).name}.${(member.property as AstNode)?.name}(${evaluateArgs(node.arguments ?? [])})`
}
}
return "call"
}
// Unary expressions: !a, -b, typeof c
case "UnaryExpression": {
const arg = evaluateExpression(node.argument)
return `${node.operator}${arg}`
}
// Logical expressions: a && b, a || b
case "LogicalExpression": {
return `${evaluateExpression(node.left)} ${node.operator} ${evaluateExpression(node.right)}`
}
// Await expressions
case "AwaitExpression": {
return `await ${evaluateExpression(node.argument)}`
}
// Spread: ...arr
case "SpreadElement": {
return `...${evaluateExpression(node.argument)}`
}
// Default: show "(expr)"
default:
return `(expr:${node.type})`
}
}
// =============================================================================

166
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}