From b7b415e2fc5bbc7ab32a63fe6b2991c9160e9fd2 Mon Sep 17 00:00:00 2001 From: JackieJK Date: Fri, 12 Jun 2026 13:41:14 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(cli):=20Bun=20Windows=20compatibility?= =?UTF-8?q?=20=E2=80=94=20shell:true=20child=5Fprocess=20workaround=20and?= =?UTF-8?q?=20iii-exec=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Bun for Windows, child_process cannot spawn native Rust-compiled executables (oven-sh/bun#32011). execFileSync and spawn hang, causing iii version check and engine startup to fail silently. - Add IS_BUN constant for runtime detection - Use shell:true in execFileSync/spawn on Windows+Bun - Generate Bun-compatible iii-config with \un run src/index.ts\ replacing \ ode dist/index.mjs\ in the iii-exec worker --- src/cli.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 48f6f09b6..fdb6334dc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -52,6 +52,7 @@ const CORE_TOOLS_COUNT = getAllTools().filter((t) => ESSENTIAL_TOOLS.has(t.name) const __dirname = dirname(fileURLToPath(import.meta.url)); const args = process.argv.slice(2); const IS_WINDOWS = platform() === "win32"; +const IS_BUN = typeof process.versions.bun === "string"; const IS_VERBOSE = args.includes("--verbose") || args.includes("-v") || @@ -386,6 +387,17 @@ function findIiiConfig(): string { return ""; } +function writeBunCompatibleConfig(originalPath: string): string { + const bunPath = join(homedir(), ".agentmemory", "iii-config.bun.yaml"); + mkdirSync(join(homedir(), ".agentmemory"), { recursive: true }); + const runtimeCmd = IS_WINDOWS ? "bun.exe run" : "bun run"; + const content = readFileSync(originalPath, "utf-8") + .replace(/- node dist\/index\.mjs/, `- ${runtimeCmd} src/index.ts`); + writeFileSync(bunPath, content, "utf-8"); + vlog(`wrote Bun-compatible config: ${bunPath}`); + return bunPath; +} + function whichBinary(name: string): string | null { const cmd = IS_WINDOWS ? "where" : "which"; try { @@ -450,6 +462,7 @@ function iiiBinVersion(binPath: string): string | null { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, + ...(IS_WINDOWS && IS_BUN ? { shell: true } : {}), }); const match = out.match(/(\d+\.\d+\.\d+(?:[-+][\w.]+)?)/); return match ? match[1]! : null; @@ -844,6 +857,7 @@ function spawnEngineBackground( detached: true, stdio: ["ignore", "ignore", "pipe"], windowsHide: true, + ...(IS_WINDOWS && IS_BUN ? { shell: true } : {}), }); const isDocker = label.includes("Docker"); if (!isDocker && typeof child.pid === "number") { @@ -908,7 +922,10 @@ function pickCompatibleIii(candidates: Array): string } async function startEngine(): Promise { - const configPath = findIiiConfig(); + const originalConfigPath = findIiiConfig(); + const configPath = (IS_BUN && originalConfigPath) + ? writeBunCompatibleConfig(originalConfigPath) + : originalConfigPath; const pathIii = whichBinary("iii"); vlog(`iii binary: ${pathIii ?? "(not on PATH)"}, config: ${configPath || "(not found)"}`); From 895765e6d9a8fc4125a64475e28c2ac398fd3dda Mon Sep 17 00:00:00 2001 From: JackieJK Date: Fri, 12 Jun 2026 14:21:12 +0800 Subject: [PATCH 2/4] fix(cli): fail fast when Bun config rewrite does not match Address CodeRabbit review: verify the config replacement actually changed the file. If upstream iii-config.yaml drifts, throw instead of silently writing an unchanged config. --- src/cli.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index fdb6334dc..ff6c943d0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -391,8 +391,14 @@ function writeBunCompatibleConfig(originalPath: string): string { const bunPath = join(homedir(), ".agentmemory", "iii-config.bun.yaml"); mkdirSync(join(homedir(), ".agentmemory"), { recursive: true }); const runtimeCmd = IS_WINDOWS ? "bun.exe run" : "bun run"; - const content = readFileSync(originalPath, "utf-8") - .replace(/- node dist\/index\.mjs/, `- ${runtimeCmd} src/index.ts`); + const original = readFileSync(originalPath, "utf-8"); + const needle = "- node dist/index.mjs"; + if (!original.includes(needle)) { + throw new Error( + `Bun config rewrite failed: expected "${needle}" not found in ${originalPath}`, + ); + } + const content = original.replaceAll(needle, `- ${runtimeCmd} src/index.ts`); writeFileSync(bunPath, content, "utf-8"); vlog(`wrote Bun-compatible config: ${bunPath}`); return bunPath; From a2edefedd03a7ea9772ed02605585adf27ecb011 Mon Sep 17 00:00:00 2001 From: JackieJK Date: Tue, 16 Jun 2026 22:49:38 +0800 Subject: [PATCH 3/4] feat: cross-runtime support (Node.js + Bun) without functional degradation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace three Node.js-only APIs with cross-runtime equivalents so the project runs under both Node.js >=20 and Bun >=1.1: - AsyncLocalStorage → per-call Map recursion guard (agent-sdk.ts) - node:dns/promises → Bun.dns.lookup() shim (mesh.ts) - Force ONNX WASM backend under Bun (local.ts) - Bun-aware CJK hint in cjk-segmenter.ts Add cross-runtime hook launcher (scripts/hook-runner.mjs) that detects the current runtime and spawns hook scripts with the correct binary. Update all 22 hook commands in hooks.json + hooks.copilot.json to use it. Switch package.json scripts from hardcoded node to tsx (already a dev dependency, works on both runtimes). Add bun >=1.1.0 to engines. --- package.json | 9 ++++--- plugin/hooks/hooks.copilot.json | 22 ++++++++-------- plugin/hooks/hooks.json | 24 ++++++++--------- scripts/hook-runner.mjs | 44 ++++++++++++++++++++++++++++++++ src/functions/mesh.ts | 23 ++++++++++++++--- src/providers/agent-sdk.ts | 35 +++++++++++++++---------- src/providers/embedding/local.ts | 11 ++++++++ src/state/cjk-segmenter.ts | 5 +++- 8 files changed, 129 insertions(+), 44 deletions(-) create mode 100644 scripts/hook-runner.mjs diff --git a/package.json b/package.json index 3c7cb5375..f1d47aa0f 100644 --- a/package.json +++ b/package.json @@ -19,15 +19,15 @@ "scripts": { "build": "tsdown && (cp iii-config.yaml dist/ 2>/dev/null || true) && (cp iii-config.docker.yaml dist/ 2>/dev/null || true) && (cp docker-compose.yml dist/ 2>/dev/null || true) && (cp .env.example dist/ 2>/dev/null || true) && mkdir -p dist/viewer && cp src/viewer/index.html dist/viewer/ && cp src/viewer/favicon.svg dist/viewer/", "dev": "tsx src/index.ts", - "start": "node dist/cli.mjs", - "migrate": "node dist/functions/migrate.js", + "start": "tsx dist/cli.mjs", + "migrate": "tsx dist/functions/migrate.js", "test": "vitest run --exclude test/integration.test.ts", "test:watch": "vitest --exclude test/integration.test.ts", "test:integration": "vitest run test/integration.test.ts", "test:all": "vitest run", "skills:gen": "tsx scripts/skills/generate.ts", "skills:check": "tsx scripts/skills/generate.ts --check && tsx scripts/skills/check.ts", - "bench:load": "node --import tsx benchmark/load-100k.ts", + "bench:load": "tsx benchmark/load-100k.ts", "eval:longmemeval": "tsx eval/runner/longmemeval.ts", "eval:coding-life": "tsx eval/runner/coding-life.ts" }, @@ -87,6 +87,7 @@ "protobufjs": "^7.5.8" }, "engines": { - "node": ">=20.0.0" + "node": ">=20.0.0", + "bun": ">=1.1.0" } } diff --git a/plugin/hooks/hooks.copilot.json b/plugin/hooks/hooks.copilot.json index b7d09f8bc..3d3cc346e 100644 --- a/plugin/hooks/hooks.copilot.json +++ b/plugin/hooks/hooks.copilot.json @@ -4,68 +4,68 @@ "sessionStart": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/session-start.mjs" + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/session-start.mjs" } ], "userPromptSubmitted": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/prompt-submit.mjs" + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/prompt-submit.mjs" } ], "preToolUse": [ { "type": "command", "matcher": "edit|write|create|read|view|glob|grep", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/pre-tool-use.mjs" + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/pre-tool-use.mjs" } ], "postToolUse": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/post-tool-use.mjs" + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/post-tool-use.mjs" } ], "postToolUseFailure": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/post-tool-failure.mjs" + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/post-tool-failure.mjs" } ], "preCompact": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/pre-compact.mjs" + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/pre-compact.mjs" } ], "agentStop": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/stop.mjs" + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/stop.mjs" } ], "sessionEnd": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/session-end.mjs" + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/session-end.mjs" } ], "subagentStart": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/subagent-start.mjs" + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/subagent-start.mjs" } ], "subagentStop": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/subagent-stop.mjs" + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/subagent-stop.mjs" } ], "notification": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/notification.mjs" + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/notification.mjs" } ] } diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index a13c99736..efbd0609d 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.mjs\"" } ] } @@ -15,7 +15,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/prompt-submit.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${CLAUDE_PLUGIN_ROOT}/scripts/prompt-submit.mjs\"" } ] } @@ -26,7 +26,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-tool-use.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-tool-use.mjs\"" } ] } @@ -36,7 +36,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-use.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-use.mjs\"" } ] } @@ -46,7 +46,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-failure.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-failure.mjs\"" } ] } @@ -56,7 +56,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-compact.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-compact.mjs\"" } ] } @@ -66,7 +66,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-start.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-start.mjs\"" } ] } @@ -76,7 +76,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-stop.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-stop.mjs\"" } ] } @@ -86,7 +86,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/notification.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${CLAUDE_PLUGIN_ROOT}/scripts/notification.mjs\"" } ] } @@ -96,7 +96,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/task-completed.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${CLAUDE_PLUGIN_ROOT}/scripts/task-completed.mjs\"" } ] } @@ -106,7 +106,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${CLAUDE_PLUGIN_ROOT}/scripts/stop.mjs\"" } ] } @@ -116,7 +116,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-end.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${CLAUDE_PLUGIN_ROOT}/scripts/session-end.mjs\"" } ] } diff --git a/scripts/hook-runner.mjs b/scripts/hook-runner.mjs new file mode 100644 index 000000000..bcf288160 --- /dev/null +++ b/scripts/hook-runner.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +/** + * Cross-runtime hook script launcher. + * + * Detects whether the current runtime is Bun or Node and spawns the target + * hook script with the appropriate runtime. This lets hook.json configs + * use a single static "command" entry while respecting whichever runtime + * the user has installed. + * + * Usage (from hooks.json): + * "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.mjs\"" + * + * The first argument is the hook script to run. All subsequent arguments + * are forwarded to the hook script. + */ +import { spawn } from "node:child_process"; +import process from "node:process"; + +const IS_BUN = typeof process.versions.bun === "string"; +const runtime = IS_BUN ? "bun" : "node"; +const hookScript = process.argv[2]; + +if (!hookScript) { + process.stderr.write("hook-runner: missing script argument\n"); + process.exit(1); +} + +// Forward remaining arguments (hook engine passes stdin JSON) +const childArgs = [hookScript, ...process.argv.slice(3)]; + +const child = spawn(runtime, childArgs, { + stdio: "inherit", + shell: IS_BUN && process.platform === "win32", + env: process.env, +}); + +child.on("exit", (code) => { + process.exit(code ?? 1); +}); + +child.on("error", (err) => { + process.stderr.write(`hook-runner: failed to spawn ${runtime}: ${err.message}\n`); + process.exit(1); +}); diff --git a/src/functions/mesh.ts b/src/functions/mesh.ts index ad52e5ea7..e65597f80 100644 --- a/src/functions/mesh.ts +++ b/src/functions/mesh.ts @@ -13,9 +13,26 @@ import type { GraphNode, GraphEdge, } from "../types.js"; -import { lookup } from "node:dns/promises"; import { isIP } from "node:net"; +// Cross-runtime DNS lookup: Bun has native Bun.dns.lookup(), Node uses +// node:dns/promises. Both return { address, family } records. +const IS_BUN = typeof (globalThis as any).Bun?.dns?.lookup === "function"; + +async function dnsLookup( + hostname: string, + options?: { all?: boolean }, +): Promise<{ address: string; family: 4 | 6 }[]> { + if (IS_BUN) { + const results = await (globalThis as any).Bun.dns.lookup(hostname, options); + return results.map((r: { address: string; family: 4 | 6 }) => r); + } + const { lookup } = await import("node:dns/promises"); + return lookup(hostname, { ...options, all: true }) as Promise< + { address: string; family: 4 | 6 }[] + >; +} + function isPrivateIP(ip: string): boolean { if (ip === "127.0.0.1" || ip === "::1" || ip === "0.0.0.0") return true; if (ip.startsWith("10.") || ip.startsWith("192.168.")) return true; @@ -41,8 +58,8 @@ async function isAllowedUrl(urlStr: string): Promise { if (!isIP(host)) { try { - const resolved = await lookup(host, { all: true }); - if (resolved.some((r) => isPrivateIP(r.address))) return false; + const resolved = await dnsLookup(host, { all: true }); + if (resolved.some((r: { address: string }) => isPrivateIP(r.address))) return false; } catch { // DNS resolution failed — allow the URL (the actual fetch will fail if unreachable) } diff --git a/src/providers/agent-sdk.ts b/src/providers/agent-sdk.ts index 06d594616..355b44586 100644 --- a/src/providers/agent-sdk.ts +++ b/src/providers/agent-sdk.ts @@ -1,4 +1,3 @@ -import { AsyncLocalStorage } from 'node:async_hooks' import type { MemoryProvider } from '../types.js' // #781: the recursion guard used to live on `process.env.AGENTMEMORY_SDK_CHILD` @@ -11,16 +10,20 @@ import type { MemoryProvider } from '../types.js' // // Split the guard so each concern uses the right primitive: // -// - **In-process** recursion guard: AsyncLocalStorage. Scoped to the -// async call tree of the SDK query, so concurrent siblings on the -// same provider instance no longer see each other's marker. +// - **In-process** recursion guard: per-call Map keyed by callId. +// Each concurrent call (chunked summarize via Promise.all) gets its +// own callId, so siblings on the same provider instance no longer +// see each other's marker. Replaces AsyncLocalStorage (Node-only) +// for cross-runtime (Node + Bun) compatibility. // - **Cross-process** recursion guard for hooks: still // `process.env.AGENTMEMORY_SDK_CHILD = "1"` around the SDK call. // Subprocesses spawned by `@anthropic-ai/claude-agent-sdk` inherit // `process.env` at spawn time, so the hook scripts (which run as // separate processes) still see the marker and skip their REST -// callback to /summarize. ALS does not cross process boundaries. -const sdkChildContext = new AsyncLocalStorage() +// callback to /summarize. Per-call Map does not cross process +// boundaries, same as ALS. +let nextCallId = 0 +const sdkActiveCalls = new Map() // Module-level refcount for the process.env marker. A per-call snapshot // races across overlapping calls: A saves prev=undef, B saves prev="1", @@ -58,10 +61,12 @@ export class AgentSDKProvider implements MemoryProvider { } private async query(systemPrompt: string, userPrompt: string): Promise { - // In-process recursion guard. Concurrent sibling calls (chunked - // summarize via Promise.all) each have their own ALS frame, so they - // do not poison each other. - if (sdkChildContext.getStore()) { + // In-process recursion guard. Each call gets a unique callId; only + // true recursive re-entry (same callId) is blocked. Concurrent + // sibling calls (chunked summarize via Promise.all) each have their + // own callId, so they do not poison each other. + const callId = nextCallId++ + if (sdkActiveCalls.has(callId)) { // We are already inside a Claude Agent SDK-spawned async call // tree. Spawning another one would let its plugin-hook-driven // Stop loop re-enter /agentmemory/summarize and cause unbounded @@ -69,11 +74,12 @@ export class AgentSDKProvider implements MemoryProvider { // short-circuit. The chunk retry path in src/functions/summarize.ts // treats "" as a parse failure but only the in-process re-entry // path can reach this branch — legitimate concurrent siblings now - // run with their own ALS frames. + // run with their own callId. return '' } - return sdkChildContext.run(true, async () => { + sdkActiveCalls.set(callId, true) + try { // Mark spawned subprocesses (the SDK's underlying Claude session // + its hook scripts) as SDK children via process.env. Hook scripts // run in separate processes and read process.env to short-circuit @@ -105,6 +111,7 @@ export class AgentSDKProvider implements MemoryProvider { } return result } finally { + sdkActiveCalls.delete(callId) sdkActiveCount-- if (sdkActiveCount === 0) { if (sdkOriginalEnv === undefined) { @@ -115,6 +122,8 @@ export class AgentSDKProvider implements MemoryProvider { sdkOriginalEnv = undefined } } - }) + } finally { + sdkActiveCalls.delete(callId) + } } } diff --git a/src/providers/embedding/local.ts b/src/providers/embedding/local.ts index ad6c2d214..c8b738f90 100644 --- a/src/providers/embedding/local.ts +++ b/src/providers/embedding/local.ts @@ -1,5 +1,16 @@ import type { EmbeddingProvider } from "../../types.js"; +// Bun does not support onnxruntime-node (native addon). Force the +// @xenova/transformers library to use the WASM backend (onnxruntime-web) +// which ships as an optional dependency and works in Bun. +const IS_BUN = typeof (globalThis as any).Bun !== "undefined"; +if (IS_BUN) { + // Prevent loading the native ONNX runtime backend + if (!process.env.ONNX_RUNTIME_BINDING) { + process.env.ONNX_RUNTIME_BINDING = "wasm"; + } +} + type Pipeline = ( task: string, model: string, diff --git a/src/state/cjk-segmenter.ts b/src/state/cjk-segmenter.ts index d6d4a1919..1f4110bfe 100644 --- a/src/state/cjk-segmenter.ts +++ b/src/state/cjk-segmenter.ts @@ -1,6 +1,7 @@ import { createRequire } from "node:module"; const cjkRequire = createRequire(import.meta.url); +const IS_BUN = typeof (globalThis as any).Bun !== "undefined"; const CJK_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; const HAN_RE = /\p{Script=Han}/u; @@ -59,7 +60,9 @@ function getJieba(): JiebaInstance | null { } catch { showHintOnce( "jieba", - "install @node-rs/jieba to improve Chinese search; falling back to whole-string tokenization", + IS_BUN + ? "install @node-rs/jieba (or jieba-wasm for WASM) to improve Chinese search; falling back to whole-string tokenization" + : "install @node-rs/jieba to improve Chinese search; falling back to whole-string tokenization", ); return null; } From 12f36b1ed76ecf871f7d303f7f8750f7ffc385ce Mon Sep 17 00:00:00 2001 From: JackieJK Date: Tue, 16 Jun 2026 23:55:42 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(pr917):=20address=20CodeRabbitAI=20revi?= =?UTF-8?q?ew=20=E2=80=94=20recursion=20guard,=20path=20quoting,=20comment?= =?UTF-8?q?=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent-sdk.ts: replace broken callId check with AsyncLocalStorage for per-async-context recursion detection (concurrent siblings get separate contexts, recursive re-entry is blocked) - hooks.copilot.json: quote COPILOT_PLUGIN_ROOT paths to handle spaces, matching hooks.json pattern - mesh.ts, local.ts: remove WHAT-style comments per coding guidelines --- plugin/hooks/hooks.copilot.json | 22 +++--- src/functions/mesh.ts | 2 - src/providers/agent-sdk.ts | 119 ++++++++++++------------------- src/providers/embedding/local.ts | 11 +-- 4 files changed, 60 insertions(+), 94 deletions(-) diff --git a/plugin/hooks/hooks.copilot.json b/plugin/hooks/hooks.copilot.json index 3d3cc346e..3d37ae710 100644 --- a/plugin/hooks/hooks.copilot.json +++ b/plugin/hooks/hooks.copilot.json @@ -4,68 +4,68 @@ "sessionStart": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/session-start.mjs" + "command": "node \"${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${COPILOT_PLUGIN_ROOT}/scripts/session-start.mjs\"" } ], "userPromptSubmitted": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/prompt-submit.mjs" + "command": "node \"${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${COPILOT_PLUGIN_ROOT}/scripts/prompt-submit.mjs\"" } ], "preToolUse": [ { "type": "command", "matcher": "edit|write|create|read|view|glob|grep", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/pre-tool-use.mjs" + "command": "node \"${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${COPILOT_PLUGIN_ROOT}/scripts/pre-tool-use.mjs\"" } ], "postToolUse": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/post-tool-use.mjs" + "command": "node \"${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${COPILOT_PLUGIN_ROOT}/scripts/post-tool-use.mjs\"" } ], "postToolUseFailure": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/post-tool-failure.mjs" + "command": "node \"${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${COPILOT_PLUGIN_ROOT}/scripts/post-tool-failure.mjs\"" } ], "preCompact": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/pre-compact.mjs" + "command": "node \"${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${COPILOT_PLUGIN_ROOT}/scripts/pre-compact.mjs\"" } ], "agentStop": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/stop.mjs" + "command": "node \"${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${COPILOT_PLUGIN_ROOT}/scripts/stop.mjs\"" } ], "sessionEnd": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/session-end.mjs" + "command": "node \"${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${COPILOT_PLUGIN_ROOT}/scripts/session-end.mjs\"" } ], "subagentStart": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/subagent-start.mjs" + "command": "node \"${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${COPILOT_PLUGIN_ROOT}/scripts/subagent-start.mjs\"" } ], "subagentStop": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/subagent-stop.mjs" + "command": "node \"${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${COPILOT_PLUGIN_ROOT}/scripts/subagent-stop.mjs\"" } ], "notification": [ { "type": "command", - "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs ${COPILOT_PLUGIN_ROOT}/scripts/notification.mjs" + "command": "node \"${COPILOT_PLUGIN_ROOT}/scripts/hook-runner.mjs\" \"${COPILOT_PLUGIN_ROOT}/scripts/notification.mjs\"" } ] } diff --git a/src/functions/mesh.ts b/src/functions/mesh.ts index e65597f80..20c3d95b8 100644 --- a/src/functions/mesh.ts +++ b/src/functions/mesh.ts @@ -15,8 +15,6 @@ import type { } from "../types.js"; import { isIP } from "node:net"; -// Cross-runtime DNS lookup: Bun has native Bun.dns.lookup(), Node uses -// node:dns/promises. Both return { address, family } records. const IS_BUN = typeof (globalThis as any).Bun?.dns?.lookup === "function"; async function dnsLookup( diff --git a/src/providers/agent-sdk.ts b/src/providers/agent-sdk.ts index 355b44586..3ef99f67d 100644 --- a/src/providers/agent-sdk.ts +++ b/src/providers/agent-sdk.ts @@ -1,27 +1,16 @@ import type { MemoryProvider } from '../types.js' +import { AsyncLocalStorage } from 'node:async_hooks' -// #781: the recursion guard used to live on `process.env.AGENTMEMORY_SDK_CHILD` -// (#181). #472 then introduced chunked summarize that runs chunks -// concurrently in the same process via Promise.all. The first chunk -// flipped the global env to "1" synchronously before its `await`, and -// every sibling chunk in the same batch immediately bailed out as a -// "child" — returning "" — so half-plus of the chunks failed to parse -// and the summarize threw `too_many_chunks_skipped: N/N`. +// In-process recursion guard: AsyncLocalStorage tracks a callId per async +// execution context. Concurrent siblings (chunked summarize via Promise.all) +// get separate contexts and don't block each other. Recursive re-entry +// (hook -> /summarize -> query within the same call tree) inherits the +// parent's async context and is detected by checking sdkActiveCalls. // -// Split the guard so each concern uses the right primitive: -// -// - **In-process** recursion guard: per-call Map keyed by callId. -// Each concurrent call (chunked summarize via Promise.all) gets its -// own callId, so siblings on the same provider instance no longer -// see each other's marker. Replaces AsyncLocalStorage (Node-only) -// for cross-runtime (Node + Bun) compatibility. -// - **Cross-process** recursion guard for hooks: still -// `process.env.AGENTMEMORY_SDK_CHILD = "1"` around the SDK call. -// Subprocesses spawned by `@anthropic-ai/claude-agent-sdk` inherit -// `process.env` at spawn time, so the hook scripts (which run as -// separate processes) still see the marker and skip their REST -// callback to /summarize. Per-call Map does not cross process -// boundaries, same as ALS. +// Cross-process recursion guard: process.env.AGENTMEMORY_SDK_CHILD = "1" +// around the SDK call so spawned subprocesses skip their REST callbacks. +// Reference-counted so overlapping calls don't race on env restore. +const sdkContext = new AsyncLocalStorage() let nextCallId = 0 const sdkActiveCalls = new Map() @@ -61,69 +50,55 @@ export class AgentSDKProvider implements MemoryProvider { } private async query(systemPrompt: string, userPrompt: string): Promise { - // In-process recursion guard. Each call gets a unique callId; only - // true recursive re-entry (same callId) is blocked. Concurrent - // sibling calls (chunked summarize via Promise.all) each have their - // own callId, so they do not poison each other. - const callId = nextCallId++ - if (sdkActiveCalls.has(callId)) { - // We are already inside a Claude Agent SDK-spawned async call - // tree. Spawning another one would let its plugin-hook-driven - // Stop loop re-enter /agentmemory/summarize and cause unbounded - // recursion (#149 follow-up). Degrade to empty string so callers - // short-circuit. The chunk retry path in src/functions/summarize.ts - // treats "" as a parse failure but only the in-process re-entry - // path can reach this branch — legitimate concurrent siblings now - // run with their own callId. + const parentCallId = sdkContext.getStore() + if (parentCallId !== undefined && sdkActiveCalls.has(parentCallId)) { return '' } + const callId = nextCallId++ sdkActiveCalls.set(callId, true) - try { - // Mark spawned subprocesses (the SDK's underlying Claude session - // + its hook scripts) as SDK children via process.env. Hook scripts - // run in separate processes and read process.env to short-circuit - // their REST callbacks. Reference-counted so overlapping calls - // don't race each other into restoring stale values. - if (sdkActiveCount === 0) { - sdkOriginalEnv = process.env.AGENTMEMORY_SDK_CHILD - process.env.AGENTMEMORY_SDK_CHILD = '1' - } - sdkActiveCount++ + return sdkContext.run(callId, async () => { try { - const { query } = await this.loadSdk() + if (sdkActiveCount === 0) { + sdkOriginalEnv = process.env.AGENTMEMORY_SDK_CHILD + process.env.AGENTMEMORY_SDK_CHILD = '1' + } + sdkActiveCount++ - const messages = query({ - prompt: userPrompt, - options: { - systemPrompt, - maxTurns: 1, - allowedTools: [], - }, - }) + try { + const { query } = await this.loadSdk() - let result = '' - for await (const msg of messages) { - if (msg.type === 'result') { - result = (msg as any).result ?? '' + const messages = query({ + prompt: userPrompt, + options: { + systemPrompt, + maxTurns: 1, + allowedTools: [], + }, + }) + + let result = '' + for await (const msg of messages) { + if (msg.type === 'result') { + result = (msg as any).result ?? '' + } + } + return result + } finally { + sdkActiveCount-- + if (sdkActiveCount === 0) { + if (sdkOriginalEnv === undefined) { + delete process.env.AGENTMEMORY_SDK_CHILD + } else { + process.env.AGENTMEMORY_SDK_CHILD = sdkOriginalEnv + } + sdkOriginalEnv = undefined } } - return result } finally { sdkActiveCalls.delete(callId) - sdkActiveCount-- - if (sdkActiveCount === 0) { - if (sdkOriginalEnv === undefined) { - delete process.env.AGENTMEMORY_SDK_CHILD - } else { - process.env.AGENTMEMORY_SDK_CHILD = sdkOriginalEnv - } - sdkOriginalEnv = undefined - } } - } finally { - sdkActiveCalls.delete(callId) - } + }) } } diff --git a/src/providers/embedding/local.ts b/src/providers/embedding/local.ts index c8b738f90..fcc5633b5 100644 --- a/src/providers/embedding/local.ts +++ b/src/providers/embedding/local.ts @@ -1,14 +1,7 @@ import type { EmbeddingProvider } from "../../types.js"; - -// Bun does not support onnxruntime-node (native addon). Force the -// @xenova/transformers library to use the WASM backend (onnxruntime-web) -// which ships as an optional dependency and works in Bun. const IS_BUN = typeof (globalThis as any).Bun !== "undefined"; -if (IS_BUN) { - // Prevent loading the native ONNX runtime backend - if (!process.env.ONNX_RUNTIME_BINDING) { - process.env.ONNX_RUNTIME_BINDING = "wasm"; - } +if (IS_BUN && !process.env.ONNX_RUNTIME_BINDING) { + process.env.ONNX_RUNTIME_BINDING = "wasm"; } type Pipeline = (