From 5871047cea13d408297317bafda0a59ed248f273 Mon Sep 17 00:00:00 2001 From: Yua Date: Tue, 29 Sep 2026 15:59:42 -0400 Subject: [PATCH 1/4] fix: trace and dispose continuity context hook --- README.md | 6 +++++ plugin/index.js | 19 ++++++++++++---- tests/plugin.test.js | 54 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 tests/plugin.test.js diff --git a/README.md b/README.md index 6496e53..5e88af5 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,12 @@ commands, set `MUSUBI_OPENCODE_BRIDGE_BIN` and foreign actor namespace and confirm the tool refuses it. Read `~/.local/state/musubi-opencode/degraded.jsonl` if any step fails. +The local OpenCode log records one `continuity context hook` line and one +`continuity injected` line with a character count per primary session. It +does not log memory contents. Those lines prove the adapter reached and +mutated the hook, but only a model-side or outgoing-request check proves the +provider received the block. + ## Development ```sh diff --git a/plugin/index.js b/plugin/index.js index d045ce7..cb156c8 100644 --- a/plugin/index.js +++ b/plugin/index.js @@ -37,17 +37,23 @@ export default Plugin.define({ }) const continuitySeen = new Set() - await ctx.session.hook("context", async (event) => { + const continuityHookSeen = new Set() + const contextHook = await ctx.session.hook("context", async (event) => { + if (!continuityHookSeen.has(event.sessionID)) { + continuityHookSeen.add(event.sessionID) + console.info("musubi-opencode continuity context hook", JSON.stringify({ session_id: event.sessionID })) + } if (continuitySeen.has(event.sessionID)) return try { const session = await ctx.session.get({ sessionID: event.sessionID }) if (session.parentID) return const result = await bridge({ action: "continuity" }) - if (result.ok && typeof result.text === "string") { + if (result.ok && typeof result.text === "string" && result.text.trim()) { event.system.push({ type: "text", text: result.text }) continuitySeen.add(event.sessionID) + console.info("musubi-opencode continuity injected", JSON.stringify({ session_id: event.sessionID, chars: result.text.length })) } else { - console.error("musubi-opencode continuity unavailable", result.detail || "unknown") + console.error("musubi-opencode continuity unavailable", result.detail || "empty_block") } } catch (error) { console.error("musubi-opencode continuity unavailable", String(error).slice(0, 200)) @@ -85,6 +91,11 @@ export default Plugin.define({ if (!controller.signal.aborted) console.error("musubi-opencode events unavailable", String(error).slice(0, 200)) } })() - return () => controller.abort() + return () => { + controller.abort() + void contextHook.dispose().catch((error) => { + console.error("musubi-opencode continuity hook disposal failed", String(error).slice(0, 200)) + }) + } }, }) diff --git a/tests/plugin.test.js b/tests/plugin.test.js new file mode 100644 index 0000000..2dd63a5 --- /dev/null +++ b/tests/plugin.test.js @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test" +import { mkdtemp, writeFile, chmod, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import plugin from "../plugin/index.js" + +test("injects a nonempty continuity block once and disposes the context hook", async () => { + const directory = await mkdtemp(join(tmpdir(), "musubi-opencode-plugin-")) + const bridge = join(directory, "bridge") + await writeFile(bridge, '#!/usr/bin/env node\nprocess.stdin.resume(); process.stdin.on("end", () => process.stdout.write(JSON.stringify({ok:true,text:"## Musubi continuity\\nRecent memory available."})))\n') + await chmod(bridge, 0o755) + const old = Object.fromEntries(["MUSUBI_ACTOR", "MUSUBI_PRESENCE", "MUSUBI_ZONE", "MUSUBI_OPENCODE_BRIDGE_BIN"].map((key) => [key, process.env[key]])) + Object.assign(process.env, { + MUSUBI_ACTOR: "iris", + MUSUBI_PRESENCE: "iris/agent", + MUSUBI_ZONE: "home", + MUSUBI_OPENCODE_BRIDGE_BIN: bridge, + }) + let contextHook + let disposed = false + const context = { + mcp: { transform: async () => {} }, + session: { + hook: async (name, callback) => { + expect(name).toBe("context") + contextHook = callback + return { dispose: async () => { disposed = true } } + }, + get: async () => ({ id: "ses_primary" }), + }, + event: { + async *subscribe({ signal }) { + await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true })) + }, + }, + } + try { + const cleanup = await plugin.setup(context) + const request = { sessionID: "ses_primary", system: [] } + await contextHook(request) + expect(request.system).toEqual([{ type: "text", text: "## Musubi continuity\nRecent memory available." }]) + await contextHook(request) + expect(request.system).toHaveLength(1) + cleanup() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(disposed).toBe(true) + } finally { + for (const [key, value] of Object.entries(old)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await rm(directory, { recursive: true, force: true }) + } +}) From 90db82b6a3bcd5f5fa07762987c98523ce010f58 Mon Sep 17 00:00:00 2001 From: Yua Date: Tue, 29 Sep 2026 16:02:29 -0400 Subject: [PATCH 2/4] fix: keep continuity in every model request --- README.md | 11 +++++++---- plugin/index.js | 32 +++++++++++++++++++++----------- tests/plugin.test.js | 12 +++++++++--- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 5e88af5..6255030 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,11 @@ canonical five-tool memory facade. turns, interrupted turns, and subagent sessions. Visible text before and after tool calls is retained. The stable event ID makes repeat completion events idempotent. -- Injects a bounded, clearly labeled recent chronology once per primary - session. These memories are historical data, never instructions. +- Fetches a bounded, clearly labeled recent chronology once per primary + session and adds it to every primary model request in that session. OpenCode + rebuilds the request system parts after each step, so one insertion would + disappear after the first request. These memories are historical data, + never instructions. - Registers `musubi_recent`, `musubi_search`, `musubi_get`, `musubi_remember`, and `musubi_status` as local MCP tools. The first three and remember are limited to the configured actor's namespace by the shared @@ -88,8 +91,8 @@ commands, set `MUSUBI_OPENCODE_BRIDGE_BIN` and foreign actor namespace and confirm the tool refuses it. Read `~/.local/state/musubi-opencode/degraded.jsonl` if any step fails. -The local OpenCode log records one `continuity context hook` line and one -`continuity injected` line with a character count per primary session. It +The local OpenCode log records one `continuity context hook` line per session +and a `continuity injected` line with a character count per model request. It does not log memory contents. Those lines prove the adapter reached and mutated the hook, but only a model-side or outgoing-request check proves the provider received the block. diff --git a/plugin/index.js b/plugin/index.js index cb156c8..7cc7b7d 100644 --- a/plugin/index.js +++ b/plugin/index.js @@ -36,26 +36,35 @@ export default Plugin.define({ editor.set("musubi", { type: "local", command: [mcpBinary] }) }) - const continuitySeen = new Set() + const continuityCache = new Map() const continuityHookSeen = new Set() const contextHook = await ctx.session.hook("context", async (event) => { if (!continuityHookSeen.has(event.sessionID)) { continuityHookSeen.add(event.sessionID) console.info("musubi-opencode continuity context hook", JSON.stringify({ session_id: event.sessionID })) } - if (continuitySeen.has(event.sessionID)) return + let cached = continuityCache.get(event.sessionID) + if (!cached) { + cached = (async () => { + const session = await ctx.session.get({ sessionID: event.sessionID }) + if (session.parentID) return null + const result = await bridge({ action: "continuity" }) + if (!result.ok || typeof result.text !== "string" || !result.text.trim()) { + throw new Error(result.detail || "empty_block") + } + return result.text + })() + continuityCache.set(event.sessionID, cached) + } try { - const session = await ctx.session.get({ sessionID: event.sessionID }) - if (session.parentID) return - const result = await bridge({ action: "continuity" }) - if (result.ok && typeof result.text === "string" && result.text.trim()) { - event.system.push({ type: "text", text: result.text }) - continuitySeen.add(event.sessionID) - console.info("musubi-opencode continuity injected", JSON.stringify({ session_id: event.sessionID, chars: result.text.length })) - } else { - console.error("musubi-opencode continuity unavailable", result.detail || "empty_block") + const text = await cached + if (!text) return + if (!event.system.some((part) => part.type === "text" && part.text === text)) { + event.system.push({ type: "text", text }) } + console.info("musubi-opencode continuity injected", JSON.stringify({ session_id: event.sessionID, chars: text.length })) } catch (error) { + continuityCache.delete(event.sessionID) console.error("musubi-opencode continuity unavailable", String(error).slice(0, 200)) } }) @@ -93,6 +102,7 @@ export default Plugin.define({ })() return () => { controller.abort() + continuityCache.clear() void contextHook.dispose().catch((error) => { console.error("musubi-opencode continuity hook disposal failed", String(error).slice(0, 200)) }) diff --git a/tests/plugin.test.js b/tests/plugin.test.js index 2dd63a5..078992d 100644 --- a/tests/plugin.test.js +++ b/tests/plugin.test.js @@ -4,17 +4,19 @@ import { tmpdir } from "node:os" import { join } from "node:path" import plugin from "../plugin/index.js" -test("injects a nonempty continuity block once and disposes the context hook", async () => { +test("fetches continuity once, applies it to every model request, and disposes the hook", async () => { const directory = await mkdtemp(join(tmpdir(), "musubi-opencode-plugin-")) const bridge = join(directory, "bridge") - await writeFile(bridge, '#!/usr/bin/env node\nprocess.stdin.resume(); process.stdin.on("end", () => process.stdout.write(JSON.stringify({ok:true,text:"## Musubi continuity\\nRecent memory available."})))\n') + const calls = join(directory, "calls") + await writeFile(bridge, '#!/usr/bin/env node\nconst fs = require("node:fs"); process.stdin.resume(); process.stdin.on("end", () => { fs.appendFileSync(process.env.MUSUBI_TEST_BRIDGE_CALLS, "x"); process.stdout.write(JSON.stringify({ok:true,text:"## Musubi continuity\\nRecent memory available."})) })\n') await chmod(bridge, 0o755) - const old = Object.fromEntries(["MUSUBI_ACTOR", "MUSUBI_PRESENCE", "MUSUBI_ZONE", "MUSUBI_OPENCODE_BRIDGE_BIN"].map((key) => [key, process.env[key]])) + const old = Object.fromEntries(["MUSUBI_ACTOR", "MUSUBI_PRESENCE", "MUSUBI_ZONE", "MUSUBI_OPENCODE_BRIDGE_BIN", "MUSUBI_TEST_BRIDGE_CALLS"].map((key) => [key, process.env[key]])) Object.assign(process.env, { MUSUBI_ACTOR: "iris", MUSUBI_PRESENCE: "iris/agent", MUSUBI_ZONE: "home", MUSUBI_OPENCODE_BRIDGE_BIN: bridge, + MUSUBI_TEST_BRIDGE_CALLS: calls, }) let contextHook let disposed = false @@ -41,6 +43,10 @@ test("injects a nonempty continuity block once and disposes the context hook", a expect(request.system).toEqual([{ type: "text", text: "## Musubi continuity\nRecent memory available." }]) await contextHook(request) expect(request.system).toHaveLength(1) + const laterRequest = { sessionID: "ses_primary", system: [] } + await contextHook(laterRequest) + expect(laterRequest.system).toEqual([{ type: "text", text: "## Musubi continuity\nRecent memory available." }]) + expect(await Bun.file(calls).text()).toBe("x") cleanup() await new Promise((resolve) => setTimeout(resolve, 0)) expect(disposed).toBe(true) From c60dd1d42e1cfbe721cad37172cb2c3f5ca0f83a Mon Sep 17 00:00:00 2001 From: Yua Date: Tue, 29 Sep 2026 16:03:26 -0400 Subject: [PATCH 3/4] chore: bound continuity cache and diagnostic logging --- README.md | 3 ++- plugin/index.js | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6255030..b0110e8 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,8 @@ commands, set `MUSUBI_OPENCODE_BRIDGE_BIN` and `~/.local/state/musubi-opencode/degraded.jsonl` if any step fails. The local OpenCode log records one `continuity context hook` line per session -and a `continuity injected` line with a character count per model request. It +and `continuity injected` lines with a character count for its first two model +requests. It does not log memory contents. Those lines prove the adapter reached and mutated the hook, but only a model-side or outgoing-request check proves the provider received the block. diff --git a/plugin/index.js b/plugin/index.js index 7cc7b7d..68dd43e 100644 --- a/plugin/index.js +++ b/plugin/index.js @@ -3,6 +3,7 @@ import { Plugin } from "@opencode/plugin" import { completedTurn } from "./turn.js" const BRIDGE_TIMEOUT_MS = 28_000 +const MAX_CONTINUITY_SESSIONS = 200 function bridge(request) { const binary = process.env.MUSUBI_OPENCODE_BRIDGE_BIN || "musubi-opencode-bridge" @@ -38,6 +39,7 @@ export default Plugin.define({ const continuityCache = new Map() const continuityHookSeen = new Set() + const continuityAppliedCount = new Map() const contextHook = await ctx.session.hook("context", async (event) => { if (!continuityHookSeen.has(event.sessionID)) { continuityHookSeen.add(event.sessionID) @@ -55,6 +57,12 @@ export default Plugin.define({ return result.text })() continuityCache.set(event.sessionID, cached) + while (continuityCache.size > MAX_CONTINUITY_SESSIONS) { + const oldest = continuityCache.keys().next().value + continuityCache.delete(oldest) + continuityHookSeen.delete(oldest) + continuityAppliedCount.delete(oldest) + } } try { const text = await cached @@ -62,7 +70,11 @@ export default Plugin.define({ if (!event.system.some((part) => part.type === "text" && part.text === text)) { event.system.push({ type: "text", text }) } - console.info("musubi-opencode continuity injected", JSON.stringify({ session_id: event.sessionID, chars: text.length })) + const applied = (continuityAppliedCount.get(event.sessionID) || 0) + 1 + continuityAppliedCount.set(event.sessionID, applied) + if (applied <= 2) { + console.info("musubi-opencode continuity injected", JSON.stringify({ session_id: event.sessionID, chars: text.length, request: applied })) + } } catch (error) { continuityCache.delete(event.sessionID) console.error("musubi-opencode continuity unavailable", String(error).slice(0, 200)) @@ -103,6 +115,8 @@ export default Plugin.define({ return () => { controller.abort() continuityCache.clear() + continuityHookSeen.clear() + continuityAppliedCount.clear() void contextHook.dispose().catch((error) => { console.error("musubi-opencode continuity hook disposal failed", String(error).slice(0, 200)) }) From 1e3c7642fd684fe7df1bcda337610e436b2734e3 Mon Sep 17 00:00:00 2001 From: Yua Date: Tue, 29 Sep 2026 16:05:46 -0400 Subject: [PATCH 4/4] test: cover empty continuity retry and await hook cleanup --- plugin/index.js | 12 ++++++------ tests/plugin.test.js | 15 +++++++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/plugin/index.js b/plugin/index.js index 68dd43e..37d6d96 100644 --- a/plugin/index.js +++ b/plugin/index.js @@ -41,10 +41,6 @@ export default Plugin.define({ const continuityHookSeen = new Set() const continuityAppliedCount = new Map() const contextHook = await ctx.session.hook("context", async (event) => { - if (!continuityHookSeen.has(event.sessionID)) { - continuityHookSeen.add(event.sessionID) - console.info("musubi-opencode continuity context hook", JSON.stringify({ session_id: event.sessionID })) - } let cached = continuityCache.get(event.sessionID) if (!cached) { cached = (async () => { @@ -67,6 +63,10 @@ export default Plugin.define({ try { const text = await cached if (!text) return + if (!continuityHookSeen.has(event.sessionID)) { + continuityHookSeen.add(event.sessionID) + console.info("musubi-opencode continuity context hook", JSON.stringify({ session_id: event.sessionID })) + } if (!event.system.some((part) => part.type === "text" && part.text === text)) { event.system.push({ type: "text", text }) } @@ -112,12 +112,12 @@ export default Plugin.define({ if (!controller.signal.aborted) console.error("musubi-opencode events unavailable", String(error).slice(0, 200)) } })() - return () => { + return async () => { controller.abort() continuityCache.clear() continuityHookSeen.clear() continuityAppliedCount.clear() - void contextHook.dispose().catch((error) => { + await contextHook.dispose().catch((error) => { console.error("musubi-opencode continuity hook disposal failed", String(error).slice(0, 200)) }) } diff --git a/tests/plugin.test.js b/tests/plugin.test.js index 078992d..1e1bb37 100644 --- a/tests/plugin.test.js +++ b/tests/plugin.test.js @@ -8,9 +8,9 @@ test("fetches continuity once, applies it to every model request, and disposes t const directory = await mkdtemp(join(tmpdir(), "musubi-opencode-plugin-")) const bridge = join(directory, "bridge") const calls = join(directory, "calls") - await writeFile(bridge, '#!/usr/bin/env node\nconst fs = require("node:fs"); process.stdin.resume(); process.stdin.on("end", () => { fs.appendFileSync(process.env.MUSUBI_TEST_BRIDGE_CALLS, "x"); process.stdout.write(JSON.stringify({ok:true,text:"## Musubi continuity\\nRecent memory available."})) })\n') + await writeFile(bridge, '#!/usr/bin/env node\nconst fs = require("node:fs"); process.stdin.resume(); process.stdin.on("end", () => { fs.appendFileSync(process.env.MUSUBI_TEST_BRIDGE_CALLS, "x"); process.stdout.write(JSON.stringify({ok:true,text:process.env.MUSUBI_TEST_BRIDGE_TEXT ?? "## Musubi continuity\\nRecent memory available."})) })\n') await chmod(bridge, 0o755) - const old = Object.fromEntries(["MUSUBI_ACTOR", "MUSUBI_PRESENCE", "MUSUBI_ZONE", "MUSUBI_OPENCODE_BRIDGE_BIN", "MUSUBI_TEST_BRIDGE_CALLS"].map((key) => [key, process.env[key]])) + const old = Object.fromEntries(["MUSUBI_ACTOR", "MUSUBI_PRESENCE", "MUSUBI_ZONE", "MUSUBI_OPENCODE_BRIDGE_BIN", "MUSUBI_TEST_BRIDGE_CALLS", "MUSUBI_TEST_BRIDGE_TEXT"].map((key) => [key, process.env[key]])) Object.assign(process.env, { MUSUBI_ACTOR: "iris", MUSUBI_PRESENCE: "iris/agent", @@ -47,8 +47,15 @@ test("fetches continuity once, applies it to every model request, and disposes t await contextHook(laterRequest) expect(laterRequest.system).toEqual([{ type: "text", text: "## Musubi continuity\nRecent memory available." }]) expect(await Bun.file(calls).text()).toBe("x") - cleanup() - await new Promise((resolve) => setTimeout(resolve, 0)) + process.env.MUSUBI_TEST_BRIDGE_TEXT = " \n " + const emptyRequest = { sessionID: "ses_empty", system: [] } + await contextHook(emptyRequest) + expect(emptyRequest.system).toHaveLength(0) + process.env.MUSUBI_TEST_BRIDGE_TEXT = "## Musubi continuity\nRecovered memory." + await contextHook(emptyRequest) + expect(emptyRequest.system).toEqual([{ type: "text", text: "## Musubi continuity\nRecovered memory." }]) + expect(await Bun.file(calls).text()).toBe("xxx") + await cleanup() expect(disposed).toBe(true) } finally { for (const [key, value] of Object.entries(old)) {