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] 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";