Compare commits

..

10 Commits

Author SHA1 Message Date
Бородин Роман 26cc61d26f feat: show execute result as spoiler after child tools
Порядок когда execute имеет детей:
1. Thinking spoiler
2. Каждый дочерний инструмент с args
3. Execute result spoiler
4. --- separator + step.text
2026-09-27 13:50:22 +03:00
Бородин Роман 542f72d449 fix: skip execute tool entirely when script has child tools
Результат execute уже в step.text (после ---), не дублируем спойлером.
Если в скрипте есть инструменты — показываем только их спойлеры.
Если инструментов нет — execute как обычный инструмент.
2026-09-27 13:43:00 +03:00
Бородин Роман f261e1361c feat: show execute result as separate spoiler after child tools
При наличии инструментов в execute скрипте:
1. Спойлеры для каждого распарсенного инструмента (с args)
2. Отдельный спойлер execute с результатом (после всех детей)
3. Без execute спойлера с args кода
2026-09-27 13:39:00 +03:00
Бородин Роман 1d4e7e6469 chore: remove unused codemode and effect dependencies
Плагин использует acorn + acorn-walk для парсинга AST,
а не @opencode-ai/codemode. effect был зависимостью codemode — тоже не нужен.
2026-09-26 12:55:27 +03:00
Бородин Роман 34f90534d2 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
2026-09-26 12:48:43 +03:00
Бородин Роман a829e0b114 fix: smarter CodeMode parse — skip execute, show children with extracted args
Проблемы исправлены:
- execute с детьми больше не показывается дубликатом
- Args извлекаются из кода балансирующим парсером скобок
- Показываются только дочерние инструменты с их аргументами из кода
- execute без детей работает как раньше
2026-09-26 12:24:15 +03:00
Бородин Роман 6dc6cf41c4 feat: expand execute tool into child tool spoilers when script has tool calls
- Если execute скрипт содержит tools.<server>.<tool>(...) — показываем
  отдельные спойлеры для каждого найденного инструмента
- Если нет вызовов инструментов — показываем execute как обычно
- Args для дочерних инструментов извлекаются из кода regex
2026-09-26 11:53:51 +03:00
Бородин Роман 729dee2c11 feat: parse CodeMode execute tool calls from JS script
Парсим JS код из аргумента execute инструмента и извлекаем
вызовы tools.<server>.<tool>(...) — как это делает CodeMode.
Дочерние инструменты показываются в спойлере вместе с обычными.
2026-09-26 11:46:44 +03:00
Бородин Роман 53e77e0608 Revert "feat: group all tools in single spoiler with names in summary"
This reverts commit 28992f8657.
2026-09-26 11:37:16 +03:00
Бородин Роман 28992f8657 feat: group all tools in single spoiler with names in summary
Раньше: каждый инструмент — отдельный <details>.
Теперь: один <details> с именами всех инструментов в summary,
каждый инструмент внутри с <b>toolName</b> заголовком.
2026-09-26 11:32:14 +03:00
3 changed files with 317 additions and 22 deletions

