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..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/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/cli.ts b/src/cli.ts index 48f6f09b6..ff6c943d0 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,23 @@ 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 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; +} + function whichBinary(name: string): string | null { const cmd = IS_WINDOWS ? "where" : "which"; try { @@ -450,6 +468,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 +863,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 +928,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)"}`); diff --git a/src/functions/mesh.ts b/src/functions/mesh.ts index ad52e5ea7..20c3d95b8 100644 --- a/src/functions/mesh.ts +++ b/src/functions/mesh.ts @@ -13,9 +13,24 @@ import type { GraphNode, GraphEdge, } from "../types.js"; -import { lookup } from "node:dns/promises"; import { isIP } from "node:net"; +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 +56,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..3ef99f67d 100644 --- a/src/providers/agent-sdk.ts +++ b/src/providers/agent-sdk.ts @@ -1,26 +1,18 @@ -import { AsyncLocalStorage } from 'node:async_hooks' 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`. -// -// Split the guard so each concern uses the right primitive: +// 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. // -// - **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. -// - **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() +// 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() // 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,62 +50,54 @@ 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()) { - // 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 ALS frames. + const parentCallId = sdkContext.getStore() + if (parentCallId !== undefined && sdkActiveCalls.has(parentCallId)) { return '' } - return sdkChildContext.run(true, async () => { - // 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++ + const callId = nextCallId++ + sdkActiveCalls.set(callId, true) + 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 + 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 } - sdkOriginalEnv = undefined } + } finally { + sdkActiveCalls.delete(callId) } }) } diff --git a/src/providers/embedding/local.ts b/src/providers/embedding/local.ts index ad6c2d214..fcc5633b5 100644 --- a/src/providers/embedding/local.ts +++ b/src/providers/embedding/local.ts @@ -1,4 +1,8 @@ import type { EmbeddingProvider } from "../../types.js"; +const IS_BUN = typeof (globalThis as any).Bun !== "undefined"; +if (IS_BUN && !process.env.ONNX_RUNTIME_BINDING) { + process.env.ONNX_RUNTIME_BINDING = "wasm"; +} type Pipeline = ( task: 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; }