diff --git a/README.md b/README.md index 6496e53..b0110e8 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,6 +91,13 @@ 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 per session +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. + ## Development ```sh diff --git a/plugin/index.js b/plugin/index.js index d045ce7..37d6d96 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" @@ -36,20 +37,46 @@ export default Plugin.define({ editor.set("musubi", { type: "local", command: [mcpBinary] }) }) - const continuitySeen = new Set() - await ctx.session.hook("context", async (event) => { - if (continuitySeen.has(event.sessionID)) return + const continuityCache = new Map() + const continuityHookSeen = new Set() + const continuityAppliedCount = new Map() + const contextHook = await ctx.session.hook("context", async (event) => { + 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) + while (continuityCache.size > MAX_CONTINUITY_SESSIONS) { + const oldest = continuityCache.keys().next().value + continuityCache.delete(oldest) + continuityHookSeen.delete(oldest) + continuityAppliedCount.delete(oldest) + } + } 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") { - event.system.push({ type: "text", text: result.text }) - continuitySeen.add(event.sessionID) - } else { - console.error("musubi-opencode continuity unavailable", result.detail || "unknown") + 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 }) + } + 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)) } }) @@ -85,6 +112,14 @@ export default Plugin.define({ if (!controller.signal.aborted) console.error("musubi-opencode events unavailable", String(error).slice(0, 200)) } })() - return () => controller.abort() + return async () => { + controller.abort() + continuityCache.clear() + continuityHookSeen.clear() + continuityAppliedCount.clear() + 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 new file mode 100644 index 0000000..1e1bb37 --- /dev/null +++ b/tests/plugin.test.js @@ -0,0 +1,67 @@ +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("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") + 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: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", "MUSUBI_TEST_BRIDGE_TEXT"].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 + 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) + 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") + 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)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await rm(directory, { recursive: true, force: true }) + } +})