diff --git a/README.md b/README.md index dd292d51c..a8b5e8bc6 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,11 @@ generated guide through `opencode.json` instructions, and installs a native plugin in `.opencode/plugins/`. The plugin injects recall context before chat requests and adds Mnemon guidance to session compaction. +The plugin supports OpenCode v2 and v1.18.29 or newer. After updating Mnemon, +rerun setup with the same scope (add `--global` for a user-wide installation), +then restart OpenCode to replace an older plugin. See the +[OpenCode setup notes](docs/USAGE.md#opencode) for runtime requirements. + ### [OpenClaw](https://github.com/openclaw/openclaw) ```bash diff --git a/docs/USAGE.md b/docs/USAGE.md index 765ac460c..54454146f 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -92,6 +92,31 @@ mnemon setup --eject --target claude-code | `--eject` | `false` | Remove mnemon integrations | | `--yes` | `false` | Auto-confirm all prompts | +### OpenCode + +The installed plugin supports OpenCode v2 (verified with 2.0.18) and v1.18.29 +or newer. Earlier v1 releases may require function exports instead of its shared +`server`/`setup` entrypoint; upgrade OpenCode before refreshing the integration. + +After updating Mnemon, run `mnemon setup --target opencode --yes` again in the +project, or add `--global` if the original installation was user-wide. Restart +OpenCode afterward. Updating the Mnemon binary alone does not replace an +already installed `.opencode/plugins/mnemon.js` (or +`~/.config/opencode/plugins/mnemon.js`). + +Keep `mnemon` on OpenCode's `PATH`. The plugin runs the CLI through Node's +child-process API, which both OpenCode runtimes provide; a separate Bun +installation is unnecessary. Native executables and standard npm installations +are supported on Unix and Windows. npm installations resolve directly to their +platform binary so a timeout also stops the memory command. + +Recall is added to the latest user message before each model request. Tool +continuations reuse that turn's recall without duplicating it; a new user turn +refreshes it. The cache is isolated by session, retains at most 128 sessions, +and is cleared on plugin unload. Compaction receives the durable-memory +guidance, and shell commands receive `MNEMON_OPENCODE=1`. Missing or failed CLI +calls leave the conversation usable; each call has a five-second timeout. + --- ## Memory CLI Commands diff --git a/docs/zh/README.md b/docs/zh/README.md index ff26ac1e6..309ca7eb5 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -210,6 +210,10 @@ OpenCode 会将 mnemon skill 部署到 `.opencode/skills/`,通过 `.opencode/plugins/` 安装原生 plugin。该 plugin 会在聊天请求前注入 recall context,并在 session compaction 中加入 Mnemon guidance。 +该 plugin 支持 OpenCode v2 和 v1.18.29 及以上版本。更新 Mnemon 后, +请按原安装范围重新运行 setup(用户级安装加 `--global`),再重启 OpenCode +以替换旧 plugin。运行环境要求见 [OpenCode 配置说明](USAGE.md#opencode)。 + ### [OpenClaw](https://github.com/openclaw/openclaw) ```bash diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 98682e265..b45a5c505 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -87,6 +87,28 @@ mnemon setup --eject --target claude-code | `--eject` | `false` | 移除 mnemon 集成 | | `--yes` | `false` | 自动确认所有提示 | +### OpenCode + +安装的 plugin 支持 OpenCode v2(已验证 2.0.18)和 v1.18.29 及以上版本。 +更早的 v1 加载器可能要求函数导出,无法使用共用的 `server`/`setup` 入口, +请先升级 OpenCode。 + +更新 Mnemon 后,在项目中重新运行 `mnemon setup --target opencode --yes`; +若原来是用户级安装,则加 `--global`。完成后重启 OpenCode。只更新 Mnemon +二进制不会替换已安装的 `.opencode/plugins/mnemon.js` 或 +`~/.config/opencode/plugins/mnemon.js`。 + +请确保 OpenCode 的 `PATH` 能找到 `mnemon`。该 plugin 使用两种 OpenCode +运行时均提供的 Node 子进程 API,无需单独安装 Bun。Unix 和 Windows 均支持 +原生可执行文件和标准 npm 安装。npm 安装会直接定位对应平台的二进制, +确保超时时能终止记忆命令。 + +每次模型请求前,recall 会加入最新的用户消息。工具续调用复用本轮 recall, +不会重复插入;新一轮用户消息会刷新 recall。缓存按 session 隔离,最多保留 +128 个 session,并在 plugin 卸载时清空。Compaction 会收到持久记忆指引, +shell 命令会收到 `MNEMON_OPENCODE=1`。CLI 不可用或调用失败不会阻断对话, +每次调用的超时上限为五秒。 + --- ## Memory CLI 命令 diff --git a/internal/memory/setup/assets/opencode/mnemon.js b/internal/memory/setup/assets/opencode/mnemon.js index 37c339086..2fd010a78 100644 --- a/internal/memory/setup/assets/opencode/mnemon.js +++ b/internal/memory/setup/assets/opencode/mnemon.js @@ -1,15 +1,63 @@ +import { spawnSync } from "node:child_process" +import { existsSync, readFileSync, realpathSync } from "node:fs" +import { createRequire } from "node:module" +import path from "node:path" +import process from "node:process" + const MAX_RECALL_CHARS = 4000 +const MAX_QUERY_CHARS = 8000 +const MAX_SESSIONS = 128 +const CONTEXT_MARKER = "" +const COMPACTION_GUIDANCE = `## Mnemon Memory + +Before compaction completes, preserve durable preferences, decisions, insights, facts, or context with mnemon remember/link when they will improve future continuity. Do not store secrets, credentials, or short-lived operational noise.` + +function npmBinary(entry) { + // Bypass npm's JS launcher so timeout kills the native process itself. Read + // its installed target registry instead of keeping a second platform list. + const root = path.dirname(path.dirname(entry)) + const targets = JSON.parse(readFileSync(path.join(root, "targets.json"), "utf8")) + const target = targets.find((item) => item.platform === process.platform && item.arch === process.arch) + if (!target) throw new Error("Unsupported Mnemon platform") + const require = createRequire(entry) + const native = path.dirname(require.resolve(`${target.alias}/package.json`)) + return path.join(native, target.binary) +} + +function mnemonCommand() { + for (const directory of (process.env.PATH || "").split(path.delimiter)) { + if (!directory) continue + const binary = path.join(directory, process.platform === "win32" ? "mnemon.exe" : "mnemon") + if (existsSync(binary)) { + const resolved = realpathSync(binary) + return resolved.endsWith(`${path.sep}bin${path.sep}mnemon.js`) ? npmBinary(resolved) : binary + } + if (process.platform === "win32") { + // Node cannot execute npm's .cmd shim directly. Locate the package + // without invoking a shell or interpreting the user's recall text. + for (const modules of [path.join(directory, "node_modules"), path.dirname(directory)]) { + const entry = path.join(modules, "@mnemon-dev", "mnemon", "bin", "mnemon.js") + if (existsSync(entry)) return npmBinary(entry) + } + } + } + return "mnemon" +} function runMnemon(args, options = {}) { try { - const proc = Bun.spawnSync(["mnemon", ...args], { - stdout: "pipe", - stderr: "pipe", + const proc = spawnSync(mnemonCommand(), args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], env: process.env, cwd: options.cwd || process.cwd(), + timeout: 5000, + killSignal: "SIGKILL", + maxBuffer: 256 * 1024, + windowsHide: true, }) - if (!proc.success) return "" - return new TextDecoder().decode(proc.stdout).trim() + if (proc.error || proc.status !== 0) return "" + return proc.stdout.trim() } catch { return "" } @@ -22,34 +70,73 @@ function textFromPart(part) { return "" } -function lastUserMessage(output) { +function lastUserMessage(output, partKey) { const messages = Array.isArray(output?.messages) ? output.messages : [] for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i] const role = msg?.info?.role || msg?.role if (role !== "user") continue - const parts = Array.isArray(msg.parts) ? msg.parts : [] - return { msg, parts, text: parts.map(textFromPart).filter(Boolean).join("\n") } + if (!Array.isArray(msg[partKey])) continue + return { msg, parts: msg[partKey] } } return null } -function prependText(parts, text) { - if (!Array.isArray(parts) || text.trim() === "") return - parts.unshift({ type: "text", text }) -} - function buildRecallContext(query, cwd) { const status = runMnemon(["status"], { cwd }) const recall = query.trim() === "" ? "" : runMnemon(["recall", query, "--limit", "5"], { cwd }) const sections = [] - if (status) sections.push(`Status:\n${status}`) + if (status) sections.push(`Status:\n${status.slice(0, MAX_RECALL_CHARS)}`) if (recall) sections.push(`Relevant recall:\n${recall.slice(0, MAX_RECALL_CHARS)}`) sections.push("Use mnemon when it materially improves continuity. After responding, decide whether durable preferences, decisions, insights, facts, or context should be stored with mnemon remember/link.") return `\n\n\n${sections.join("\n\n")}\n\n\n` } -export const MnemonPlugin = async ({ directory, client }) => { +function recallInjector(directory) { + // OpenCode rebuilds model messages for tool continuations. Reuse the recall + // for the latest user message, but attach it to every fresh outgoing copy. + const sessions = new Map() + return { + inject(sessionID, messageID, parts) { + const text = parts.map(textFromPart).filter(Boolean).join("\n") + if (text.includes(CONTEXT_MARKER)) return + const query = text.slice(0, MAX_QUERY_CHARS) + const cached = sessionID && messageID ? sessions.get(sessionID) : undefined + const context = cached?.messageID === messageID && cached?.query === query + ? cached.context + : buildRecallContext(query, directory) + if (sessionID && messageID) { + sessions.delete(sessionID) + sessions.set(sessionID, { messageID, query, context }) + if (sessions.size > MAX_SESSIONS) sessions.delete(sessions.keys().next().value) + } + parts.unshift({ type: "text", text: context }) + }, + clear() { sessions.clear() }, + } +} + +async function setup(ctx) { + const recall = recallInjector(ctx.location.directory) + await ctx.shell.hook("create.before", (event) => { + event.env.MNEMON_OPENCODE = "1" + }) + await ctx.session.hook("context", (event) => { + const current = lastUserMessage(event, "content") + if (current) recall.inject(event.sessionID, current.msg.id, current.parts) + }) + await ctx.session.hook("compaction", (event) => { + if (!event.system.some((part) => part.type === "text" && part.text === COMPACTION_GUIDANCE)) { + event.system.push({ type: "text", text: COMPACTION_GUIDANCE }) + } + }) + // Registrations belong to OpenCode's plugin scope. No event loop or timer is + // needed; cap cached sessions and release their text when the plugin unloads. + return () => recall.clear() +} + +async function server({ directory, client }) { + const recall = recallInjector(directory) await client?.app?.log?.({ body: { service: "mnemon", @@ -65,18 +152,14 @@ export const MnemonPlugin = async ({ directory, client }) => { }, "experimental.chat.messages.transform": async (_input, output) => { - const current = lastUserMessage(output) + const current = lastUserMessage(output, "parts") if (!current) return - const marker = "" - if (current.text.includes(marker)) return - prependText(current.parts, buildRecallContext(current.text, directory)) + recall.inject(current.msg.info?.sessionID, current.msg.info?.id, current.parts) }, "experimental.session.compacting": async (_input, output) => { if (!Array.isArray(output.context)) output.context = [] - output.context.push(`## Mnemon Memory - -Before compaction completes, preserve durable preferences, decisions, insights, facts, or context with mnemon remember/link when they will improve future continuity. Do not store secrets, credentials, or short-lived operational noise.`) + if (!output.context.includes(COMPACTION_GUIDANCE)) output.context.push(COMPACTION_GUIDANCE) }, event: async ({ event }) => { @@ -89,5 +172,10 @@ Before compaction completes, preserve durable preferences, decisions, insights, }, }) }, + dispose: () => recall.clear(), } } + +// One export avoids double registration. OpenCode 1.18.29+ calls server(); +// OpenCode 2 calls setup() and ignores the legacy adapter. +export default { id: "mnemon", setup, server } diff --git a/internal/memory/setup/opencode_test.go b/internal/memory/setup/opencode_test.go index b3f797c91..c1d3f0e80 100644 --- a/internal/memory/setup/opencode_test.go +++ b/internal/memory/setup/opencode_test.go @@ -36,7 +36,11 @@ func TestOpenCodeWriteSkillAndPlugin(t *testing.T) { if err != nil { t.Fatalf("read plugin: %v", err) } - for _, want := range []string{"experimental.chat.messages.transform", "experimental.session.compacting", "shell.env"} { + for _, want := range []string{ + `export default { id: "mnemon", setup, server }`, + `ctx.session.hook("context"`, `ctx.session.hook("compaction"`, `ctx.shell.hook("create.before"`, + "experimental.chat.messages.transform", "experimental.session.compacting", "shell.env", + } { if !strings.Contains(string(plugin), want) { t.Fatalf("plugin missing %q: %s", want, string(plugin)) } diff --git a/test/memory/opencode/plugin.test.mjs b/test/memory/opencode/plugin.test.mjs new file mode 100644 index 000000000..b1deb4c73 --- /dev/null +++ b/test/memory/opencode/plugin.test.mjs @@ -0,0 +1,168 @@ +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import path from "node:path" +import { mock, test } from "node:test" + +const source = await readFile(new URL("../../../internal/memory/setup/assets/opencode/mnemon.js", import.meta.url), "utf8") +let generation = 0 + +async function loadPlugin(t, { platform = "linux", files = [], env = {}, links = {} } = {}) { + const calls = [] + let result = (args) => args[0] === "status" ? "status fixture" : `recall fixture: ${args[1]}` + mock.module("node:child_process", { namedExports: { spawnSync(command, args, options) { + calls.push({ command, args, options }) + return { status: 0, stdout: result(args) } + } } }) + const paths = platform === "win32" ? path.win32 : path.posix + const nativeRoot = platform === "win32" ? "C:\\native" : "/native" + const binary = platform === "win32" ? "bin/mnemon.exe" : "bin/mnemon" + mock.module("node:fs", { namedExports: { + existsSync: (file) => files.includes(file), + realpathSync: (file) => links[file] || file, + readFileSync: () => JSON.stringify([{ platform, arch: "x64", alias: "@mnemon-dev/fixture", binary }]), + } }) + mock.module("node:module", { namedExports: { createRequire: () => ({ resolve: (target) => { + assert.equal(target, "@mnemon-dev/fixture/package.json") + return paths.join(nativeRoot, "package.json") + } }) } }) + mock.module("node:path", { defaultExport: platform === "win32" ? path.win32 : path.posix }) + mock.module("node:process", { defaultExport: { platform, arch: "x64", env, cwd: () => "/fallback" } }) + t.after(() => mock.restoreAll()) + const module = await import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}#${++generation}`) + assert.deepEqual(Object.keys(module), ["default"], "only one loader entrypoint") + const plugin = module.default + assert.equal(plugin.id, "mnemon") + assert.equal(typeof plugin.server, "function") + const hooks = new Map() + const register = (domain) => async (name, callback) => { + const key = `${domain}.${name}` + assert.equal(hooks.has(key), false, `duplicate hook ${key}`) + hooks.set(key, callback) + return { dispose: async () => hooks.delete(key) } + } + const cleanup = await plugin.setup({ location: { directory: "/project" }, session: { hook: register("session") }, shell: { hook: register("shell") } }) + assert.deepEqual([...hooks.keys()], ["shell.create.before", "session.context", "session.compaction"]) + return { plugin, hooks, calls, cleanup, setResult(fn) { result = fn } } +} + +function context(sessionID = "session-a", id = "user-a", text = "Orion release color") { + return { sessionID, system: [], messages: [ + { id: "older", role: "user", content: [{ type: "text", text: "previous prompt" }] }, + { id, role: "user", content: [{ type: "image", url: "fixture" }, { type: "text", text }], metadata: { kept: true } }, + { id: "assistant", role: "assistant", content: [{ type: "text", text: "tool continuation" }] }, + ] } +} + +test("v2 attaches once to the last user message and reuses recall for fresh continuation copies", async (t) => { + const p = await loadPlugin(t) + const hook = p.hooks.get("session.context") + const event = context() + hook(event) + const current = event.messages[1] + assert.match(current.content[0].text, /recall fixture: Orion release color/) + assert.equal(current.content.length, 3) + assert.deepEqual(current.metadata, { kept: true }) + assert.equal(event.messages[0].content.length, 1) + assert.equal(event.messages[2].content.length, 1) + hook(event) + assert.equal(current.content.length, 3) + const fresh = context() + hook(fresh) + assert.equal(fresh.messages[1].content[0].text, current.content[0].text) + assert.equal(p.calls.length, 2, "one status and recall per user turn") + assert.deepEqual(p.calls[1].args, ["recall", "Orion release color", "--limit", "5"]) + assert.equal(p.calls[0].options.cwd, "/project") + assert.equal(p.calls[0].options.timeout, 5000) + assert.equal(p.calls[0].options.killSignal, "SIGKILL") + assert.equal(p.calls[0].options.maxBuffer, 256 * 1024) + assert.equal(p.calls[0].options.windowsHide, true) + assert.equal(p.calls[0].options.shell, undefined) + hook(context("session-b", "user-a")) + hook(context("session-a", "user-b", "next query")) + assert.equal(p.calls.length, 6, "sessions and new user messages get their own recall") + await p.cleanup() + hook(context("session-a", "user-b", "next query")) + assert.equal(p.calls.length, 8, "unload releases cached text") +}) + +test("recall cache and CLI input/output are bounded", async (t) => { + const p = await loadPlugin(t) + const hook = p.hooks.get("session.context") + for (let i = 0; i < 129; i++) hook(context(`session-${i}`)) + hook(context("session-128")) + assert.equal(p.calls.length, 258) + hook(context("session-0")) + assert.equal(p.calls.length, 260, "oldest session is evicted") + p.setResult(() => "x".repeat(10000)) + const event = context("long", "long", "q".repeat(20000)) + hook(event) + assert.equal(p.calls.at(-1).args[1].length, 8000) + assert.ok(event.messages[1].content[0].text.length < 8500) +}) + +test("v2 shell and compaction hooks use native event shapes", async (t) => { + const p = await loadPlugin(t) + const shell = { env: { EXISTING: "keep" } } + p.hooks.get("shell.create.before")(shell) + assert.deepEqual(shell.env, { EXISTING: "keep", MNEMON_OPENCODE: "1" }) + const event = { system: [{ type: "text", text: "keep system" }], messages: [] } + const hook = p.hooks.get("session.compaction") + hook(event); hook(event) + assert.equal(event.system.length, 2) + assert.equal(event.system[1].type, "text") + assert.match(event.system[1].text, /mnemon remember\/link/) + assert.match(event.system[1].text, /Do not store secrets/) +}) + +test("v1 server adapter preserves legacy hooks and message parts", async (t) => { + const p = await loadPlugin(t) + const hooks = await p.plugin.server({ directory: "/legacy" }) + const output = { messages: [{ info: { role: "user", id: "u1", sessionID: "s1" }, parts: [{ type: "text", text: "legacy prompt" }] }] } + await hooks["experimental.chat.messages.transform"]({}, output) + await hooks["experimental.chat.messages.transform"]({}, output) + assert.equal(output.messages[0].parts.length, 2) + assert.match(output.messages[0].parts[0].text, /recall fixture: legacy prompt/) + assert.equal(p.calls[0].options.cwd, "/legacy") + const shell = {} + await hooks["shell.env"]({}, shell) + assert.equal(shell.env.MNEMON_OPENCODE, "1") + const compaction = {} + await hooks["experimental.session.compacting"]({}, compaction) + await hooks["experimental.session.compacting"]({}, compaction) + assert.equal(compaction.context.length, 1) + assert.match(compaction.context[0], /mnemon remember\/link/) + await hooks.dispose() +}) + +test("missing CLI and malformed messages do not break the model request", async (t) => { + const p = await loadPlugin(t) + const hook = p.hooks.get("session.context") + p.setResult(() => { throw new Error("ENOENT") }) + for (const messages of [undefined, [], [{ role: "assistant", content: [] }], [{ role: "user" }]]) hook({ messages }) + assert.equal(p.calls.length, 0) + const event = context() + hook(event) + assert.match(event.messages[1].content[0].text, /Use mnemon/) + assert.doesNotMatch(event.messages[1].content[0].text, /Status:|Relevant recall:/) +}) + +for (const [name, entry, command] of [ + ["native", "C:\\bin\\mnemon.exe", "C:\\bin\\mnemon.exe"], + ["npm global", "C:\\bin\\node_modules\\@mnemon-dev\\mnemon\\bin\\mnemon.js", "C:\\native\\bin\\mnemon.exe"], + ["npm local", "C:\\project\\node_modules\\@mnemon-dev\\mnemon\\bin\\mnemon.js", "C:\\native\\bin\\mnemon.exe"], +]) { + test(`Windows ${name} keeps recall text out of shell syntax`, async (t) => { + const p = await loadPlugin(t, { platform: "win32", files: [entry], env: { PATH: "C:\\bin;C:\\project\\node_modules\\.bin" } }) + const query = 'Orion "quote" & echo %TOKEN% $(command)' + p.hooks.get("session.context")(context("s", "u", query)) + assert.equal(p.calls[1].command, command) + assert.deepEqual(p.calls[1].args, ["recall", query, "--limit", "5"]) + assert.equal(p.calls[1].options.shell, undefined) + }) +} + +test("Unix npm symlinks resolve to the native child without an intermediate launcher", async (t) => { + const p = await loadPlugin(t, { files: ["/bin/mnemon"], env: { PATH: "/bin" }, links: { "/bin/mnemon": "/npm/@mnemon-dev/mnemon/bin/mnemon.js" } }) + p.hooks.get("session.context")(context()) + assert.equal(p.calls[0].command, "/native/bin/mnemon") +}) diff --git a/test/memory/opencode/plugin_test.go b/test/memory/opencode/plugin_test.go new file mode 100644 index 000000000..4cf80b6cc --- /dev/null +++ b/test/memory/opencode/plugin_test.go @@ -0,0 +1,22 @@ +package opencode_test + +import ( + "context" + "os/exec" + "testing" + "time" +) + +// JavaScript runtime tests belong to the explicit integration tier. +func TestPluginRuntime(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("OpenCode plugin runtime tests require Node.js 22.3 or newer") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, node, "--experimental-test-module-mocks", "--test", "plugin.test.mjs", "timeout.test.mjs") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("OpenCode plugin runtime: %v\n%s", err, output) + } +} diff --git a/test/memory/opencode/timeout.test.mjs b/test/memory/opencode/timeout.test.mjs new file mode 100644 index 000000000..bf96278c2 --- /dev/null +++ b/test/memory/opencode/timeout.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict" +import { spawnSync } from "node:child_process" +import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { test } from "node:test" + +for (const npm of [false, true]) { + test(`timeout kills a SIGTERM-ignoring ${npm ? "npm" : "native"} command`, { skip: process.platform === "win32" }, async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "mnemon-opencode-timeout-")) + const bin = path.join(root, "bin") + const pidFile = path.join(root, "pid") + try { + await mkdir(bin) + let native = path.join(bin, "mnemon") + if (npm) { + const modules = path.join(root, "node_modules") + const pkg = path.join(modules, "@mnemon-dev", "mnemon") + const runtime = path.join(modules, "fixture-runtime") + await mkdir(path.join(pkg, "bin"), { recursive: true }) + await mkdir(runtime, { recursive: true }) + await writeFile(path.join(pkg, "targets.json"), JSON.stringify([ + { platform: process.platform, arch: process.arch, alias: "fixture-runtime", binary: "mnemon" }, + ])) + await writeFile(path.join(runtime, "package.json"), '{"name":"fixture-runtime"}') + const entry = path.join(pkg, "bin", "mnemon.js") + await writeFile(entry, 'throw new Error("must run the native child directly")') + await symlink(entry, native) + native = path.join(runtime, "mnemon") + } + await writeFile(native, `#!/usr/bin/env node +if (process.argv[2] === "status") { console.log("{}"); process.exit(0) } +process.on("SIGTERM", () => {}) +require("node:fs").writeFileSync(process.env.MNEMON_TEST_PID, String(process.pid)) +setInterval(() => {}, 1000) +`, { mode: 0o755 }) + await copyFile(new URL("../../../internal/memory/setup/assets/opencode/mnemon.js", import.meta.url), path.join(root, "plugin.mjs")) + await writeFile(path.join(root, "worker.mjs"), `import plugin from "./plugin.mjs" +import { writeFileSync } from "node:fs" +let context +await plugin.setup({ location: { directory: process.cwd() }, shell: { hook: async () => {} }, session: { hook: async (name, fn) => { if (name === "context") context = fn } } }) +const event = { sessionID: "s", messages: [{ id: "u", role: "user", content: [{ type: "text", text: "query" }] }] } +await context(event) +writeFileSync("result.json", JSON.stringify(event)) +`) + const started = Date.now() + // Ignored stdio keeps this outer deadline independent of inherited pipes + // if a broken plugin leaves the fixture process running. + const child = spawnSync(process.execPath, [path.join(root, "worker.mjs")], { + cwd: root, + env: { ...process.env, PATH: bin + path.delimiter + process.env.PATH, MNEMON_TEST_PID: pidFile }, + stdio: "ignore", timeout: 9000, killSignal: "SIGKILL", + }) + assert.equal(child.status, 0, `plugin exceeded its deadline: ${child.error}`) + assert.ok(Date.now() - started < 8000, "five-second timeout must remain bounded") + const result = JSON.parse(await readFile(path.join(root, "result.json"), "utf8")) + assert.match(result.messages[0].content[0].text, //) + const pid = Number(await readFile(pidFile, "utf8")) + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }, "native child must be reaped") + } finally { + try { process.kill(Number(await readFile(pidFile, "utf8")), "SIGKILL") } catch {} + await rm(root, { recursive: true, force: true }) + } + }) +}