267
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"
@ -131,6 +133,222 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
const sessionsBySessionID = new Map<string, SessionState>()
// =============================================================================
// 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: "..." })
// =============================================================================
interface ChildToolInfo {
name: string
args: string
}
// Minimal AST node type (acorn doesn't export AST namespace in usable way)
type AstNode = { type: string; [key: string]: any }
/**
* Parse the CodeMode JS script and extract all tool calls.
* Uses acorn AST parser + walk — same approach as OpenCode's CodeMode interpreter.
* Does NOT execute the code, only parses and analyzes the AST.
*/
function parseCodeModeTools(code: string): ChildToolInfo[] {
const results: ChildToolInfo[] = []
const seen = new Set<string>()
// Parse the code into an AST using acorn (same parser as CodeMode)
const ast = acorn.parse(code, {
ecmaVersion: "latest",
sourceType: "script",
allowReturnOutsideFunction: true,
allowAwaitOutsideFunction: true,
locations: true,
}) as AstNode
// Walk the AST and find all CallExpression nodes for tools.xxx.yyy(...)
acornWalk.simple(ast as any, {
CallExpression(node) {
// Check if callee is tools.<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
}
/**
* 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 {
const callee = node.callee as AstNode
// Must be a MemberExpression: tools.xxx.yyy
if (callee.type !== "MemberExpression") return null
if (callee.computed) return null
const prop = callee.property as AstNode
if (prop.type !== "Identifier") return null
const toolName = prop.name
// Object must be tools.xxx (nested MemberExpression) or just "tools"
let obj: AstNode = callee.object
const path: string[] = []
// Walk up the nested MemberExpression chain
while (obj.type === "MemberExpression" && !obj.computed) {
const innerProp = obj.property as AstNode
if (innerProp.type !== "Identifier") return null
path.unshift(innerProp.name)
obj = obj.object
}
// The base must be the identifier "tools"
if (obj.type !== "Identifier" || obj.name !== "tools") return null
path.unshift(toolName)
return { path, args: (node.arguments ?? []) as AstNode[] }
}
/**
* Evaluate arguments to a string representation.
* Handles literals, identifiers, objects, arrays, and template literals.
* Matches how CodeMode's interpreter evaluates expression values.
*/
function evaluateArgs(args: AstNode[]): string {
if (!args || args.length === 0) return ""
const values: string[] = []
for (const arg of args) {
values.push(evaluateExpression(arg))
}
return values.join(", ")
}
/**
* Evaluate an AST expression node to its string value.
* Handles the expression types used in CodeMode tool calls.
*/
function evaluateExpression(node: AstNode): string {
switch (node.type) {
// Literals
case "Literal":
return String(node.value)
// String/number/boolean/null/regexp literals
case "Identifier": {
const name = node.name
// Known globals with known values
if (name === "undefined") return "undefined"
if (name === "true") return "true"
if (name === "false") return "false"
if (name === "null") return "null"
// For variables, show the variable name
return name
}
// Object literals: { key: value, ... }
case "ObjectExpression": {
const parts: string[] = []
for (const prop of node.properties ?? []) {
if (prop.type === "Property") {
const key = prop.key.type === "Identifier" ? prop.key.name : String((prop.key as AstNode).value)
const value = evaluateExpression(prop.value)
parts.push(`${key}: ${value}`)
}
}
return `{ ${parts.join(", ")} }`
}
// Array literals: [value1, value2, ...]
case "ArrayExpression": {
const elements = (node.elements ?? []).map((el: AstNode | null) =>
el ? evaluateExpression(el) : "",
)
return `[${elements.join(", ")}]`
}
// Template literals: `text ${expr} text`
case "TemplateLiteral": {
let result = "`"
const quasis = node.quasis ?? []
const expressions = node.expressions ?? []
for (let i = 0; i < quasis.length; i++) {
result += quasis[i]?.value?.raw ?? ""
if (expressions[i]) {
result += "${" + evaluateExpression(expressions[i]) + "}"
}
}
result += "`"
return result
}
// Binary expressions: a + b, a === b, etc.
case "BinaryExpression": {
const left = evaluateExpression(node.left)
const right = evaluateExpression(node.right)
return `${left} ${node.operator} ${right}`
}
// Conditional: a ? b : c
case "ConditionalExpression": {
return `${evaluateExpression(node.test)} ? ${evaluateExpression(node.consequent)} : ${evaluateExpression(node.alternate)}`
}
// Function expressions (arrow functions, etc.)
case "ArrowFunctionExpression":
case "FunctionExpression":
return "function"
// Call expressions: other().method()
case "CallExpression": {
if (node.callee?.type === "MemberExpression") {
const member = node.callee as AstNode
if (member.object?.type === "Identifier") {
return `${(member.object as AstNode).name}.${(member.property as AstNode)?.name}(${evaluateArgs(node.arguments ?? [])})`
}
}
return "call"
}
// Unary expressions: !a, -b, typeof c
case "UnaryExpression": {
const arg = evaluateExpression(node.argument)
return `${node.operator}${arg}`
}
// Logical expressions: a && b, a || b
case "LogicalExpression": {
return `${evaluateExpression(node.left)} ${node.operator} ${evaluateExpression(node.right)}`
}
// Await expressions
case "AwaitExpression": {
return `await ${evaluateExpression(node.argument)}`
}
// Spread: ...arr
case "SpreadElement": {
return `...${evaluateExpression(node.argument)}`
}
// Default: show "(expr)"
default:
return `(expr:${node.type})`
}
}
// =============================================================================
// Event subscription — collects step data and sends messages per step
// =============================================================================
@ -208,6 +426,26 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
const tool = step.tools.find(t => t.toolID === toolID)
if (tool) tool.args = args
}
// Parse CodeMode execute tool — extract child tool calls from JS code
if (toolID && step && args) {
let parsedArgs: any
try { parsedArgs = JSON.parse(args) } catch { continue }
const code = parsedArgs?.code || ""
if (code) {
const childTools = parseCodeModeTools(code)
for (const child of childTools) {
if (!step.tools.find(t => t.name === child.name)) {
step.tools.push({
toolID: `${toolID}/${child.name}`,
name: child.name,
args: child.args,
result: "",
failed: false,
})
}
}
}
}
continue
}
@ -332,6 +570,34 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
// Tool calls
if (step.tools.length > 0) {
// Check if any tool is an execute with child tool calls
const executeTool = step.tools.find(
(t) => t.name === "execute" && t.toolID.includes("/"),
)
const childTools = executeTool
? step.tools.filter((t) => t.toolID.startsWith(executeTool.toolID + "/"))
: []
if (childTools.length > 0) {
// Execute has child tool calls — show children, then execute result
for (const child of childTools) {
parts.push(`<details>\n<summary>🔧 ${child.name}</summary>\n\n`)
parts.push(`<b>Args:</b>\n\`\`\`\n${child.args}\n\`\`\`\n\n`)
if (child.failed) {
parts.push(`<b>Failed:</b> ${child.result}\n\n`)
} else if (child.result) {
parts.push(`<b>Result:</b>\n\`\`\`\n${child.result}\n\`\`\`\n\n`)
}
parts.push(`</details>`)
}
// Execute result as a separate spoiler (like a regular tool result)
if (executeTool?.result) {
parts.push(`<details>\n<summary>🔧 execute</summary>\n\n`)
parts.push(`<b>Result:</b>\n\`\`\`\n${executeTool.result}\n\`\`\`\n\n`)
parts.push(`</details>`)
}
} else {
// No child tool calls — show tools normally (including execute as-is)
for (const tool of step.tools) {
parts.push(`<details>\n<summary>🔧 ${tool.name}</summary>\n\n`)
parts.push(`<b>Args:</b>\n\`\`\`\n${tool.args}\n\`\`\`\n\n`)
@ -343,6 +609,7 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
parts.push(`</details>`)
}
}
}
// Separator before text
if (step.text) {

46
package-lock.json generated
View File

@ -1,19 +1,21 @@
{
"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/plugin": "^2.0.16",
"acorn": "^8.18.0",
"acorn-walk": "^8.3.5",
"marked": "^15.0.0",
"matrix-bot-sdk": "^0.8.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.7.0"
}
},
@ -1660,12 +1662,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 +1764,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",
@ -5532,9 +5558,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": {

View File

@ -10,11 +10,13 @@
},
"dependencies": {
"@opencode/plugin": "^2.0.16",
"matrix-bot-sdk": "^0.8.0",
"marked": "^15.0.0"
"acorn": "^8.18.0",
"acorn-walk": "^8.3.5",
"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"
}
}