From b27104516d8f01969694cc6b354567b70af4c25a Mon Sep 17 00:00:00 2001 From: Mirro Chou <3250823+Mirr0ch1@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:16:30 +0800 Subject: [PATCH 1/6] feat: add OpenCode V2 plugin adapter - extract host-agnostic memory behavior into src/runtime.ts - add src/plugin-v2.ts using session/tool hooks and event subscription - port /memory TUI command to the V2 keymap and dialog APIs - export dual entrypoint: setup() for V2, server() for V1 - keep the existing on-disk memory format and storage paths - track V2 todowrite snapshots for compaction context --- AGENTS.md | 49 +- CHANGELOG.md | 17 + README.md | 38 +- docs/architecture.md | 21 +- docs/installation.md | 54 +- index.ts | 11 +- package.json | 14 +- src/extractors.ts | 13 +- src/plugin-v2.ts | 196 +++++++ src/plugin.ts | 1222 +++------------------------------------ src/runtime.ts | 1212 ++++++++++++++++++++++++++++++++++++++ src/session-state.ts | 18 +- src/tui-plugin-v1.ts | 303 ++++++++++ src/tui-plugin.ts | 370 +++++------- src/types.ts | 9 + tests/plugin-v2.test.ts | 353 +++++++++++ tui.ts | 5 + 17 files changed, 2473 insertions(+), 1432 deletions(-) create mode 100644 src/plugin-v2.ts create mode 100644 src/runtime.ts create mode 100644 src/tui-plugin-v1.ts create mode 100644 tests/plugin-v2.test.ts create mode 100644 tui.ts diff --git a/AGENTS.md b/AGENTS.md index b628f31..6da0bab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,18 +51,24 @@ Tests verify: ### File Structure ``` opencode-working-memory/ -├── index.ts # Plugin entry point (exports PluginModule) +├── index.ts # Dual V1/V2 server entry point +├── tui.ts # TUI entry point discovered beside index.ts ├── src/ -│ ├── plugin.ts # Main plugin implementation +│ ├── runtime.ts # Host-agnostic memory logic +│ ├── plugin.ts # V1 adapter +│ ├── plugin-v2.ts # V2 adapter +│ ├── tui-plugin.ts # V2 TUI + V1 TUI dual export +│ ├── tui-plugin-v1.ts # Legacy V1 TUI implementation │ ├── extractors.ts # Memory extraction logic │ ├── workspace-memory.ts # Workspace memory management │ ├── session-state.ts # Session state tracking │ ├── storage.ts # File storage utilities │ ├── paths.ts # Path utilities -│ ├── opencode.ts # OpenCode SDK types +│ ├── opencode.ts # V1 OpenCode SDK helpers │ └── types.ts # Type definitions ├── tests/ -│ ├── plugin.test.ts # Plugin hook tests +│ ├── plugin.test.ts # V1 adapter and runtime hook tests +│ ├── plugin-v2.test.ts # V2 adapter and TUI tests │ ├── extractors.test.ts # Extractor tests │ └── workspace-memory.test.ts # Workspace memory tests ├── package.json # Plugin manifest @@ -221,16 +227,26 @@ const canonical = normalizeText(text); // Lowercase, strip punctuation, collaps ### Plugin Entry Point +`index.ts` exposes both host adapters from one default export: + ```typescript // index.ts +import { WorkingMemoryPlugin } from "./src/plugin-v2.ts"; import { MemoryV2Plugin } from "./src/plugin.ts"; export default { - id: "working-memory", - server: MemoryV2Plugin, + ...WorkingMemoryPlugin, // V2: { id, setup } + server: MemoryV2Plugin, // V1: server(input, options) }; ``` +Shared behavior lives in `src/runtime.ts`. The adapters only translate host +payloads: + +- `src/plugin.ts` — V1 string hooks and `client.session.*` calls. +- `src/plugin-v2.ts` — `ctx.session.hook`, `ctx.tool.hook`, `ctx.event.subscribe`. +- `src/tui-plugin.ts` — V2 keymap/slash command plus the V1 TUI export. + ### Workspace Memory Files - **Location**: `~/.local/share/opencode-working-memory/workspaces/{workspaceKey}/workspace-memory.json` @@ -261,25 +277,30 @@ export default { ## Plugin Hooks -### `experimental.chat.system.transform` +### `experimental.chat.system.transform` (V1) / `session.hook("context")` (V2) -Injects workspace memory and hot session state into system prompt. +Injects workspace memory and hot session state into the system prompt. V2 pushes +one `{ type: "text", text }` part per memory block. -### `tool.execute.after` +### `tool.execute.after` (V1) / `tool.hook("execute.after")` (V2) - Tracks active files (read, grep, edit, write actions) - Tracks open errors from failed commands - Clears errors when commands succeed - Ignores `exitCode === undefined` +- V2 reads `metadata.exit` and also records `todowrite` snapshots -### `experimental.session.compacting` +### `experimental.session.compacting` (V1) / `session.hook("compaction")` (V2) -Extracts workspace memory candidates from conversation, applies quality gate and deduplication. +V1 replaces the compaction prompt with a `---`-free template. V2 keeps +OpenCode's own compaction prompt and appends the memory rules and numbered refs +as a system part. -### `event` +### `event` (V1) / `ctx.event.subscribe()` (V2) -- `session.compacted`: Promote session decisions to workspace memory -- `session.deleted`: Clean up session state files +- `session.compacted` (V1) / `session.compaction.ended` (V2): parse the summary + and promote memory candidates to workspace memory +- `session.deleted`: promote owned pending memories, then clean up session state ## Debugging & Testing diff --git a/CHANGELOG.md b/CHANGELOG.md index d5f0f90..3a1f968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.7.0] - 2026-10-21 + +### Added + +- Added an OpenCode V2 plugin adapter (`src/plugin-v2.ts`) using `Plugin.define`, `ctx.session.hook("context"|"compaction")`, `ctx.tool.hook("execute.after")`, and `ctx.event.subscribe()`. +- Added a V2 TUI plugin that registers the `/memory` slash command through `ctx.keymap.layer` and native dialogs. +- Added `tui.ts` beside `index.ts` so V2 discovers the TUI entrypoint for local directory plugins; published packages expose it through `exports["./tui"]`. +- Added `tests/plugin-v2.test.ts` covering hook registration, system-part injection, shell error tracking, `todowrite` snapshots, compaction summaries, session cleanup, and the TUI command. + +### Changed + +- Extracted host-agnostic memory behavior into `src/runtime.ts`; `src/plugin.ts` is now the V1 adapter. +- `index.ts` exports both shapes (`setup` for V2, `server` for V1) so one package works on both hosts. +- V2 keeps OpenCode's own compaction prompt and appends memory rules and numbered refs as a system part instead of replacing the prompt. +- V2 records todo snapshots from the `todowrite` tool because V2 has no session todo read API. +- Workspace memory and session state paths are unchanged, so existing memory survives the upgrade. + ## [1.6.7] - 2026-05-31 ### Fixed diff --git a/README.md b/README.md index 01888b5..852bc14 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,33 @@ compaction ─────► reinforce / replace ──► selective prompt con ## Installation -New users: add OpenCode Working Memory to both OpenCode plugin configs. +### OpenCode V2 (recommended) + +Add the package to the `plugins` array in `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugins": ["opencode-working-memory"] +} +``` + +V2 discovers the CLI/TUI entrypoint from the package's `./tui` export automatically, so there is no separate `tui.json` step. Restart OpenCode after changing the config. + +To run a local checkout, point at the plugin directory (V2 requires a directory, not an `index.ts` file): + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugins": ["/home/mirro/coding/opencode-working-memory"] +} +``` + +Local directory plugins keep the server and TUI entrypoints side by side: V2 discovers `tui.ts` next to `index.ts`. The published package exposes the same module through `exports["./tui"]`. + +### OpenCode V1 + +V1 keeps the original two-file setup: `.opencode/opencode.json`: @@ -62,13 +88,11 @@ New users: add OpenCode Working Memory to both OpenCode plugin configs. } ``` -Existing users: keep your current `.opencode/opencode.json` config and add only the `.opencode/tui.json` block above to enable the native `/memory` TUI menu. - -Then restart OpenCode. Memory activates automatically, and `/memory` appears in the TUI slash command menu. +Both adapters share the same on-disk memory format, so switching between V1 and V2 keeps existing workspace memory. -### Strict OpenAI-Compatible Providers +### Strict OpenAI-Compatible Providers (V1 only) -Some providers, including GWDG/Qwen endpoints, reject requests with multiple system messages. If your provider returns `System message must be at the beginning`, enable merged system prompts in `.opencode/opencode.json`: +Some V1 providers, including GWDG/Qwen endpoints, reject requests with multiple system messages. If your provider returns `System message must be at the beginning`, enable merged system prompts in `.opencode/opencode.json`: ```json { @@ -77,7 +101,7 @@ Some providers, including GWDG/Qwen endpoints, reject requests with multiple sys } ``` -This keeps memory context available by appending workspace memory and hot session state into the first system message. Leave this option off for providers that already accept multiple system messages; the default separated prompts are more cache-friendly. +This keeps memory context available by appending workspace memory and hot session state into the first system message. Leave this option off for providers that already accept multiple system messages; the default separated prompts are more cache-friendly. V2 injects memory as separate system parts and ignores this option. Fallback for older OpenCode/plugin hosts that do not pass plugin tuple options: diff --git a/docs/architecture.md b/docs/architecture.md index d3068d4..ef6d001 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -286,27 +286,34 @@ Delegate task tracking to OpenCode's native features. ## Plugin Hooks -OpenCode Working Memory hooks into OpenCode lifecycle events: +OpenCode Working Memory hooks into OpenCode lifecycle events. The shared +behavior lives in `src/runtime.ts`; `src/plugin.ts` and `src/plugin-v2.ts` are +thin host adapters. -### `experimental.chat.system.transform` +### `experimental.chat.system.transform` (V1) / `session.hook("context")` (V2) -Injects cached frozen workspace memory and cached frozen hot snapshot prompts into the system prompt. Normal tool/user churn updates storage but does not mutate these pre-history prompts until a new epoch starts. +Injects cached frozen workspace memory and cached frozen hot snapshot prompts into the system prompt. Normal tool/user churn updates storage but does not mutate these pre-history prompts until a new epoch starts. V2 pushes each block as its own text system part. -### `tool.execute.after` +### `tool.execute.after` (V1) / `tool.hook("execute.after")` (V2) - Tracks active files (read, grep, edit, write actions) - Tracks open errors from failed commands - Clears errors when commands succeed - Ignores `exitCode === undefined` (successful commands without explicit exit codes) +- V2 reads the shell tool's `metadata.exit` and records `todowrite` snapshots in session state -### `experimental.session.compacting` +### `experimental.session.compacting` (V1) / `session.hook("compaction")` (V2) Extracts workspace memory candidates from conversation. Applies quality gate, redaction, migration, consolidation accounting, deduplication, and source priority. -### `event` (session.compacted, session.deleted) +V1 replaces the compaction prompt so it can remove `---` separators. V2 keeps +OpenCode's own compaction prompt and appends the memory rules and numbered +`[M#]` refs as a system part. -- `session.compacted`: Promote session decisions to workspace memory +### `event` (V1) / `ctx.event.subscribe()` (V2) + +- `session.compacted` (V1) / `session.compaction.ended` (V2): Promote session decisions to workspace memory - `session.deleted`: Clean up session state files Promotion uses accounting results from workspace memory normalization. Pending memories that are kept are promoted; duplicate memories are absorbed; exact decision replacements can be superseded; over-capacity compaction memories are rejected. Stale-marked memories are not hard-pruned by age; they lose rendered space through retention strength and cap competition. diff --git a/docs/installation.md b/docs/installation.md index e3f403e..d0daf80 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,24 +1,57 @@ # Installation Guide -## Quick Install +## OpenCode V2 -Add to your `~/.config/opencode/opencode.json`: +Add to your `~/.config/opencode/opencode.json` (or project `opencode.json`): ```json { - "plugin": ["opencode-working-memory"] + "$schema": "https://opencode.ai/config.json", + "plugins": ["opencode-working-memory"] +} +``` + +For a local checkout, use the plugin directory: + +```json +{ + "plugins": ["/home/mirro/coding/opencode-working-memory"] } ``` +V2 requires a directory, not an `index.ts` file. The package's `./tui` export is +loaded automatically, so no separate `tui.json` is needed. + Restart OpenCode. OpenCode Working Memory activates automatically — no manual setup needed. -> **Note**: The correct key is `plugin` (singular), not `plugins`. +## OpenCode V1 + +Add to `~/.config/opencode/opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-working-memory"] +} +``` + +For the `/memory` TUI menu, also add the package to `~/.config/opencode/tui.json`: + +```json +{ + "$schema": "https://opencode.ai/tui.json", + "plugin": ["opencode-working-memory"] +} +``` + +> **Note**: V1 uses `plugin` (singular); V2 uses `plugins` (plural). ## For LLM Agents 1. Edit `~/.config/opencode/opencode.json` -2. Add `"opencode-working-memory"` to the `plugin` array +2. On V2, add the package or local directory to the `plugins` array; on V1, add it to `plugin` 3. Verify the config file was saved correctly +4. Restart OpenCode (V2 requires a server restart after adding a local plugin) ## Verification @@ -59,12 +92,13 @@ Tracks current session: ### Plugin Not Loading -**Symptom**: No memory context in system prompt +**Symptom**: No memory context in system prompt, or `1 plugin failed` **Solution**: -1. Check `~/.config/opencode/opencode.json` uses `"plugin"` (not `"plugins"`) +1. Check the config key for your host: V2 uses `"plugins"` with a directory path; V1 uses `"plugin"` with the package name 2. Restart OpenCode to trigger automatic installation -3. Check OpenCode logs for any download errors +3. Check OpenCode logs for plugin load errors (`~/.local/share/opencode/log/opencode.log`) +4. V2: confirm the plugin appears as `working-memory` with `state.status: active` in `opencode api get /api/plugin` ### Memory Files Not Created @@ -95,7 +129,9 @@ Tracks current session: ## Uninstallation -Remove `"opencode-working-memory"` from the `plugin` array in `~/.config/opencode/opencode.json`. +V2: remove `"opencode-working-memory"` (or the local directory) from the `plugins` array in `~/.config/opencode/opencode.json`. + +V1: remove `"opencode-working-memory"` from the `plugin` arrays in `opencode.json` and `tui.json`. Memory files in `~/.local/share/opencode-working-memory/` persist unless manually deleted. diff --git a/index.ts b/index.ts index cac6641..1678af8 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,13 @@ +import { WorkingMemoryPlugin } from "./src/plugin-v2.ts"; import { MemoryV2Plugin } from "./src/plugin.ts"; +/** + * Dual entrypoint: + * - OpenCode V2 calls `setup(ctx)`. + * - OpenCode V1 (>= 1.18.29) calls `server(input, options)`. + * - Older V1 releases can import the `./server` export directly. + */ export default { - id: "working-memory", + ...WorkingMemoryPlugin, server: MemoryV2Plugin, -}; \ No newline at end of file +}; diff --git a/package.json b/package.json index 6b4fe79..4e5daae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-working-memory", - "version": "1.6.9", + "version": "1.7.0", "description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state", "type": "module", "main": "dist/index.js", @@ -14,6 +14,7 @@ }, "files": [ "index.ts", + "tui.ts", "src/", "scripts/memory-diag.ts", "scripts/memory-diag/", @@ -54,9 +55,18 @@ }, "homepage": "https://github.com/sdwolf4103/opencode-working-memory#readme", "peerDependencies": { - "@opencode-ai/plugin": ">=1.2.0 <2.0.0" + "@opencode-ai/plugin": ">=1.18.29 <2.0.0" + }, + "peerDependenciesMeta": { + "@opencode-ai/plugin": { + "optional": true + } + }, + "dependencies": { + "@opencode/plugin": "^2.1.1" }, "devDependencies": { + "@opencode-ai/plugin": "^1.19.3", "@types/node": "^24.3.0", "bun-types": "^1.2.21", "typescript": "^5.9.2" diff --git a/src/extractors.ts b/src/extractors.ts index 194f6c7..b5153be 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -220,9 +220,16 @@ export function extractActiveFiles( args: Record, output: string, ): Array<{ path: string; action: ActiveFile["action"] }> { - if (toolName === "read" && typeof args.filePath === "string") return [{ path: args.filePath, action: "read" }]; - if (toolName === "edit" && typeof args.filePath === "string") return [{ path: args.filePath, action: "edit" }]; - if (toolName === "write" && typeof args.filePath === "string") return [{ path: args.filePath, action: "write" }]; + // V1 tool inputs use `filePath`; V2 uses `path`. + const filePath = typeof args.filePath === "string" + ? args.filePath + : typeof args.path === "string" + ? args.path + : undefined; + + if (toolName === "read" && filePath) return [{ path: filePath, action: "read" }]; + if (toolName === "edit" && filePath) return [{ path: filePath, action: "edit" }]; + if (toolName === "write" && filePath) return [{ path: filePath, action: "write" }]; if (toolName === "grep") return extractGrepPaths(output).map(path => ({ path, action: "grep" as const })); return []; } diff --git a/src/plugin-v2.ts b/src/plugin-v2.ts new file mode 100644 index 0000000..531d3a5 --- /dev/null +++ b/src/plugin-v2.ts @@ -0,0 +1,196 @@ +/** + * OpenCode V2 adapter for the memory runtime. + * + * V2 plugins default-export a definition with an `id` and `setup(ctx)`; hooks + * are registered on their owning domain. This adapter translates V2 session, + * tool, compaction, and event payloads into the shared runtime. + */ + +import { Plugin } from "@opencode/plugin"; +import { + createMemoryRuntime, + type MemoryRuntimeHost, + type MemoryTodo, + type ToolAfterEvent, +} from "./runtime.ts"; + +type V2PluginContext = Plugin.Context; + +function safeErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/\s+/g, " ").slice(0, 240); +} + +function toolResultText(result: { content?: unknown; output?: unknown } | undefined): string { + if (!result) return ""; + + const content = result.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((part): part is { type: "text"; text: string } => + Boolean(part) && typeof part === "object" && (part as { type?: string }).type === "text") + .map(part => part.text) + .join("\n"); + } + + const output = result.output; + if (typeof output === "string") return output; + if (output !== undefined) { + try { + return JSON.stringify(output); + } catch { + return String(output); + } + } + + return ""; +} + +function numericMetadataValue(metadata: Record | undefined): number | undefined { + if (!metadata) return undefined; + const candidates = [ + metadata.exit, + metadata.exitCode, + metadata.exit_code, + metadata.code, + metadata.status, + ]; + for (const candidate of candidates) { + if (typeof candidate === "number" && Number.isFinite(candidate)) return candidate; + if (typeof candidate === "string" && /^-?\d+$/.test(candidate)) return Number(candidate); + } + return undefined; +} + +function normalizeTodos(value: unknown): MemoryTodo[] | undefined { + if (!Array.isArray(value)) return undefined; + return value + .filter((todo): todo is Record => Boolean(todo) && typeof todo === "object") + .map(todo => ({ + content: String(todo.content ?? ""), + status: String(todo.status ?? "pending"), + ...(todo.priority !== undefined ? { priority: String(todo.priority) } : {}), + })) + .filter(todo => todo.content.length > 0); +} + +function normalizeToolEvent(event: { + tool: string; + sessionID: string; + input: unknown; + status: "completed" | "error"; + result?: { content?: unknown; output?: unknown; metadata?: Record }; + error?: { message?: string; metadata?: Record }; +}): ToolAfterEvent { + const completed = event.status === "completed"; + const input = (event.input ?? {}) as Record; + const metadata = completed ? event.result?.metadata : event.error?.metadata; + + return { + sessionID: event.sessionID, + tool: event.tool, + args: input, + outputText: completed + ? toolResultText(event.result) + : (event.error?.message ?? ""), + // A failed tool call is a non-zero outcome even when the tool omits an exit code. + exitCode: completed ? numericMetadataValue(metadata) : 1, + todos: completed && event.tool === "todowrite" ? normalizeTodos(input.todos) : undefined, + }; +} + +function buildHost(ctx: V2PluginContext): MemoryRuntimeHost { + return { + directory: ctx.location.directory, + + async getSessionParentID(sessionID: string): Promise { + const session = await ctx.session.get({ sessionID }); + return session.parentID ?? null; + }, + + async getLatestUserMessage(sessionID: string): Promise<{ id: string; text: string } | null> { + const messages = await ctx.session.context({ sessionID }); + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.type !== "user") continue; + if (typeof message.text !== "string" || !message.text.trim()) continue; + return { id: message.id, text: message.text.trim() }; + } + return null; + }, + + // V2 has no session todo read API; the runtime records todowrite snapshots. + async getPendingTodos(): Promise { + return []; + }, + + async getLatestCompactionSummary(sessionID: string): Promise { + const messages = await ctx.session.context({ sessionID }); + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.type !== "compaction") continue; + if (message.status !== "completed") continue; + if (typeof message.summary !== "string" || !message.summary.trim()) continue; + return message.summary.trim(); + } + return null; + }, + }; +} + +export const WorkingMemoryPlugin = Plugin.define({ + id: "working-memory", + + async setup(ctx: V2PluginContext) { + const runtime = createMemoryRuntime(buildHost(ctx), ctx.options); + const controller = new AbortController(); + + await ctx.session.hook("context", async event => { + const parts = await runtime.buildContextParts(event.sessionID); + for (const part of parts) { + event.system.push({ type: "text", text: part }); + } + }); + + await ctx.tool.hook("execute.after", async event => { + await runtime.handleToolAfter(normalizeToolEvent(event)); + }); + + await ctx.session.hook("compaction", async event => { + const prepared = await runtime.prepareCompaction(event.sessionID, ""); + if (prepared?.systemInstruction) { + event.system.push({ type: "text", text: prepared.systemInstruction }); + } + }); + + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (event.type === "session.compaction.ended") { + const data = event.data as { sessionID?: string; text?: string }; + if (data?.sessionID) { + await runtime.handleSessionCompacted(data.sessionID, data.text ?? null); + } + continue; + } + + if (event.type === "session.deleted") { + const data = event.data as { sessionID?: string }; + if (data?.sessionID) { + await runtime.handleSessionDeleted(data.sessionID); + } + } + } + } catch (error) { + if (!controller.signal.aborted) { + console.error(`[memory] event subscription failed: ${safeErrorMessage(error)}`); + } + } + })(); + + return () => controller.abort(); + }, +}); + +export default WorkingMemoryPlugin; diff --git a/src/plugin.ts b/src/plugin.ts index bfd65f8..e929058 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,237 +1,19 @@ /** - * Memory V2 Plugin for OpenCode + * OpenCode V1 adapter for the memory runtime. * - * Architecture: - * - Layer 1: Stable Workspace Memory (frozen per session cache epoch, refreshed at compaction) - * - Layer 2: Frozen Hot Session State (active files, open errors, recent decisions, pending memories) - * - Layer 3: Native OpenCode State (todos owned by OpenCode, read during compaction) - * - * Cache Epoch Model: - * - Each session creates frozen workspace memory and hot session snapshots on first transform. - * - Normal turns reuse the exact rendered strings (pre-history system prompts remain stable). - * - Normal tool/user churn updates session storage but does not mutate pre-history prompts - * until compaction, session restart, or process restart starts a new epoch; conversation - * and tool history are the source of truth for newer events after epoch start. - * - Compaction starts a new cache epoch: pending memories are promoted, caches are cleared, - * and the next transform re-renders workspace memory and hot session state. - * - Explicit memory ("remember X") goes to SessionState.pendingMemories + durable journal, - * visible in the hot snapshot only if processed before epoch creation, promoted after compaction. - * - * This plugin: - * - Caches frozen workspace memory and hot session state per sessionID epoch - * - Processes explicit memory from latest user text once per message id - * - Injects frozen workspace memory and frozen hot session state into system prompt - * - Updates session state after tool execution - * - Augments compaction context with numbered memory refs, todos, and instruction - * - Parses compaction summaries for memory candidates and merges them + * V1 plugins return a map of string hooks. The V2 adapter lives in + * `plugin-v2.ts`; both share `runtime.ts`. */ -import { createHash, randomUUID } from "node:crypto"; -import { realpath, rm } from "fs/promises"; import type { Plugin } from "@opencode-ai/plugin"; -import { - extractExplicitMemoriesWithEvidence, - extractActiveFiles, - extractErrorsFromBash, - parseWorkspaceMemoryCandidatesWithEvidence, - staleAfterDaysFor, - type WorkspaceMemoryCommand, -} from "./extractors.ts"; -import { assessMemoryQuality } from "./memory-quality.ts"; -import { - loadWorkspaceMemory, - updateWorkspaceMemoryWithAccounting, - accountWorkspaceMemoryRender, - accountWorkspaceMemoryCompactionRefs, - workspaceMemoryExactKey, - workspaceMemoryIdentityKey, -} from "./workspace-memory.ts"; -import { REINFORCEMENT_MAX_COUNT, tryReinforceMemory, type ReinforcementDecision } from "./retention.ts"; -import { - appendPendingMemories, - clearPendingMemories, - hasPendingJournalEntries, - loadPendingJournal, - memoryKey, - recordPromotionRejections, -} from "./pending-journal.ts"; -import { - loadSessionState, - updateSessionState, - touchActiveFile, - upsertOpenError, - clearErrorsForSuccessfulCommand, - markErrorsMaybeFixedForFile, - addRecentDecision, - renderHotSessionState, -} from "./session-state.ts"; -import { sessionStatePath, workspaceKey } from "./paths.ts"; +import { createMemoryRuntime, type MemoryRuntimeHost } from "./runtime.ts"; import { latestUserText, latestCompactionSummary, pendingTodos, } from "./opencode.ts"; -import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "./promotion-accounting.ts"; -import { appendEvidenceEvent, appendEvidenceEvents, type EvidenceEventInput, type MemoryEvidenceRef } from "./evidence-log.ts"; -import { type CompactionMemoryRef, type LongTermMemoryEntry, LONG_TERM_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts"; - -/** - * Build the complete compaction prompt. - * - * Replaces OpenCode's default template (which uses --- separators that trigger - * YAML frontmatter comment scope in markdown rendering, producing purple italic text). - * Our template uses only ## Markdown headings and explicitly forbids YAML frontmatter, - * horizontal rules, and delimiter lines. - * - * @param privateContext - Background context (numbered workspace memory refs, - * pending todos) from our plugin and any other plugins. Shown to the model to - * inform the summary but not copied verbatim. - */ -function buildCompactionPrompt(privateContext: string, compactionId?: string): string { - const snapshotInstruction = compactionId - ? `- If you emit any REINFORCE or REPLACE command, include \`Memory ref snapshot id: ${compactionId}\` as the first line under \"Memory candidates:\" so numbered refs match the correct compaction snapshot. REINFORCE/REPLACE commands without this id will be ignored.` - : ""; - return [ - "Provide a detailed summary for continuing our conversation above.", - "Focus on information that would help another agent continue the work: the goal, user instructions, completed work, current state, decisions, relevant files, and next steps.", - "", - "Do not call any tools. Respond only with the summary text.", - "Respond in the same language as the user's messages in the conversation.", - "", - "Formatting rules:", - "- Start the response with \"## Goal\".", - "- Use Markdown headings only.", - "- Do not output YAML frontmatter.", - "- Do not output horizontal rules.", - "- Do not wrap the summary in delimiter lines such as ---.", - "- Do not use code fences around the summary.", - ...(snapshotInstruction ? [snapshotInstruction] : []), - "", - "Use this structure:", - "", - "## Goal", - "", - "## Instructions", - "", - "## Progress", - "", - "## Key Decisions", - "", - "## Discoveries", - "", - "## Next Steps", - "", - "## Relevant Files", - "", - "At the end of the summary, include a Memory candidates section only if there are durable facts that will change future behavior.", - "", - "CRITICAL MEMORY RULES:", - "- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.", - "- Existing memories are numbered [M#]. If an existing memory is still accurate, emit at most 3 lines like `REINFORCE [M#]`; do not rephrase it.", - "- Use `REPLACE [M#] [type] text` only for eligible unreinforced compaction-sourced memories where the old text itself needs correction; this is rarely the right choice.", - "- To supplement or correct a memory, REINFORCE the existing [M#] if it is still accurate, and also emit a new complete [type] candidate with the addition or correction. Do not use REPLACE for additions; do not reinforce a memory that is now inaccurate.", - "- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.", - "- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.", - "- decision = future rule/architecture choice; reference = stable lookup fact; project = stable project fact; feedback = stable user preference.", - "- Do not use decision for service names, IDs, URLs, file paths, or one-off session status; use reference/project or skip.", - "- If unsure, skip it.", - "", - "Good memory examples:", - "- REINFORCE [M1]", - "- [feedback] User prefers architecture reviews in Traditional Chinese.", - "- [decision] Keep memory dedupe exact-only for decisions.", - "- [project] This repository is an OpenCode plugin using local JSON stores.", - "- [reference] Workspace memory is rendered as frozen system[1]; pending memories remain in hot state until compaction.", - "", - "Bad memory examples to skip:", - "- Wave 2 completed successfully.", - "- 180 tests passed and CI is green.", - "- Implemented owner-aware cleanup in plugin.ts.", - "- The assistant reviewed code reviewer feedback and updated the plan.", - "- Commit a762e86 contains the owner scope fix.", - "", - "Format when there ARE REINFORCE/REPLACE commands or durable new candidates:", - "Memory candidates:", - "REINFORCE [M#]", - "REPLACE [M#] [feedback|decision|project|reference] corrected durable fact", - "- [feedback|decision|project|reference] new future-facing durable fact", - "", - "Format when there are NO REINFORCE/REPLACE commands or durable candidates:", - "Memory candidates:", - "(none)", - "", - "Background context, use this to inform the summary above.", - "Do not output this context verbatim:", - "", - privateContext, - ].join("\n"); -} - -/** - * Render todos for compaction context (plain text format, no Markdown headers). - */ -function renderTodosForCompaction(todos: Array<{ content: string; status: string; priority?: string }>): string { - if (todos.length === 0) return ""; - const lines = ["Pending todos:"]; - for (const todo of todos) { - const priority = todo.priority ? ` [${todo.priority}]` : ""; - const status = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "→" : "○"; - lines.push(`- ${status} ${todo.content}${priority}`); - } - return lines.join("\n"); -} - -function safeErrorMessage(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - return message.replace(/\s+/g, " ").slice(0, 240); -} - -type CompactionRefResolution = - | { - ok: true; - refSnapshot: CompactionMemoryRef; - target: LongTermMemoryEntry; - targetIndex: number; - } - | { - ok: false; - reason: "missing_memory_ref_snapshot" | "invalid_memory_ref" | "memory_ref_target_unavailable" | "memory_ref_target_changed"; - refSnapshot?: CompactionMemoryRef; - target?: LongTermMemoryEntry; - }; - -async function warnMemoryHook(scope: string, error: unknown, root?: string): Promise { - const message = safeErrorMessage(error); - console.error(`[memory] ${scope} failed: ${message}`); - if (root) { - await appendEvidenceEvent(root, { - type: "hook_failed", - phase: "hook", - outcome: "failed", - reasonCodes: [scope], - details: { message }, - }).catch(() => undefined); - } -} -async function workspaceRootHash(root: string): Promise { - const resolved = await realpath(root).catch(() => root); - return createHash("sha256").update(resolved).digest("hex").slice(0, 16); -} - -async function workspaceIdentity(root: string): Promise<{ workspaceKey: string; workspaceRootHash: string }> { - const [workspaceKeyValue, workspaceRootHashValue] = await Promise.all([ - workspaceKey(root), - workspaceRootHash(root), - ]); - return { workspaceKey: workspaceKeyValue, workspaceRootHash: workspaceRootHashValue }; -} - -function compactionIdFromSummary(summary: string): string | undefined { - return summary.match(/^\s*Memory ref snapshot id:[ \t]*([a-zA-Z0-9_-]+)[ \t]*$/im)?.[1]; -} - -type MemoryPluginOptions = { +export type MemoryPluginOptions = { mergeSystemMessages?: boolean; }; @@ -263,725 +45,49 @@ function appendMemorySystemPrompts( output.system[0] = `${output.system[0]}\n\n---\n\n${block}`; } +function bashExitCode(hookOutput: unknown): number | undefined { + const output = hookOutput as { + exitCode?: unknown; + metadata?: Record; + output?: string; + }; + const candidates = [ + output.exitCode, + output.metadata?.exitCode, + output.metadata?.exit_code, + output.metadata?.code, + output.metadata?.status, + ]; + for (const candidate of candidates) { + if (typeof candidate === "number") return candidate; + if (typeof candidate === "string" && /^-?\d+$/.test(candidate)) return Number(candidate); + } + const text = output.output ?? ""; + const match = text.match(/(?:exit\s*code|exitCode|status)[:=]\s*(-?\d+)/i); + return match ? Number(match[1]) : undefined; +} + +function sessionIDFromEventProperties(properties: unknown): string | undefined { + const props = properties as { sessionID?: string; info?: { id?: string } } | undefined; + return props?.sessionID ?? props?.info?.id; +} + export const MemoryV2Plugin: Plugin = async (input, options?: MemoryPluginOptions) => { const { directory, client } = input; const mergeSystemMessages = resolveMergeSystemMessagesOption(options); - // Cache for sub-agent detection — avoids repeated API calls per session. - // Maps sessionID → parentID (string) or null (root session). - const sessionParentCache = new Map(); - - async function isSubAgent(sessionID: string): Promise { - if (sessionParentCache.has(sessionID)) { - return sessionParentCache.get(sessionID) !== null; - } - try { + const host: MemoryRuntimeHost = { + directory, + async getSessionParentID(sessionID: string): Promise { const result = await client.session.get({ path: { id: sessionID } }); - const parentID = result.data?.parentID ?? null; - sessionParentCache.set(sessionID, parentID); - return parentID !== null; - } catch { - // If we can't determine, assume it's NOT a sub-agent (safe default). - sessionParentCache.set(sessionID, null); - return false; - } - } - - // Cache for frozen workspace memory per session - const frozenWorkspaceMemoryCache = new Map< - string, - { - store: Awaited>; - renderedPrompt: string; - loadedAt: number; - lastAccessedAt: number; - } - >(); - - // Cache for frozen hot session state per session epoch. - // Lifecycle is unified with frozenWorkspaceMemoryCache; do not clear independently. - const frozenHotSessionStateCache = new Map< - string, - { - renderedPrompt: string; - loadedAt: number; - lastAccessedAt: number; - } - >(); - - // Cache for processed user message IDs (to avoid duplicate processing) - const processedUserMessages = new Map>(); - - function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef { - return { - memoryId: memory.id, - memoryKeyHash: memoryKey(memory), - identityKeyHash: workspaceMemoryIdentityKey(memory), - type: memory.type, - source: memory.source, - status: memory.status, - }; - } - - function pendingAppendedEvidence(memory: LongTermMemoryEntry): EvidenceEventInput { - return { - type: "pending_memory_appended", - phase: "pending_journal", - outcome: "accepted", - memory: memoryEvidenceRef(memory), - relations: [{ role: "pending", memory: memoryEvidenceRef(memory) }], - reasonCodes: ["pending_journal_append"], - textPreview: memory.text, - }; - } - - function memoryReinforcedEvidence( - memory: LongTermMemoryEntry | undefined, - ref: string, - outcome: "reinforced" | "rejected", - reasonCodes: string[], - details: EvidenceEventInput["details"] = {}, - ): EvidenceEventInput { - const relationRole = outcome === "rejected" ? "target" : "reinforced"; - return { - type: "memory_reinforced", - phase: "reinforcement", - outcome, - memory: memory ? memoryEvidenceRef(memory) : undefined, - relations: memory ? [{ role: relationRole, memory: memoryEvidenceRef(memory) }] : undefined, - reasonCodes, - details: { - ref, - ...details, - }, - textPreview: memory?.text, - }; - } - - function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] { - return { - attemptedAtMs: decision.attemptedAt, - attemptedAtIso: new Date(decision.attemptedAt).toISOString(), - ...(decision.lastReinforcedAt !== undefined ? { - lastReinforcedAtMs: decision.lastReinforcedAt, - lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(), - } : {}), - ...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}), - requiredElapsedMs: decision.requiredElapsedMs, - sameSession: decision.sameSession, - ...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}), - }; - } - - function replacementMemoryId(): string { - return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - } - - function memoryReplacedEvidence( - oldMemory: LongTermMemoryEntry | undefined, - newMemory: LongTermMemoryEntry | undefined, - ref: string, - outcome: "superseded" | "rejected", - reasonCodes: string[], - details: EvidenceEventInput["details"] = {}, - ): EvidenceEventInput { - const relations = outcome === "rejected" - ? [ - ...(oldMemory ? [{ role: "target" as const, memory: memoryEvidenceRef(oldMemory) }] : []), - ] - : [ - ...(oldMemory ? [{ role: "superseded" as const, memory: memoryEvidenceRef(oldMemory) }] : []), - ...(newMemory ? [{ role: "superseded_by" as const, memory: memoryEvidenceRef(newMemory) }] : []), - ]; - return { - type: "memory_replaced_numbered_ref", - phase: "storage", - outcome, - memory: oldMemory ? memoryEvidenceRef(oldMemory) : undefined, - relations: relations.length > 0 ? relations : undefined, - reasonCodes, - details: { - ref, - ...details, - }, - textPreview: newMemory?.text ?? oldMemory?.text, - }; - } - - function compactionRefByLabel(refs: CompactionMemoryRef[]): Map { - return new Map(refs.map(ref => [ref.ref.toUpperCase(), ref])); - } - - function storedCompactionIdForRefs(refs: CompactionMemoryRef[]): string { - const ids = new Set(refs.map(ref => ref.compactionId).filter((id): id is string => typeof id === "string" && id.length > 0)); - if (ids.size === 0) return "none"; - if (ids.size === 1) return [...ids][0]; - return "mixed"; - } - - function compactionSnapshotStatus( - refs: CompactionMemoryRef[], - expectedCompactionId: string | undefined, - ): { ok: true } | { ok: false; storedCompactionId: string; reason: "missing_compaction_snapshot_id" | "compaction_snapshot_mismatch" } { - if (!expectedCompactionId) { - return { ok: false, storedCompactionId: storedCompactionIdForRefs(refs), reason: "missing_compaction_snapshot_id" }; - } - - if (refs.length === 0) return { ok: false, storedCompactionId: "none", reason: "compaction_snapshot_mismatch" }; - - const ids = new Set(refs.map(ref => ref.compactionId).filter((id): id is string => typeof id === "string" && id.length > 0)); - if (ids.size === 1 && ids.has(expectedCompactionId)) return { ok: true }; - if (ids.size === 0) return { ok: false, storedCompactionId: "none", reason: "compaction_snapshot_mismatch" }; - if (ids.size === 1) return { ok: false, storedCompactionId: [...ids][0], reason: "compaction_snapshot_mismatch" }; - return { ok: false, storedCompactionId: "mixed", reason: "compaction_snapshot_mismatch" }; - } - - function commandProvenanceRejectedEvidence( - command: WorkspaceMemoryCommand, - refsByLabel: Map, - reason: "missing_compaction_snapshot_id" | "compaction_snapshot_mismatch", - details: EvidenceEventInput["details"] = {}, - ): EvidenceEventInput { - const refSnapshot = refsByLabel.get(command.ref.toUpperCase()); - const commandDetails: EvidenceEventInput["details"] = { - commandKind: command.kind, - ...(refSnapshot?.memoryId ? { memoryId: refSnapshot.memoryId } : {}), - ...(command.kind === "REPLACE" ? { replacementType: command.type } : {}), - ...details, - }; - - if (command.kind === "REINFORCE") { - return memoryReinforcedEvidence(undefined, command.ref, "rejected", [reason], commandDetails); - } - - return memoryReplacedEvidence(undefined, undefined, command.ref, "rejected", [reason], commandDetails); - } - - function resolveCompactionMemoryRef( - refs: CompactionMemoryRef[], - refsByLabel: Map, - entries: LongTermMemoryEntry[], - ref: string, - ): CompactionRefResolution { - if (refs.length === 0) { - return { ok: false, reason: "missing_memory_ref_snapshot" }; - } - - const refSnapshot = refsByLabel.get(ref.toUpperCase()); - if (!refSnapshot) { - return { ok: false, reason: "invalid_memory_ref" }; - } - - const targetIndex = entries.findIndex(entry => entry.id === refSnapshot.memoryId); - const target = targetIndex >= 0 ? entries[targetIndex] : undefined; - if (!target || target.status !== "active") { - return { ok: false, reason: "memory_ref_target_unavailable", refSnapshot, target }; - } - - if (workspaceMemoryExactKey(target) !== refSnapshot.exactKey) { - return { ok: false, reason: "memory_ref_target_changed", refSnapshot, target }; - } - - return { ok: true, refSnapshot, target, targetIndex }; - } - - async function applyCompactionMemoryCommands( - sessionID: string, - commands: WorkspaceMemoryCommand[], - compactionId: string | undefined, - ): Promise { - if (commands.length === 0) return; - - const sessionState = await loadSessionState(directory, sessionID); - const snapshotStatus = compactionSnapshotStatus(sessionState.compactionMemoryRefs, compactionId); - const refs = sessionState.compactionMemoryRefs; - const refsByLabel = compactionRefByLabel(refs); - const evidence: EvidenceEventInput[] = []; - const now = Date.now(); - - if (snapshotStatus.ok === false) { - const details: EvidenceEventInput["details"] = { - ...(compactionId ? { compactionId } : {}), - storedCompactionId: snapshotStatus.storedCompactionId, - }; - await appendEvidenceEvents(directory, commands.map(command => ({ - ...commandProvenanceRejectedEvidence(command, refsByLabel, snapshotStatus.reason, details), - sessionHash: sessionID, - }))); - return; - } - - const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => { - for (const command of commands) { - const resolution = resolveCompactionMemoryRef(refs, refsByLabel, workspaceMemory.entries, command.ref); - if (resolution.ok === false) { - const memoryId = resolution.refSnapshot?.memoryId; - const details = memoryId ? { memoryId } : {}; - if (command.kind === "REINFORCE") { - evidence.push(memoryReinforcedEvidence(resolution.target, command.ref, "rejected", [resolution.reason], details)); - } else { - evidence.push(memoryReplacedEvidence(resolution.target, undefined, command.ref, "rejected", [resolution.reason], details)); - } - continue; - } - - const { refSnapshot, target, targetIndex } = resolution; - if (command.kind === "REINFORCE") { - const decision = tryReinforceMemory(target, sessionID, now); - if (decision.outcome === "blocked") { - evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`], { - memoryId: refSnapshot.memoryId, - blockReason: decision.blockReason, - ...reinforcementDecisionTimingDetails(decision), - reinforcementCount: decision.reinforcementCount, - maxReinforcementCount: decision.maxReinforcementCount, - })); - continue; - } - - const reinforced = decision.memory; - workspaceMemory.entries[targetIndex] = reinforced; - const reasonCodes = ["numbered_ref_reinforce", "reinforcement_window_allowed"]; - if (decision.reinforcementMode === "refresh_only") { - reasonCodes.push("reinforcement_saturation_refresh"); - } - evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", reasonCodes, { - memoryId: refSnapshot.memoryId, - reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced", - reinforcementMode: decision.reinforcementMode, - ...reinforcementDecisionTimingDetails(decision), - previousReinforcementCount: decision.previousReinforcementCount, - newReinforcementCount: decision.newReinforcementCount, - reinforcementCount: decision.newReinforcementCount, - maxReinforcementCount: REINFORCEMENT_MAX_COUNT, - })); - continue; - } - - if (target.source !== "compaction") { - evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", ["protected_memory_source"], { - memoryId: refSnapshot.memoryId, - source: target.source, - })); - continue; - } - - if ((target.reinforcementCount ?? 0) > 0) { - evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", ["protected_reinforced_target"], { - memoryId: refSnapshot.memoryId, - reinforcementCount: target.reinforcementCount ?? 0, - })); - continue; - } - - const quality = assessMemoryQuality({ type: command.type, text: command.text, source: "compaction" }); - if (!quality.accepted) { - evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", quality.reasons, { - memoryId: refSnapshot.memoryId, - })); - continue; - } - - const supersededTarget: LongTermMemoryEntry = { - ...target, - status: "superseded", - updatedAt: new Date(now).toISOString(), - }; - const replacement: LongTermMemoryEntry = { - id: replacementMemoryId(), - type: command.type, - text: command.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars), - source: "compaction", - confidence: 0.75, - status: "active", - createdAt: new Date(now).toISOString(), - updatedAt: new Date(now).toISOString(), - retentionClock: now, - staleAfterDays: staleAfterDaysFor(command.type), - supersedes: [target.id], - }; - - workspaceMemory.entries[targetIndex] = supersededTarget; - workspaceMemory.entries.push(replacement); - evidence.push(memoryReplacedEvidence(supersededTarget, replacement, command.ref, "superseded", [ - "numbered_ref_replace", - command.type === target.type ? "same_type_replace" : "cross_type_replace", - ], { - oldMemoryId: target.id, - newMemoryId: replacement.id, - oldType: target.type, - newType: command.type, - })); - } - - return workspaceMemory; - }); - - await appendEvidenceEvents(directory, [...updateResult.evidence, ...evidence].map(event => ({ - ...event, - sessionHash: sessionID, - }))); - } - - function clearFrozenPromptEpoch(sessionID: string): void { - frozenWorkspaceMemoryCache.delete(sessionID); - frozenHotSessionStateCache.delete(sessionID); - } - - function pruneFrozenPromptEpochCaches(): void { - const lastAccessedAtBySession = new Map(); - for (const [sessionID, cached] of frozenWorkspaceMemoryCache) { - lastAccessedAtBySession.set( - sessionID, - Math.max(lastAccessedAtBySession.get(sessionID) ?? cached.lastAccessedAt, cached.lastAccessedAt), - ); - } - for (const [sessionID, cached] of frozenHotSessionStateCache) { - lastAccessedAtBySession.set( - sessionID, - Math.max(lastAccessedAtBySession.get(sessionID) ?? cached.lastAccessedAt, cached.lastAccessedAt), - ); - } - - const sorted = [...lastAccessedAtBySession.entries()].sort((a, b) => a[1] - b[1]); - while (lastAccessedAtBySession.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) { - const [oldestSessionID] = sorted.shift() ?? []; - if (!oldestSessionID) break; - lastAccessedAtBySession.delete(oldestSessionID); - clearFrozenPromptEpoch(oldestSessionID); - } - - for (const sessionID of frozenWorkspaceMemoryCache.keys()) { - if (!lastAccessedAtBySession.has(sessionID)) frozenWorkspaceMemoryCache.delete(sessionID); - } - for (const sessionID of frozenHotSessionStateCache.keys()) { - if (!lastAccessedAtBySession.has(sessionID)) frozenHotSessionStateCache.delete(sessionID); - } - } - - function pruneProcessedUserMessagesCache(): void { - for (const [sessionID, messages] of processedUserMessages) { - while (messages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) { - const oldest = messages.values().next().value as string | undefined; - if (!oldest) break; - messages.delete(oldest); - } - - if (messages.size === 0) { - processedUserMessages.delete(sessionID); - } - } - - while (processedUserMessages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedSessionIDs) { - const oldestSessionID = processedUserMessages.keys().next().value as string | undefined; - if (!oldestSessionID) break; - processedUserMessages.delete(oldestSessionID); - } - } - - function rememberProcessedUserMessage(sessionID: string, messageID: string, processedForSession: Set): void { - processedForSession.add(messageID); - while (processedForSession.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) { - const oldest = processedForSession.values().next().value as string | undefined; - if (!oldest) break; - processedForSession.delete(oldest); - } - - if (processedUserMessages.has(sessionID)) { - processedUserMessages.delete(sessionID); - } - processedUserMessages.set(sessionID, processedForSession); - pruneProcessedUserMessagesCache(); - } - - async function processLatestUserMessage(sessionID: string): Promise { - const processedForSession = processedUserMessages.get(sessionID) ?? new Set(); - const latestMessage = await latestUserText(client, sessionID); - - if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return; - - const extraction = extractExplicitMemoriesWithEvidence(latestMessage.text); - await appendEvidenceEvents(directory, extraction.evidence.map(event => ({ - ...event, - sessionHash: sessionID, - messageHash: latestMessage.id, - }))); - - const memories = extraction.entries.map(memory => ({ - ...memory, - pendingOwnerSessionID: sessionID, - pendingMessageID: latestMessage.id, - })); - const decisions = memories.filter(memory => memory.type === "decision"); - - if (memories.length > 0) { - await updateSessionState(directory, sessionID, state => { - state.pendingMemories.push(...memories); - return state; - }); - await appendPendingMemories(directory, memories); - await appendEvidenceEvents(directory, memories.map(memory => ({ - ...pendingAppendedEvidence(memory), - sessionHash: sessionID, - messageHash: latestMessage.id, - }))); - } - - if (decisions.length > 0) { - await updateSessionState(directory, sessionID, state => { - for (const decision of decisions) { - addRecentDecision(state, { - text: decision.text, - rationale: decision.rationale, - source: "user", - }); - } - return state; - }); - } - - rememberProcessedUserMessage(sessionID, latestMessage.id, processedForSession); - } - - function dedupePendingPromotionMemories( - memories: LongTermMemoryEntry[], - ): LongTermMemoryEntry[] { - const seen = new Set(); - const deduped: LongTermMemoryEntry[] = []; - - for (const memory of memories) { - const key = memoryKey(memory); - if (seen.has(key)) continue; - seen.add(key); - deduped.push(memory); - } - - return deduped; - } - - async function promotePendingMemories( - sessionID?: string, - options: { includeUnownedJournal?: boolean; includeOwnedJournal?: boolean } = {}, - ): Promise { - const includeUnownedJournal = options.includeUnownedJournal ?? !sessionID; - const includeOwnedJournal = options.includeOwnedJournal ?? Boolean(sessionID); - const [journal, sessionState] = await Promise.all([ - loadPendingJournal(directory), - sessionID ? loadSessionState(directory, sessionID) : Promise.resolve(undefined), - ]); - - const journalPending = journal.entries.filter(memory => { - if (sessionID && includeOwnedJournal && memory.pendingOwnerSessionID === sessionID) return true; - if (includeUnownedJournal && !memory.pendingOwnerSessionID) return true; - return false; - }); - - const pending = dedupePendingPromotionMemories([ - ...(sessionState?.pendingMemories ?? []), - ...journalPending, - ]); - if (pending.length === 0) return; - - let beforeEntries: Awaited>["entries"] = []; - - const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => { - beforeEntries = [...workspaceMemory.entries]; - const existingByKey = new Map(); - workspaceMemory.entries.forEach((memory, index) => { - if (memory.status === "superseded") return; - existingByKey.set(memoryKey(memory), { memory, index }); - }); - - const promotedAt = Date.now(); - for (const memory of pending) { - const key = memoryKey(memory); - const existing = existingByKey.get(key); - if (existing) { - const decision = tryReinforceMemory( - existing.memory, - sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion", - promotedAt, - ); - const reinforced = decision.memory; - if (reinforced !== existing.memory) { - workspaceMemory.entries[existing.index] = reinforced; - existingByKey.set(key, { memory: reinforced, index: existing.index }); - } - } else { - workspaceMemory.entries.push({ - ...memory, - retentionClock: memory.retentionClock ?? promotedAt, - }); - existingByKey.set(key, { - memory: workspaceMemory.entries[workspaceMemory.entries.length - 1], - index: workspaceMemory.entries.length - 1, - }); - } - } - - return workspaceMemory; - }); - - const accounting = accountPendingPromotions({ - pending, - before: beforeEntries, - after: updateResult.store.entries, - events: updateResult.events, - }); - - const exhaustedRejectedKeys = await recordPromotionRejections( - directory, - accounting.retryableRejectedKeys, - "rejected_capacity", - { - ownerSessionID: sessionID, - includeUnownedOnly: !sessionID, - }, - ); - - await appendEvidenceEvents(directory, [ - ...updateResult.evidence, - ...promotionAccountingEvidenceEvents({ - pending, - after: updateResult.store.entries, - events: updateResult.events, - accounting, - exhaustedRejectedKeys, - }), - ].map(event => ({ - ...event, - sessionHash: sessionID, - }))); - - const sessionRemovalKeys = new Set([ - ...accounting.clearableKeys, - ...exhaustedRejectedKeys, - ]); - - if (sessionID) { - await updateSessionState(directory, sessionID, state => { - state.pendingMemories = state.pendingMemories.filter(memory => { - const key = memoryKey(memory); - if (!sessionRemovalKeys.has(key)) return true; - - if (accounting.clearableKeys.has(key)) return false; - if (exhaustedRejectedKeys.has(key)) return false; - - return true; - }); - return state; - }); - clearFrozenPromptEpoch(sessionID); - } - - if (accounting.clearableKeys.size > 0) { - await clearPendingMemories(directory, accounting.clearableKeys, { - ownerSessionID: sessionID, - clearUnowned: !sessionID || includeUnownedJournal === true, - }); - } - } - - function bashExitCode(hookOutput: unknown): number | undefined { - const output = hookOutput as { - exitCode?: unknown; - metadata?: Record; - output?: string; - }; - const candidates = [ - output.exitCode, - output.metadata?.exitCode, - output.metadata?.exit_code, - output.metadata?.code, - output.metadata?.status, - ]; - for (const candidate of candidates) { - if (typeof candidate === "number") return candidate; - if (typeof candidate === "string" && /^-?\d+$/.test(candidate)) return Number(candidate); - } - const text = output.output ?? ""; - const match = text.match(/(?:exit\s*code|exitCode|status)[:=]\s*(-?\d+)/i); - return match ? Number(match[1]) : undefined; - } - - /** - * Get frozen workspace memory snapshot for a session. - * Loads and renders from disk once per session, then reuses the exact rendered string. - */ - async function getFrozenWorkspaceMemorySnapshot( - root: string, - sessionID: string - ): Promise<{ - store: Awaited>; - renderedPrompt: string; - }> { - const now = Date.now(); - const cached = frozenWorkspaceMemoryCache.get(sessionID); - - // Cache is valid for the current session cache epoch. - // It is intentionally invalidated after compaction so promoted memories - // become visible in the next compacted context (new epoch starts). - if (cached) { - cached.lastAccessedAt = now; - return { store: cached.store, renderedPrompt: cached.renderedPrompt }; - } - - const store = await loadWorkspaceMemory(root); - const renderAccounting = accountWorkspaceMemoryRender(store); - const renderedPrompt = renderAccounting.prompt; - await appendEvidenceEvents(root, renderAccounting.evidence.map(event => ({ - ...event, - sessionHash: sessionID, - }))); - frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now, lastAccessedAt: now }); - pruneFrozenPromptEpochCaches(); - return { store, renderedPrompt }; - } - - /** - * Get frozen hot session state snapshot for a session. - * Loads and renders from disk once per prompt epoch, then reuses the exact rendered string. - */ - async function getFrozenHotSessionStateSnapshot( - root: string, - sessionID: string, - ): Promise<{ renderedPrompt: string }> { - const now = Date.now(); - const cached = frozenHotSessionStateCache.get(sessionID); - if (cached) { - cached.lastAccessedAt = now; - return { renderedPrompt: cached.renderedPrompt }; - } - - const sessionState = await loadSessionState(root, sessionID); - const renderedPrompt = renderHotSessionState(sessionState, root); - frozenHotSessionStateCache.set(sessionID, { renderedPrompt, loadedAt: now, lastAccessedAt: now }); - pruneFrozenPromptEpochCaches(); - return { renderedPrompt }; - } - - async function promoteUnownedBacklogForEpochSnapshot(sessionID: string): Promise { - if (frozenWorkspaceMemoryCache.has(sessionID) || frozenHotSessionStateCache.has(sessionID)) return; - if (!await hasPendingJournalEntries(directory)) return; - - try { - await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false }); - } catch (error) { - await warnMemoryHook("chat.system.transform.promote_unowned", error, directory); - } - } - - function sessionIDFromEventProperties(properties: unknown): string | undefined { - const props = properties as { sessionID?: string; info?: { id?: string } } | undefined; - return props?.sessionID ?? props?.info?.id; - } + return result.data?.parentID ?? null; + }, + getLatestUserMessage: (sessionID: string) => latestUserText(client, sessionID), + getPendingTodos: (sessionID: string) => pendingTodos(client, sessionID), + getLatestCompactionSummary: (sessionID: string) => latestCompactionSummary(client, sessionID), + }; - async function clearCompactionMemoryRefs(sessionID: string): Promise { - await updateSessionState(directory, sessionID, state => { - state.compactionMemoryRefs = []; - return state; - }); - } + const runtime = createMemoryRuntime(host, options); return { // Inject frozen workspace memory and frozen hot session state into system prompt @@ -989,246 +95,68 @@ export const MemoryV2Plugin: Plugin = async (input, options?: MemoryPluginOption const { sessionID } = hookInput; if (!sessionID) return; - try { - pruneFrozenPromptEpochCaches(); - pruneProcessedUserMessagesCache(); - - // Sub-agents are short-lived - skip memory system - if (await isSubAgent(sessionID)) return; - - // Process explicit user memory even on no-tool turns. Keep this after the - // sub-agent guard so child sessions never append to the parent journal. - await processLatestUserMessage(sessionID); - - // Before first snapshot in this session, best-effort promote durable - // unowned backlog from prior sessions. Current-turn owned explicit memory - // remains pending and appears in hot state only if the epoch snapshot is new. - await promoteUnownedBacklogForEpochSnapshot(sessionID); - - let workspaceSnapshot: Awaited> | undefined; - try { - workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID); - } catch (error) { - await warnMemoryHook("chat.system.transform.workspace_snapshot", error, directory); - } - - let hotSnapshot: Awaited> | undefined; - try { - hotSnapshot = await getFrozenHotSessionStateSnapshot(directory, sessionID); - } catch (error) { - await warnMemoryHook("chat.system.transform.hot_snapshot", error, directory); - } - - const memorySystemPromptParts: string[] = []; - - // Inject frozen workspace memory snapshot - if (workspaceSnapshot?.renderedPrompt) { - memorySystemPromptParts.push(workspaceSnapshot.renderedPrompt); - } - - // Inject frozen hot session state snapshot - if (hotSnapshot?.renderedPrompt) { - memorySystemPromptParts.push(hotSnapshot.renderedPrompt); - } - - appendMemorySystemPrompts(output, memorySystemPromptParts, mergeSystemMessages); - } catch (error) { - await warnMemoryHook("chat.system.transform", error, directory); - } + const parts = await runtime.buildContextParts(sessionID); + appendMemorySystemPrompts(output, parts, mergeSystemMessages); }, // Track tool usage and update session state "tool.execute.after": async (hookInput, hookOutput) => { - const { sessionID, tool: toolName, args } = hookInput; - const { output: toolOutput } = hookOutput; + const { sessionID, tool: toolName } = hookInput; if (!sessionID) return; - // Sub-agents don't need memory tracking - if (await isSubAgent(sessionID)) return; - - try { - await updateSessionState(directory, sessionID, state => { - // Track active files from tool usage - if (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep") { - const files = extractActiveFiles( - toolName, - args as Record, - toolOutput ?? "" - ); - for (const { path, action } of files) { - touchActiveFile(state, path, action); - if (action === "edit" || action === "write") { - markErrorsMaybeFixedForFile(state, path, directory); - } - } - } - - // Track errors from failed bash commands - if (toolName === "bash") { - const argsRecord = args as Record; - const command: string = typeof argsRecord?.command === "string" - ? argsRecord.command - : ""; - const outputText: string = toolOutput ?? ""; - - // Check if command succeeded - clear errors for that category - const exitCode = bashExitCode(hookOutput); - if (typeof exitCode !== "number") { - // Unknown exit status: do not extract and do not clear - } else if (exitCode === 0 && command) { - clearErrorsForSuccessfulCommand(state, command); - } else if (command) { - // Only extract errors for commands with explicit non-zero exit - const errors = extractErrorsFromBash(command, outputText); - for (const error of errors) { - upsertOpenError(state, error); - } - } - } - return state; - }); - - // Process explicit memory from latest user message - // Only process once per message ID - await processLatestUserMessage(sessionID); - } catch (error) { - await warnMemoryHook("tool.execute.after", error, directory); - } + const rawOutput = hookOutput?.output; + const outputText = typeof rawOutput === "string" + ? rawOutput + : rawOutput == null + ? "" + : JSON.stringify(rawOutput) ?? ""; + await runtime.handleToolAfter({ + sessionID, + tool: toolName, + args: (hookInput.args ?? {}) as Record, + outputText, + exitCode: toolName === "bash" ? bashExitCode(hookOutput) : undefined, + }); }, /** * Replace the default compaction prompt with a ---free template. * - * OpenCode's default template wraps sections in --- separators. When the + * OpenCode V1's default template wraps sections in --- separators. When the * model follows the template (which our structured context encourages), * the TUI renders --- at position 0 as YAML frontmatter, applying the * "comment" syntax scope (purple italic in palenight theme). - * - * We set output.prompt to replace the entire prompt, removing all --- - * and explicitly forbidding YAML frontmatter / horizontal rules. */ "experimental.session.compacting": async (hookInput, output) => { const { sessionID } = hookInput; if (!sessionID) return; - try { - // Sub-agents don't need compaction support - if (await isSubAgent(sessionID)) return; - - // Preserve context injected by other plugins that ran before us. - // Setting output.prompt bypasses the default prompt + context join, - // so we must explicitly carry forward any existing output.context. - const otherContext = output.context.filter(Boolean).join("\n\n"); + // Preserve context injected by other plugins that ran before us. + // Setting output.prompt bypasses the default prompt + context join, + // so we must explicitly carry forward any existing output.context. + const otherContext = output.context.filter(Boolean).join("\n\n"); + const prepared = await runtime.prepareCompaction(sessionID, otherContext); + if (!prepared) return; - // Build our private context (numbered workspace memory refs, todos) - const contextParts: string[] = []; + output.prompt = prepared.prompt; - // 1. Compaction-only numbered workspace memory snapshot - const compactionId = randomUUID(); - const workspaceStore = await loadWorkspaceMemory(directory); - const compactionRefs = accountWorkspaceMemoryCompactionRefs(workspaceStore); - const refsWithCompactionId = compactionRefs.refs.map(ref => ({ ...ref, compactionId })); - await updateSessionState(directory, sessionID, state => { - state.compactionMemoryRefs = refsWithCompactionId; - return state; - }); - await appendEvidenceEvents(directory, compactionRefs.evidence.map(event => ({ - ...event, - sessionHash: sessionID, - }))); - if (compactionRefs.prompt) { - contextParts.push(compactionRefs.prompt); - } - - // 2. Pending todos from OpenCode - const todos = await pendingTodos(client, sessionID); - const todosPrompt = renderTodosForCompaction(todos); - if (todosPrompt) { - contextParts.push(todosPrompt); - } - - // Combine: other plugins' context first, then our private context - const privateContext = [otherContext, ...contextParts] - .filter(Boolean) - .join("\n\n"); - - // Replace the default prompt entirely with our ---free template - output.prompt = buildCompactionPrompt(privateContext, compactionId); - - // Clear context array since we consumed it into output.prompt. - // Subsequent plugins that set output.prompt will also need to check - // output.context if they want to preserve other plugin contributions. - output.context.length = 0; - } catch (error) { - await warnMemoryHook("session.compacting", error, directory); - } + // Clear context array since we consumed it into output.prompt. + // Subsequent plugins that set output.prompt will also need to check + // output.context if they want to preserve other plugin contributions. + output.context.length = 0; }, // Handle session events event: async ({ event }) => { if (event.type === "session.compacted") { - let sessionID: string | undefined; - try { - sessionID = sessionIDFromEventProperties(event.properties); - if (!sessionID) return; - - // Sub-agents don't need post-compaction processing - if (await isSubAgent(sessionID)) return; - - try { - // Parse latest compaction summary for memory candidates, stage them into - // durable pending journal, then promote pending memories. - const summary = await latestCompactionSummary(client, sessionID); - const compactionId = summary ? compactionIdFromSummary(summary) : undefined; - const parseResult = summary - ? parseWorkspaceMemoryCandidatesWithEvidence(summary, await workspaceIdentity(directory)) - : { entries: [], commands: [], evidence: [] }; - await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({ - ...event, - sessionHash: sessionID, - }))); - await applyCompactionMemoryCommands(sessionID, parseResult.commands, compactionId); - const candidates = parseResult.entries; - if (candidates.length > 0) { - await appendPendingMemories(directory, candidates); - await appendEvidenceEvents(directory, candidates.map(memory => ({ - ...pendingAppendedEvidence(memory), - sessionHash: sessionID, - }))); - } - - await promotePendingMemories(sessionID, { includeUnownedJournal: true }); - } finally { - await clearCompactionMemoryRefs(sessionID); - clearFrozenPromptEpoch(sessionID); - } - } catch (error) { - // Keep pending memories in session/journal for retry on next event/session. - await warnMemoryHook("event.session.compacted", error, directory); - } + const sessionID = sessionIDFromEventProperties(event.properties); + if (sessionID) await runtime.handleSessionCompacted(sessionID); } if (event.type === "session.deleted") { - try { - const sessionID = sessionIDFromEventProperties(event.properties); - if (sessionID) { - // Promote pending memories before deleting per-session state. - // If promotion fails, leave session state and journal intact. - let promoted = false; - await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false }); - promoted = true; - if (promoted) { - clearFrozenPromptEpoch(sessionID); - processedUserMessages.delete(sessionID); - sessionParentCache.delete(sessionID); - } - - await rm(await sessionStatePath(directory, sessionID), { force: true }); - } - } catch (error) { - await warnMemoryHook("event.session.deleted", error, directory); - } + const sessionID = sessionIDFromEventProperties(event.properties); + if (sessionID) await runtime.handleSessionDeleted(sessionID); } }, }; -} +}; diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..e0cc052 --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,1212 @@ +/** + * Memory V2 runtime shared by the OpenCode V1 and V2 plugin adapters. + * + * Architecture: + * - Layer 1: Stable Workspace Memory (frozen per session cache epoch, refreshed at compaction) + * - Layer 2: Frozen Hot Session State (active files, open errors, recent decisions, pending memories) + * - Layer 3: Native OpenCode State (todos owned by OpenCode, read during compaction) + * + * Cache Epoch Model: + * - Each session creates frozen workspace memory and hot session snapshots on first transform. + * - Normal turns reuse the exact rendered strings (pre-history system prompts remain stable). + * - Normal tool/user churn updates session storage but does not mutate pre-history prompts + * until compaction, session restart, or process restart starts a new epoch; conversation + * and tool history are the source of truth for newer events after epoch start. + * - Compaction starts a new cache epoch: pending memories are promoted, caches are cleared, + * and the next transform re-renders workspace memory and hot session state. + * - Explicit memory ("remember X") goes to SessionState.pendingMemories + durable journal, + * visible in the hot snapshot only if processed before epoch creation, promoted after compaction. + * + * This plugin: + * - Caches frozen workspace memory and hot session state per sessionID epoch + * - Processes explicit memory from latest user text once per message id + * - Injects frozen workspace memory and frozen hot session state into system prompt + * - Updates session state after tool execution + * - Augments compaction context with numbered memory refs, todos, and instruction + * - Parses compaction summaries for memory candidates and merges them + */ + +import { createHash, randomUUID } from "node:crypto"; +import { realpath, rm } from "fs/promises"; +import { + extractExplicitMemoriesWithEvidence, + extractActiveFiles, + extractErrorsFromBash, + parseWorkspaceMemoryCandidatesWithEvidence, + staleAfterDaysFor, + type WorkspaceMemoryCommand, +} from "./extractors.ts"; +import { assessMemoryQuality } from "./memory-quality.ts"; +import { + loadWorkspaceMemory, + updateWorkspaceMemoryWithAccounting, + accountWorkspaceMemoryRender, + accountWorkspaceMemoryCompactionRefs, + workspaceMemoryExactKey, + workspaceMemoryIdentityKey, +} from "./workspace-memory.ts"; +import { REINFORCEMENT_MAX_COUNT, tryReinforceMemory, type ReinforcementDecision } from "./retention.ts"; +import { + appendPendingMemories, + clearPendingMemories, + hasPendingJournalEntries, + loadPendingJournal, + memoryKey, + recordPromotionRejections, +} from "./pending-journal.ts"; +import { + loadSessionState, + updateSessionState, + touchActiveFile, + upsertOpenError, + clearErrorsForSuccessfulCommand, + markErrorsMaybeFixedForFile, + addRecentDecision, + renderHotSessionState, +} from "./session-state.ts"; +import { sessionStatePath, workspaceKey } from "./paths.ts"; +import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "./promotion-accounting.ts"; +import { appendEvidenceEvent, appendEvidenceEvents, type EvidenceEventInput, type MemoryEvidenceRef } from "./evidence-log.ts"; +import { type CompactionMemoryRef, type LongTermMemoryEntry, LONG_TERM_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts"; + +/** + * Build the complete compaction prompt. + * + * Replaces OpenCode's default template (which uses --- separators that trigger + * YAML frontmatter comment scope in markdown rendering, producing purple italic text). + * Our template uses only ## Markdown headings and explicitly forbids YAML frontmatter, + * horizontal rules, and delimiter lines. + * + * @param privateContext - Background context (numbered workspace memory refs, + * pending todos) from our plugin and any other plugins. Shown to the model to + * inform the summary but not copied verbatim. + */ +function compactionInstructionLines(compactionId?: string): string[] { + const snapshotInstruction = compactionId + ? `- If you emit any REINFORCE or REPLACE command, include \`Memory ref snapshot id: ${compactionId}\` as the first line under "Memory candidates:" so numbered refs match the correct compaction snapshot. REINFORCE/REPLACE commands without this id will be ignored.` + : ""; + return [ + "Formatting rules:", + "- Start the response with \"## Goal\".", + "- Use Markdown headings only.", + "- Do not output YAML frontmatter.", + "- Do not output horizontal rules.", + "- Do not wrap the summary in delimiter lines such as ---.", + "- Do not use code fences around the summary.", + ...(snapshotInstruction ? [snapshotInstruction] : []), + "", + "Use this structure:", + "", + "## Goal", + "", + "## Instructions", + "", + "## Progress", + "", + "## Key Decisions", + "", + "## Discoveries", + "", + "## Next Steps", + "", + "## Relevant Files", + "", + "At the end of the summary, include a Memory candidates section only if there are durable facts that will change future behavior.", + "", + "CRITICAL MEMORY RULES:", + "- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.", + "- Existing memories are numbered [M#]. If an existing memory is still accurate, emit at most 3 lines like `REINFORCE [M#]`; do not rephrase it.", + "- Use `REPLACE [M#] [type] text` only for eligible unreinforced compaction-sourced memories where the old text itself needs correction; this is rarely the right choice.", + "- To supplement or correct a memory, REINFORCE the existing [M#] if it is still accurate, and also emit a new complete [type] candidate with the addition or correction. Do not use REPLACE for additions; do not reinforce a memory that is now inaccurate.", + "- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.", + "- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.", + "- decision = future rule/architecture choice; reference = stable lookup fact; project = stable project fact; feedback = stable user preference.", + "- Do not use decision for service names, IDs, URLs, file paths, or one-off session status; use reference/project or skip.", + "- If unsure, skip it.", + "", + "Good memory examples:", + "- REINFORCE [M1]", + "- [feedback] User prefers architecture reviews in Traditional Chinese.", + "- [decision] Keep memory dedupe exact-only for decisions.", + "- [project] This repository is an OpenCode plugin using local JSON stores.", + "- [reference] Workspace memory is rendered as frozen system[1]; pending memories remain in hot state until compaction.", + "", + "Bad memory examples to skip:", + "- Wave 2 completed successfully.", + "- 180 tests passed and CI is green.", + "- Implemented owner-aware cleanup in plugin.ts.", + "- The assistant reviewed code reviewer feedback and updated the plan.", + "- Commit a762e86 contains the owner scope fix.", + "", + "Format when there ARE REINFORCE/REPLACE commands or durable new candidates:", + "Memory candidates:", + "REINFORCE [M#]", + "REPLACE [M#] [feedback|decision|project|reference] corrected durable fact", + "- [feedback|decision|project|reference] new future-facing durable fact", + "", + "Format when there are NO REINFORCE/REPLACE commands or durable candidates:", + "Memory candidates:", + "(none)", + ]; +} + +function buildCompactionPrompt(privateContext: string, compactionId?: string): string { + return [ + "Provide a detailed summary for continuing our conversation above.", + "Focus on information that would help another agent continue the work: the goal, user instructions, completed work, current state, decisions, relevant files, and next steps.", + "", + "Do not call any tools. Respond only with the summary text.", + "Respond in the same language as the user's messages in the conversation.", + "", + ...compactionInstructionLines(compactionId), + "", + "Background context, use this to inform the summary above.", + "Do not output this context verbatim:", + "", + privateContext, + ].join("\n"); +} + +/** + * V2 keeps OpenCode's own compaction prompt and appends this instruction to + * the system message instead of replacing the prompt. + */ +function buildCompactionSystemInstruction(privateContext: string, compactionId?: string): string { + return [ + "This compaction also maintains durable workspace memory.", + "Write the summary OpenCode asks for, then apply the memory rules below.", + "Do not call any tools.", + "", + ...compactionInstructionLines(compactionId), + "", + "Memory refs and pending todos, for interpreting [M#] references only.", + "Do not output this context verbatim:", + "", + privateContext, + ].join("\n"); +} + +/** + * Render todos for compaction context (plain text format, no Markdown headers). + */ +function renderTodosForCompaction(todos: Array<{ content: string; status: string; priority?: string }>): string { + if (todos.length === 0) return ""; + const lines = ["Pending todos:"]; + for (const todo of todos) { + const priority = todo.priority ? ` [${todo.priority}]` : ""; + const status = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "→" : "○"; + lines.push(`- ${status} ${todo.content}${priority}`); + } + return lines.join("\n"); +} + +function safeErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/\s+/g, " ").slice(0, 240); +} + +type CompactionRefResolution = + | { + ok: true; + refSnapshot: CompactionMemoryRef; + target: LongTermMemoryEntry; + targetIndex: number; + } + | { + ok: false; + reason: "missing_memory_ref_snapshot" | "invalid_memory_ref" | "memory_ref_target_unavailable" | "memory_ref_target_changed"; + refSnapshot?: CompactionMemoryRef; + target?: LongTermMemoryEntry; + }; + +async function warnMemoryHook(scope: string, error: unknown, root?: string): Promise { + const message = safeErrorMessage(error); + console.error(`[memory] ${scope} failed: ${message}`); + if (root) { + await appendEvidenceEvent(root, { + type: "hook_failed", + phase: "hook", + outcome: "failed", + reasonCodes: [scope], + details: { message }, + }).catch(() => undefined); + } +} + +async function workspaceRootHash(root: string): Promise { + const resolved = await realpath(root).catch(() => root); + return createHash("sha256").update(resolved).digest("hex").slice(0, 16); +} + +async function workspaceIdentity(root: string): Promise<{ workspaceKey: string; workspaceRootHash: string }> { + const [workspaceKeyValue, workspaceRootHashValue] = await Promise.all([ + workspaceKey(root), + workspaceRootHash(root), + ]); + return { workspaceKey: workspaceKeyValue, workspaceRootHash: workspaceRootHashValue }; +} + +function compactionIdFromSummary(summary: string): string | undefined { + return summary.match(/^\s*Memory ref snapshot id:[ \t]*([a-zA-Z0-9_-]+)[ \t]*$/im)?.[1]; +} + +export type MemoryTodo = { + content: string; + status: string; + priority?: string; +}; + +export type MemoryRuntimeHost = { + /** Workspace root that owns memory files. */ + directory: string; + /** Parent session ID, or null when this is a root session. */ + getSessionParentID(sessionID: string): Promise; + /** Latest user message text and its stable ID. */ + getLatestUserMessage(sessionID: string): Promise<{ id: string; text: string } | null>; + /** Pending todos, for hosts that keep todos outside the session state. */ + getPendingTodos(sessionID: string): Promise; + /** Latest compaction summary, for hosts that cannot pass it with the event. */ + getLatestCompactionSummary(sessionID: string): Promise; +}; + +export type ToolAfterEvent = { + sessionID: string; + tool: string; + args: Record; + outputText: string; + exitCode: number | undefined; + todos?: MemoryTodo[]; +}; + +export type CompactionSupport = { + /** Full replacement prompt for hosts that can replace the compaction prompt. */ + prompt: string; + /** Memory instructions to append to the host's own compaction prompt. */ + systemInstruction: string; +}; + +export type MemoryRuntime = { + /** Build the system-prompt parts for the current turn. */ + buildContextParts(sessionID: string): Promise; + /** Record tool activity in session state. */ + handleToolAfter(event: ToolAfterEvent): Promise; + /** Prepare numbered refs and prompt material for a compaction. */ + prepareCompaction(sessionID: string, otherContext: string): Promise; + /** Parse the summary and promote pending memories. */ + handleSessionCompacted(sessionID: string, summary?: string | null): Promise; + /** Promote pending memories and remove per-session state. */ + handleSessionDeleted(sessionID: string): Promise; +}; + +export function createMemoryRuntime( + host: MemoryRuntimeHost, + _options?: unknown, +): MemoryRuntime { + const directory = host.directory; + + // Cache for sub-agent detection — avoids repeated API calls per session. + // Maps sessionID → parentID (string) or null (root session). + const sessionParentCache = new Map(); + + async function isSubAgent(sessionID: string): Promise { + if (sessionParentCache.has(sessionID)) { + return sessionParentCache.get(sessionID) !== null; + } + try { + const parentID = await host.getSessionParentID(sessionID); + sessionParentCache.set(sessionID, parentID ?? null); + return parentID != null; + } catch { + // If we can't determine, assume it's NOT a sub-agent (safe default). + sessionParentCache.set(sessionID, null); + return false; + } + } + + // Cache for frozen workspace memory per session + const frozenWorkspaceMemoryCache = new Map< + string, + { + store: Awaited>; + renderedPrompt: string; + loadedAt: number; + lastAccessedAt: number; + } + >(); + + // Cache for frozen hot session state per session epoch. + // Lifecycle is unified with frozenWorkspaceMemoryCache; do not clear independently. + const frozenHotSessionStateCache = new Map< + string, + { + renderedPrompt: string; + loadedAt: number; + lastAccessedAt: number; + } + >(); + + // Cache for processed user message IDs (to avoid duplicate processing) + const processedUserMessages = new Map>(); + + function memoryEvidenceRef(memory: LongTermMemoryEntry): MemoryEvidenceRef { + return { + memoryId: memory.id, + memoryKeyHash: memoryKey(memory), + identityKeyHash: workspaceMemoryIdentityKey(memory), + type: memory.type, + source: memory.source, + status: memory.status, + }; + } + + function pendingAppendedEvidence(memory: LongTermMemoryEntry): EvidenceEventInput { + return { + type: "pending_memory_appended", + phase: "pending_journal", + outcome: "accepted", + memory: memoryEvidenceRef(memory), + relations: [{ role: "pending", memory: memoryEvidenceRef(memory) }], + reasonCodes: ["pending_journal_append"], + textPreview: memory.text, + }; + } + + function memoryReinforcedEvidence( + memory: LongTermMemoryEntry | undefined, + ref: string, + outcome: "reinforced" | "rejected", + reasonCodes: string[], + details: EvidenceEventInput["details"] = {}, + ): EvidenceEventInput { + const relationRole = outcome === "rejected" ? "target" : "reinforced"; + return { + type: "memory_reinforced", + phase: "reinforcement", + outcome, + memory: memory ? memoryEvidenceRef(memory) : undefined, + relations: memory ? [{ role: relationRole, memory: memoryEvidenceRef(memory) }] : undefined, + reasonCodes, + details: { + ref, + ...details, + }, + textPreview: memory?.text, + }; + } + + function reinforcementDecisionTimingDetails(decision: ReinforcementDecision): EvidenceEventInput["details"] { + return { + attemptedAtMs: decision.attemptedAt, + attemptedAtIso: new Date(decision.attemptedAt).toISOString(), + ...(decision.lastReinforcedAt !== undefined ? { + lastReinforcedAtMs: decision.lastReinforcedAt, + lastReinforcedAtIso: new Date(decision.lastReinforcedAt).toISOString(), + } : {}), + ...(decision.elapsedMs !== undefined ? { elapsedMs: decision.elapsedMs } : {}), + requiredElapsedMs: decision.requiredElapsedMs, + sameSession: decision.sameSession, + ...(decision.legacyMissingTimestamp ? { legacyMissingTimestamp: true } : {}), + }; + } + + function replacementMemoryId(): string { + return `mem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + } + + function memoryReplacedEvidence( + oldMemory: LongTermMemoryEntry | undefined, + newMemory: LongTermMemoryEntry | undefined, + ref: string, + outcome: "superseded" | "rejected", + reasonCodes: string[], + details: EvidenceEventInput["details"] = {}, + ): EvidenceEventInput { + const relations = outcome === "rejected" + ? [ + ...(oldMemory ? [{ role: "target" as const, memory: memoryEvidenceRef(oldMemory) }] : []), + ] + : [ + ...(oldMemory ? [{ role: "superseded" as const, memory: memoryEvidenceRef(oldMemory) }] : []), + ...(newMemory ? [{ role: "superseded_by" as const, memory: memoryEvidenceRef(newMemory) }] : []), + ]; + return { + type: "memory_replaced_numbered_ref", + phase: "storage", + outcome, + memory: oldMemory ? memoryEvidenceRef(oldMemory) : undefined, + relations: relations.length > 0 ? relations : undefined, + reasonCodes, + details: { + ref, + ...details, + }, + textPreview: newMemory?.text ?? oldMemory?.text, + }; + } + + function compactionRefByLabel(refs: CompactionMemoryRef[]): Map { + return new Map(refs.map(ref => [ref.ref.toUpperCase(), ref])); + } + + function storedCompactionIdForRefs(refs: CompactionMemoryRef[]): string { + const ids = new Set(refs.map(ref => ref.compactionId).filter((id): id is string => typeof id === "string" && id.length > 0)); + if (ids.size === 0) return "none"; + if (ids.size === 1) return [...ids][0]; + return "mixed"; + } + + function compactionSnapshotStatus( + refs: CompactionMemoryRef[], + expectedCompactionId: string | undefined, + ): { ok: true } | { ok: false; storedCompactionId: string; reason: "missing_compaction_snapshot_id" | "compaction_snapshot_mismatch" } { + if (!expectedCompactionId) { + return { ok: false, storedCompactionId: storedCompactionIdForRefs(refs), reason: "missing_compaction_snapshot_id" }; + } + + if (refs.length === 0) return { ok: false, storedCompactionId: "none", reason: "compaction_snapshot_mismatch" }; + + const ids = new Set(refs.map(ref => ref.compactionId).filter((id): id is string => typeof id === "string" && id.length > 0)); + if (ids.size === 1 && ids.has(expectedCompactionId)) return { ok: true }; + if (ids.size === 0) return { ok: false, storedCompactionId: "none", reason: "compaction_snapshot_mismatch" }; + if (ids.size === 1) return { ok: false, storedCompactionId: [...ids][0], reason: "compaction_snapshot_mismatch" }; + return { ok: false, storedCompactionId: "mixed", reason: "compaction_snapshot_mismatch" }; + } + + function commandProvenanceRejectedEvidence( + command: WorkspaceMemoryCommand, + refsByLabel: Map, + reason: "missing_compaction_snapshot_id" | "compaction_snapshot_mismatch", + details: EvidenceEventInput["details"] = {}, + ): EvidenceEventInput { + const refSnapshot = refsByLabel.get(command.ref.toUpperCase()); + const commandDetails: EvidenceEventInput["details"] = { + commandKind: command.kind, + ...(refSnapshot?.memoryId ? { memoryId: refSnapshot.memoryId } : {}), + ...(command.kind === "REPLACE" ? { replacementType: command.type } : {}), + ...details, + }; + + if (command.kind === "REINFORCE") { + return memoryReinforcedEvidence(undefined, command.ref, "rejected", [reason], commandDetails); + } + + return memoryReplacedEvidence(undefined, undefined, command.ref, "rejected", [reason], commandDetails); + } + + function resolveCompactionMemoryRef( + refs: CompactionMemoryRef[], + refsByLabel: Map, + entries: LongTermMemoryEntry[], + ref: string, + ): CompactionRefResolution { + if (refs.length === 0) { + return { ok: false, reason: "missing_memory_ref_snapshot" }; + } + + const refSnapshot = refsByLabel.get(ref.toUpperCase()); + if (!refSnapshot) { + return { ok: false, reason: "invalid_memory_ref" }; + } + + const targetIndex = entries.findIndex(entry => entry.id === refSnapshot.memoryId); + const target = targetIndex >= 0 ? entries[targetIndex] : undefined; + if (!target || target.status !== "active") { + return { ok: false, reason: "memory_ref_target_unavailable", refSnapshot, target }; + } + + if (workspaceMemoryExactKey(target) !== refSnapshot.exactKey) { + return { ok: false, reason: "memory_ref_target_changed", refSnapshot, target }; + } + + return { ok: true, refSnapshot, target, targetIndex }; + } + + async function applyCompactionMemoryCommands( + sessionID: string, + commands: WorkspaceMemoryCommand[], + compactionId: string | undefined, + ): Promise { + if (commands.length === 0) return; + + const sessionState = await loadSessionState(directory, sessionID); + const snapshotStatus = compactionSnapshotStatus(sessionState.compactionMemoryRefs, compactionId); + const refs = sessionState.compactionMemoryRefs; + const refsByLabel = compactionRefByLabel(refs); + const evidence: EvidenceEventInput[] = []; + const now = Date.now(); + + if (snapshotStatus.ok === false) { + const details: EvidenceEventInput["details"] = { + ...(compactionId ? { compactionId } : {}), + storedCompactionId: snapshotStatus.storedCompactionId, + }; + await appendEvidenceEvents(directory, commands.map(command => ({ + ...commandProvenanceRejectedEvidence(command, refsByLabel, snapshotStatus.reason, details), + sessionHash: sessionID, + }))); + return; + } + + const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => { + for (const command of commands) { + const resolution = resolveCompactionMemoryRef(refs, refsByLabel, workspaceMemory.entries, command.ref); + if (resolution.ok === false) { + const memoryId = resolution.refSnapshot?.memoryId; + const details = memoryId ? { memoryId } : {}; + if (command.kind === "REINFORCE") { + evidence.push(memoryReinforcedEvidence(resolution.target, command.ref, "rejected", [resolution.reason], details)); + } else { + evidence.push(memoryReplacedEvidence(resolution.target, undefined, command.ref, "rejected", [resolution.reason], details)); + } + continue; + } + + const { refSnapshot, target, targetIndex } = resolution; + if (command.kind === "REINFORCE") { + const decision = tryReinforceMemory(target, sessionID, now); + if (decision.outcome === "blocked") { + evidence.push(memoryReinforcedEvidence(target, command.ref, "rejected", ["numbered_ref_reinforce", "reinforcement_window_blocked", `reinforcement_block_${decision.blockReason}`], { + memoryId: refSnapshot.memoryId, + blockReason: decision.blockReason, + ...reinforcementDecisionTimingDetails(decision), + reinforcementCount: decision.reinforcementCount, + maxReinforcementCount: decision.maxReinforcementCount, + })); + continue; + } + + const reinforced = decision.memory; + workspaceMemory.entries[targetIndex] = reinforced; + const reasonCodes = ["numbered_ref_reinforce", "reinforcement_window_allowed"]; + if (decision.reinforcementMode === "refresh_only") { + reasonCodes.push("reinforcement_saturation_refresh"); + } + evidence.push(memoryReinforcedEvidence(reinforced, command.ref, "reinforced", reasonCodes, { + memoryId: refSnapshot.memoryId, + reinforcementOutcome: decision.reinforcementMode === "refresh_only" ? "refreshed" : "reinforced", + reinforcementMode: decision.reinforcementMode, + ...reinforcementDecisionTimingDetails(decision), + previousReinforcementCount: decision.previousReinforcementCount, + newReinforcementCount: decision.newReinforcementCount, + reinforcementCount: decision.newReinforcementCount, + maxReinforcementCount: REINFORCEMENT_MAX_COUNT, + })); + continue; + } + + if (target.source !== "compaction") { + evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", ["protected_memory_source"], { + memoryId: refSnapshot.memoryId, + source: target.source, + })); + continue; + } + + if ((target.reinforcementCount ?? 0) > 0) { + evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", ["protected_reinforced_target"], { + memoryId: refSnapshot.memoryId, + reinforcementCount: target.reinforcementCount ?? 0, + })); + continue; + } + + const quality = assessMemoryQuality({ type: command.type, text: command.text, source: "compaction" }); + if (!quality.accepted) { + evidence.push(memoryReplacedEvidence(target, undefined, command.ref, "rejected", quality.reasons, { + memoryId: refSnapshot.memoryId, + })); + continue; + } + + const supersededTarget: LongTermMemoryEntry = { + ...target, + status: "superseded", + updatedAt: new Date(now).toISOString(), + }; + const replacement: LongTermMemoryEntry = { + id: replacementMemoryId(), + type: command.type, + text: command.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars), + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: new Date(now).toISOString(), + updatedAt: new Date(now).toISOString(), + retentionClock: now, + staleAfterDays: staleAfterDaysFor(command.type), + supersedes: [target.id], + }; + + workspaceMemory.entries[targetIndex] = supersededTarget; + workspaceMemory.entries.push(replacement); + evidence.push(memoryReplacedEvidence(supersededTarget, replacement, command.ref, "superseded", [ + "numbered_ref_replace", + command.type === target.type ? "same_type_replace" : "cross_type_replace", + ], { + oldMemoryId: target.id, + newMemoryId: replacement.id, + oldType: target.type, + newType: command.type, + })); + } + + return workspaceMemory; + }); + + await appendEvidenceEvents(directory, [...updateResult.evidence, ...evidence].map(event => ({ + ...event, + sessionHash: sessionID, + }))); + } + + function clearFrozenPromptEpoch(sessionID: string): void { + frozenWorkspaceMemoryCache.delete(sessionID); + frozenHotSessionStateCache.delete(sessionID); + } + + async function clearCompactionMemoryRefs(sessionID: string): Promise { + await updateSessionState(directory, sessionID, state => { + state.compactionMemoryRefs = []; + return state; + }); + } + + function pruneFrozenPromptEpochCaches(): void { + const lastAccessedAtBySession = new Map(); + for (const [sessionID, cached] of frozenWorkspaceMemoryCache) { + lastAccessedAtBySession.set( + sessionID, + Math.max(lastAccessedAtBySession.get(sessionID) ?? cached.lastAccessedAt, cached.lastAccessedAt), + ); + } + for (const [sessionID, cached] of frozenHotSessionStateCache) { + lastAccessedAtBySession.set( + sessionID, + Math.max(lastAccessedAtBySession.get(sessionID) ?? cached.lastAccessedAt, cached.lastAccessedAt), + ); + } + + const sorted = [...lastAccessedAtBySession.entries()].sort((a, b) => a[1] - b[1]); + while (lastAccessedAtBySession.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) { + const [oldestSessionID] = sorted.shift() ?? []; + if (!oldestSessionID) break; + lastAccessedAtBySession.delete(oldestSessionID); + clearFrozenPromptEpoch(oldestSessionID); + } + + for (const sessionID of frozenWorkspaceMemoryCache.keys()) { + if (!lastAccessedAtBySession.has(sessionID)) frozenWorkspaceMemoryCache.delete(sessionID); + } + for (const sessionID of frozenHotSessionStateCache.keys()) { + if (!lastAccessedAtBySession.has(sessionID)) frozenHotSessionStateCache.delete(sessionID); + } + } + + function pruneProcessedUserMessagesCache(): void { + for (const [sessionID, messages] of processedUserMessages) { + while (messages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) { + const oldest = messages.values().next().value as string | undefined; + if (!oldest) break; + messages.delete(oldest); + } + + if (messages.size === 0) { + processedUserMessages.delete(sessionID); + } + } + + while (processedUserMessages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedSessionIDs) { + const oldestSessionID = processedUserMessages.keys().next().value as string | undefined; + if (!oldestSessionID) break; + processedUserMessages.delete(oldestSessionID); + } + } + + function rememberProcessedUserMessage(sessionID: string, messageID: string, processedForSession: Set): void { + processedForSession.add(messageID); + while (processedForSession.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) { + const oldest = processedForSession.values().next().value as string | undefined; + if (!oldest) break; + processedForSession.delete(oldest); + } + + if (processedUserMessages.has(sessionID)) { + processedUserMessages.delete(sessionID); + } + processedUserMessages.set(sessionID, processedForSession); + pruneProcessedUserMessagesCache(); + } + + async function processLatestUserMessage(sessionID: string): Promise { + const processedForSession = processedUserMessages.get(sessionID) ?? new Set(); + const latestMessage = await host.getLatestUserMessage(sessionID); + + if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return; + + const extraction = extractExplicitMemoriesWithEvidence(latestMessage.text); + await appendEvidenceEvents(directory, extraction.evidence.map(event => ({ + ...event, + sessionHash: sessionID, + messageHash: latestMessage.id, + }))); + + const memories = extraction.entries.map(memory => ({ + ...memory, + pendingOwnerSessionID: sessionID, + pendingMessageID: latestMessage.id, + })); + const decisions = memories.filter(memory => memory.type === "decision"); + + if (memories.length > 0) { + await updateSessionState(directory, sessionID, state => { + state.pendingMemories.push(...memories); + return state; + }); + await appendPendingMemories(directory, memories); + await appendEvidenceEvents(directory, memories.map(memory => ({ + ...pendingAppendedEvidence(memory), + sessionHash: sessionID, + messageHash: latestMessage.id, + }))); + } + + if (decisions.length > 0) { + await updateSessionState(directory, sessionID, state => { + for (const decision of decisions) { + addRecentDecision(state, { + text: decision.text, + rationale: decision.rationale, + source: "user", + }); + } + return state; + }); + } + + rememberProcessedUserMessage(sessionID, latestMessage.id, processedForSession); + } + + function dedupePendingPromotionMemories( + memories: LongTermMemoryEntry[], + ): LongTermMemoryEntry[] { + const seen = new Set(); + const deduped: LongTermMemoryEntry[] = []; + + for (const memory of memories) { + const key = memoryKey(memory); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(memory); + } + + return deduped; + } + + async function promotePendingMemories( + sessionID?: string, + options: { includeUnownedJournal?: boolean; includeOwnedJournal?: boolean } = {}, + ): Promise { + const includeUnownedJournal = options.includeUnownedJournal ?? !sessionID; + const includeOwnedJournal = options.includeOwnedJournal ?? Boolean(sessionID); + const [journal, sessionState] = await Promise.all([ + loadPendingJournal(directory), + sessionID ? loadSessionState(directory, sessionID) : Promise.resolve(undefined), + ]); + + const journalPending = journal.entries.filter(memory => { + if (sessionID && includeOwnedJournal && memory.pendingOwnerSessionID === sessionID) return true; + if (includeUnownedJournal && !memory.pendingOwnerSessionID) return true; + return false; + }); + + const pending = dedupePendingPromotionMemories([ + ...(sessionState?.pendingMemories ?? []), + ...journalPending, + ]); + if (pending.length === 0) return; + + let beforeEntries: Awaited>["entries"] = []; + + const updateResult = await updateWorkspaceMemoryWithAccounting(directory, workspaceMemory => { + beforeEntries = [...workspaceMemory.entries]; + const existingByKey = new Map(); + workspaceMemory.entries.forEach((memory, index) => { + if (memory.status === "superseded") return; + existingByKey.set(memoryKey(memory), { memory, index }); + }); + + const promotedAt = Date.now(); + for (const memory of pending) { + const key = memoryKey(memory); + const existing = existingByKey.get(key); + if (existing) { + const decision = tryReinforceMemory( + existing.memory, + sessionID ?? memory.pendingOwnerSessionID ?? "workspace-promotion", + promotedAt, + ); + const reinforced = decision.memory; + if (reinforced !== existing.memory) { + workspaceMemory.entries[existing.index] = reinforced; + existingByKey.set(key, { memory: reinforced, index: existing.index }); + } + } else { + workspaceMemory.entries.push({ + ...memory, + retentionClock: memory.retentionClock ?? promotedAt, + }); + existingByKey.set(key, { + memory: workspaceMemory.entries[workspaceMemory.entries.length - 1], + index: workspaceMemory.entries.length - 1, + }); + } + } + + return workspaceMemory; + }); + + const accounting = accountPendingPromotions({ + pending, + before: beforeEntries, + after: updateResult.store.entries, + events: updateResult.events, + }); + + const exhaustedRejectedKeys = await recordPromotionRejections( + directory, + accounting.retryableRejectedKeys, + "rejected_capacity", + { + ownerSessionID: sessionID, + includeUnownedOnly: !sessionID, + }, + ); + + await appendEvidenceEvents(directory, [ + ...updateResult.evidence, + ...promotionAccountingEvidenceEvents({ + pending, + after: updateResult.store.entries, + events: updateResult.events, + accounting, + exhaustedRejectedKeys, + }), + ].map(event => ({ + ...event, + sessionHash: sessionID, + }))); + + const sessionRemovalKeys = new Set([ + ...accounting.clearableKeys, + ...exhaustedRejectedKeys, + ]); + + if (sessionID) { + await updateSessionState(directory, sessionID, state => { + state.pendingMemories = state.pendingMemories.filter(memory => { + const key = memoryKey(memory); + if (!sessionRemovalKeys.has(key)) return true; + + if (accounting.clearableKeys.has(key)) return false; + if (exhaustedRejectedKeys.has(key)) return false; + + return true; + }); + return state; + }); + clearFrozenPromptEpoch(sessionID); + } + + if (accounting.clearableKeys.size > 0) { + await clearPendingMemories(directory, accounting.clearableKeys, { + ownerSessionID: sessionID, + clearUnowned: !sessionID || includeUnownedJournal === true, + }); + } + } + + /** + * Get frozen workspace memory snapshot for a session. + * Loads and renders from disk once per session, then reuses the exact rendered string. + */ + async function getFrozenWorkspaceMemorySnapshot( + root: string, + sessionID: string + ): Promise<{ + store: Awaited>; + renderedPrompt: string; + }> { + const now = Date.now(); + const cached = frozenWorkspaceMemoryCache.get(sessionID); + + // Cache is valid for the current session cache epoch. + // It is intentionally invalidated after compaction so promoted memories + // become visible in the next compacted context (new epoch starts). + if (cached) { + cached.lastAccessedAt = now; + return { store: cached.store, renderedPrompt: cached.renderedPrompt }; + } + + const store = await loadWorkspaceMemory(root); + const renderAccounting = accountWorkspaceMemoryRender(store); + const renderedPrompt = renderAccounting.prompt; + await appendEvidenceEvents(root, renderAccounting.evidence.map(event => ({ + ...event, + sessionHash: sessionID, + }))); + frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now, lastAccessedAt: now }); + pruneFrozenPromptEpochCaches(); + return { store, renderedPrompt }; + } + + /** + * Get frozen hot session state snapshot for a session. + * Loads and renders from disk once per prompt epoch, then reuses the exact rendered string. + */ + async function getFrozenHotSessionStateSnapshot( + root: string, + sessionID: string, + ): Promise<{ renderedPrompt: string }> { + const now = Date.now(); + const cached = frozenHotSessionStateCache.get(sessionID); + if (cached) { + cached.lastAccessedAt = now; + return { renderedPrompt: cached.renderedPrompt }; + } + + const sessionState = await loadSessionState(root, sessionID); + const renderedPrompt = renderHotSessionState(sessionState, root); + frozenHotSessionStateCache.set(sessionID, { renderedPrompt, loadedAt: now, lastAccessedAt: now }); + pruneFrozenPromptEpochCaches(); + return { renderedPrompt }; + } + + async function promoteUnownedBacklogForEpochSnapshot(sessionID: string): Promise { + if (frozenWorkspaceMemoryCache.has(sessionID) || frozenHotSessionStateCache.has(sessionID)) return; + if (!await hasPendingJournalEntries(directory)) return; + + try { + await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false }); + } catch (error) { + await warnMemoryHook("chat.system.transform.promote_unowned", error, directory); + } + } + + return { + // Build frozen workspace memory and frozen hot session state system parts. + buildContextParts: async (sessionID: string): Promise => { + if (!sessionID) return []; + + try { + pruneFrozenPromptEpochCaches(); + pruneProcessedUserMessagesCache(); + + // Sub-agents are short-lived - skip memory system + if (await isSubAgent(sessionID)) return []; + + // Process explicit user memory even on no-tool turns. Keep this after the + // sub-agent guard so child sessions never append to the parent journal. + await processLatestUserMessage(sessionID); + + // Before first snapshot in this session, best-effort promote durable + // unowned backlog from prior sessions. Current-turn owned explicit memory + // remains pending and appears in hot state only if the epoch snapshot is new. + await promoteUnownedBacklogForEpochSnapshot(sessionID); + + let workspaceSnapshot: Awaited> | undefined; + try { + workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID); + } catch (error) { + await warnMemoryHook("chat.system.transform.workspace_snapshot", error, directory); + } + + let hotSnapshot: Awaited> | undefined; + try { + hotSnapshot = await getFrozenHotSessionStateSnapshot(directory, sessionID); + } catch (error) { + await warnMemoryHook("chat.system.transform.hot_snapshot", error, directory); + } + + const memorySystemPromptParts: string[] = []; + if (workspaceSnapshot?.renderedPrompt) { + memorySystemPromptParts.push(workspaceSnapshot.renderedPrompt); + } + if (hotSnapshot?.renderedPrompt) { + memorySystemPromptParts.push(hotSnapshot.renderedPrompt); + } + return memorySystemPromptParts; + } catch (error) { + await warnMemoryHook("chat.system.transform", error, directory); + return []; + } + }, + + // Track tool usage and update session state. + handleToolAfter: async (event: ToolAfterEvent): Promise => { + const { sessionID, tool: toolName, args, outputText, exitCode, todos } = event; + if (!sessionID) return; + + // Sub-agents don't need memory tracking + if (await isSubAgent(sessionID)) return; + + try { + await updateSessionState(directory, sessionID, state => { + // Track active files from tool usage + if (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep") { + const files = extractActiveFiles( + toolName, + args, + outputText ?? "" + ); + for (const { path, action } of files) { + touchActiveFile(state, path, action); + if (action === "edit" || action === "write") { + markErrorsMaybeFixedForFile(state, path, directory); + } + } + } + + // Track errors from failed shell commands. V2 calls the tool "shell". + if (toolName === "bash" || toolName === "shell") { + const command: string = typeof args?.command === "string" ? args.command : ""; + if (typeof exitCode !== "number") { + // Unknown exit status: do not extract and do not clear + } else if (exitCode === 0 && command) { + clearErrorsForSuccessfulCommand(state, command); + } else if (command) { + // Only extract errors for commands with explicit non-zero exit + const errors = extractErrorsFromBash(command, outputText); + for (const error of errors) { + upsertOpenError(state, error); + } + } + } + + // V2 has no todo read API; the todowrite tool carries the current list. + if (toolName === "todowrite" && todos !== undefined) { + state.todos = todos; + } + return state; + }); + + // Process explicit memory from latest user message + // Only process once per message ID + await processLatestUserMessage(sessionID); + } catch (error) { + await warnMemoryHook("tool.execute.after", error, directory); + } + }, + + /** + * Prepare numbered memory refs and compaction prompt material. + * + * V1 replaces the host compaction prompt with `prompt`; V2 appends + * `systemInstruction` to the host's own compaction prompt. + */ + prepareCompaction: async (sessionID: string, otherContext: string): Promise => { + if (!sessionID) return null; + + try { + // Sub-agents don't need compaction support + if (await isSubAgent(sessionID)) return null; + + // Build our private context (numbered workspace memory refs, todos) + const contextParts: string[] = []; + + // 1. Compaction-only numbered workspace memory snapshot + const compactionId = randomUUID(); + const workspaceStore = await loadWorkspaceMemory(directory); + const compactionRefs = accountWorkspaceMemoryCompactionRefs(workspaceStore); + const refsWithCompactionId = compactionRefs.refs.map(ref => ({ ...ref, compactionId })); + const sessionState = await updateSessionState(directory, sessionID, state => { + state.compactionMemoryRefs = refsWithCompactionId; + return state; + }); + await appendEvidenceEvents(directory, compactionRefs.evidence.map(event => ({ + ...event, + sessionHash: sessionID, + }))); + if (compactionRefs.prompt) { + contextParts.push(compactionRefs.prompt); + } + + // 2. Pending todos. V2 records todowrite snapshots in session state. + const todos = sessionState.todos !== undefined + ? sessionState.todos + : await host.getPendingTodos(sessionID); + const todosPrompt = renderTodosForCompaction(todos); + if (todosPrompt) { + contextParts.push(todosPrompt); + } + + const privateContext = [otherContext, ...contextParts] + .filter(Boolean) + .join("\n\n"); + const memoryContext = contextParts.filter(Boolean).join("\n\n"); + + return { + prompt: buildCompactionPrompt(privateContext, compactionId), + systemInstruction: buildCompactionSystemInstruction(memoryContext, compactionId), + }; + } catch (error) { + await warnMemoryHook("session.compacting", error, directory); + return null; + } + }, + + // Parse the compaction summary for memory candidates and promote pending memories. + handleSessionCompacted: async (sessionID: string, summaryOverride?: string | null): Promise => { + if (!sessionID) return; + try { + // Sub-agents don't need post-compaction processing + if (await isSubAgent(sessionID)) return; + + try { + const summary = summaryOverride ?? await host.getLatestCompactionSummary(sessionID); + const compactionId = summary ? compactionIdFromSummary(summary) : undefined; + const parseResult = summary + ? parseWorkspaceMemoryCandidatesWithEvidence(summary, await workspaceIdentity(directory)) + : { entries: [], commands: [], evidence: [] }; + await appendEvidenceEvents(directory, parseResult.evidence.map(event => ({ + ...event, + sessionHash: sessionID, + }))); + await applyCompactionMemoryCommands(sessionID, parseResult.commands, compactionId); + const candidates = parseResult.entries; + if (candidates.length > 0) { + await appendPendingMemories(directory, candidates); + await appendEvidenceEvents(directory, candidates.map(memory => ({ + ...pendingAppendedEvidence(memory), + sessionHash: sessionID, + }))); + } + + await promotePendingMemories(sessionID, { includeUnownedJournal: true }); + } finally { + await clearCompactionMemoryRefs(sessionID); + clearFrozenPromptEpoch(sessionID); + } + } catch (error) { + // Keep pending memories in session/journal for retry on next event/session. + await warnMemoryHook("event.session.compacted", error, directory); + } + }, + + // Promote pending memories and remove per-session state. + handleSessionDeleted: async (sessionID: string): Promise => { + if (!sessionID) return; + try { + // Promote pending memories before deleting per-session state. + // If promotion fails, leave session state and journal intact. + await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false }); + clearFrozenPromptEpoch(sessionID); + processedUserMessages.delete(sessionID); + sessionParentCache.delete(sessionID); + + await rm(await sessionStatePath(directory, sessionID), { force: true }); + } catch (error) { + await warnMemoryHook("event.session.deleted", error, directory); + } + }, + }; +} diff --git a/src/session-state.ts b/src/session-state.ts index ae1838f..03183f9 100644 --- a/src/session-state.ts +++ b/src/session-state.ts @@ -1,7 +1,7 @@ import { relative } from "path"; import { sessionStatePath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; -import type { ActiveFile, CompactionMemoryRef, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "./types.ts"; +import type { ActiveFile, CompactionMemoryRef, LongTermMemoryEntry, OpenError, SessionDecision, SessionState, SessionTodo } from "./types.ts"; import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "./types.ts"; import { memoryKey } from "./pending-journal.ts"; import { redactCredentials } from "./redaction.ts"; @@ -40,6 +40,7 @@ export async function loadSessionState(root: string, sessionID: string): Promise loaded.recentDecisions = Array.isArray(loaded.recentDecisions) ? loaded.recentDecisions : []; loaded.pendingMemories = Array.isArray(loaded.pendingMemories) ? loaded.pendingMemories : []; loaded.compactionMemoryRefs = normalizeCompactionMemoryRefs((loaded as SessionStateInput).compactionMemoryRefs); + loaded.todos = normalizeTodos(loaded.todos); return loaded; } @@ -60,6 +61,7 @@ export async function updateSessionState( current.recentDecisions = Array.isArray(current.recentDecisions) ? current.recentDecisions : []; current.pendingMemories = Array.isArray(current.pendingMemories) ? current.pendingMemories : []; current.compactionMemoryRefs = normalizeCompactionMemoryRefs((current as SessionStateInput).compactionMemoryRefs); + current.todos = normalizeTodos(current.todos); return normalizeSessionState(await updater(current)); }); } @@ -76,9 +78,23 @@ function normalizeSessionState(state: SessionState | SessionStateInput): Session ...state, pendingMemories, compactionMemoryRefs: normalizeCompactionMemoryRefs(state.compactionMemoryRefs), + ...(state.todos !== undefined ? { todos: normalizeTodos(state.todos) } : {}), }; } +function normalizeTodos(value: unknown): SessionTodo[] | undefined { + if (!Array.isArray(value)) return undefined; + return value + .filter((todo): todo is Record => Boolean(todo) && typeof todo === "object") + .map(todo => ({ + content: String(todo.content ?? ""), + status: String(todo.status ?? "pending"), + ...(todo.priority !== undefined ? { priority: String(todo.priority) } : {}), + })) + .filter(todo => todo.content.length > 0) + .slice(0, HOT_STATE_LIMITS.maxTodosStored); +} + function redactPendingMemoryEntry(entry: LongTermMemoryEntry): LongTermMemoryEntry { return { ...entry, diff --git a/src/tui-plugin-v1.ts b/src/tui-plugin-v1.ts new file mode 100644 index 0000000..efefe79 --- /dev/null +++ b/src/tui-plugin-v1.ts @@ -0,0 +1,303 @@ +import { + formatMemoryHelp, + formatMemoryList, + getMemoryList, + renderMemoryCommand, + type MemoryVisibilityCommand, +} from "./memory-visibility.ts"; +import { MEMORY_TYPE_ORDER } from "./memory-kind-policy.ts"; + +type DialogContext = { + clear?: () => void; +}; + +type DialogSize = "medium" | "large" | "xlarge"; +type DialogElement = unknown; +type DialogStackContext = { + clear?: () => void; + replace?: (render: () => DialogElement, onClose?: () => void) => void; + setSize?: (size: DialogSize) => void; +}; +type DialogAlertComponent = (props: { title: string; message: string; onConfirm?: () => void }) => DialogElement; +type DialogSelectOption = { + title: string; + value: Value; + description?: string; + footer?: string; + category?: string; + disabled?: boolean; + onSelect?: () => void | Promise; +}; +type DialogSelectProps = { + title: string; + placeholder?: string; + options: DialogSelectOption[]; + onSelect?: (option: DialogSelectOption) => void | Promise; + skipFilter?: boolean; +}; +type DialogSelectComponent = (props: DialogSelectProps) => DialogElement; + +type TuiCommand = { + title: string; + value: string; + description?: string; + category?: string; + suggested?: boolean; + slash?: { + name: string; + aliases?: string[]; + }; + onSelect?: (dialog?: DialogContext) => void | Promise; +}; + +type TuiRouteCurrent = + | { name: "home" } + | { name: "session"; params: { sessionID: string; prompt?: unknown } } + | { name: string; params?: Record }; + +type TuiPluginApi = { + command: { + register: (cb: () => TuiCommand[]) => () => void; + }; + route: ({ readonly current: TuiRouteCurrent } | TuiRouteCurrent); + ui: { + DialogAlert?: DialogAlertComponent; + DialogSelect?: DialogSelectComponent; + toast: (input: { variant?: "info" | "success" | "warning" | "error"; message: string }) => void; + dialog?: DialogStackContext; + }; + state: { + path: { + directory: string; + }; + }; + client?: unknown; +}; + +type TuiPlugin = (api: TuiPluginApi, options: unknown, meta: unknown) => Promise; + +function currentRoute(api: TuiPluginApi): TuiRouteCurrent { + const route = api.route as ({ readonly current?: TuiRouteCurrent } & Partial); + return route.current ?? (route as TuiRouteCurrent); +} + +function renderErrorReport(error: unknown): string { + const detail = error instanceof Error ? error.message : String(error); + return [ + "## Memory error", + "", + "Unable to render local memory visibility output.", + `Error: ${detail}`, + "", + "Local only: no LLM request was made.", + ].join("\n"); +} + +function dialogSizeForCommand(command: MemoryVisibilityCommand): DialogSize { + if (command === "list") return "xlarge"; + if (command === "status") return "large"; + return "medium"; +} + +function fallbackTitleForCommand(command: MemoryVisibilityCommand): string { + if (command === "list") return "Current workspace memories"; + if (command === "status") return "Memory status"; + return "Memory help"; +} + +function dialogCopyFromMarkdown(text: string, fallbackTitle: string): { title: string; message: string } { + const match = /^##\s+(.+)$/m.exec(text); + if (!match) return { title: fallbackTitle, message: text }; + + const headingStart = match.index; + const headingEnd = text.indexOf("\n", headingStart); + const before = text.slice(0, headingStart); + const after = headingEnd === -1 ? "" : text.slice(headingEnd + 1); + + return { + title: match[1].trim(), + message: `${before}${after}`.replace(/^\s+/, ""), + }; +} + +function getDialogApi(api: TuiPluginApi): { + DialogAlert: DialogAlertComponent; + DialogSelect: DialogSelectComponent; + dialog: Required>; +} | undefined { + if ( + typeof api.ui.DialogAlert !== "function" || + typeof api.ui.DialogSelect !== "function" || + typeof api.ui.dialog?.replace !== "function" || + typeof api.ui.dialog?.setSize !== "function" + ) { + api.ui.toast({ + variant: "error", + message: "Memory dialog UI is unavailable in this OpenCode runtime.", + }); + return undefined; + } + + return { + DialogAlert: api.ui.DialogAlert, + DialogSelect: api.ui.DialogSelect, + dialog: { + replace: api.ui.dialog.replace, + setSize: api.ui.dialog.setSize, + }, + }; +} + +function showDialogError(api: TuiPluginApi, error: unknown): void { + const detail = error instanceof Error ? error.message : String(error); + api.ui.toast({ + variant: "error", + message: `Unable to show memory dialog: ${detail}`, + }); +} + +function showAlertFromMarkdown(api: TuiPluginApi, text: string, fallbackTitle: string, size: DialogSize): void { + const dialogApi = getDialogApi(api); + if (!dialogApi) return; + const { title, message } = dialogCopyFromMarkdown(text, fallbackTitle); + + try { + dialogApi.dialog.replace(() => dialogApi.DialogAlert({ title, message })); + dialogApi.dialog.setSize(size); + } catch (error) { + showDialogError(api, error); + } +} + +function showMemoryMenu(api: TuiPluginApi, dialog?: DialogContext): void { + const dialogApi = getDialogApi(api); + if (!dialogApi) return; + + const options: DialogSelectOption[] = [ + { + title: "Status", + value: "memory.status", + description: "Show local memory statistics", + onSelect: () => showMemoryStatus(api), + }, + { + title: "Current memories", + value: "memory.list", + description: "Browse active workspace memories with display-local refs", + onSelect: () => showMemoryList(api), + }, + { + title: "Help", + value: "memory.help", + description: "Show memory command help", + onSelect: () => showMemoryHelp(api), + }, + ]; + + try { + dialog?.clear?.(); + dialogApi.dialog.replace(() => dialogApi.DialogSelect({ + title: "Memory", + placeholder: "Search memory actions", + options, + })); + dialogApi.dialog.setSize("large"); + } catch (error) { + showDialogError(api, error); + } +} + +async function showMemoryStatus(api: TuiPluginApi): Promise { + const route = currentRoute(api); + + if (route.name !== "session" || typeof route.params?.sessionID !== "string") { + api.ui.toast({ + variant: "warning", + message: "Open a session to use memory commands.", + }); + return; + } + + const sessionID = route.params.sessionID; + const dialogApi = getDialogApi(api); + if (!dialogApi) return; + + let text: string; + let fallbackTitle = fallbackTitleForCommand("status"); + + try { + text = await renderMemoryCommand(api.state.path.directory, sessionID, "status"); + } catch (error) { + text = renderErrorReport(error); + fallbackTitle = "Memory error"; + } + + const { title, message } = dialogCopyFromMarkdown(text, fallbackTitle); + + try { + dialogApi.dialog.replace(() => dialogApi.DialogAlert({ title, message })); + dialogApi.dialog.setSize(dialogSizeForCommand("status")); + } catch (error) { + showDialogError(api, error); + } +} + +function showMemoryHelp(api: TuiPluginApi): void { + showAlertFromMarkdown(api, formatMemoryHelp(), "Memory help", "medium"); +} + +async function showMemoryList(api: TuiPluginApi): Promise { + const dialogApi = getDialogApi(api); + if (!dialogApi) return; + + try { + const model = await getMemoryList(api.state.path.directory); + if (model.renderedMemories === 0) { + showAlertFromMarkdown(api, formatMemoryList(model), "Current workspace memories", "medium"); + return; + } + + const options: DialogSelectOption[] = []; + for (const type of MEMORY_TYPE_ORDER) { + for (const item of model.groups[type]) { + options.push({ + title: `[${item.ref}] ${item.text}`, + value: item.ref, + category: type, + footer: "display-local", + }); + } + } + + dialogApi.dialog.replace(() => dialogApi.DialogSelect({ + title: "Current workspace memories", + placeholder: "Search memory refs", + options, + })); + dialogApi.dialog.setSize("xlarge"); + } catch (error) { + showAlertFromMarkdown(api, renderErrorReport(error), "Memory error", "medium"); + } +} + +function memoryCommands(api: TuiPluginApi): TuiCommand[] { + return [ + { + title: "Memory", + value: "memory.menu", + description: "Browse local working memory.", + category: "Memory", + slash: { name: "memory" }, + onSelect: (dialog?: DialogContext) => showMemoryMenu(api, dialog), + }, + ]; +} + +export const MemoryTuiPlugin: TuiPlugin = async (api) => { + api.command.register(() => memoryCommands(api)); +}; + +export default { + id: "working-memory-tui", + tui: MemoryTuiPlugin, +}; diff --git a/src/tui-plugin.ts b/src/tui-plugin.ts index efefe79..89fb557 100644 --- a/src/tui-plugin.ts +++ b/src/tui-plugin.ts @@ -1,111 +1,75 @@ +/** + * TUI entrypoint. + * + * Exports both shapes from one package: + * - V2 calls `setup(context)` and registers a keymap/slash command. + * - V1 calls `tui(api)` and registers through the legacy command API. + */ + import { formatMemoryHelp, formatMemoryList, getMemoryList, renderMemoryCommand, - type MemoryVisibilityCommand, } from "./memory-visibility.ts"; import { MEMORY_TYPE_ORDER } from "./memory-kind-policy.ts"; +import { MemoryTuiPlugin as MemoryTuiPluginV1 } from "./tui-plugin-v1.ts"; + +/** + * Local identity helper. Importing `@opencode/plugin/tui` pulls in OpenTUI + * peers at module load time, which a server-only install does not need. + */ +function defineTuiPlugin unknown }>(plugin: T): T { + return plugin; +} -type DialogContext = { - clear?: () => void; -}; - -type DialogSize = "medium" | "large" | "xlarge"; -type DialogElement = unknown; -type DialogStackContext = { - clear?: () => void; - replace?: (render: () => DialogElement, onClose?: () => void) => void; - setSize?: (size: DialogSize) => void; -}; -type DialogAlertComponent = (props: { title: string; message: string; onConfirm?: () => void }) => DialogElement; -type DialogSelectOption = { - title: string; - value: Value; - description?: string; - footer?: string; - category?: string; - disabled?: boolean; - onSelect?: () => void | Promise; -}; -type DialogSelectProps = { - title: string; - placeholder?: string; - options: DialogSelectOption[]; - onSelect?: (option: DialogSelectOption) => void | Promise; - skipFilter?: boolean; -}; -type DialogSelectComponent = (props: DialogSelectProps) => DialogElement; - -type TuiCommand = { - title: string; - value: string; - description?: string; - category?: string; - suggested?: boolean; - slash?: { - name: string; - aliases?: string[]; - }; - onSelect?: (dialog?: DialogContext) => void | Promise; -}; - -type TuiRouteCurrent = - | { name: "home" } - | { name: "session"; params: { sessionID: string; prompt?: unknown } } - | { name: string; params?: Record }; +type TuiRoute = + | { type: "home" } + | { type: "session"; sessionID: string } + | { type: string; sessionID?: string }; -type TuiPluginApi = { - command: { - register: (cb: () => TuiCommand[]) => () => void; - }; - route: ({ readonly current: TuiRouteCurrent } | TuiRouteCurrent); +type TuiContext = { + location?: { directory: string }; + data: { location: { default(): { directory: string } } }; ui: { - DialogAlert?: DialogAlertComponent; - DialogSelect?: DialogSelectComponent; - toast: (input: { variant?: "info" | "success" | "warning" | "error"; message: string }) => void; - dialog?: DialogStackContext; - }; - state: { - path: { - directory: string; + router: { current(): TuiRoute }; + toast: { show(input: { variant?: string; message: string }): void }; + dialog: { + alert(input: { title: string; message: string }): Promise; + select(input: { + title: string; + placeholder?: string; + options: Array<{ title: string; value: Value; description?: string; category?: string; footer?: string }>; + }): Promise; }; }; - client?: unknown; + keymap: { + layer(factory: () => { + mode?: string; + priority?: number; + commands: Array<{ + id: string; + title?: string; + description?: string; + group?: string; + palette?: true; + slash?: { name: string; aliases?: string[]; arguments?: true }; + run: () => void | Promise; + }>; + }): void; + }; }; -type TuiPlugin = (api: TuiPluginApi, options: unknown, meta: unknown) => Promise; - -function currentRoute(api: TuiPluginApi): TuiRouteCurrent { - const route = api.route as ({ readonly current?: TuiRouteCurrent } & Partial); - return route.current ?? (route as TuiRouteCurrent); -} - -function renderErrorReport(error: unknown): string { - const detail = error instanceof Error ? error.message : String(error); - return [ - "## Memory error", - "", - "Unable to render local memory visibility output.", - `Error: ${detail}`, - "", - "Local only: no LLM request was made.", - ].join("\n"); -} - -function dialogSizeForCommand(command: MemoryVisibilityCommand): DialogSize { - if (command === "list") return "xlarge"; - if (command === "status") return "large"; - return "medium"; +function currentSessionID(ctx: TuiContext): string | undefined { + const route = ctx.ui.router.current(); + return route.type === "session" ? route.sessionID : undefined; } -function fallbackTitleForCommand(command: MemoryVisibilityCommand): string { - if (command === "list") return "Current workspace memories"; - if (command === "status") return "Memory status"; - return "Memory help"; +function currentDirectory(ctx: TuiContext): string { + return ctx.location?.directory ?? ctx.data.location.default().directory; } -function dialogCopyFromMarkdown(text: string, fallbackTitle: string): { title: string; message: string } { +function splitMarkdownHeading(text: string, fallbackTitle: string): { title: string; message: string } { const match = /^##\s+(.+)$/m.exec(text); if (!match) return { title: fallbackTitle, message: text }; @@ -120,184 +84,110 @@ function dialogCopyFromMarkdown(text: string, fallbackTitle: string): { title: s }; } -function getDialogApi(api: TuiPluginApi): { - DialogAlert: DialogAlertComponent; - DialogSelect: DialogSelectComponent; - dialog: Required>; -} | undefined { - if ( - typeof api.ui.DialogAlert !== "function" || - typeof api.ui.DialogSelect !== "function" || - typeof api.ui.dialog?.replace !== "function" || - typeof api.ui.dialog?.setSize !== "function" - ) { - api.ui.toast({ - variant: "error", - message: "Memory dialog UI is unavailable in this OpenCode runtime.", - }); - return undefined; - } - - return { - DialogAlert: api.ui.DialogAlert, - DialogSelect: api.ui.DialogSelect, - dialog: { - replace: api.ui.dialog.replace, - setSize: api.ui.dialog.setSize, - }, - }; -} - -function showDialogError(api: TuiPluginApi, error: unknown): void { +function errorReport(error: unknown): string { const detail = error instanceof Error ? error.message : String(error); - api.ui.toast({ - variant: "error", - message: `Unable to show memory dialog: ${detail}`, - }); -} - -function showAlertFromMarkdown(api: TuiPluginApi, text: string, fallbackTitle: string, size: DialogSize): void { - const dialogApi = getDialogApi(api); - if (!dialogApi) return; - const { title, message } = dialogCopyFromMarkdown(text, fallbackTitle); - - try { - dialogApi.dialog.replace(() => dialogApi.DialogAlert({ title, message })); - dialogApi.dialog.setSize(size); - } catch (error) { - showDialogError(api, error); - } -} - -function showMemoryMenu(api: TuiPluginApi, dialog?: DialogContext): void { - const dialogApi = getDialogApi(api); - if (!dialogApi) return; - - const options: DialogSelectOption[] = [ - { - title: "Status", - value: "memory.status", - description: "Show local memory statistics", - onSelect: () => showMemoryStatus(api), - }, - { - title: "Current memories", - value: "memory.list", - description: "Browse active workspace memories with display-local refs", - onSelect: () => showMemoryList(api), - }, - { - title: "Help", - value: "memory.help", - description: "Show memory command help", - onSelect: () => showMemoryHelp(api), - }, - ]; - - try { - dialog?.clear?.(); - dialogApi.dialog.replace(() => dialogApi.DialogSelect({ - title: "Memory", - placeholder: "Search memory actions", - options, - })); - dialogApi.dialog.setSize("large"); - } catch (error) { - showDialogError(api, error); - } + return [ + "## Memory error", + "", + "Unable to render local memory visibility output.", + `Error: ${detail}`, + "", + "Local only: no LLM request was made.", + ].join("\n"); } -async function showMemoryStatus(api: TuiPluginApi): Promise { - const route = currentRoute(api); - - if (route.name !== "session" || typeof route.params?.sessionID !== "string") { - api.ui.toast({ - variant: "warning", - message: "Open a session to use memory commands.", - }); +async function showStatus(ctx: TuiContext): Promise { + const sessionID = currentSessionID(ctx); + if (!sessionID) { + ctx.ui.toast.show({ variant: "warning", message: "Open a session to use memory commands." }); return; } - const sessionID = route.params.sessionID; - const dialogApi = getDialogApi(api); - if (!dialogApi) return; - - let text: string; - let fallbackTitle = fallbackTitleForCommand("status"); - - try { - text = await renderMemoryCommand(api.state.path.directory, sessionID, "status"); - } catch (error) { - text = renderErrorReport(error); - fallbackTitle = "Memory error"; - } - - const { title, message } = dialogCopyFromMarkdown(text, fallbackTitle); - try { - dialogApi.dialog.replace(() => dialogApi.DialogAlert({ title, message })); - dialogApi.dialog.setSize(dialogSizeForCommand("status")); + const text = await renderMemoryCommand(currentDirectory(ctx), sessionID, "status"); + const { title, message } = splitMarkdownHeading(text, "Memory status"); + await ctx.ui.dialog.alert({ title, message }); } catch (error) { - showDialogError(api, error); + const { title, message } = splitMarkdownHeading(errorReport(error), "Memory error"); + await ctx.ui.dialog.alert({ title, message }); } } -function showMemoryHelp(api: TuiPluginApi): void { - showAlertFromMarkdown(api, formatMemoryHelp(), "Memory help", "medium"); +async function showHelp(ctx: TuiContext): Promise { + const { title, message } = splitMarkdownHeading(formatMemoryHelp(), "Memory help"); + await ctx.ui.dialog.alert({ title, message }); } -async function showMemoryList(api: TuiPluginApi): Promise { - const dialogApi = getDialogApi(api); - if (!dialogApi) return; - +async function showList(ctx: TuiContext): Promise { try { - const model = await getMemoryList(api.state.path.directory); + const model = await getMemoryList(currentDirectory(ctx)); if (model.renderedMemories === 0) { - showAlertFromMarkdown(api, formatMemoryList(model), "Current workspace memories", "medium"); + const { title, message } = splitMarkdownHeading(formatMemoryList(model), "Current workspace memories"); + await ctx.ui.dialog.alert({ title, message }); return; } - const options: DialogSelectOption[] = []; - for (const type of MEMORY_TYPE_ORDER) { - for (const item of model.groups[type]) { - options.push({ - title: `[${item.ref}] ${item.text}`, - value: item.ref, - category: type, - footer: "display-local", - }); - } - } + const options = MEMORY_TYPE_ORDER.flatMap(type => + model.groups[type].map(item => ({ + title: `[${item.ref}] ${item.text}`, + value: item.ref, + category: type, + footer: "display-local", + })), + ); - dialogApi.dialog.replace(() => dialogApi.DialogSelect({ + await ctx.ui.dialog.select({ title: "Current workspace memories", placeholder: "Search memory refs", options, - })); - dialogApi.dialog.setSize("xlarge"); + }); } catch (error) { - showAlertFromMarkdown(api, renderErrorReport(error), "Memory error", "medium"); + const { title, message } = splitMarkdownHeading(errorReport(error), "Memory error"); + await ctx.ui.dialog.alert({ title, message }); } } -function memoryCommands(api: TuiPluginApi): TuiCommand[] { - return [ - { - title: "Memory", - value: "memory.menu", - description: "Browse local working memory.", - category: "Memory", - slash: { name: "memory" }, - onSelect: (dialog?: DialogContext) => showMemoryMenu(api, dialog), - }, - ]; +async function showMemoryMenu(ctx: TuiContext): Promise { + const choice = await ctx.ui.dialog.select({ + title: "Memory", + placeholder: "Search memory actions", + options: [ + { title: "Status", value: "status", description: "Show local memory statistics" }, + { title: "Current memories", value: "list", description: "Browse active workspace memories" }, + { title: "Help", value: "help", description: "Show memory command help" }, + ], + }); + + if (choice === "status") await showStatus(ctx); + else if (choice === "list") await showList(ctx); + else if (choice === "help") await showHelp(ctx); } -export const MemoryTuiPlugin: TuiPlugin = async (api) => { - api.command.register(() => memoryCommands(api)); -}; +export { MemoryTuiPluginV1 as MemoryTuiPlugin }; -export default { +export const MemoryTuiPluginV2 = defineTuiPlugin({ id: "working-memory-tui", - tui: MemoryTuiPlugin, + + setup(ctx: TuiContext) { + ctx.keymap.layer(() => ({ + mode: "global", + priority: 10, + commands: [ + { + id: "working-memory.memory", + title: "Memory", + description: "Browse local working memory.", + group: "Memory", + palette: true, + slash: { name: "memory" }, + run: () => showMemoryMenu(ctx), + }, + ], + })); + }, +}); + +export default { + ...MemoryTuiPluginV2, + tui: MemoryTuiPluginV1, }; diff --git a/src/types.ts b/src/types.ts index 4fbf807..a75f946 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,6 +95,12 @@ export type SessionDecision = { promotedToLongTerm?: boolean; }; +export type SessionTodo = { + content: string; + status: string; + priority?: string; +}; + export type SessionState = { version: 1; sessionID: string; @@ -105,6 +111,8 @@ export type SessionState = { recentDecisions: SessionDecision[]; pendingMemories: LongTermMemoryEntry[]; compactionMemoryRefs: CompactionMemoryRef[]; + /** Latest todo snapshot observed from the host, when the host exposes todos. */ + todos?: SessionTodo[]; }; export const LONG_TERM_LIMITS = { @@ -124,6 +132,7 @@ export const HOT_STATE_LIMITS = { maxRecentDecisionsStored: 8, maxPendingMemoriesStored: 12, maxPendingMemoriesRendered: 6, + maxTodosStored: 40, } as const; export const PROMOTION_RETRY_LIMITS = { diff --git a/tests/plugin-v2.test.ts b/tests/plugin-v2.test.ts new file mode 100644 index 0000000..392bbe2 --- /dev/null +++ b/tests/plugin-v2.test.ts @@ -0,0 +1,353 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { WorkingMemoryPlugin } from "../src/plugin-v2.ts"; +import { MemoryTuiPluginV2 } from "../src/tui-plugin.ts"; +import dualEntry from "../index.ts"; +import { loadSessionState, saveSessionState } from "../src/session-state.ts"; +import { loadWorkspaceMemory, updateWorkspaceMemory } from "../src/workspace-memory.ts"; +import { sessionStatePath } from "../src/paths.ts"; + +type Hook = (event: any) => Promise | void; + +type FakeEventStream = { + push(event: unknown): void; + close(): void; + [Symbol.asyncIterator](): AsyncGenerator; +}; + +function createEventStream(): FakeEventStream { + const queue: unknown[] = []; + let wake: (() => void) | undefined; + let closed = false; + + return { + push(event: unknown): void { + queue.push(event); + wake?.(); + wake = undefined; + }, + close(): void { + closed = true; + wake?.(); + wake = undefined; + }, + async *[Symbol.asyncIterator](): AsyncGenerator { + while (!closed || queue.length > 0) { + if (queue.length > 0) { + yield queue.shift(); + continue; + } + await new Promise(resolve => { + wake = resolve; + }); + } + }, + }; +} + +async function createContext(options: { + directory: string; + messages?: Array>; + parentID?: string | null; + events?: FakeEventStream; +}) { + const sessionHooks: Record = {}; + const toolHooks: Record = {}; + + const ctx = { + location: { directory: options.directory }, + options: {}, + session: { + hook: async (name: string, callback: Hook) => { + sessionHooks[name] = callback; + return { dispose: async () => undefined }; + }, + get: async () => ({ parentID: options.parentID ?? null }), + context: async () => options.messages ?? [], + }, + tool: { + hook: async (name: string, callback: Hook) => { + toolHooks[name] = callback; + return { dispose: async () => undefined }; + }, + }, + event: { + subscribe: () => options.events ?? createEventStream(), + }, + }; + + await WorkingMemoryPlugin.setup(ctx as never); + return { ctx, sessionHooks, toolHooks }; +} + +async function eventually(check: () => Promise | boolean, timeoutMs = 3000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await check()) return; + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error("condition was not met before timeout"); +} + +async function withWorkspace(fn: (directory: string) => Promise): Promise { + const directory = await mkdtemp(join(tmpdir(), "memory-plugin-v2-test-")); + try { + return await fn(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +test("dual entrypoint exposes both V2 setup and V1 server", () => { + assert.equal(dualEntry.id, "working-memory"); + assert.equal(typeof dualEntry.setup, "function"); + assert.equal(typeof dualEntry.server, "function"); +}); + +test("V2 setup registers context, tool, and compaction hooks", async () => { + await withWorkspace(async directory => { + const { sessionHooks, toolHooks } = await createContext({ directory }); + assert.deepEqual(Object.keys(sessionHooks).sort(), ["compaction", "context"]); + assert.deepEqual(Object.keys(toolHooks), ["execute.after"]); + }); +}); + +test("V2 context hook appends frozen workspace memory as system parts", async () => { + await withWorkspace(async directory => { + const now = new Date().toISOString(); + await updateWorkspaceMemory(directory, store => { + store.entries.push({ + id: "v2-memory", + type: "project", + text: "V2 memory adapter injects workspace memory.", + source: "manual", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + }); + return store; + }); + + const { sessionHooks } = await createContext({ directory }); + const event = { sessionID: "session-v2-context", system: [] as Array<{ type: string; text: string }> }; + await sessionHooks.context(event); + + assert.equal(event.system.length, 1); + assert.equal(event.system[0].type, "text"); + assert.match(event.system[0].text, /V2 memory adapter injects workspace memory/); + }); +}); + +test("V2 tool hook tracks active files from read results", async () => { + await withWorkspace(async directory => { + const { toolHooks } = await createContext({ directory }); + + await toolHooks["execute.after"]({ + tool: "read", + sessionID: "session-v2-read", + input: { path: "/workspace/src/index.ts" }, + status: "completed", + result: { content: [{ type: "text", text: "file contents" }], metadata: {} }, + }); + + const state = await loadSessionState(directory, "session-v2-read"); + assert.equal(state.activeFiles.length, 1); + assert.equal(state.activeFiles[0].path, "/workspace/src/index.ts"); + assert.equal(state.activeFiles[0].action, "read"); + }); +}); + +test("V2 tool hook records shell errors from metadata.exit", async () => { + await withWorkspace(async directory => { + const { toolHooks } = await createContext({ directory }); + + await toolHooks["execute.after"]({ + tool: "shell", + sessionID: "session-v2-shell", + input: { command: "npm test" }, + status: "completed", + result: { + content: [{ type: "text", text: "Error: expected 1 test to pass" }], + metadata: { status: "completed", exit: 1 }, + }, + }); + + const state = await loadSessionState(directory, "session-v2-shell"); + assert.equal(state.openErrors.length, 1); + assert.match(state.openErrors[0].summary, /Error/); + }); +}); + +test("V2 tool hook clears shell errors on exit 0", async () => { + await withWorkspace(async directory => { + const { toolHooks } = await createContext({ directory }); + + await toolHooks["execute.after"]({ + tool: "shell", + sessionID: "session-v2-clear", + input: { command: "npm test" }, + status: "completed", + result: { + content: [{ type: "text", text: "Error: expected 1 test to pass" }], + metadata: { status: "completed", exit: 1 }, + }, + }); + await toolHooks["execute.after"]({ + tool: "shell", + sessionID: "session-v2-clear", + input: { command: "npm test" }, + status: "completed", + result: { content: [{ type: "text", text: "ok" }], metadata: { status: "completed", exit: 0 } }, + }); + + const state = await loadSessionState(directory, "session-v2-clear"); + assert.equal(state.openErrors.length, 0); + }); +}); + +test("V2 compaction hook appends memory instructions and numbered refs", async () => { + await withWorkspace(async directory => { + const now = new Date().toISOString(); + await updateWorkspaceMemory(directory, store => { + store.entries.push({ + id: "v2-compaction-memory", + type: "decision", + text: "Keep V2 compaction on the host prompt.", + source: "compaction", + confidence: 0.9, + status: "active", + createdAt: now, + updatedAt: now, + }); + return store; + }); + + const { sessionHooks } = await createContext({ directory }); + const event = { sessionID: "session-v2-compaction", system: [] as Array<{ type: string; text: string }> }; + await sessionHooks.compaction(event); + + assert.equal(event.system.length, 1); + assert.match(event.system[0].text, /Memory candidates:/); + assert.match(event.system[0].text, /\[M1\] Keep V2 compaction on the host prompt\./); + assert.match(event.system[0].text, /Memory ref snapshot id:/); + + const state = await loadSessionState(directory, "session-v2-compaction"); + assert.equal(state.compactionMemoryRefs.length, 1); + }); +}); + +test("V2 todowrite snapshots are rendered in compaction context", async () => { + await withWorkspace(async directory => { + const { sessionHooks, toolHooks } = await createContext({ directory }); + + await toolHooks["execute.after"]({ + tool: "todowrite", + sessionID: "session-v2-todos", + input: { + todos: [ + { content: "Port the compaction hook", status: "in_progress", priority: "high" }, + { content: "Write V2 tests", status: "pending", priority: "medium" }, + ], + }, + status: "completed", + result: { content: [{ type: "text", text: "ok" }], metadata: {} }, + }); + + const event = { sessionID: "session-v2-todos", system: [] as Array<{ type: string; text: string }> }; + await sessionHooks.compaction(event); + + assert.match(event.system[0].text, /Pending todos:/); + assert.match(event.system[0].text, /Port the compaction hook/); + }); +}); + +test("V2 compaction.ended event parses summary and promotes memory candidates", async () => { + await withWorkspace(async directory => { + const events = createEventStream(); + await createContext({ + directory, + events, + messages: [{ type: "compaction", id: "msg-compaction", status: "completed", summary: "summary" }], + }); + + events.push({ + type: "session.compaction.ended", + data: { + sessionID: "session-v2-event", + text: [ + "## Goal", + "", + "Port the plugin.", + "", + "Memory candidates:", + "- [project] This fork adds an OpenCode V2 adapter.", + ].join("\n"), + }, + }); + + await eventually(async () => { + const memory = await loadWorkspaceMemory(directory); + return memory.entries.some(entry => entry.text === "This fork adds an OpenCode V2 adapter."); + }); + + events.close(); + }); +}); + +test("V2 session.deleted event removes per-session state", async () => { + await withWorkspace(async directory => { + const events = createEventStream(); + const { ctx } = await createContext({ directory, events }); + + await saveSessionState(directory, { + version: 1, + sessionID: "session-v2-deleted", + turn: 0, + updatedAt: new Date().toISOString(), + activeFiles: [], + openErrors: [], + recentDecisions: [], + pendingMemories: [], + compactionMemoryRefs: [], + }); + + const statePath = await sessionStatePath(ctx.location.directory, "session-v2-deleted"); + assert.equal((await stat(statePath)).isFile(), true); + + events.push({ type: "session.deleted", data: { sessionID: "session-v2-deleted" } }); + + await eventually(async () => { + try { + await stat(statePath); + return false; + } catch { + return true; + } + }); + + events.close(); + }); +}); + +test("V2 TUI plugin registers the /memory slash command", () => { + let layer: any; + const ctx = { + keymap: { + layer(factory: () => unknown) { + layer = factory(); + }, + }, + }; + + MemoryTuiPluginV2.setup(ctx as never); + + assert.equal(layer.mode, "global"); + assert.equal(layer.commands.length, 1); + assert.equal(layer.commands[0].slash.name, "memory"); + assert.equal(typeof layer.commands[0].run, "function"); +}); + diff --git a/tui.ts b/tui.ts new file mode 100644 index 0000000..e912747 --- /dev/null +++ b/tui.ts @@ -0,0 +1,5 @@ +/** + * TUI entrypoint discovered next to index.ts for local directory plugins. + * The published package exposes the same module through exports["./tui"]. + */ +export { default } from "./src/tui-plugin.ts"; From 0e5ecd4660379072d98accb87cd75edd34071f01 Mon Sep 17 00:00:00 2001 From: Mirro Chou <3250823+Mirr0ch1@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:24:18 +0800 Subject: [PATCH 2/6] feat: share memory with CodeBuddy via two-way markdown bridge - export active OpenCode memories as opencode-*.md into CodeBuddy's project memory - maintain a marked block in CodeBuddy MEMORY.md without touching its own lines - index CodeBuddy project/global topics in the OpenCode prompt, bounded by a char budget - import CodeBuddy edits to opencode-*.md and supersede on deletion - import CodeBuddy files marked shared: true into workspace JSON - skip credential-looking files by default; redact exported text - add per-workspace hash state to prevent sync loops - add bridge tests and fix a cross-process storage test race --- AGENTS.md | 1 + CHANGELOG.md | 13 + README.md | 47 +++ docs/architecture.md | 24 ++ docs/configuration.md | 37 ++ package.json | 2 +- src/codebuddy-bridge.ts | 631 +++++++++++++++++++++++++++++++++ src/plugin.ts | 2 + src/runtime.ts | 58 ++- src/storage.ts | 7 + tests/codebuddy-bridge.test.ts | 211 +++++++++++ tests/plugin-v2.test.ts | 68 +++- tests/storage.test.ts | 11 +- 13 files changed, 1103 insertions(+), 9 deletions(-) create mode 100644 src/codebuddy-bridge.ts create mode 100644 tests/codebuddy-bridge.test.ts diff --git a/AGENTS.md b/AGENTS.md index 6da0bab..74a7ca1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ opencode-working-memory/ ├── tui.ts # TUI entry point discovered beside index.ts ├── src/ │ ├── runtime.ts # Host-agnostic memory logic +│ ├── codebuddy-bridge.ts # Two-way Markdown bridge with CodeBuddy memory │ ├── plugin.ts # V1 adapter │ ├── plugin-v2.ts # V2 adapter │ ├── tui-plugin.ts # V2 TUI + V1 TUI dual export diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1f968..d20de16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.8.0] - 2026-10-21 + +### Added + +- Added an optional two-way CodeBuddy memory bridge. OpenCode exports active workspace memories as `opencode-.md` files into CodeBuddy's project memory directory and maintains a marked block in its `MEMORY.md`; CodeBuddy topic files are indexed in the OpenCode prompt and can be promoted with `shared: true`. +- Added `tests/codebuddy-bridge.test.ts` for export, edit import, shared-file import, sensitivity filtering, idempotency, and deletion handling. +- Added `atomicWriteText` to the storage layer for generated Markdown mirrors. + +### Changed + +- CodeBuddy edits to `opencode-*.md` files are imported back into workspace memory; deleting one supersedes the memory. +- The bridge is disabled by default and skips credential-looking files unless `includeSensitive` is enabled. + ## [1.7.0] - 2026-10-21 ### Added diff --git a/README.md b/README.md index 852bc14..1d5601e 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,53 @@ Use `/memory` when you want to inspect what the agent currently remembers withou Compaction output already appears through OpenCode's built-in conversation flow. This plugin does not add duplicate compaction notices. +## Shared Memory with CodeBuddy + +The optional CodeBuddy bridge shares memory both ways without changing CodeBuddy's code. It is disabled by default because it writes into CodeBuddy's memory directory. + +Enable it in V2 `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugins": [ + { + "package": "opencode-working-memory", + "options": { + "codeBuddyBridge": { + "includeGlobal": true, + "includeSensitive": false + } + } + } + ] +} +``` + +For a local checkout, use the plugin directory as `package`. + +How it works: + +- OpenCode exports each active workspace memory as `opencode-.md` in CodeBuddy's project memory directory: `~/.codebuddy/projects//memory/`. +- It maintains one marked block in that directory's `MEMORY.md`; CodeBuddy's own index lines are preserved. +- CodeBuddy's project (and optionally global) topic files are indexed in OpenCode's system prompt. The agent reads the full file on demand with `read`/`grep`, so the 51-file / 17 KB index does not have to fit in OpenCode's memory budget. +- Editing an `opencode-*.md` file and saving it updates the matching OpenCode memory on the next OpenCode request. Deleting it supersedes the memory. +- To promote a CodeBuddy topic file into OpenCode's own JSON memory (and compaction flow), add `shared: true` to its frontmatter. +- Files whose names or descriptions look credential-related (`password`, `token`, `ssh`, `vps`, `access`, etc.) are skipped unless `includeSensitive: true`. + +Defaults: + +| Option | Default | Purpose | +|---|---|---| +| `includeGlobal` | `false` | Also index/import `~/.codebuddy/memories/global` | +| `includeSensitive` | `false` | Include credential-looking files | +| `maxIndexEntries` | `14` | CodeBuddy entries in the OpenCode prompt | +| `maxIndexChars` | `1600` | Character budget for that index | +| `maxImportedShared` | `12` | CodeBuddy `shared: true` files imported into JSON | +| `syncIntervalMs` | `3000` | Minimum gap between full scans | + +CodeBuddy reads its memory at session start, so start a new CodeBuddy session to see newly exported OpenCode memories. OpenCode sees CodeBuddy edits on its next request. + ## How It Works OpenCode Working Memory adds durable memory without making extra LLM/API calls. diff --git a/docs/architecture.md b/docs/architecture.md index ef6d001..56115a7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -318,6 +318,30 @@ OpenCode's own compaction prompt and appends the memory rules and numbered Promotion uses accounting results from workspace memory normalization. Pending memories that are kept are promoted; duplicate memories are absorbed; exact decision replacements can be superseded; over-capacity compaction memories are rejected. Stale-marked memories are not hard-pruned by age; they lose rendered space through retention strength and cap competition. +## CodeBuddy Bridge + +`src/codebuddy-bridge.ts` provides an optional two-way bridge to CodeBuddy's +Markdown memory stores: + +- Project: `~/.codebuddy/projects//memory/` +- Global: `~/.codebuddy/memories/global/` + +Ownership is file-based: + +- OpenCode writes `opencode-.md` topic files and one marked block in + CodeBuddy's `MEMORY.md`. CodeBuddy may edit those files; OpenCode imports the + new body text and treats deletion as supersede. +- CodeBuddy's own topic files are never rewritten. They are indexed in the + OpenCode prompt (bounded by `maxIndexEntries` / `maxIndexChars`) and read on + demand. A file with `shared: true` is additionally imported into OpenCode's + JSON store. +- Credential-looking files are skipped unless `includeSensitive` is enabled. +- Per-workspace hashes live in `codebuddy-bridge.json` beside + `workspace-memory.json`, preventing write/read loops. +- Sync runs from `buildContextParts` (throttled) and after compaction promotion. + No CodeBuddy hooks are required; CodeBuddy sees new files on its next session. + + ## Quality Guarantees ### No False Positive Errors diff --git a/docs/configuration.md b/docs/configuration.md index 9610ad2..9ef10f7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -251,6 +251,43 @@ rm ~/.local/share/opencode-working-memory/workspaces/*/workspace-memory.json rm ~/.local/share/opencode-working-memory/workspaces/*/sessions/*.json ``` +## CodeBuddy Bridge Options + +The bridge is disabled unless `codeBuddyBridge` is present in the plugin options. + +```jsonc +{ + "plugins": [ + { + "package": "opencode-working-memory", + "options": { + "codeBuddyBridge": { + "includeGlobal": true, + "includeSensitive": false, + "maxIndexEntries": 14, + "maxIndexChars": 1600, + "maxImportedShared": 12, + "syncIntervalMs": 3000 + } + } + } + ] +} +``` + +| Option | Default | Purpose | +|---|---|---| +| `enabled` | `true` when the object is present | Turn the bridge off without removing the object | +| `homeDir` | `~/.codebuddy` | CodeBuddy home (tests/non-standard installs) | +| `includeGlobal` | `false` | Index/import `~/.codebuddy/memories/global` | +| `includeSensitive` | `false` | Include filenames/descriptions matching credential patterns | +| `maxIndexEntries` | `14` | CodeBuddy topics listed in the OpenCode prompt | +| `maxIndexChars` | `1600` | Character budget for that index | +| `maxImportedShared` | `12` | CodeBuddy `shared: true` files imported into workspace JSON | +| `syncIntervalMs` | `3000` | Minimum gap between full scans | + +To promote a CodeBuddy topic file into OpenCode's JSON store, add `shared: true` to its frontmatter. Files without that flag remain indexed and readable but are not injected into the 3600-character workspace memory budget. + ## Best Practices 1. **Workspace Memory Hygiene**: diff --git a/package.json b/package.json index 4e5daae..180e6e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-working-memory", - "version": "1.7.0", + "version": "1.8.0", "description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state", "type": "module", "main": "dist/index.js", diff --git a/src/codebuddy-bridge.ts b/src/codebuddy-bridge.ts new file mode 100644 index 0000000..ef7370b --- /dev/null +++ b/src/codebuddy-bridge.ts @@ -0,0 +1,631 @@ +/** + * Two-way filesystem bridge between OpenCode working memory (JSON) and + * CodeBuddy memory (Markdown topic files with YAML frontmatter). + * + * Ownership model: + * - `opencode-*.md` files are written by OpenCode. CodeBuddy may edit them; + * edits are imported back into workspace memory. + * - CodeBuddy's own `*.md` files are never rewritten. They are indexed in the + * OpenCode prompt and, when marked `shared: true`, imported as read-mostly + * entries. + * - `MEMORY.md` is owned by CodeBuddy; the bridge only maintains a marked + * block for OpenCode-owned entries. + */ + +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, rm, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, join, relative, resolve } from "node:path"; +import { atomicWriteJSON, atomicWriteText, readJSON } from "./storage.ts"; +import { memoryRoot } from "./paths.ts"; +import { loadWorkspaceMemory, updateWorkspaceMemory } from "./workspace-memory.ts"; +import { redactCredentials } from "./redaction.ts"; +import { LONG_TERM_LIMITS, type LongTermMemoryEntry, type LongTermType, type WorkspaceMemoryStore } from "./types.ts"; + +export type CodeBuddyMemoryType = "user" | "feedback" | "project" | "reference"; + +export type CodeBuddyBridgeOptions = { + enabled?: boolean; + /** Override for tests or a non-standard CodeBuddy home. */ + homeDir?: string; + /** Also index/import ~/.codebuddy/memories/global. Off by default: it can contain credentials. */ + includeGlobal?: boolean; + /** Include filenames/descriptions that look credential-related. Off by default. */ + includeSensitive?: boolean; + /** Maximum CodeBuddy entries listed in the OpenCode prompt. */ + maxIndexEntries?: number; + /** Maximum characters spent on the CodeBuddy index prompt. */ + maxIndexChars?: number; + /** Maximum CodeBuddy-owned files imported as workspace entries. */ + maxImportedShared?: number; + /** Minimum time between full bridge scans. */ + syncIntervalMs?: number; +}; + +type ResolvedOptions = { + enabled: boolean; + homeDir: string; + includeGlobal: boolean; + includeSensitive: boolean; + maxIndexEntries: number; + maxIndexChars: number; + maxImportedShared: number; + syncIntervalMs: number; +}; + +type BridgeFile = { + /** Stable key: `project:` or `global:`. */ + key: string; + scope: "project" | "global"; + filePath: string; + relativePath: string; + name?: string; + description?: string; + type?: CodeBuddyMemoryType; + shared: boolean; + opencodeId?: string; + opencodeType?: string; + sensitive: boolean; + body: string; + mtimeMs: number; + hash: string; +}; + +type BridgeStateFile = { + hash: string; + mtimeMs: number; + lastSyncedAt: string; + opencodeId?: string; + sharedKey?: string; +}; + +type BridgeState = { + version: 1; + lastSyncAt?: string; + files: Record; +}; + +export type CodeBuddyBridgeResult = { + changed: boolean; + indexPrompt: string; + imported: number; + exported: number; + projectDir?: string; +}; + +const START_MARKER = ""; +const END_MARKER = ""; +const METADATA_MARKER = ""; +const SENSITIVE_PATTERN = /password|passwd|pwd|secret|credential|token|api[_-]?key|private[_-]?key|ssh|sudo|root|access|vps/i; +const OPENCODE_FILE_PREFIX = "opencode-"; + +function sha256(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +function resolveOptions(value: boolean | CodeBuddyBridgeOptions | undefined): ResolvedOptions { + if (value === true) { + return { + enabled: true, + homeDir: join(homedir(), ".codebuddy"), + includeGlobal: false, + includeSensitive: false, + maxIndexEntries: 14, + maxIndexChars: 1600, + maxImportedShared: 12, + syncIntervalMs: 3000, + }; + } + + if (!value) { + return { + enabled: false, + homeDir: join(homedir(), ".codebuddy"), + includeGlobal: false, + includeSensitive: false, + maxIndexEntries: 14, + maxIndexChars: 1600, + maxImportedShared: 12, + syncIntervalMs: 3000, + }; + } + + return { + enabled: value.enabled !== false, + homeDir: value.homeDir ?? join(homedir(), ".codebuddy"), + includeGlobal: value.includeGlobal === true, + includeSensitive: value.includeSensitive === true, + maxIndexEntries: value.maxIndexEntries ?? 14, + maxIndexChars: value.maxIndexChars ?? 1600, + maxImportedShared: value.maxImportedShared ?? 12, + syncIntervalMs: value.syncIntervalMs ?? 3000, + }; +} + +function compressWorkspacePath(path: string): string { + return resolve(path) + .replace(/[/\\:]/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + .replace(/-+/g, "-"); +} + +function projectMemoryDir(workspaceRoot: string, homeDir: string): string { + const compressed = compressWorkspacePath(workspaceRoot); + const modern = join(homeDir, "projects", compressed, "memory"); + const legacy = join(homeDir, "memories", compressed); + if (!existsSync(modern) && existsSync(legacy)) return legacy; + return modern; +} + +function globalMemoryDir(homeDir: string): string { + return join(homeDir, "memories", "global"); +} + +function parseFrontmatter(text: string): { data: Record; body: string } { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text); + if (!match) return { data: {}, body: text.trim() }; + + const data: Record = {}; + for (const line of match[1].split(/\r?\n/)) { + const keyValue = /^([A-Za-z0-9_.-]+):(?:\s*(.*))?$/.exec(line); + if (!keyValue) continue; + const key = keyValue[1]; + let value = (keyValue[2] ?? "").trim(); + if (value.startsWith("\"") && value.endsWith("\"")) { + try { + value = JSON.parse(value) as string; + } catch { + value = value.slice(1, -1); + } + } else if (value.startsWith("'") && value.endsWith("'")) { + value = value.slice(1, -1); + } + data[key] = value; + } + + return { data, body: text.slice(match[0].length).trim() }; +} + +function yamlValue(value: string): string { + if (/^[A-Za-z0-9\u4e00-\u9fff][^:#\n"'`]*$/.test(value)) return value; + return JSON.stringify(value); +} + +function normalizeType(value: string | undefined): CodeBuddyMemoryType | undefined { + if (value === "user" || value === "feedback" || value === "project" || value === "reference") return value; + return undefined; +} + +function codeBuddyTypeFor(type: LongTermType): CodeBuddyMemoryType { + if (type === "feedback") return "feedback"; + if (type === "project" || type === "decision") return "project"; + return "reference"; +} + +function openCodeTypefrom(type: CodeBuddyMemoryType | undefined, fallback: LongTermType): LongTermType { + if (type === "feedback" || type === "user") return "feedback"; + if (type === "project") return "project"; + if (type === "reference") return "reference"; + return fallback; +} + +function sanitizeFilePart(value: string): string { + const safe = value.replace(/[^A-Za-z0-9._-]/g, "_"); + return safe.length > 0 ? safe : "memory"; +} + +function isSensitive(file: Pick): boolean { + return SENSITIVE_PATTERN.test(file.relativePath) + || SENSITIVE_PATTERN.test(file.name ?? "") + || SENSITIVE_PATTERN.test(file.description ?? ""); +} + +async function scanMemoryDir( + dir: string, + scope: "project" | "global", + options: ResolvedOptions, +): Promise { + if (!existsSync(dir)) return []; + + let entries: string[]; + try { + entries = await readdir(dir, { recursive: true }); + } catch { + return []; + } + + const files: BridgeFile[] = []; + for (const entry of entries) { + if (!entry.endsWith(".md")) continue; + if (basename(entry) === "MEMORY.md") continue; + + const filePath = join(dir, entry); + let stats; + try { + stats = await stat(filePath); + } catch { + continue; + } + if (!stats.isFile()) continue; + + let text: string; + try { + text = await readFile(filePath, "utf8"); + } catch { + continue; + } + + const relativePath = entry.split("\\").join("/"); + const { data, body } = parseFrontmatter(text); + const file: BridgeFile = { + key: `${scope}:${relativePath}`, + scope, + filePath, + relativePath, + name: data.name, + description: data.description, + type: normalizeType(data.type), + shared: data.shared === "true" || relativePath.startsWith("shared/"), + opencodeId: data.opencode_id, + opencodeType: data.opencode_type, + sensitive: false, + body, + mtimeMs: stats.mtimeMs, + hash: sha256(text), + }; + file.sensitive = isSensitive(file); + if (file.sensitive && !options.includeSensitive) continue; + files.push(file); + } + + return files; +} + +async function readState(root: string): Promise<{ path: string; state: BridgeState }> { + const path = join(await memoryRoot(root), "codebuddy-bridge.json"); + const state = await readJSON(path, () => ({ version: 1, files: {} })); + return { + path, + state: { + version: 1, + lastSyncAt: state.lastSyncAt, + files: state.files && typeof state.files === "object" ? state.files : {}, + }, + }; +} + +function renderOpenCodeMemory(entry: LongTermMemoryEntry): string { + const text = redactCredentials(entry.text); + const rationale = entry.rationale ? redactCredentials(entry.rationale) : ""; + const name = `OpenCode: ${text.slice(0, 70)}`; + const description = `[${entry.type}] ${text.slice(0, 160)}`; + const body = [ + text, + "", + METADATA_MARKER, + "> Generated by OpenCode working memory. Edit the text above to update this memory; do not delete this file.", + `**Why:** ${rationale || "Captured by OpenCode working memory."}`, + `**How to apply:** Treat this as ${entry.type} memory from OpenCode when it is relevant to the current task.`, + ].join("\n"); + + return [ + "---", + `name: ${yamlValue(name)}`, + `description: ${yamlValue(description)}`, + `type: ${codeBuddyTypeFor(entry.type)}`, + `opencode_id: ${yamlValue(entry.id)}`, + `opencode_type: ${entry.type}`, + "shared: true", + "---", + "", + body, + "", + ].join("\n"); +} + +function extractMemoryText(body: string): string { + const metadataIndex = body.indexOf(METADATA_MARKER); + const beforeMetadata = metadataIndex >= 0 ? body.slice(0, metadataIndex) : body; + const whyIndex = beforeMetadata.indexOf("**Why:**"); + return (whyIndex >= 0 ? beforeMetadata.slice(0, whyIndex) : beforeMetadata).trim(); +} + +function upsertManagedBlock(existing: string, block: string): string { + const blockPattern = new RegExp( + `${START_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${END_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`, + "g", + ); + const withoutBlock = existing.replace(blockPattern, "").replace(/\s+$/, ""); + if (!block) return withoutBlock ? `${withoutBlock}\n` : ""; + return `${withoutBlock}${withoutBlock ? "\n\n" : ""}${block}\n`; +} + +function buildIndexBlock(entries: Array<{ file: string; entry: LongTermMemoryEntry }>): string { + if (entries.length === 0) return ""; + const lines = entries.map(({ file, entry }) => { + const text = redactCredentials(entry.text).replace(/\s+/g, " ").slice(0, 120); + return `- [${entry.type}] [${text}](${file})`; + }); + return [ + START_MARKER, + "### OpenCode Working Memory (auto-synced; do not edit this block)", + "", + ...lines, + END_MARKER, + ].join("\n"); +} + +function buildIndexPrompt(files: BridgeFile[], projectDir: string, globalDir: string | undefined, options: ResolvedOptions): string { + const projectFiles = files + .filter(file => file.scope === "project" && !file.relativePath.startsWith(OPENCODE_FILE_PREFIX)) + .sort((left, right) => right.mtimeMs - left.mtimeMs); + const globalFiles = files + .filter(file => file.scope === "global" && !file.relativePath.startsWith(OPENCODE_FILE_PREFIX)) + .sort((left, right) => right.mtimeMs - left.mtimeMs); + const visible = [...globalFiles.slice(0, 3), ...projectFiles].slice(0, options.maxIndexEntries); + + if (visible.length === 0) return ""; + + const lines = ["## Shared CodeBuddy memory (read-only index)", ""]; + lines.push(`Project memory: ${projectDir}`); + if (globalDir) lines.push(`Global memory: ${globalDir}`); + lines.push("", "Recent entries:"); + + let text = lines.join("\n"); + let truncated = false; + for (const file of visible) { + const label = file.name ?? file.relativePath; + const description = redactCredentials(file.description ?? "").replace(/\s+/g, " ").slice(0, 80); + const line = `- [${file.type ?? "reference"}] ${label}${description ? `: ${description}` : ""}`; + if (text.length + line.length + 1 > options.maxIndexChars) { + truncated = true; + break; + } + text += `\n${line}`; + } + + text += "\n\nUse the read/grep tools on these paths when the user references prior work."; + if (truncated) text += "\n[index truncated]"; + return text; +} + +function applyImportedMemories( + store: WorkspaceMemoryStore, + files: BridgeFile[], + state: BridgeState, + options: ResolvedOptions, +): { changed: boolean; imported: number } { + let changed = false; + let imported = 0; + + const byId = new Map(store.entries.map(entry => [entry.id, entry])); + const sharedFiles = files + .filter(file => !file.opencodeId && file.shared) + .sort((left, right) => right.mtimeMs - left.mtimeMs) + .slice(0, options.maxImportedShared); + + for (const file of files) { + const previous = state.files[file.key]; + if (previous?.hash === file.hash) continue; + + if (file.opencodeId) { + const entry = byId.get(file.opencodeId); + if (!entry || entry.status !== "active") continue; + const text = extractMemoryText(file.body); + if (!text || text === entry.text) continue; + + entry.text = text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars); + entry.updatedAt = new Date(Math.max(Date.now(), file.mtimeMs)).toISOString(); + entry.tags = [...new Set([...(entry.tags ?? []), "codebuddy-edited"])]; + changed = true; + imported += 1; + continue; + } + + if (!sharedFiles.includes(file)) continue; + + const id = `cb_${sha256(file.key).slice(0, 16)}`; + const type = openCodeTypefrom(file.type, "reference"); + const text = redactCredentials(extractMemoryText(file.body)).slice(0, LONG_TERM_LIMITS.maxEntryTextChars); + if (!text) continue; + + const existing = byId.get(id); + if (existing) { + if (existing.text === text && existing.type === type) continue; + existing.text = text; + existing.type = type; + existing.updatedAt = new Date(Math.max(Date.now(), file.mtimeMs)).toISOString(); + existing.tags = [...new Set([...(existing.tags ?? []), "codebuddy", "shared", `cb:${file.key}`])]; + } else { + const createdAt = new Date(file.mtimeMs).toISOString(); + const entry: LongTermMemoryEntry = { + id, + type, + text, + source: "manual", + confidence: 0.85, + status: "active", + createdAt, + updatedAt: createdAt, + tags: ["codebuddy", "shared", `cb:${file.key}`], + }; + store.entries.push(entry); + byId.set(id, entry); + } + changed = true; + imported += 1; + } + + return { changed, imported }; +} + +function applyDeletedMemories( + store: WorkspaceMemoryStore, + files: BridgeFile[], + state: BridgeState, +): { changed: boolean; imported: number } { + const liveKeys = new Set(files.map(file => file.key)); + let changed = false; + let imported = 0; + + for (const [key, previous] of Object.entries(state.files)) { + if (liveKeys.has(key)) continue; + + if (previous.opencodeId) { + const entry = store.entries.find(item => item.id === previous.opencodeId); + if (entry && entry.status === "active") { + entry.status = "superseded"; + entry.updatedAt = new Date().toISOString(); + changed = true; + imported += 1; + } + } + + if (previous.sharedKey) { + const entry = store.entries.find(item => (item.tags ?? []).includes(previous.sharedKey!)); + if (entry && entry.status === "active") { + entry.status = "superseded"; + entry.updatedAt = new Date().toISOString(); + changed = true; + imported += 1; + } + } + } + + return { changed, imported }; +} + +async function exportOpenCodeMemories( + root: string, + store: WorkspaceMemoryStore, + projectDir: string, + state: BridgeState, +): Promise<{ exported: number }> { + await mkdir(projectDir, { recursive: true }); + + const exportable = store.entries + .filter(entry => entry.status === "active") + .filter(entry => !(entry.tags ?? []).some(tag => tag === "codebuddy" || tag.startsWith("cb:"))) + .slice(0, LONG_TERM_LIMITS.maxEntries); + + const desired = new Map(); + for (const entry of exportable) { + const file = `${OPENCODE_FILE_PREFIX}${sanitizeFilePart(entry.id)}.md`; + desired.set(file, { content: renderOpenCodeMemory(entry), entry }); + } + + for (const [file, { content, entry }] of desired) { + const filePath = join(projectDir, file); + let current: string | undefined; + try { + current = await readFile(filePath, "utf8"); + } catch { + current = undefined; + } + if (current !== content) { + await atomicWriteText(filePath, content); + } + state.files[`project:${file}`] = { + hash: sha256(content), + mtimeMs: Date.now(), + lastSyncedAt: new Date().toISOString(), + opencodeId: entry.id, + }; + } + + let projectEntries: string[] = []; + try { + projectEntries = await readdir(projectDir); + } catch { + projectEntries = []; + } + for (const file of projectEntries) { + if (!file.startsWith(OPENCODE_FILE_PREFIX) || !file.endsWith(".md")) continue; + if (desired.has(file)) continue; + await rm(join(projectDir, file), { force: true }); + delete state.files[`project:${file}`]; + } + + const indexEntries = [...desired.entries()].map(([file, value]) => ({ file, entry: value.entry })); + const indexPath = join(projectDir, "MEMORY.md"); + let index = ""; + try { + index = await readFile(indexPath, "utf8"); + } catch { + index = "# Memory Index\n"; + } + const nextIndex = upsertManagedBlock(index, buildIndexBlock(indexEntries)); + if (nextIndex !== index) { + await atomicWriteText(indexPath, nextIndex, 0o644); + } + + return { exported: desired.size }; +} + +export async function syncCodeBuddyBridge( + workspaceRoot: string, + value?: boolean | CodeBuddyBridgeOptions, +): Promise { + const options = resolveOptions(value); + if (!options.enabled) return { changed: false, indexPrompt: "", imported: 0, exported: 0 }; + + if (!existsSync(options.homeDir)) { + return { changed: false, indexPrompt: "", imported: 0, exported: 0 }; + } + + const projectDir = projectMemoryDir(workspaceRoot, options.homeDir); + const globalDir = options.includeGlobal ? globalMemoryDir(options.homeDir) : undefined; + + const files = [ + ...await scanMemoryDir(projectDir, "project", options), + ...(globalDir ? await scanMemoryDir(globalDir, "global", options) : []), + ]; + + const { path: statePath, state } = await readState(workspaceRoot); + const probe = await loadWorkspaceMemory(workspaceRoot); + const probeImport = applyImportedMemories(probe, files, state, options); + const probeDeletes = applyDeletedMemories(probe, files, state); + + let changed = probeImport.changed || probeDeletes.changed; + let imported = probeImport.imported + probeDeletes.imported; + + let store = probe; + if (changed) { + store = await updateWorkspaceMemory(workspaceRoot, current => { + applyImportedMemories(current, files, state, options); + applyDeletedMemories(current, files, state); + return current; + }); + } + + const { exported } = await exportOpenCodeMemories(workspaceRoot, store, projectDir, state); + + state.lastSyncAt = new Date().toISOString(); + await atomicWriteJSON(statePath, state); + + return { + changed, + indexPrompt: buildIndexPrompt(files, projectDir, globalDir, options), + imported, + exported, + projectDir, + }; +} + +/** Export only; used after compaction promotion, when no scan is needed. */ +export async function exportCodeBuddyBridge( + workspaceRoot: string, + value?: boolean | CodeBuddyBridgeOptions, +): Promise { + const options = resolveOptions(value); + if (!options.enabled || !existsSync(options.homeDir)) return; + + const projectDir = projectMemoryDir(workspaceRoot, options.homeDir); + const { path: statePath, state } = await readState(workspaceRoot); + const store = await loadWorkspaceMemory(workspaceRoot); + await exportOpenCodeMemories(workspaceRoot, store, projectDir, state); + state.lastSyncAt = new Date().toISOString(); + await atomicWriteJSON(statePath, state); +} diff --git a/src/plugin.ts b/src/plugin.ts index e929058..5014dc2 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -7,6 +7,7 @@ import type { Plugin } from "@opencode-ai/plugin"; import { createMemoryRuntime, type MemoryRuntimeHost } from "./runtime.ts"; +import type { CodeBuddyBridgeOptions } from "./codebuddy-bridge.ts"; import { latestUserText, latestCompactionSummary, @@ -15,6 +16,7 @@ import { export type MemoryPluginOptions = { mergeSystemMessages?: boolean; + codeBuddyBridge?: boolean | CodeBuddyBridgeOptions; }; function resolveMergeSystemMessagesOption(options: unknown): boolean { diff --git a/src/runtime.ts b/src/runtime.ts index e0cc052..9e1c4f1 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -66,6 +66,11 @@ import { } from "./session-state.ts"; import { sessionStatePath, workspaceKey } from "./paths.ts"; import { accountPendingPromotions, promotionAccountingEvidenceEvents } from "./promotion-accounting.ts"; +import { + exportCodeBuddyBridge, + syncCodeBuddyBridge, + type CodeBuddyBridgeOptions, +} from "./codebuddy-bridge.ts"; import { appendEvidenceEvent, appendEvidenceEvents, type EvidenceEventInput, type MemoryEvidenceRef } from "./evidence-log.ts"; import { type CompactionMemoryRef, type LongTermMemoryEntry, LONG_TERM_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts"; @@ -298,11 +303,22 @@ export type MemoryRuntime = { handleSessionDeleted(sessionID: string): Promise; }; +export type MemoryRuntimeOptions = { + /** Optional two-way bridge with CodeBuddy's Markdown memory store. */ + codeBuddyBridge?: boolean | CodeBuddyBridgeOptions; +}; + export function createMemoryRuntime( host: MemoryRuntimeHost, - _options?: unknown, + options?: MemoryRuntimeOptions, ): MemoryRuntime { const directory = host.directory; + const codeBuddyBridge = options?.codeBuddyBridge; + const codeBuddyBridgeInterval = typeof codeBuddyBridge === "object" && codeBuddyBridge?.syncIntervalMs + ? codeBuddyBridge.syncIntervalMs + : 3000; + let lastCodeBuddySyncAt = 0; + let lastCodeBuddyIndex = ""; // Cache for sub-agent detection — avoids repeated API calls per session. // Maps sessionID → parentID (string) or null (root session). @@ -664,6 +680,11 @@ export function createMemoryRuntime( frozenHotSessionStateCache.delete(sessionID); } + function clearAllFrozenPromptEpochs(): void { + frozenWorkspaceMemoryCache.clear(); + frozenHotSessionStateCache.clear(); + } + async function clearCompactionMemoryRefs(sessionID: string): Promise { await updateSessionState(directory, sessionID, state => { state.compactionMemoryRefs = []; @@ -1004,6 +1025,23 @@ export function createMemoryRuntime( // Sub-agents are short-lived - skip memory system if (await isSubAgent(sessionID)) return []; + // Two-way Markdown bridge with CodeBuddy. Import edits first; if they + // changed workspace memory, drop every frozen snapshot so this turn + // renders the fresh store. + if (codeBuddyBridge) { + try { + const now = Date.now(); + if (now - lastCodeBuddySyncAt >= codeBuddyBridgeInterval) { + const bridge = await syncCodeBuddyBridge(directory, codeBuddyBridge); + lastCodeBuddySyncAt = now; + lastCodeBuddyIndex = bridge.indexPrompt; + if (bridge.changed) clearAllFrozenPromptEpochs(); + } + } catch (error) { + await warnMemoryHook("codebuddy.bridge", error, directory); + } + } + // Process explicit user memory even on no-tool turns. Keep this after the // sub-agent guard so child sessions never append to the parent journal. await processLatestUserMessage(sessionID); @@ -1034,6 +1072,9 @@ export function createMemoryRuntime( if (hotSnapshot?.renderedPrompt) { memorySystemPromptParts.push(hotSnapshot.renderedPrompt); } + if (lastCodeBuddyIndex) { + memorySystemPromptParts.push(lastCodeBuddyIndex); + } return memorySystemPromptParts; } catch (error) { await warnMemoryHook("chat.system.transform", error, directory); @@ -1182,6 +1223,14 @@ export function createMemoryRuntime( } await promotePendingMemories(sessionID, { includeUnownedJournal: true }); + + if (codeBuddyBridge) { + try { + await exportCodeBuddyBridge(directory, codeBuddyBridge); + } catch (error) { + await warnMemoryHook("codebuddy.export", error, directory); + } + } } finally { await clearCompactionMemoryRefs(sessionID); clearFrozenPromptEpoch(sessionID); @@ -1199,6 +1248,13 @@ export function createMemoryRuntime( // Promote pending memories before deleting per-session state. // If promotion fails, leave session state and journal intact. await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false }); + if (codeBuddyBridge) { + try { + await exportCodeBuddyBridge(directory, codeBuddyBridge); + } catch (error) { + await warnMemoryHook("codebuddy.export", error, directory); + } + } clearFrozenPromptEpoch(sessionID); processedUserMessages.delete(sessionID); sessionParentCache.delete(sessionID); diff --git a/src/storage.ts b/src/storage.ts index c041cea..af8d48c 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -173,6 +173,13 @@ export async function atomicWriteJSON(path: string, data: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; + await writeFile(tmp, content, { encoding: "utf8", mode }); + await rename(tmp, path); +} + export async function updateJSON( path: string, fallback: () => T, diff --git a/tests/codebuddy-bridge.test.ts b/tests/codebuddy-bridge.test.ts new file mode 100644 index 0000000..58e180d --- /dev/null +++ b/tests/codebuddy-bridge.test.ts @@ -0,0 +1,211 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { syncCodeBuddyBridge } from "../src/codebuddy-bridge.ts"; +import { loadWorkspaceMemory, updateWorkspaceMemory } from "../src/workspace-memory.ts"; + +function compressed(path: string): string { + return resolve(path) + .replace(/[/\\:]/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + .replace(/-+/g, "-"); +} + +async function withBridgeWorkspace( + fn: (paths: { workspace: string; home: string; memoryDir: string }) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), "memory-codebuddy-bridge-test-")); + const workspace = join(root, "workspace"); + const home = join(root, "codebuddy-home"); + const memoryDir = join(home, "projects", compressed(workspace), "memory"); + await mkdir(workspace, { recursive: true }); + await mkdir(home, { recursive: true }); + try { + return await fn({ workspace, home, memoryDir }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function seedMemory(workspace: string): Promise { + const now = new Date().toISOString(); + await updateWorkspaceMemory(workspace, store => { + store.entries.push( + { + id: "mem_bridge_one", + type: "project", + text: "OpenCode project fact one.", + source: "manual", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + }, + { + id: "mem_bridge_two", + type: "feedback", + text: "OpenCode feedback fact two.", + source: "manual", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + }, + ); + return store; + }); +} + +test("bridge exports OpenCode memories as CodeBuddy markdown and maintains an index block", async () => { + await withBridgeWorkspace(async ({ workspace, home, memoryDir }) => { + await seedMemory(workspace); + + const first = await syncCodeBuddyBridge(workspace, { homeDir: home }); + assert.equal(first.imported, 0); + assert.equal(first.exported, 2); + + const projectFact = await readFile(join(memoryDir, "opencode-mem_bridge_one.md"), "utf8"); + assert.match(projectFact, /^---\n/); + assert.match(projectFact, /name: "OpenCode: OpenCode project fact one\."/); + assert.match(projectFact, /type: project/); + assert.match(projectFact, /opencode_id: mem_bridge_one/); + assert.match(projectFact, /OpenCode project fact one\./); + + const feedbackFact = await readFile(join(memoryDir, "opencode-mem_bridge_two.md"), "utf8"); + assert.match(feedbackFact, /type: feedback/); + + const index = await readFile(join(memoryDir, "MEMORY.md"), "utf8"); + assert.match(index, //); + assert.match(index, /OpenCode project fact one\./); + assert.match(index, /\[project\]/); + }); +}); + +test("bridge imports edits made to an OpenCode-owned markdown file", async () => { + await withBridgeWorkspace(async ({ workspace, home, memoryDir }) => { + await seedMemory(workspace); + await syncCodeBuddyBridge(workspace, { homeDir: home }); + + const path = join(memoryDir, "opencode-mem_bridge_one.md"); + const original = await readFile(path, "utf8"); + await writeFile( + path, + original.replace(/\nOpenCode project fact one\.\n/, "\nEdited by CodeBuddy.\n"), + "utf8", + ); + + const result = await syncCodeBuddyBridge(workspace, { homeDir: home }); + assert.equal(result.changed, true); + assert.equal(result.imported, 1); + + const store = await loadWorkspaceMemory(workspace); + const entry = store.entries.find(item => item.id === "mem_bridge_one"); + assert.equal(entry?.text, "Edited by CodeBuddy."); + assert.equal(entry?.tags?.includes("codebuddy-edited"), true); + }); +}); + +test("bridge imports CodeBuddy-owned files marked shared", async () => { + await withBridgeWorkspace(async ({ workspace, home, memoryDir }) => { + await mkdir(memoryDir, { recursive: true }); + await writeFile(join(memoryDir, "shared_fact.md"), [ + "---", + "name: Shared CodeBuddy fact", + "description: A fact shared with OpenCode", + "type: project", + "shared: true", + "---", + "", + "CodeBuddy shared memory body.", + "", + ].join("\n"), "utf8"); + + const result = await syncCodeBuddyBridge(workspace, { homeDir: home }); + assert.equal(result.changed, true); + assert.equal(result.imported, 1); + + const store = await loadWorkspaceMemory(workspace); + const entry = store.entries.find(item => item.text === "CodeBuddy shared memory body."); + assert.ok(entry); + assert.equal(entry?.type, "project"); + assert.equal(entry?.tags?.includes("cb:project:shared_fact.md"), true); + }); +}); + +test("bridge index excludes OpenCode exports and includes CodeBuddy entries", async () => { + await withBridgeWorkspace(async ({ workspace, home, memoryDir }) => { + await seedMemory(workspace); + await mkdir(memoryDir, { recursive: true }); + await writeFile(join(memoryDir, "codebuddy_topic.md"), [ + "---", + "name: CodeBuddy topic", + "description: Indexed topic description", + "type: reference", + "---", + "", + "Topic body.", + "", + ].join("\n"), "utf8"); + + const result = await syncCodeBuddyBridge(workspace, { homeDir: home }); + assert.match(result.indexPrompt, /Shared CodeBuddy memory/); + assert.match(result.indexPrompt, /CodeBuddy topic/); + assert.match(result.indexPrompt, /Indexed topic description/); + assert.doesNotMatch(result.indexPrompt, /OpenCode project fact one/); + }); +}); + +test("bridge is idempotent when nothing changed", async () => { + await withBridgeWorkspace(async ({ workspace, home }) => { + await seedMemory(workspace); + await syncCodeBuddyBridge(workspace, { homeDir: home }); + + const before = await loadWorkspaceMemory(workspace); + const second = await syncCodeBuddyBridge(workspace, { homeDir: home }); + const after = await loadWorkspaceMemory(workspace); + + assert.equal(second.changed, false); + assert.equal(second.imported, 0); + assert.deepEqual(after.entries, before.entries); + }); +}); + +test("bridge skips sensitive files unless includeSensitive is enabled", async () => { + await withBridgeWorkspace(async ({ workspace, home, memoryDir }) => { + await mkdir(memoryDir, { recursive: true }); + await writeFile(join(memoryDir, "vps_access.md"), [ + "---", + "name: VPS access", + "description: Server access details", + "type: reference", + "---", + "", + "host and credentials", + "", + ].join("\n"), "utf8"); + + const safe = await syncCodeBuddyBridge(workspace, { homeDir: home }); + assert.doesNotMatch(safe.indexPrompt, /VPS access/); + + const unsafe = await syncCodeBuddyBridge(workspace, { homeDir: home, includeSensitive: true }); + assert.match(unsafe.indexPrompt, /VPS access/); + }); +}); + +test("bridge supersedes an entry when its OpenCode markdown file is deleted", async () => { + await withBridgeWorkspace(async ({ workspace, home, memoryDir }) => { + await seedMemory(workspace); + await syncCodeBuddyBridge(workspace, { homeDir: home }); + + await rm(join(memoryDir, "opencode-mem_bridge_one.md"), { force: true }); + const result = await syncCodeBuddyBridge(workspace, { homeDir: home }); + assert.equal(result.changed, true); + + const store = await loadWorkspaceMemory(workspace); + const entry = store.entries.find(item => item.id === "mem_bridge_one"); + assert.equal(entry?.status, "superseded"); + }); +}); diff --git a/tests/plugin-v2.test.ts b/tests/plugin-v2.test.ts index 392bbe2..02ced55 100644 --- a/tests/plugin-v2.test.ts +++ b/tests/plugin-v2.test.ts @@ -1,8 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp, rm, stat } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { WorkingMemoryPlugin } from "../src/plugin-v2.ts"; import { MemoryTuiPluginV2 } from "../src/tui-plugin.ts"; import dualEntry from "../index.ts"; @@ -53,13 +53,14 @@ async function createContext(options: { messages?: Array>; parentID?: string | null; events?: FakeEventStream; + pluginOptions?: Record; }) { const sessionHooks: Record = {}; const toolHooks: Record = {}; const ctx = { location: { directory: options.directory }, - options: {}, + options: options.pluginOptions ?? {}, session: { hook: async (name: string, callback: Hook) => { sessionHooks[name] = callback; @@ -351,3 +352,64 @@ test("V2 TUI plugin registers the /memory slash command", () => { assert.equal(typeof layer.commands[0].run, "function"); }); +test("V2 context hook bridges CodeBuddy markdown memory in both directions", async () => { + await withWorkspace(async directory => { + const home = join(directory, "codebuddy-home"); + const compressed = resolve(directory) + .replace(/[/\\:]/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + .replace(/-+/g, "-"); + const memoryDir = join(home, "projects", compressed, "memory"); + await mkdir(memoryDir, { recursive: true }); + + await writeFile(join(memoryDir, "codebuddy_topic.md"), [ + "---", + "name: CodeBuddy bridge topic", + "description: Topic visible to OpenCode", + "type: reference", + "---", + "", + "CodeBuddy bridge body.", + "", + ].join("\n"), "utf8"); + + const now = new Date().toISOString(); + await updateWorkspaceMemory(directory, store => { + store.entries.push({ + id: "v2_bridge_entry", + type: "project", + text: "OpenCode bridge export.", + source: "manual", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + }); + return store; + }); + + const { sessionHooks } = await createContext({ + directory, + pluginOptions: { + codeBuddyBridge: { homeDir: home, syncIntervalMs: 0 }, + }, + }); + const event = { sessionID: "session-v2-bridge", system: [] as Array<{ type: string; text: string }> }; + await sessionHooks.context(event); + + const systemText = event.system.map(part => part.text).join("\n"); + assert.match(systemText, /Shared CodeBuddy memory/); + assert.match(systemText, /CodeBuddy bridge topic/); + + const exported = await readFile(join(memoryDir, "opencode-v2_bridge_entry.md"), "utf8"); + assert.match(exported, /opencode_id: v2_bridge_entry/); + assert.match(exported, /OpenCode bridge export\./); + + const index = await readFile(join(memoryDir, "MEMORY.md"), "utf8"); + assert.match(index, //); + assert.match(index, /OpenCode bridge export\./); + }); +}); + + diff --git a/tests/storage.test.ts b/tests/storage.test.ts index f71cbf9..b0c094f 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -199,6 +199,12 @@ test("updateJSON waits for a live cross-process lock and preserves both updates" { stdio: "inherit" }, ); + // Attach before the child can exit; a missed `exit` event would hang the test. + const childExit = new Promise((resolve, reject) => { + child.on("exit", code => code === 0 ? resolve() : reject(new Error(`child exited ${code}`))); + child.on("error", reject); + }); + await new Promise(resolve => setTimeout(resolve, 50)); await updateJSON(path, () => ({ count: 0, order: [] as string[] }), current => ({ @@ -206,10 +212,7 @@ test("updateJSON waits for a live cross-process lock and preserves both updates" order: [...current.order, "parent"], })); - await new Promise((resolve, reject) => { - child.on("exit", code => code === 0 ? resolve() : reject(new Error(`child exited ${code}`))); - child.on("error", reject); - }); + await childExit; const final = await updateJSON(path, () => ({ count: 0, order: [] as string[] }), current => current); assert.equal(final.count, 2); From a74ef820b851c0db1b826bcbcb72901b89b0fad8 Mon Sep 17 00:00:00 2001 From: Mirro Chou <3250823+Mirr0ch1@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:51:31 +0800 Subject: [PATCH 3/6] fix: honor includeSensitive for bridge content - keep index summaries redacted but preserve raw body text when includeSensitive is enabled - list sensitive CodeBuddy entries by name, prioritizing root/sudo/vps/access files - import shared sensitive files without redaction when explicitly enabled --- src/codebuddy-bridge.ts | 42 ++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/codebuddy-bridge.ts b/src/codebuddy-bridge.ts index ef7370b..0ade8fb 100644 --- a/src/codebuddy-bridge.ts +++ b/src/codebuddy-bridge.ts @@ -296,11 +296,16 @@ async function readState(root: string): Promise<{ path: string; state: BridgeSta }; } -function renderOpenCodeMemory(entry: LongTermMemoryEntry): string { - const text = redactCredentials(entry.text); - const rationale = entry.rationale ? redactCredentials(entry.rationale) : ""; - const name = `OpenCode: ${text.slice(0, 70)}`; - const description = `[${entry.type}] ${text.slice(0, 160)}`; +function renderOpenCodeMemory(entry: LongTermMemoryEntry, options: ResolvedOptions): string { + const text = options.includeSensitive ? entry.text : redactCredentials(entry.text); + const rationale = entry.rationale + ? (options.includeSensitive ? entry.rationale : redactCredentials(entry.rationale)) + : ""; + // Keep index-visible fields redacted even when the body carries raw content: + // CodeBuddy loads MEMORY.md on every session, while the body is read on demand. + const indexText = redactCredentials(entry.text); + const name = `OpenCode: ${indexText.slice(0, 70)}`; + const description = `[${entry.type}] ${indexText.slice(0, 160)}`; const body = [ text, "", @@ -387,6 +392,22 @@ function buildIndexPrompt(files: BridgeFile[], projectDir: string, globalDir: st } text += "\n\nUse the read/grep tools on these paths when the user references prior work."; + const sensitive = files + .filter(file => file.sensitive && !file.relativePath.startsWith(OPENCODE_FILE_PREFIX)) + .sort((left, right) => { + const priority = (file: BridgeFile): number => { + const label = `${file.relativePath} ${file.name ?? ""}`.toLowerCase(); + if (/sudo|root/.test(label)) return 0; + if (/vps|ssh|access|password|credential/.test(label)) return 1; + return 2; + }; + const delta = priority(left) - priority(right); + return delta !== 0 ? delta : right.mtimeMs - left.mtimeMs; + }) + .slice(0, 5); + if (sensitive.length > 0) { + text += `\nSensitive entries (read only when needed): ${sensitive.map(file => file.name ?? file.relativePath).join("; ")}`; + } if (truncated) text += "\n[index truncated]"; return text; } @@ -428,7 +449,9 @@ function applyImportedMemories( const id = `cb_${sha256(file.key).slice(0, 16)}`; const type = openCodeTypefrom(file.type, "reference"); - const text = redactCredentials(extractMemoryText(file.body)).slice(0, LONG_TERM_LIMITS.maxEntryTextChars); + const rawText = extractMemoryText(file.body); + const text = (options.includeSensitive ? rawText : redactCredentials(rawText)) + .slice(0, LONG_TERM_LIMITS.maxEntryTextChars); if (!text) continue; const existing = byId.get(id); @@ -502,6 +525,7 @@ async function exportOpenCodeMemories( store: WorkspaceMemoryStore, projectDir: string, state: BridgeState, + options: ResolvedOptions, ): Promise<{ exported: number }> { await mkdir(projectDir, { recursive: true }); @@ -513,7 +537,7 @@ async function exportOpenCodeMemories( const desired = new Map(); for (const entry of exportable) { const file = `${OPENCODE_FILE_PREFIX}${sanitizeFilePart(entry.id)}.md`; - desired.set(file, { content: renderOpenCodeMemory(entry), entry }); + desired.set(file, { content: renderOpenCodeMemory(entry, options), entry }); } for (const [file, { content, entry }] of desired) { @@ -600,7 +624,7 @@ export async function syncCodeBuddyBridge( }); } - const { exported } = await exportOpenCodeMemories(workspaceRoot, store, projectDir, state); + const { exported } = await exportOpenCodeMemories(workspaceRoot, store, projectDir, state, options); state.lastSyncAt = new Date().toISOString(); await atomicWriteJSON(statePath, state); @@ -625,7 +649,7 @@ export async function exportCodeBuddyBridge( const projectDir = projectMemoryDir(workspaceRoot, options.homeDir); const { path: statePath, state } = await readState(workspaceRoot); const store = await loadWorkspaceMemory(workspaceRoot); - await exportOpenCodeMemories(workspaceRoot, store, projectDir, state); + await exportOpenCodeMemories(workspaceRoot, store, projectDir, state, options); state.lastSyncAt = new Date().toISOString(); await atomicWriteJSON(statePath, state); } From 66b20aa9467297ac23eb60bdaa7573244eef97db Mon Sep 17 00:00:00 2001 From: Mirro Chou <3250823+Mirr0ch1@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:14:47 +0800 Subject: [PATCH 4/6] docs: present memory sharing with local harness agents Frame the CodeBuddy bridge as the first cross-harness memory integration and state that CodeBuddy Code is supported today while other local harness agents are in progress. --- CHANGELOG.md | 2 +- README.md | 11 ++++++++++- docs/architecture.md | 3 +++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d20de16..2f885a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added an optional two-way CodeBuddy memory bridge. OpenCode exports active workspace memories as `opencode-.md` files into CodeBuddy's project memory directory and maintains a marked block in its `MEMORY.md`; CodeBuddy topic files are indexed in the OpenCode prompt and can be promoted with `shared: true`. +- Added an optional two-way CodeBuddy memory bridge, the first integration for sharing workspace memory with other local harness agents. OpenCode exports active workspace memories as `opencode-.md` files into CodeBuddy's project memory directory and maintains a marked block in its `MEMORY.md`; CodeBuddy topic files are indexed in the OpenCode prompt and can be promoted with `shared: true`. - Added `tests/codebuddy-bridge.test.ts` for export, edit import, shared-file import, sensitivity filtering, idempotency, and deletion handling. - Added `atomicWriteText` to the storage layer for generated Markdown mirrors. diff --git a/README.md b/README.md index 1d5601e..27166fc 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ Working memory is context that **remembers what matters, fades what changes, and OpenCode Working Memory preserves project decisions, preferences, and references across compactions and sessions, while keeping active files and unresolved errors fresh for the current session — with no manual tools or extra LLM/API calls. +It can also share that memory with other local coding agents. **CodeBuddy Code is supported today; support for more local harness agents is in progress.** + ## Why This Exists OpenCode compaction keeps conversations manageable, but important context can still get lost over time. @@ -32,6 +34,7 @@ Use it when you want your agent to remember things like: | Inspect memory locally | Use `/memory` in the OpenCode TUI to browse status, help, and searchable current `[M#]` memories. | | Stay out of the way | Memory is injected automatically and piggybacks on OpenCode compaction — no manual tools, no extra LLM/API calls. | | Keep memory clean | Quality guards filter noise, redact credentials, dedupe repeats, and let weak memories fade. | +| Share memory across agents | A two-way bridge lets other local harness agents read and edit the same workspace memory. CodeBuddy Code is supported now; more harnesses are in progress. | ```text remember this ──► workspace memory ──► /memory @@ -135,7 +138,13 @@ Use `/memory` when you want to inspect what the agent currently remembers withou Compaction output already appears through OpenCode's built-in conversation flow. This plugin does not add duplicate compaction notices. -## Shared Memory with CodeBuddy +## Shared Memory with Other Local Harness Agents + +OpenCode Working Memory can act as the shared memory layer for the coding agents you already run locally, so a decision captured in OpenCode is still available when you switch to another harness. **CodeBuddy Code is the first supported integration; compatibility with other local harness agents is in progress.** + +Each integration follows the same rules: it uses the target agent's native memory format and directory, only writes files that OpenCode owns, never rewrites another agent's files, and stays disabled until you configure it. No hooks or patches are required in the other agent. + +### CodeBuddy Code The optional CodeBuddy bridge shares memory both ways without changing CodeBuddy's code. It is disabled by default because it writes into CodeBuddy's memory directory. diff --git a/docs/architecture.md b/docs/architecture.md index 56115a7..26ef400 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -326,6 +326,9 @@ Markdown memory stores: - Project: `~/.codebuddy/projects//memory/` - Global: `~/.codebuddy/memories/global/` +It is the first integration for sharing workspace memory with other local +harness agents; further harness adapters can follow the same ownership model. + Ownership is file-based: - OpenCode writes `opencode-.md` topic files and one marked block in From 96b37d74a87dc30a147bc234efb734ca94dfef5f Mon Sep 17 00:00:00 2001 From: Mirro Chou <3250823+Mirr0ch1@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:40:02 +0800 Subject: [PATCH 5/6] feat: share memory with oh-my-pi local backend Add a two-way bridge for oh-my-pi's plain-Markdown local memory backend. OpenCode exports memories as [opencode:] lesson lines in learned.md, imports edits and deletions back, indexes oh-my-pi lessons in the prompt, and imports recent lessons as omp-tagged JSON memories. memory_summary.md, MEMORY.md, and skills/ stay owned by oh-my-pi. --- AGENTS.md | 5 +- CHANGELOG.md | 4 +- README.md | 50 ++- docs/architecture.md | 28 +- docs/configuration.md | 38 +++ src/oh-my-pi-bridge.ts | 586 ++++++++++++++++++++++++++++++++++ src/plugin.ts | 2 + src/runtime.ts | 46 +++ tests/oh-my-pi-bridge.test.ts | 256 +++++++++++++++ tests/plugin-v2.test.ts | 47 +++ 10 files changed, 1056 insertions(+), 6 deletions(-) create mode 100644 src/oh-my-pi-bridge.ts create mode 100644 tests/oh-my-pi-bridge.test.ts diff --git a/AGENTS.md b/AGENTS.md index 74a7ca1..2a0bdca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,7 @@ opencode-working-memory/ ├── src/ │ ├── runtime.ts # Host-agnostic memory logic │ ├── codebuddy-bridge.ts # Two-way Markdown bridge with CodeBuddy memory +│ ├── oh-my-pi-bridge.ts # Two-way Markdown bridge with oh-my-pi local memory │ ├── plugin.ts # V1 adapter │ ├── plugin-v2.ts # V2 adapter │ ├── tui-plugin.ts # V2 TUI + V1 TUI dual export @@ -281,7 +282,9 @@ payloads: ### `experimental.chat.system.transform` (V1) / `session.hook("context")` (V2) Injects workspace memory and hot session state into the system prompt. V2 pushes -one `{ type: "text", text }` part per memory block. +one `{ type: "text", text }` part per memory block. The same hook runs the +CodeBuddy and oh-my-pi Markdown bridges (throttled by `syncIntervalMs`) and +appends their memory indexes. ### `tool.execute.after` (V1) / `tool.hook("execute.after")` (V2) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f885a2..f3de18e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added an optional two-way CodeBuddy memory bridge, the first integration for sharing workspace memory with other local harness agents. OpenCode exports active workspace memories as `opencode-.md` files into CodeBuddy's project memory directory and maintains a marked block in its `MEMORY.md`; CodeBuddy topic files are indexed in the OpenCode prompt and can be promoted with `shared: true`. +- Added an optional two-way CodeBuddy memory bridge, an integration for sharing workspace memory with other local harness agents. OpenCode exports active workspace memories as `opencode-.md` files into CodeBuddy's project memory directory and maintains a marked block in its `MEMORY.md`; CodeBuddy topic files are indexed in the OpenCode prompt and can be promoted with `shared: true`. +- Added an optional two-way bridge for oh-my-pi's `local` memory backend. OpenCode exports memories as `[opencode:]` lesson lines in `learned.md`, imports edits and deletions back, indexes oh-my-pi lessons, and imports recent ones as `omp`-tagged memories. - Added `tests/codebuddy-bridge.test.ts` for export, edit import, shared-file import, sensitivity filtering, idempotency, and deletion handling. +- Added `tests/oh-my-pi-bridge.test.ts` for Markdown preservation, edit import, deletion handling, lesson import, sensitivity filtering, idempotency, and missing-file recovery. - Added `atomicWriteText` to the storage layer for generated Markdown mirrors. ### Changed diff --git a/README.md b/README.md index 27166fc..32a929e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Working memory is context that **remembers what matters, fades what changes, and OpenCode Working Memory preserves project decisions, preferences, and references across compactions and sessions, while keeping active files and unresolved errors fresh for the current session — with no manual tools or extra LLM/API calls. -It can also share that memory with other local coding agents. **CodeBuddy Code is supported today; support for more local harness agents is in progress.** +It can also share that memory with other local coding agents. **CodeBuddy Code and oh-my-pi are supported today; support for more local harness agents is in progress.** ## Why This Exists @@ -34,7 +34,7 @@ Use it when you want your agent to remember things like: | Inspect memory locally | Use `/memory` in the OpenCode TUI to browse status, help, and searchable current `[M#]` memories. | | Stay out of the way | Memory is injected automatically and piggybacks on OpenCode compaction — no manual tools, no extra LLM/API calls. | | Keep memory clean | Quality guards filter noise, redact credentials, dedupe repeats, and let weak memories fade. | -| Share memory across agents | A two-way bridge lets other local harness agents read and edit the same workspace memory. CodeBuddy Code is supported now; more harnesses are in progress. | +| Share memory across agents | A two-way bridge lets other local harness agents read and edit the same workspace memory. CodeBuddy Code and oh-my-pi are supported now; more harnesses are in progress. | ```text remember this ──► workspace memory ──► /memory @@ -140,7 +140,7 @@ Compaction output already appears through OpenCode's built-in conversation flow. ## Shared Memory with Other Local Harness Agents -OpenCode Working Memory can act as the shared memory layer for the coding agents you already run locally, so a decision captured in OpenCode is still available when you switch to another harness. **CodeBuddy Code is the first supported integration; compatibility with other local harness agents is in progress.** +OpenCode Working Memory can act as the shared memory layer for the coding agents you already run locally, so a decision captured in OpenCode is still available when you switch to another harness. **CodeBuddy Code and oh-my-pi are supported today; compatibility with other local harness agents is in progress.** Each integration follows the same rules: it uses the target agent's native memory format and directory, only writes files that OpenCode owns, never rewrites another agent's files, and stays disabled until you configure it. No hooks or patches are required in the other agent. @@ -191,6 +191,50 @@ Defaults: CodeBuddy reads its memory at session start, so start a new CodeBuddy session to see newly exported OpenCode memories. OpenCode sees CodeBuddy edits on its next request. +### oh-my-pi (local memory backend) + +The oh-my-pi bridge shares memory with omp's `local` memory backend, which stores plain Markdown. Keep `memory.backend: local` in `~/.omp/agent/config.yml`, then enable the bridge in V2 `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugins": [ + { + "package": "opencode-working-memory", + "options": { + "ohMyPiBridge": { + "includeSensitive": false + } + } + } + ] +} +``` + +How it works: + +- oh-my-pi keeps per-project memory under `~/.omp/agent/memories//` and injects `memory_summary.md` plus `learned.md` into its system prompt. Only the `local` backend is supported; `mnemopi`, `hindsight`, and `sharpshooter` are not. +- OpenCode exports active workspace memories as lesson lines prefixed `[opencode:]` in `learned.md`, at the head of the lesson list. Every other line in the file is preserved. +- oh-my-pi `learn` lessons are indexed in OpenCode's prompt, and up to 12 recent lessons are imported as read-mostly JSON memories tagged `omp`. Deleting an oh-my-pi lesson supersedes its imported copy, and re-adding it reactivates that memory. +- Editing an `[opencode:]` line updates the matching OpenCode memory on the next request. Deleting the line supersedes it. +- The bridge never writes `memory_summary.md`, `MEMORY.md`, or `skills/`; those belong to oh-my-pi's consolidation pipeline. +- Credential-looking lessons are skipped unless `includeSensitive: true`. + +Defaults: + +| Option | Default | Purpose | +|---|---|---| +| `homeDir` | `~/.omp` | oh-my-pi config root | +| `agentDir` | `/agent` | Agent state directory | +| `includeSensitive` | `false` | Include credential-looking lessons | +| `maxExported` | `12` | OpenCode memories written to `learned.md` | +| `maxImportedLessons` | `12` | oh-my-pi lessons imported into JSON memory | +| `maxIndexEntries` | `14` | oh-my-pi lessons listed in the OpenCode prompt | +| `maxIndexChars` | `1600` | Character budget for that index | +| `syncIntervalMs` | `3000` | Minimum gap between full scans | + +oh-my-pi builds its memory prompt when a session starts, so start a new omp session to see newly exported OpenCode memories. OpenCode sees oh-my-pi edits on its next request. + ## How It Works OpenCode Working Memory adds durable memory without making extra LLM/API calls. diff --git a/docs/architecture.md b/docs/architecture.md index 26ef400..3888f7c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -326,7 +326,7 @@ Markdown memory stores: - Project: `~/.codebuddy/projects//memory/` - Global: `~/.codebuddy/memories/global/` -It is the first integration for sharing workspace memory with other local +It is one of the integrations for sharing workspace memory with other local harness agents; further harness adapters can follow the same ownership model. Ownership is file-based: @@ -344,6 +344,32 @@ Ownership is file-based: - Sync runs from `buildContextParts` (throttled) and after compaction promotion. No CodeBuddy hooks are required; CodeBuddy sees new files on its next session. +## oh-my-pi Local Backend Bridge + +`src/oh-my-pi-bridge.ts` bridges the `local` oh-my-pi memory backend, which is +plain Markdown: + +- Project: `~/.omp/agent/memories//learned.md` +- Summary (owned by oh-my-pi): `memory_summary.md` in the same directory +- Consolidation artifacts (owned by oh-my-pi): `MEMORY.md` and `skills/` + +Ownership is line-based: + +- OpenCode writes lesson lines prefixed `[opencode:]`. oh-my-pi may edit + them; OpenCode imports the new text and treats deletion as supersede. +- oh-my-pi's own `- ` lines are never rewritten. They are indexed in + the OpenCode prompt (bounded by `maxIndexEntries` / `maxIndexChars`) and the + most recent ones are imported as `omp`-tagged JSON memories. Deleting a + lesson supersedes its imported copy; re-adding the exact lesson reactivates it. +- Exported lines count toward oh-my-pi's 100-lesson cap. The bridge places them + at the head of the lesson list and only treats a missing line as a deletion + below the cap, so retention eviction does not supersede JSON memory. +- Credential-looking lessons are skipped unless `includeSensitive` is enabled. +- Per-workspace hashes live in `oh-my-pi-bridge.json` beside + `workspace-memory.json`, preventing write/read loops. +- Sync runs from `buildContextParts` (throttled) and after compaction promotion + or session cleanup. No oh-my-pi hooks are required; oh-my-pi reads memory + files when a session starts. ## Quality Guarantees diff --git a/docs/configuration.md b/docs/configuration.md index 9ef10f7..246f62c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -288,6 +288,44 @@ The bridge is disabled unless `codeBuddyBridge` is present in the plugin options To promote a CodeBuddy topic file into OpenCode's JSON store, add `shared: true` to its frontmatter. Files without that flag remain indexed and readable but are not injected into the 3600-character workspace memory budget. +## oh-my-pi Bridge Options + +The bridge targets oh-my-pi's `local` memory backend (`memory.backend: local`). It is disabled unless `ohMyPiBridge` is present in the plugin options. + +```jsonc +{ + "plugins": [ + { + "package": "opencode-working-memory", + "options": { + "ohMyPiBridge": { + "includeSensitive": false, + "maxExported": 12, + "maxImportedLessons": 12, + "maxIndexEntries": 14, + "maxIndexChars": 1600, + "syncIntervalMs": 3000 + } + } + } + ] +} +``` + +| Option | Default | Purpose | +|---|---|---| +| `enabled` | `true` when the object is present | Turn the bridge off without removing the object | +| `homeDir` | `~/.omp` | oh-my-pi config root | +| `agentDir` | `/agent` | Agent state directory (profiles/tests) | +| `includeSensitive` | `false` | Include credential-looking lessons | +| `maxExported` | `12` | OpenCode memories written to `learned.md` | +| `maxImportedLessons` | `12` | oh-my-pi lessons imported into workspace JSON | +| `maxIndexEntries` | `14` | oh-my-pi lessons listed in the OpenCode prompt | +| `maxIndexChars` | `1600` | Character budget for that index | +| `syncIntervalMs` | `3000` | Minimum gap between full scans | + +OpenCode-owned lines use the form `- [opencode:] ` and live beside oh-my-pi's own `- ` lines. Editing one of those lines updates the matching JSON memory; deleting it supersedes the memory. The bridge never touches `memory_summary.md`, `MEMORY.md`, or `skills/`. + ## Best Practices 1. **Workspace Memory Hygiene**: diff --git a/src/oh-my-pi-bridge.ts b/src/oh-my-pi-bridge.ts new file mode 100644 index 0000000..6869f85 --- /dev/null +++ b/src/oh-my-pi-bridge.ts @@ -0,0 +1,586 @@ +/** + * Two-way filesystem bridge between OpenCode working memory (JSON) and + * oh-my-pi's `local` memory backend (plain Markdown). + * + * Ownership model: + * - Lesson lines prefixed with `[opencode:]` in `learned.md` are written by + * OpenCode. oh-my-pi may edit them; edits are imported back into workspace + * memory. Deleting a line supersedes the memory. + * - All other `learned.md` lessons belong to oh-my-pi. They are indexed in the + * OpenCode prompt and the most recent ones are imported as read-mostly + * memories. The bridge never rewrites them. + * - `memory_summary.md`, `MEMORY.md`, and `skills/` are owned by oh-my-pi's + * consolidation pipeline and are never touched. + * + * oh-my-pi reads `memory_summary.md` and `learned.md` from the project memory + * root when it builds its system prompt, so exported lessons become visible on + * the next oh-my-pi session. There are no hooks to install on either side. + */ + +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { atomicWriteJSON, atomicWriteText, readJSON } from "./storage.ts"; +import { memoryRoot } from "./paths.ts"; +import { loadWorkspaceMemory, updateWorkspaceMemory } from "./workspace-memory.ts"; +import { redactCredentials } from "./redaction.ts"; +import { + LONG_TERM_LIMITS, + type LongTermMemoryEntry, + type LongTermType, + type WorkspaceMemoryStore, +} from "./types.ts"; + +export type OhMyPiBridgeOptions = { + enabled?: boolean; + /** oh-my-pi config root. Defaults to ~/.omp. */ + homeDir?: string; + /** Agent state directory. Defaults to /agent. */ + agentDir?: string; + /** Include credential-looking lessons in the index/import. Off by default. */ + includeSensitive?: boolean; + /** Maximum OpenCode memories exported to learned.md. */ + maxExported?: number; + /** Maximum oh-my-pi lessons imported into JSON memory. */ + maxImportedLessons?: number; + /** Maximum oh-my-pi lessons listed in the OpenCode prompt. */ + maxIndexEntries?: number; + /** Maximum characters spent on the oh-my-pi lesson index. */ + maxIndexChars?: number; + /** Minimum time between full bridge scans. */ + syncIntervalMs?: number; +}; + +type ResolvedOptions = { + enabled: boolean; + homeDir: string; + agentDir: string; + includeSensitive: boolean; + maxExported: number; + maxImportedLessons: number; + maxIndexEntries: number; + maxIndexChars: number; + syncIntervalMs: number; +}; + +type BridgeExportedEntry = { + hash: string; + text: string; +}; + +type BridgeImportedEntry = { + entryId: string; +}; + +type BridgeState = { + version: 1; + lastSyncAt?: string; + exported: Record; + imported: Record; +}; + +type ParsedLesson = { + text: string; + context?: string; +}; + +type ParsedLearned = { + lines: string[]; + exported: Map; + lessons: ParsedLesson[]; +}; + +export type OhMyPiBridgeResult = { + changed: boolean; + indexPrompt: string; + imported: number; + exported: number; + projectDir?: string; +}; + +const EXPORT_LINE = /^- \[opencode:([^\]]+)\] (.*)$/; +const LESSON_LINE = /^- (.*)$/; +const LESSON_CONTEXT = /^(.*) _\(context: ([\s\S]*)\)_$/; +const OMP_LESSON_CAP = 100; +const OMP_TAG = "omp"; +const SENSITIVE_PATTERN = + /password|passwd|pwd|secret|credential|token|api[_-]?key|private[_-]?key|ssh|sudo|root|access|vps/i; + +function sha256(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +function resolveOptions(value: boolean | OhMyPiBridgeOptions | undefined): ResolvedOptions { + const homeDir = join(homedir(), ".omp"); + if (value === true) { + return { + enabled: true, + homeDir, + agentDir: join(homeDir, "agent"), + includeSensitive: false, + maxExported: 12, + maxImportedLessons: 12, + maxIndexEntries: 14, + maxIndexChars: 1600, + syncIntervalMs: 3000, + }; + } + + if (!value) { + return { + enabled: false, + homeDir, + agentDir: join(homeDir, "agent"), + includeSensitive: false, + maxExported: 12, + maxImportedLessons: 12, + maxIndexEntries: 14, + maxIndexChars: 1600, + syncIntervalMs: 3000, + }; + } + + const resolvedHome = value.homeDir ?? homeDir; + return { + enabled: value.enabled !== false, + homeDir: resolvedHome, + agentDir: value.agentDir ?? join(resolvedHome, "agent"), + includeSensitive: value.includeSensitive === true, + maxExported: value.maxExported ?? 12, + maxImportedLessons: value.maxImportedLessons ?? 12, + maxIndexEntries: value.maxIndexEntries ?? 14, + maxIndexChars: value.maxIndexChars ?? 1600, + syncIntervalMs: value.syncIntervalMs ?? 3000, + }; +} + +/** + * Mirror oh-my-pi's `encodeProjectPath` in `memories/index.ts`: + * `-- dashes>--`. + * Windows paths are case-folded because oh-my-pi normalizes scope cwds there. + */ +function encodeProjectPath(workspaceRoot: string): string { + const resolved = resolve(workspaceRoot); + const normalized = process.platform === "win32" ? resolved.toLowerCase() : resolved; + return `--${normalized.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; +} + +function projectMemoryDir(workspaceRoot: string, agentDir: string): string { + return join(agentDir, "memories", encodeProjectPath(workspaceRoot)); +} + +function parseLesson(rawText: string): ParsedLesson { + const match = LESSON_CONTEXT.exec(rawText); + if (!match) return { text: rawText.trim() }; + return { text: match[1].trim(), context: match[2].trim() }; +} + +function parseLearned(content: string): ParsedLearned { + const lines = content.split("\n"); + if (lines.at(-1) === "") lines.pop(); + + const exported = new Map(); + const lessons: ParsedLesson[] = []; + for (const line of lines) { + const exportMatch = EXPORT_LINE.exec(line); + if (exportMatch) { + exported.set(exportMatch[1], exportMatch[2]); + continue; + } + const lessonMatch = LESSON_LINE.exec(line); + if (lessonMatch) lessons.push(parseLesson(lessonMatch[1])); + } + + return { lines, exported, lessons }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function readState(root: string): Promise<{ path: string; state: BridgeState }> { + const path = join(await memoryRoot(root), "oh-my-pi-bridge.json"); + const raw = await readJSON>(path, () => ({ + version: 1, + exported: {}, + imported: {}, + })); + const exported = isRecord(raw.exported) ? (raw.exported as Record) : {}; + const imported = isRecord(raw.imported) ? (raw.imported as Record) : {}; + return { + path, + state: { + version: 1, + lastSyncAt: raw.lastSyncAt, + exported, + imported, + }, + }; +} + +function flattenText(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function normalizeLessonKey(text: string): string { + return flattenText(text).toLowerCase(); +} + +function lessonHash(text: string): string { + return sha256(flattenText(text)).slice(0, 16); +} + +function isSensitive(text: string): boolean { + return SENSITIVE_PATTERN.test(text); +} + +function addTag(tags: string[] | undefined, tag: string): string[] { + return [...new Set([...(tags ?? []), tag])]; +} + +function importedText(entry: LongTermMemoryEntry, options: ResolvedOptions): string { + const text = options.includeSensitive ? entry.text : redactCredentials(entry.text); + return flattenText(text).slice(0, LONG_TERM_LIMITS.maxEntryTextChars); +} + +function importType(text: string): LongTermType { + if (/\b(prefer|always|never|do not|don't|must|avoid)\b/i.test(text)) return "feedback"; + if (/\b(decided|decision|chose|choose|convention)\b/i.test(text)) return "decision"; + return "project"; +} + +function applyExportedEdits( + store: WorkspaceMemoryStore, + parsed: ParsedLearned, + options: ResolvedOptions, +): { changed: boolean; imported: number } { + let changed = false; + let imported = 0; + const byId = new Map(store.entries.map(entry => [entry.id, entry])); + + for (const [id, rawText] of parsed.exported) { + const entry = byId.get(id); + if (!entry || entry.status !== "active") continue; + const text = flattenText(options.includeSensitive ? rawText : redactCredentials(rawText)) + .slice(0, LONG_TERM_LIMITS.maxEntryTextChars); + if (!text || text === entry.text) continue; + + entry.text = text; + entry.updatedAt = new Date().toISOString(); + entry.tags = addTag(entry.tags, "omp-edited"); + changed = true; + imported += 1; + } + + return { changed, imported }; +} + +function applyDeletedExports( + store: WorkspaceMemoryStore, + parsed: ParsedLearned, + state: BridgeState, +): { changed: boolean; imported: number } { + let changed = false; + let imported = 0; + + // At the lesson cap, a missing line may have been evicted by oh-my-pi's own + // retention instead of deleted by the user. Do not supersede on that signal; + // the next export puts the line back at the head of the lesson list. + if (parsed.lessons.length >= OMP_LESSON_CAP - 1) return { changed, imported }; + + for (const id of Object.keys(state.exported)) { + if (parsed.exported.has(id)) continue; + const entry = store.entries.find(item => item.id === id); + if (!entry || entry.status !== "active") continue; + entry.status = "superseded"; + entry.updatedAt = new Date().toISOString(); + changed = true; + imported += 1; + } + + return { changed, imported }; +} + +function applyDeletedOmpLessons( + store: WorkspaceMemoryStore, + parsed: ParsedLearned, + state: BridgeState, +): { changed: boolean; imported: number; removedHashes: string[] } { + let changed = false; + let imported = 0; + const removedHashes: string[] = []; + + // Same cap caveat as exported lines: eviction is not a user deletion. + if (parsed.lessons.length >= OMP_LESSON_CAP - 1) return { changed, imported, removedHashes }; + + const live = new Set(parsed.lessons.map(lesson => lessonHash(lesson.text))); + for (const [hash, info] of Object.entries(state.imported)) { + if (live.has(hash)) continue; + removedHashes.push(hash); + const entry = store.entries.find(item => item.id === info.entryId); + if (!entry || entry.status !== "active") continue; + entry.status = "superseded"; + entry.updatedAt = new Date().toISOString(); + changed = true; + imported += 1; + } + + return { changed, imported, removedHashes }; +} + +function applyOmpLessons( + store: WorkspaceMemoryStore, + parsed: ParsedLearned, + state: BridgeState, + options: ResolvedOptions, +): { changed: boolean; imported: number; newImports: Record } { + const active = store.entries.filter(entry => entry.status === "active"); + const ompCount = active.filter(entry => (entry.tags ?? []).includes(OMP_TAG)).length; + let slots = Math.min( + options.maxImportedLessons - ompCount, + LONG_TERM_LIMITS.maxEntries - active.length, + ); + + const newImports: Record = {}; + if (slots <= 0) return { changed: false, imported: 0, newImports }; + + const existingText = new Set(active.map(entry => normalizeLessonKey(entry.text))); + const seen = new Set(); + let changed = false; + let imported = 0; + + for (const lesson of parsed.lessons) { + if (slots <= 0) break; + + const text = flattenText(lesson.text); + if (!text) continue; + if (!options.includeSensitive && isSensitive(text)) continue; + + const key = normalizeLessonKey(text); + if (seen.has(key) || existingText.has(key)) continue; + seen.add(key); + + const hash = lessonHash(text); + if (state.imported[hash]) continue; + + const id = `omp_${hash}`; + const createdAt = new Date().toISOString(); + const existing = store.entries.find(item => item.id === id); + if (existing) { + existing.status = "active"; + existing.text = text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars); + existing.rationale = lesson.context?.slice(0, LONG_TERM_LIMITS.maxRationaleChars); + existing.updatedAt = createdAt; + existing.tags = addTag(existing.tags, OMP_TAG); + } else { + store.entries.push({ + id, + type: importType(text), + text: text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars), + rationale: lesson.context?.slice(0, LONG_TERM_LIMITS.maxRationaleChars), + source: "manual", + confidence: 0.85, + status: "active", + createdAt, + updatedAt: createdAt, + tags: [OMP_TAG, "shared", `${OMP_TAG}:${hash}`], + }); + } + + newImports[hash] = { entryId: id }; + changed = true; + imported += 1; + slots -= 1; + } + + return { changed, imported, newImports }; +} + +function buildExportLines( + store: WorkspaceMemoryStore, + parsed: ParsedLearned, + options: ResolvedOptions, +): string[] { + const existing = new Set(parsed.lessons.map(lesson => normalizeLessonKey(lesson.text))); + const exportable = store.entries + .filter(entry => entry.status === "active") + .filter(entry => !(entry.tags ?? []).some(tag => tag === OMP_TAG || tag.startsWith(`${OMP_TAG}:`))) + .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) + .slice(0, options.maxExported); + + const lines: string[] = []; + for (const entry of exportable) { + const text = importedText(entry, options); + if (!text) continue; + // Do not duplicate a lesson oh-my-pi already owns verbatim. + if (existing.has(normalizeLessonKey(text))) continue; + lines.push(`- [opencode:${entry.id}] ${text}`); + } + return lines; +} + +function renderLearnedContent(parsed: ParsedLearned, exportLines: string[]): string { + const out: string[] = []; + let inserted = false; + + for (const line of parsed.lines) { + if (EXPORT_LINE.test(line)) { + if (!inserted) { + out.push(...exportLines); + inserted = true; + } + continue; + } + out.push(line); + } + + if (!inserted && exportLines.length > 0) { + const firstLesson = out.findIndex(line => line.trimStart().startsWith("- ")); + if (firstLesson === -1) out.push(...exportLines); + else out.splice(firstLesson, 0, ...exportLines); + } + + while (out.length > 0 && out.at(-1) === "") out.pop(); + return out.length > 0 ? `${out.join("\n")}\n` : ""; +} + +function buildIndexPrompt(lessons: ParsedLesson[], filePath: string, options: ResolvedOptions): string { + const visible: ParsedLesson[] = []; + const seen = new Set(); + for (const lesson of lessons) { + const text = flattenText(lesson.text); + if (!text) continue; + if (!options.includeSensitive && isSensitive(text)) continue; + const key = normalizeLessonKey(text); + if (seen.has(key)) continue; + seen.add(key); + visible.push(lesson); + if (visible.length >= options.maxIndexEntries) break; + } + + if (visible.length === 0) return ""; + + const lines = [ + "## Shared oh-my-pi memory (local backend)", + "", + `Memory file: ${filePath}`, + "", + "Recent lessons:", + ]; + + let text = lines.join("\n"); + let truncated = visible.length < lessons.length; + for (const lesson of visible) { + const label = flattenText(lesson.text).slice(0, 140); + const line = `- ${label}`; + if (text.length + line.length + 1 > options.maxIndexChars) { + truncated = true; + break; + } + text += `\n${line}`; + } + + text += "\n\nUse the read tool on the memory file for the full list."; + if (truncated) text += "\n[index truncated]"; + return text; +} + +export async function syncOhMyPiBridge( + workspaceRoot: string, + value?: boolean | OhMyPiBridgeOptions, +): Promise { + const options = resolveOptions(value); + if (!options.enabled) return { changed: false, indexPrompt: "", imported: 0, exported: 0 }; + if (!existsSync(options.homeDir)) { + return { changed: false, indexPrompt: "", imported: 0, exported: 0 }; + } + + const projectDir = projectMemoryDir(workspaceRoot, options.agentDir); + const filePath = join(projectDir, "learned.md"); + + let content: string | undefined; + try { + content = await readFile(filePath, "utf8"); + } catch { + content = undefined; + } + const parsed = parseLearned(content ?? ""); + + const { path: statePath, state } = await readState(workspaceRoot); + const probe = await loadWorkspaceMemory(workspaceRoot); + + const probeEdits = applyExportedEdits(probe, parsed, options); + const probeDeletes = content === undefined + ? { changed: false, imported: 0 } + : applyDeletedExports(probe, parsed, state); + const probeLessonDeletes = applyDeletedOmpLessons(probe, parsed, state); + const probeImports = applyOmpLessons(probe, parsed, state, options); + + const changed = probeEdits.changed + || probeDeletes.changed + || probeLessonDeletes.changed + || probeImports.changed; + const imported = probeEdits.imported + + probeDeletes.imported + + probeLessonDeletes.imported + + probeImports.imported; + + let store = probe; + if (changed) { + store = await updateWorkspaceMemory(workspaceRoot, current => { + applyExportedEdits(current, parsed, options); + if (content !== undefined) applyDeletedExports(current, parsed, state); + applyDeletedOmpLessons(current, parsed, state); + applyOmpLessons(current, parsed, state, options); + return current; + }); + } + + const exportLines = buildExportLines(store, parsed, options); + const nextContent = renderLearnedContent(parsed, exportLines); + if (nextContent !== (content ?? "")) { + await mkdir(projectDir, { recursive: true }); + await atomicWriteText(filePath, nextContent, 0o600); + } + + const nextExported: Record = {}; + for (const line of exportLines) { + const match = EXPORT_LINE.exec(line); + if (!match) continue; + nextExported[match[1]] = { hash: sha256(line), text: match[2] }; + } + const nextImported: Record = { ...state.imported }; + for (const hash of probeLessonDeletes.removedHashes) { + delete nextImported[hash]; + } + for (const [hash, info] of Object.entries(probeImports.newImports)) { + if (store.entries.some(entry => entry.id === info.entryId && entry.status === "active")) { + nextImported[hash] = info; + } + } + + await atomicWriteJSON(statePath, { + version: 1, + lastSyncAt: new Date().toISOString(), + exported: nextExported, + imported: nextImported, + } satisfies BridgeState); + + return { + changed, + indexPrompt: buildIndexPrompt(parsed.lessons, filePath, options), + imported, + exported: exportLines.length, + projectDir, + }; +} + +/** Export only; used after compaction promotion and session cleanup. */ +export async function exportOhMyPiBridge( + workspaceRoot: string, + value?: boolean | OhMyPiBridgeOptions, +): Promise { + await syncOhMyPiBridge(workspaceRoot, value); +} diff --git a/src/plugin.ts b/src/plugin.ts index 5014dc2..69f304b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -8,6 +8,7 @@ import type { Plugin } from "@opencode-ai/plugin"; import { createMemoryRuntime, type MemoryRuntimeHost } from "./runtime.ts"; import type { CodeBuddyBridgeOptions } from "./codebuddy-bridge.ts"; +import type { OhMyPiBridgeOptions } from "./oh-my-pi-bridge.ts"; import { latestUserText, latestCompactionSummary, @@ -17,6 +18,7 @@ import { export type MemoryPluginOptions = { mergeSystemMessages?: boolean; codeBuddyBridge?: boolean | CodeBuddyBridgeOptions; + ohMyPiBridge?: boolean | OhMyPiBridgeOptions; }; function resolveMergeSystemMessagesOption(options: unknown): boolean { diff --git a/src/runtime.ts b/src/runtime.ts index 9e1c4f1..6c7302b 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -71,6 +71,11 @@ import { syncCodeBuddyBridge, type CodeBuddyBridgeOptions, } from "./codebuddy-bridge.ts"; +import { + exportOhMyPiBridge, + syncOhMyPiBridge, + type OhMyPiBridgeOptions, +} from "./oh-my-pi-bridge.ts"; import { appendEvidenceEvent, appendEvidenceEvents, type EvidenceEventInput, type MemoryEvidenceRef } from "./evidence-log.ts"; import { type CompactionMemoryRef, type LongTermMemoryEntry, LONG_TERM_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts"; @@ -306,6 +311,8 @@ export type MemoryRuntime = { export type MemoryRuntimeOptions = { /** Optional two-way bridge with CodeBuddy's Markdown memory store. */ codeBuddyBridge?: boolean | CodeBuddyBridgeOptions; + /** Optional two-way bridge with oh-my-pi's `local` Markdown memory backend. */ + ohMyPiBridge?: boolean | OhMyPiBridgeOptions; }; export function createMemoryRuntime( @@ -317,8 +324,14 @@ export function createMemoryRuntime( const codeBuddyBridgeInterval = typeof codeBuddyBridge === "object" && codeBuddyBridge?.syncIntervalMs ? codeBuddyBridge.syncIntervalMs : 3000; + const ohMyPiBridge = options?.ohMyPiBridge; + const ohMyPiBridgeInterval = typeof ohMyPiBridge === "object" && ohMyPiBridge?.syncIntervalMs + ? ohMyPiBridge.syncIntervalMs + : 3000; let lastCodeBuddySyncAt = 0; let lastCodeBuddyIndex = ""; + let lastOhMyPiSyncAt = 0; + let lastOhMyPiIndex = ""; // Cache for sub-agent detection — avoids repeated API calls per session. // Maps sessionID → parentID (string) or null (root session). @@ -1042,6 +1055,22 @@ export function createMemoryRuntime( } } + // Two-way Markdown bridge with oh-my-pi's `local` backend. Imported + // edits invalidate the frozen snapshots exactly like the CodeBuddy bridge. + if (ohMyPiBridge) { + try { + const now = Date.now(); + if (now - lastOhMyPiSyncAt >= ohMyPiBridgeInterval) { + const bridge = await syncOhMyPiBridge(directory, ohMyPiBridge); + lastOhMyPiSyncAt = now; + lastOhMyPiIndex = bridge.indexPrompt; + if (bridge.changed) clearAllFrozenPromptEpochs(); + } + } catch (error) { + await warnMemoryHook("oh-my-pi.bridge", error, directory); + } + } + // Process explicit user memory even on no-tool turns. Keep this after the // sub-agent guard so child sessions never append to the parent journal. await processLatestUserMessage(sessionID); @@ -1075,6 +1104,9 @@ export function createMemoryRuntime( if (lastCodeBuddyIndex) { memorySystemPromptParts.push(lastCodeBuddyIndex); } + if (lastOhMyPiIndex) { + memorySystemPromptParts.push(lastOhMyPiIndex); + } return memorySystemPromptParts; } catch (error) { await warnMemoryHook("chat.system.transform", error, directory); @@ -1231,6 +1263,13 @@ export function createMemoryRuntime( await warnMemoryHook("codebuddy.export", error, directory); } } + if (ohMyPiBridge) { + try { + await exportOhMyPiBridge(directory, ohMyPiBridge); + } catch (error) { + await warnMemoryHook("oh-my-pi.export", error, directory); + } + } } finally { await clearCompactionMemoryRefs(sessionID); clearFrozenPromptEpoch(sessionID); @@ -1255,6 +1294,13 @@ export function createMemoryRuntime( await warnMemoryHook("codebuddy.export", error, directory); } } + if (ohMyPiBridge) { + try { + await exportOhMyPiBridge(directory, ohMyPiBridge); + } catch (error) { + await warnMemoryHook("oh-my-pi.export", error, directory); + } + } clearFrozenPromptEpoch(sessionID); processedUserMessages.delete(sessionID); sessionParentCache.delete(sessionID); diff --git a/tests/oh-my-pi-bridge.test.ts b/tests/oh-my-pi-bridge.test.ts new file mode 100644 index 0000000..c4d054a --- /dev/null +++ b/tests/oh-my-pi-bridge.test.ts @@ -0,0 +1,256 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { syncOhMyPiBridge } from "../src/oh-my-pi-bridge.ts"; +import { loadWorkspaceMemory, updateWorkspaceMemory } from "../src/workspace-memory.ts"; + +function encodeProjectPath(workspace: string): string { + const resolved = resolve(workspace); + return `--${resolved.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; +} + +async function withBridgeWorkspace( + fn: (paths: { workspace: string; home: string; memoryDir: string; learnedPath: string }) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), "memory-oh-my-pi-bridge-test-")); + const workspace = join(root, "workspace"); + const home = join(root, "omp-home"); + const agentDir = join(home, "agent"); + const memoryDir = join(agentDir, "memories", encodeProjectPath(workspace)); + const learnedPath = join(memoryDir, "learned.md"); + await mkdir(workspace, { recursive: true }); + await mkdir(agentDir, { recursive: true }); + try { + return await fn({ workspace, home, memoryDir, learnedPath }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function seedMemory(workspace: string): Promise { + const now = new Date().toISOString(); + await updateWorkspaceMemory(workspace, store => { + store.entries.push( + { + id: "mem_omp_one", + type: "project", + text: "OpenCode project fact one.", + source: "manual", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + }, + { + id: "mem_omp_two", + type: "feedback", + text: "OpenCode feedback fact two.", + source: "manual", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + }, + ); + return store; + }); +} + +test("bridge exports OpenCode memories into learned.md without touching oh-my-pi content", async () => { + await withBridgeWorkspace(async ({ workspace, home, memoryDir, learnedPath }) => { + await mkdir(memoryDir, { recursive: true }); + const original = [ + "# Lessons", + "", + "Some hand-written prose.", + "", + "- Use pnpm for this repo. _(context: user preference)_", + "", + ].join("\n"); + await writeFile(learnedPath, original, "utf8"); + await writeFile(join(memoryDir, "memory_summary.md"), "oh-my-pi summary stays put.\n", "utf8"); + await seedMemory(workspace); + + const result = await syncOhMyPiBridge(workspace, { homeDir: home }); + assert.equal(result.imported, 1); + assert.equal(result.exported, 2); + assert.equal(result.projectDir, memoryDir); + + const store = await loadWorkspaceMemory(workspace); + assert.ok(store.entries.some(entry => entry.text === "Use pnpm for this repo.")); + + const content = await readFile(learnedPath, "utf8"); + assert.match(content, /# Lessons/); + assert.match(content, /Some hand-written prose\./); + assert.match(content, /- Use pnpm for this repo\. _\(context: user preference\)_/); + assert.match(content, /- \[opencode:mem_omp_one\] OpenCode project fact one\./); + assert.match(content, /- \[opencode:mem_omp_two\] OpenCode feedback fact two\./); + + const proseIndex = content.indexOf("Some hand-written prose."); + const existingLessonIndex = content.indexOf("- Use pnpm for this repo."); + const exportedIndex = content.indexOf("- [opencode:mem_omp_one]"); + assert.ok(proseIndex < exportedIndex); + assert.ok(exportedIndex < existingLessonIndex); + + const summary = await readFile(join(memoryDir, "memory_summary.md"), "utf8"); + assert.equal(summary, "oh-my-pi summary stays put.\n"); + }); +}); + +test("bridge imports edits made to an OpenCode-owned lesson line", async () => { + await withBridgeWorkspace(async ({ workspace, home, learnedPath }) => { + await seedMemory(workspace); + await syncOhMyPiBridge(workspace, { homeDir: home }); + + const original = await readFile(learnedPath, "utf8"); + await writeFile( + learnedPath, + original.replace("OpenCode project fact one.", "Edited by oh-my-pi."), + "utf8", + ); + + const result = await syncOhMyPiBridge(workspace, { homeDir: home }); + assert.equal(result.changed, true); + assert.equal(result.imported, 1); + + const store = await loadWorkspaceMemory(workspace); + const entry = store.entries.find(item => item.id === "mem_omp_one"); + assert.equal(entry?.text, "Edited by oh-my-pi."); + assert.equal(entry?.tags?.includes("omp-edited"), true); + + const content = await readFile(learnedPath, "utf8"); + assert.match(content, /- \[opencode:mem_omp_one\] Edited by oh-my-pi\./); + }); +}); + +test("bridge supersedes an entry when its lesson line is deleted", async () => { + await withBridgeWorkspace(async ({ workspace, home, learnedPath }) => { + await seedMemory(workspace); + await syncOhMyPiBridge(workspace, { homeDir: home }); + + const original = await readFile(learnedPath, "utf8"); + const next = original + .split("\n") + .filter(line => !line.startsWith("- [opencode:mem_omp_one]")) + .join("\n"); + await writeFile(learnedPath, next, "utf8"); + + const result = await syncOhMyPiBridge(workspace, { homeDir: home }); + assert.equal(result.changed, true); + + const store = await loadWorkspaceMemory(workspace); + const entry = store.entries.find(item => item.id === "mem_omp_one"); + assert.equal(entry?.status, "superseded"); + + const content = await readFile(learnedPath, "utf8"); + assert.doesNotMatch(content, /\[opencode:mem_omp_one\]/); + }); +}); + +test("bridge imports oh-my-pi lessons and indexes them in the prompt", async () => { + await withBridgeWorkspace(async ({ workspace, home, memoryDir, learnedPath }) => { + await mkdir(memoryDir, { recursive: true }); + await writeFile(learnedPath, [ + "- Deploy only through the staging gateway. _(context: production rule)_", + "- Prefer small, reviewable commits.", + "", + ].join("\n"), "utf8"); + + const result = await syncOhMyPiBridge(workspace, { homeDir: home }); + assert.equal(result.changed, true); + assert.equal(result.imported, 2); + assert.match(result.indexPrompt, /Shared oh-my-pi memory/); + assert.match(result.indexPrompt, /Deploy only through the staging gateway/); + assert.match(result.indexPrompt, /memory_summary|learned\.md/); + + const store = await loadWorkspaceMemory(workspace); + const deploy = store.entries.find(entry => entry.text === "Deploy only through the staging gateway."); + const commits = store.entries.find(entry => entry.text === "Prefer small, reviewable commits."); + assert.ok(deploy); + assert.equal(deploy?.rationale, "production rule"); + assert.equal(deploy?.type, "project"); + assert.deepEqual(deploy?.tags?.slice(0, 2), ["omp", "shared"]); + assert.equal(commits?.type, "feedback"); + + const second = await syncOhMyPiBridge(workspace, { homeDir: home }); + assert.equal(second.imported, 0); + const after = await loadWorkspaceMemory(workspace); + assert.equal(after.entries.filter(entry => entry.id.startsWith("omp_")).length, 2); + }); +}); + +test("bridge mirrors oh-my-pi lesson deletion and re-addition", async () => { + await withBridgeWorkspace(async ({ workspace, home, memoryDir, learnedPath }) => { + await mkdir(memoryDir, { recursive: true }); + const lesson = "- Mirror-safe deploys use the staging gateway."; + await writeFile(learnedPath, `${lesson}\n`, "utf8"); + + await syncOhMyPiBridge(workspace, { homeDir: home }); + let store = await loadWorkspaceMemory(workspace); + const entry = store.entries.find(item => item.text === "Mirror-safe deploys use the staging gateway."); + assert.ok(entry); + assert.equal(entry?.status, "active"); + + await writeFile(learnedPath, "", "utf8"); + const removed = await syncOhMyPiBridge(workspace, { homeDir: home }); + assert.equal(removed.changed, true); + store = await loadWorkspaceMemory(workspace); + assert.equal(store.entries.find(item => item.id === entry?.id)?.status, "superseded"); + + await writeFile(learnedPath, `${lesson}\n`, "utf8"); + const restored = await syncOhMyPiBridge(workspace, { homeDir: home }); + assert.equal(restored.changed, true); + store = await loadWorkspaceMemory(workspace); + assert.equal(store.entries.find(item => item.id === entry?.id)?.status, "active"); + }); +}); + +test("bridge skips sensitive oh-my-pi lessons unless includeSensitive is enabled", async () => { + await withBridgeWorkspace(async ({ workspace, home, memoryDir, learnedPath }) => { + await mkdir(memoryDir, { recursive: true }); + await writeFile(learnedPath, "- The VPS root password lives in the vault.\n", "utf8"); + + const safe = await syncOhMyPiBridge(workspace, { homeDir: home }); + assert.equal(safe.imported, 0); + assert.doesNotMatch(safe.indexPrompt, /VPS root password/); + + const unsafe = await syncOhMyPiBridge(workspace, { homeDir: home, includeSensitive: true }); + assert.equal(unsafe.imported, 1); + assert.match(unsafe.indexPrompt, /VPS root password/); + }); +}); + +test("bridge is idempotent and recreates a missing learned.md", async () => { + await withBridgeWorkspace(async ({ workspace, home, learnedPath }) => { + await seedMemory(workspace); + + const first = await syncOhMyPiBridge(workspace, { homeDir: home }); + assert.equal(first.exported, 2); + const content = await readFile(learnedPath, "utf8"); + + const second = await syncOhMyPiBridge(workspace, { homeDir: home }); + assert.equal(second.changed, false); + assert.equal(second.exported, 2); + assert.equal(await readFile(learnedPath, "utf8"), content); + + await rm(learnedPath, { force: true }); + const third = await syncOhMyPiBridge(workspace, { homeDir: home }); + assert.equal(third.changed, false); + assert.equal(third.exported, 2); + assert.equal(await readFile(learnedPath, "utf8"), content); + }); +}); + +test("bridge leaves oh-my-pi alone when its home directory is absent", async () => { + await withBridgeWorkspace(async ({ workspace, home }) => { + await seedMemory(workspace); + await rm(home, { recursive: true, force: true }); + + const result = await syncOhMyPiBridge(workspace, { homeDir: home }); + assert.equal(result.changed, false); + assert.equal(result.exported, 0); + assert.equal(result.indexPrompt, ""); + }); +}); diff --git a/tests/plugin-v2.test.ts b/tests/plugin-v2.test.ts index 02ced55..7dadace 100644 --- a/tests/plugin-v2.test.ts +++ b/tests/plugin-v2.test.ts @@ -412,4 +412,51 @@ test("V2 context hook bridges CodeBuddy markdown memory in both directions", asy }); }); +test("V2 adapter wires the oh-my-pi local memory bridge", async () => { + const directory = await mkdtemp(join(tmpdir(), "memory-plugin-v2-omp-test-")); + try { + const home = join(directory, "omp-home"); + const encoded = resolve(directory) + .replace(/^[/\\]/, "") + .replace(/[/\\:]/g, "-"); + const memoryDir = join(home, "agent", "memories", `--${encoded}--`); + await mkdir(memoryDir, { recursive: true }); + await writeFile(join(memoryDir, "learned.md"), "- Use the staging gateway for deploys.\n", "utf8"); + + const now = new Date().toISOString(); + await updateWorkspaceMemory(directory, store => { + store.entries.push({ + id: "v2_omp_entry", + type: "project", + text: "OpenCode omp export.", + source: "manual", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + }); + return store; + }); + + const { sessionHooks } = await createContext({ + directory, + pluginOptions: { + ohMyPiBridge: { homeDir: home, syncIntervalMs: 0 }, + }, + }); + const event = { sessionID: "session-v2-omp-bridge", system: [] as Array<{ type: string; text: string }> }; + await sessionHooks.context(event); + + const systemText = event.system.map(part => part.text).join("\n"); + assert.match(systemText, /Shared oh-my-pi memory/); + assert.match(systemText, /Use the staging gateway for deploys\./); + + const learned = await readFile(join(memoryDir, "learned.md"), "utf8"); + assert.match(learned, /- \[opencode:v2_omp_entry\] OpenCode omp export\./); + assert.match(learned, /Use the staging gateway for deploys\./); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + From 960439672fe0945594fd6045a3a4f1327c9120c7 Mon Sep 17 00:00:00 2001 From: Mirro Chou <3250823+Mirr0ch1@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:40:54 +0800 Subject: [PATCH 6/6] fix: accept extra whitespace in oh-my-pi export lines --- src/oh-my-pi-bridge.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/oh-my-pi-bridge.ts b/src/oh-my-pi-bridge.ts index 6869f85..d85d284 100644 --- a/src/oh-my-pi-bridge.ts +++ b/src/oh-my-pi-bridge.ts @@ -100,7 +100,7 @@ export type OhMyPiBridgeResult = { projectDir?: string; }; -const EXPORT_LINE = /^- \[opencode:([^\]]+)\] (.*)$/; +const EXPORT_LINE = /^- \[opencode:([^\]]+)\]\s+(.*)$/; const LESSON_LINE = /^- (.*)$/; const LESSON_CONTEXT = /^(.*) _\(context: ([\s\S]*)\)_$/; const OMP_LESSON_CAP = 100;