Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
59 changes: 47 additions & 12 deletions plugin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}
})
Expand Down Expand Up @@ -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))
})
}
},
})
67 changes: 67 additions & 0 deletions tests/plugin.test.js
Original file line number Diff line number Diff line change
@@ -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." }])
Comment thread
ericmey marked this conversation as resolved.
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 })
}
})
Loading