From 721544e7a8b7e35d33477c583cd77901ca804990 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Sun, 26 Apr 2026 15:13:58 +0800 Subject: [PATCH] fix: use plain text labels instead of Markdown headers - Changed '## Memory Candidates' to 'Memory candidates:' in compaction context - Changed '## Pending Todos' to 'Pending todos:' in todo rendering - Updated extractCandidateBlock() to parse plain text format (primary) - Removed stripXmlTags() function (no longer needed) - All 42 tests pass Root cause: Markdown headings (##) render as purple in OpenCode UI, same issue as XML tags and HTML comments. Plain text labels avoid all special markup rendering. --- .../plans/2026-04-25-memory-v2-redesign.md | 976 ++++++++++++++++++ src/extractors.ts | 18 +- src/plugin.ts | 74 +- src/session-state.ts | 3 +- src/workspace-memory.ts | 9 +- tests/extractors.test.ts | 36 +- tests/plugin.test.ts | 44 +- tests/workspace-memory.test.ts | 12 +- 8 files changed, 1056 insertions(+), 116 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-25-memory-v2-redesign.md diff --git a/docs/superpowers/plans/2026-04-25-memory-v2-redesign.md b/docs/superpowers/plans/2026-04-25-memory-v2-redesign.md new file mode 100644 index 0000000..2e60a51 --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-memory-v2-redesign.md @@ -0,0 +1,976 @@ +# Memory V2 Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the current heavy four-tier memory plugin with a low-token, no-extra-agent-call memory system that provides workspace-scoped long-term memory and session hot state. + +**Architecture:** Implement three layers: stable workspace memory, hot session state, and native OpenCode state integration. Workspace memory is frozen per session and refreshed at compaction boundaries; hot session state tracks active files and unresolved blocking errors automatically from tool events; OpenCode todos remain owned by OpenCode and are only read during compaction. + +**Tech Stack:** TypeScript, OpenCode Plugin hooks, Node/Bun file APIs, JSON sidecar storage under user data directory, TypeScript typecheck via `npm run typecheck`. + +--- + +## Design Summary + +### What changes + +- Remove default agent-visible memory tools from the normal flow. +- Remove raw tool-output cache and pressure-monitor intervention from the core path. +- Add workspace-scoped long-term memory that persists across sessions but does not cross workspaces. +- Add hot session state that is fully automatic and tiny: active files, open blocking errors, and recent decisions for compaction only. +- Reuse OpenCode compaction to extract long-term memory candidates with no extra LLM call. +- Read OpenCode todos during compaction instead of duplicating todo storage. + +### What stays out of memory + +- Long-term memory does **not** save file lists, stack traces, code signatures, API docs, git history, architecture snapshots, or temporary task progress. +- Short-term memory does **not** save todos or dependency facts because OpenCode and project files already own those. + +--- + +## File Structure + +Current project has a single `index.ts`. This plan splits memory behavior into focused modules while keeping `index.ts` as the plugin entrypoint. + +### Create + +- `src/paths.ts` — computes workspace-scoped storage paths under user data directory. +- `src/storage.ts` — atomic JSON read/write helpers with safe defaults. +- `src/types.ts` — canonical schemas and constants for long-term memory and session state. +- `src/workspace-memory.ts` — load/save/merge/render long-term workspace memory. +- `src/session-state.ts` — load/save/update/render active files, open errors, recent decisions. +- `src/extractors.ts` — deterministic extraction from user messages, tool args, bash output, and compaction summaries. +- `src/opencode.ts` — thin wrappers around OpenCode SDK calls for latest user messages, summaries, and todos. +- `src/plugin.ts` — hook orchestration. +- `tests/extractors.test.ts` — unit tests for deterministic extraction. +- `tests/workspace-memory.test.ts` — unit tests for merge, dedupe, limits, staleness rendering. +- `tests/session-state.test.ts` — unit tests for active files and error lifecycle. + +### Modify + +- `index.ts` — replace monolithic implementation with `export { default } from "./src/plugin";`. +- `package.json` — add a test script using Node’s built-in test runner or Bun test depending available runtime. +- `README.md` — update feature description from four-tier memory to Memory V2. +- `docs/architecture.md` — replace stale four-tier docs with three-layer design. +- `docs/configuration.md` — document limits and optional debug tools. +- `AGENTS.md` — update development guide, storage paths, and testing commands. + +--- + +## Wave 1 — Storage, Types, and Deterministic Core + +### Task 1: Add canonical types and limits + +**Files:** +- Create: `src/types.ts` + +- [ ] **Step 1: Create memory and session schemas** + +Add this file: + +```ts +export type LongTermType = "feedback" | "project" | "decision" | "reference"; + +export type LongTermSource = "explicit" | "compaction" | "manual"; + +export type LongTermMemoryEntry = { + id: string; + type: LongTermType; + text: string; + rationale?: string; + source: LongTermSource; + confidence: number; + status: "active" | "superseded"; + createdAt: string; + updatedAt: string; + staleAfterDays?: number; + supersedes?: string[]; + tags?: string[]; +}; + +export type WorkspaceMemoryStore = { + version: 1; + workspace: { + root: string; + key: string; + }; + limits: { + maxRenderedChars: number; + maxEntries: number; + }; + entries: LongTermMemoryEntry[]; + updatedAt: string; +}; + +export type ActiveFile = { + path: string; + action: "read" | "grep" | "edit" | "write"; + count: number; + lastSeen: number; +}; + +export type OpenError = { + id: string; + category: "typecheck" | "test" | "lint" | "build" | "runtime" | "tool"; + summary: string; + command?: string; + file?: string; + fingerprint: string; + status: "open" | "maybe_fixed"; + firstSeen: number; + lastSeen: number; + seenCount: number; +}; + +export type SessionDecision = { + id: string; + text: string; + rationale?: string; + source: "assistant" | "user" | "compaction"; + createdAt: number; + promotedToLongTerm?: boolean; +}; + +export type SessionState = { + version: 1; + sessionID: string; + turn: number; + updatedAt: string; + activeFiles: ActiveFile[]; + openErrors: OpenError[]; + recentDecisions: SessionDecision[]; +}; + +export const LONG_TERM_LIMITS = { + maxRenderedChars: 5200, + targetRenderedChars: 4200, + maxEntries: 28, + maxEntryTextChars: 260, + maxRationaleChars: 180, +} as const; + +export const HOT_STATE_LIMITS = { + maxRenderedChars: 1200, + maxActiveFilesStored: 20, + maxActiveFilesRendered: 8, + maxOpenErrorsStored: 5, + maxOpenErrorsRendered: 3, + maxRecentDecisionsStored: 8, +} as const; +``` + +- [ ] **Step 2: Run typecheck** + +Run: `npm run typecheck` + +Expected: PASS or existing unrelated failures only. Since file is not imported yet, it should not introduce errors. + +--- + +### Task 2: Add workspace-scoped paths and atomic storage + +**Files:** +- Create: `src/paths.ts` +- Create: `src/storage.ts` + +- [ ] **Step 1: Create `src/paths.ts`** + +```ts +import { createHash } from "crypto"; +import { homedir } from "os"; +import { join } from "path"; +import { realpath } from "fs/promises"; + +export function dataHome(): string { + return process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"); +} + +export async function workspaceKey(root: string): Promise { + const resolved = await realpath(root).catch(() => root); + return createHash("sha256").update(resolved).digest("hex").slice(0, 16); +} + +export async function memoryRoot(root: string): Promise { + return join(dataHome(), "opencode-working-memory", "workspaces", await workspaceKey(root)); +} + +export async function workspaceMemoryPath(root: string): Promise { + return join(await memoryRoot(root), "workspace-memory.json"); +} + +export async function sessionStatePath(root: string, sessionID: string): Promise { + return join(await memoryRoot(root), "sessions", `${sessionID}.json`); +} +``` + +- [ ] **Step 2: Create `src/storage.ts`** + +```ts +import { existsSync } from "fs"; +import { mkdir, readFile, rename, writeFile } from "fs/promises"; +import { dirname } from "path"; + +export async function readJSON(path: string, fallback: () => T): Promise { + if (!existsSync(path)) return fallback(); + try { + return JSON.parse(await readFile(path, "utf8")) as T; + } catch { + return fallback(); + } +} + +export async function atomicWriteJSON(path: string, data: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 0o600 }); + await rename(tmp, path); +} +``` + +- [ ] **Step 3: Run typecheck** + +Run: `npm run typecheck` + +Expected: PASS. + +--- + +### Task 3: Add extractor tests before implementation + +**Files:** +- Create: `tests/extractors.test.ts` +- Modify: `package.json` + +- [ ] **Step 1: Add test script** + +Modify `package.json` scripts: + +```json +{ + "scripts": { + "build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"", + "typecheck": "tsc --noEmit", + "test": "node --test --experimental-strip-types tests/*.test.ts" + } +} +``` + +- [ ] **Step 2: Write failing tests** + +Create `tests/extractors.test.ts`: + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import { + extractExplicitMemories, + extractActiveFiles, + extractErrorsFromBash, + parseWorkspaceMemoryCandidates, +} from "../src/extractors.ts"; + +test("extractExplicitMemories captures clear remember instruction", () => { + const items = extractExplicitMemories("请记住:这个 workspace 的 memory 功能必须默认无感"); + assert.equal(items.length, 1); + assert.equal(items[0].type, "feedback"); + assert.match(items[0].text, /默认无感/); +}); + +test("extractExplicitMemories avoids casual negative commands", () => { + assert.equal(extractExplicitMemories("不要吃这个").length, 0); + assert.equal(extractExplicitMemories("以后再说").length, 0); +}); + +test("extractActiveFiles uses tool args before output", () => { + assert.deepEqual(extractActiveFiles("read", { filePath: "/repo/index.ts" }, "random content"), [ + { path: "/repo/index.ts", action: "read" }, + ]); +}); + +test("extractErrorsFromBash captures typecheck failure", () => { + const errors = extractErrorsFromBash("npm run typecheck", "src/index.ts(10,3): error TS2345: bad type"); + assert.equal(errors.length, 1); + assert.equal(errors[0].category, "typecheck"); + assert.match(errors[0].summary, /TS2345/); +}); + +test("parseWorkspaceMemoryCandidates parses compaction block", () => { + const entries = parseWorkspaceMemoryCandidates(`summary + +- [decision] Use JSON as canonical storage because it is easier to validate. +- [reference] External design notes are in Notion. +`); + assert.equal(entries.length, 2); + assert.equal(entries[0].type, "decision"); + assert.equal(entries[1].type, "reference"); +}); +``` + +- [ ] **Step 3: Run tests and confirm failure** + +Run: `npm test` + +Expected: FAIL because `src/extractors.ts` does not exist. + +--- + +### Task 4: Implement deterministic extractors + +**Files:** +- Create: `src/extractors.ts` + +- [ ] **Step 1: Add extractor implementation** + +```ts +import { createHash } from "crypto"; +import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types"; +import { LONG_TERM_LIMITS } from "./types"; + +function id(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +function hash(value: string): string { + return createHash("sha1").update(value).digest("hex").slice(0, 12); +} + +export function extractExplicitMemories(text: string): LongTermMemoryEntry[] { + const patterns = [ + /(?:请记住|記住|记住这一点|remember this|commit to memory)[::]?\s*(.+)$/im, + /(?:从现在开始|從現在開始|从今以后|從今以後|from now on|always)[::]?\s*(.+)$/im, + ]; + + const now = new Date().toISOString(); + const entries: LongTermMemoryEntry[] = []; + + for (const pattern of patterns) { + const match = text.match(pattern); + const body = match?.[1]?.trim(); + if (!body || body.length < 8) continue; + if (/^(再说|再說|later|next time)$/i.test(body)) continue; + + entries.push({ + id: id("mem"), + type: classifyExplicitMemory(body), + text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars), + source: "explicit", + confidence: 1, + status: "active", + createdAt: now, + updatedAt: now, + staleAfterDays: staleAfterDaysFor(classifyExplicitMemory(body)), + }); + } + + return entries; +} + +function classifyExplicitMemory(text: string): LongTermType { + const lower = text.toLowerCase(); + if (/https?:\/\/|linear|slack|notion|dashboard|grafana/.test(lower)) return "reference"; + if (/decide|decision|choose|chosen|决定|決定|选择|選擇/.test(lower)) return "decision"; + if (/project|workspace|repo|项目|專案/.test(lower)) return "project"; + return "feedback"; +} + +export function staleAfterDaysFor(type: LongTermType): number | undefined { + if (type === "feedback") return undefined; + if (type === "decision") return 45; + if (type === "project") return 60; + return 90; +} + +export function extractActiveFiles( + toolName: string, + 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" }]; + if (toolName === "grep") return extractGrepPaths(output).map(path => ({ path, action: "grep" as const })); + return []; +} + +function extractGrepPaths(output: string): string[] { + const matches = output.match(/^(\/[^ + return [...new Set(matches.map(match => match.replace(/:$/, "")))].slice(0, 10); +} + +export function extractErrorsFromBash(command: string, output: string): OpenError[] { + const lines = output.split("\n").filter(line => /error|failed|failure|exception|TS\d{4}|ERR!/i.test(line)).slice(0, 5); + if (lines.length === 0) return []; + + const category = classifyCommand(command) ?? "runtime"; + const summary = lines.join(" ").slice(0, 280); + const fingerprint = hash(`${category}:${summary.toLowerCase().replace(/\s+/g, " ")}`); + const now = Date.now(); + + return [{ + id: `err_${fingerprint}`, + category, + summary, + command, + file: extractFirstPath(summary), + fingerprint, + status: "open", + firstSeen: now, + lastSeen: now, + seenCount: 1, + }]; +} + +export function classifyCommand(command: string): OpenError["category"] | null { + const c = command.toLowerCase(); + if (/\b(tsc|typecheck)\b/.test(c)) return "typecheck"; + if (/\b(test|vitest|jest|mocha|pytest|go test|cargo test)\b/.test(c)) return "test"; + if (/\b(lint|eslint|biome)\b/.test(c)) return "lint"; + if (/\b(build|vite build|webpack|tsup)\b/.test(c)) return "build"; + return null; +} + +function extractFirstPath(text: string): string | undefined { + return text.match(/[\w./-]+\.(ts|tsx|js|jsx|json|md|py|go|rs)/)?.[0]; +} + +export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] { + const match = summary.match(/([\s\S]*?)<\/workspace_memory_candidates>/i); + if (!match) return []; + + const now = new Date().toISOString(); + const entries: LongTermMemoryEntry[] = []; + + for (const line of match[1].split("\n")) { + const item = line.trim().match(/^-\s*\[(feedback|project|decision|reference)\]\s*(.+)$/i); + if (!item) continue; + const type = item[1].toLowerCase() as LongTermType; + const body = item[2].trim(); + if (body.length < 12) continue; + entries.push({ + id: id("mem"), + type, + text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars), + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + staleAfterDays: staleAfterDaysFor(type), + }); + } + + return entries; +} +``` + +- [ ] **Step 2: Run extractor tests** + +Run: `npm test` + +Expected: PASS for extractor tests. + +--- + +### Wave 1 verification checkpoint + +- [ ] **Step 1: Run all checks** + +Run: `npm test && npm run typecheck` + +Expected: PASS. + +- [ ] **Step 2: Review wave output** + +Confirm: Types, paths, storage helpers, and deterministic extractors exist and tests cover clear remember, false positives, active files, bash errors, and compaction candidates. + +- [ ] **Step 3: Commit wave** + +```bash +git add package.json src tests +git commit -m "refactor: add memory v2 core primitives" +``` + +--- + +## Wave 2 — Workspace Memory and Hot Session State + +### Task 5: Implement workspace memory store + +**Files:** +- Create: `src/workspace-memory.ts` +- Test: `tests/workspace-memory.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `tests/workspace-memory.test.ts`: + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import type { LongTermMemoryEntry } from "../src/types.ts"; +import { enforceLongTermLimits, renderWorkspaceMemory } from "../src/workspace-memory.ts"; + +function entry(text: string, type: LongTermMemoryEntry["type"] = "feedback"): LongTermMemoryEntry { + const now = new Date().toISOString(); + return { id: text, type, text, source: "explicit", confidence: 1, status: "active", createdAt: now, updatedAt: now }; +} + +test("enforceLongTermLimits dedupes entries", () => { + const kept = enforceLongTermLimits([entry("Memory must be invisible"), entry("Memory must be invisible")]); + assert.equal(kept.length, 1); +}); + +test("renderWorkspaceMemory includes verify marker for stale decisions", () => { + const old = entry("Use JSON storage", "decision"); + old.createdAt = "2020-01-01T00:00:00.000Z"; + old.staleAfterDays = 45; + const rendered = renderWorkspaceMemory({ version: 1, workspace: { root: "/repo", key: "abc" }, limits: { maxRenderedChars: 5200, maxEntries: 28 }, entries: [old], updatedAt: old.createdAt }); + assert.match(rendered, /verify/); +}); +``` + +- [ ] **Step 2: Implement workspace memory functions** + +Create `src/workspace-memory.ts` with: + +```ts +import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types"; +import { LONG_TERM_LIMITS } from "./types"; +import { workspaceKey, workspaceMemoryPath } from "./paths"; +import { atomicWriteJSON, readJSON } from "./storage"; + +export async function emptyWorkspaceMemory(root: string): Promise { + return { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [], + updatedAt: new Date().toISOString(), + }; +} + +export async function loadWorkspaceMemory(root: string): Promise { + return readJSON(await workspaceMemoryPath(root), () => ({ + version: 1, + workspace: { root, key: "unknown" }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [], + updatedAt: new Date().toISOString(), + })); +} + +export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise { + store.workspace = { root, key: await workspaceKey(root) }; + store.entries = enforceLongTermLimits(store.entries); + store.updatedAt = new Date().toISOString(); + await atomicWriteJSON(await workspaceMemoryPath(root), store); +} + +export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] { + const byKey = new Map(); + for (const entry of entries.filter(e => e.status === "active")) { + const text = entry.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars); + const key = `${entry.type}:${text.toLowerCase().replace(/\s+/g, " ").trim()}`; + const existing = byKey.get(key); + if (!existing || entry.source === "explicit") byKey.set(key, { ...entry, text }); + } + return [...byKey.values()] + .sort((a, b) => priority(b) - priority(a)) + .slice(0, LONG_TERM_LIMITS.maxEntries); +} + +function priority(entry: LongTermMemoryEntry): number { + const type = { feedback: 400, decision: 300, project: 200, reference: 100 }[entry.type]; + const source = entry.source === "explicit" ? 1000 : 0; + return source + type + entry.confidence * 10; +} + +export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string { + const active = enforceLongTermLimits(store.entries); + if (active.length === 0) return ""; + const lines = [ + "", + "Persistent workspace memory. Use as background; verify stale or code-related claims.", + ]; + for (const type of ["feedback", "project", "decision", "reference"] as const) { + const items = active.filter(e => e.type === type); + if (items.length === 0) continue; + lines.push(`${type}:`); + for (const item of items) lines.push(`- ${renderEntry(item)}`); + } + lines.push(""); + return lines.join("\n").slice(0, store.limits.maxRenderedChars); +} + +function renderEntry(entry: LongTermMemoryEntry): string { + const ageDays = Math.floor((Date.now() - new Date(entry.createdAt).getTime()) / 86_400_000); + const stale = entry.staleAfterDays && ageDays > entry.staleAfterDays ? ` [${ageDays}d old, verify]` : ""; + const rationale = entry.rationale ? ` Why: ${entry.rationale.slice(0, LONG_TERM_LIMITS.maxRationaleChars)}` : ""; + return `${entry.text}${rationale}${stale}`; +} +``` + +- [ ] **Step 3: Run tests** + +Run: `npm test` + +Expected: PASS. + +--- + +### Task 6: Implement session state lifecycle + +**Files:** +- Create: `src/session-state.ts` +- Test: `tests/session-state.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `tests/session-state.test.ts`: + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import { createEmptySessionState, touchActiveFile, upsertOpenError, clearErrorsForSuccessfulCommand, renderHotSessionState } from "../src/session-state.ts"; +import type { OpenError } from "../src/types.ts"; + +test("touchActiveFile weights edits above reads", () => { + const state = createEmptySessionState("s1"); + touchActiveFile(state, "/repo/a.ts", "read"); + touchActiveFile(state, "/repo/b.ts", "edit"); + assert.equal(state.activeFiles[0].path, "/repo/b.ts"); +}); + +test("clearErrorsForSuccessfulCommand clears category", () => { + const state = createEmptySessionState("s1"); + const err: OpenError = { id: "e", category: "typecheck", summary: "TS error", fingerprint: "f", status: "open", firstSeen: 1, lastSeen: 1, seenCount: 1 }; + upsertOpenError(state, err); + clearErrorsForSuccessfulCommand(state, "npm run typecheck"); + assert.equal(state.openErrors.length, 0); +}); + +test("renderHotSessionState includes active files and open errors", () => { + const state = createEmptySessionState("s1"); + touchActiveFile(state, "/repo/index.ts", "edit"); + upsertOpenError(state, { id: "e", category: "test", summary: "test failed", fingerprint: "f", status: "open", firstSeen: 1, lastSeen: 1, seenCount: 1 }); + const rendered = renderHotSessionState(state, "/repo"); + assert.match(rendered, /index.ts/); + assert.match(rendered, /test failed/); +}); +``` + +- [ ] **Step 2: Implement session state functions** + +Create `src/session-state.ts` with create/load/save/touch/upsert/clear/render functions matching the tests. + +- [ ] **Step 3: Run tests** + +Run: `npm test` + +Expected: PASS. + +--- + +### Wave 2 verification checkpoint + +- [ ] **Step 1: Run all checks** + +Run: `npm test && npm run typecheck` + +Expected: PASS. + +- [ ] **Step 2: Review wave output** + +Confirm: Long-term store enforces limits and renders staleness. Hot session state ranks active files, stores open errors, and clears category errors on successful validation commands. + +- [ ] **Step 3: Commit wave** + +```bash +git add src tests +git commit -m "feat: add workspace memory and hot session state" +``` + +--- + +## Wave 3 — Plugin Hook Integration + +### Task 7: Wire OpenCode helper functions + +**Files:** +- Create: `src/opencode.ts` + +- [ ] **Step 1: Add SDK wrappers** + +Create `src/opencode.ts` with helpers: + +```ts +export async function latestUserText(client: any, sessionID: string): Promise<{ id: string; text: string } | null> { + const result = await client.session.messages({ path: { id: sessionID } }); + const messages = result.data ?? []; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.info?.role !== "user") continue; + const text = msg.parts?.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") ?? ""; + if (text.trim()) return { id: msg.info.id, text }; + } + return null; +} + +export async function latestCompactionSummary(client: any, sessionID: string): Promise { + const result = await client.session.messages({ path: { id: sessionID } }); + const messages = result.data ?? []; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.info?.role !== "assistant" || msg.info?.summary !== true) continue; + const text = msg.parts?.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") ?? ""; + if (text.trim()) return text; + } + return null; +} + +export async function pendingTodos(client: any, sessionID: string): Promise> { + try { + const result = await client.session.todo({ path: { id: sessionID } }); + return (result.data ?? []).filter((todo: any) => todo.status !== "completed"); + } catch { + return []; + } +} +``` + +- [ ] **Step 2: Run typecheck** + +Run: `npm run typecheck` + +Expected: PASS. + +--- + +### Task 8: Implement plugin orchestration + +**Files:** +- Create: `src/plugin.ts` +- Modify: `index.ts` + +- [ ] **Step 1: Replace `index.ts` entrypoint** + +```ts +export { default } from "./src/plugin"; +``` + +- [ ] **Step 2: Implement hooks in `src/plugin.ts`** + +Create plugin that: + +- caches frozen workspace memory per `sessionID` +- processes explicit memory from latest user text once per message id +- injects frozen workspace memory and dynamic hot session state +- updates session state after tools +- augments compaction context with memory, hot state, todos, and memory candidate instruction +- parses compaction summaries from `session.compacted` event and merges candidates + +The compaction instruction must be: + +```ts +function memoryCandidateInstruction(): string { + return ` +At the end of the compaction summary, include: + + +- [feedback] ... +- [project] ... +- [decision] ... +- [reference] ... + + +Only include durable information useful across future sessions in this exact workspace. +Do NOT include active file lists, raw errors, temporary progress, stack traces, code signatures, API docs, git history, or facts easily rediscovered from the repository. +For decisions, include rationale in one sentence. +If nothing qualifies, output an empty block. +`.trim(); +} +``` + +- [ ] **Step 3: Run typecheck** + +Run: `npm run typecheck` + +Expected: PASS. + +--- + +### Wave 3 verification checkpoint + +- [ ] **Step 1: Run all checks** + +Run: `npm test && npm run typecheck` + +Expected: PASS. + +- [ ] **Step 2: Manual plugin smoke test** + +Run OpenCode with local plugin and verify: + +- user message `请记住:这个 workspace 的 memory 功能要默认无感` creates a long-term entry +- reading/editing files updates hot session state +- failed typecheck creates an open error +- successful typecheck clears typecheck errors + +- [ ] **Step 3: Commit wave** + +```bash +git add index.ts src tests +git commit -m "feat: wire memory v2 plugin hooks" +``` + +--- + +## Wave 4 — Documentation and Migration + +### Task 9: Update documentation + +**Files:** +- Modify: `README.md` +- Modify: `docs/architecture.md` +- Modify: `docs/configuration.md` +- Modify: `AGENTS.md` + +- [ ] **Step 1: Update README feature summary** + +Describe Memory V2 as: + +- workspace-scoped long-term memory +- hot session state +- no default agent-visible memory tools +- no raw tool-output cache +- compaction boundary extraction with no extra LLM call + +- [ ] **Step 2: Update architecture doc** + +Replace four-tier architecture with: + +```text +Layer 1: Stable Workspace Memory +Layer 2: Hot Session State +Layer 3: Native OpenCode State +``` + +- [ ] **Step 3: Update configuration doc** + +Document: + +- `LONG_TERM_LIMITS` +- `HOT_STATE_LIMITS` +- storage root under `XDG_DATA_HOME` or `~/.local/share` +- optional future `/memory import` + +- [ ] **Step 4: Update AGENTS.md** + +Update commands: + +```bash +npm test +npm run typecheck +``` + +Update storage and testing guidance to match Memory V2. + +--- + +### Task 10: Remove obsolete implementation paths + +**Files:** +- Modify: `index.ts` if old code remains +- Modify: docs references if any still mention old APIs + +- [ ] **Step 1: Remove obsolete references** + +Ensure repo no longer advertises default tools: + +- `core_memory_update` +- `core_memory_read` +- `working_memory_add` +- `working_memory_clear` +- `working_memory_clear_slot` +- `working_memory_remove` + +Unless a debug-only compatibility layer is explicitly retained, these names must not appear in README or architecture docs. + +- [ ] **Step 2: Remove obsolete concepts from docs** + +Remove or mark deprecated: + +- slots/pool/decay +- pressure monitor as core feature +- raw tool-output cache +- smart pruning replacing old tool outputs + +- [ ] **Step 3: Run docs grep** + +Run: `grep -R "core_memory_update\|working_memory_add\|pressure monitor\|tool-output cache" README.md docs AGENTS.md` + +Expected: no matches, or matches only under a clearly marked migration note. + +--- + +### Wave 4 verification checkpoint + +- [ ] **Step 1: Run all checks** + +Run: `npm test && npm run typecheck` + +Expected: PASS. + +- [ ] **Step 2: Verify docs match code** + +Confirm: README, architecture, configuration, and AGENTS describe Memory V2 and do not promise old tools or old four-tier behavior. + +- [ ] **Step 3: Commit wave** + +```bash +git add README.md docs AGENTS.md index.ts src tests package.json +git commit -m "docs: document memory v2 design" +``` + +--- + +## Verification Strategy + +### Automated + +- `npm test` validates extractors, long-term merge/render, and hot session lifecycle. +- `npm run typecheck` validates TypeScript imports and plugin entrypoint. + +### Manual OpenCode smoke tests + +1. Start a session with the plugin enabled. +2. Send: `请记住:这个 workspace 的 memory 功能要默认无感`. +3. Confirm `workspace-memory.json` is written under `~/.local/share/opencode-working-memory/workspaces//`. +4. Read and edit a file. +5. Confirm session state active files update. +6. Run a failing typecheck command. +7. Confirm open error appears in hot state. +8. Run a passing typecheck command. +9. Confirm typecheck error clears. +10. Trigger or simulate compaction. +11. Confirm compaction context includes memory candidate instruction and parsed candidates merge after compaction. + +--- + +## Risk Controls + +- **False memory extraction:** explicit regex only matches strong remember/from-now-on phrasing; compaction extraction uses explicit “what not to save” boundaries. +- **Token overhead:** no background LLM agent; compaction extraction piggybacks existing compaction call; hot state capped at 1200 chars. +- **Stale memory:** decision/project/reference entries have stale markers during render. +- **Privacy:** storage lives in user data directory, not repo, and writes with `0600` mode. +- **Duplicate todo state:** todos are not stored by the plugin; OpenCode remains source of truth. +- **Error staleness:** errors clear only after successful validation commands and become `maybe_fixed` after related edits. + +--- + +## Self-Review + +- Spec coverage: plan implements workspace-scoped cross-session memory, bounded long-term memory, compaction-boundary update, fully automatic hot session memory, and no extra LLM calls. +- Placeholder scan: plan contains no TBD/TODO placeholders; Tasks 8-10 reference exact expected behavior and code boundaries. +- Type consistency: `LongTermMemoryEntry`, `WorkspaceMemoryStore`, `SessionState`, `ActiveFile`, `OpenError`, and `SessionDecision` are defined once in Task 1 and reused consistently. +- Wave coherence: each wave ends with tests/typecheck and a committable checkpoint. diff --git a/src/extractors.ts b/src/extractors.ts index 34b080f..8c506d4 100644 --- a/src/extractors.ts +++ b/src/extractors.ts @@ -223,18 +223,22 @@ function shouldAcceptWorkspaceMemoryCandidate(entry: { /** * Extract candidate block from summary using multiple formats. - * Supports: HTML comment, Markdown section, legacy XML. + * Supports: Plain text label, Markdown section, legacy XML. */ function extractCandidateBlock(summary: string): string | null { - // 1. HTML comment block (preferred, hidden from user) - const commentMatch = summary.match(//i); - if (commentMatch) return commentMatch[1]; + // 1. Plain text label (primary format, no Markdown header) + const plainMatch = summary.match(/Memory candidates:\s*\n([\s\S]*?)(?:\n[A-Z][a-z]+ [a-z]+:|\n##\s|$)/i); + if (plainMatch) return plainMatch[1]; - // 2. Markdown section (visible but clean) - const markdownMatch = summary.match(/##\s*Workspace Memory Candidates\s*\n([\s\S]*?)(?:\n##|$)/i); + // 2. Markdown section (legacy) + const markdownMatch = summary.match(/##\s*Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i); if (markdownMatch) return markdownMatch[1]; - // 3. Legacy XML block (backward compatible) + // 3. Legacy "Workspace Memory Candidates" section + const legacyMatch = summary.match(/##\s*Workspace Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i); + if (legacyMatch) return legacyMatch[1]; + + // 4. Legacy XML block (backward compatible) const xmlMatch = summary.match(/([\s\S]*?)<\/workspace_memory_candidates>/i); if (xmlMatch) return xmlMatch[1]; diff --git a/src/plugin.ts b/src/plugin.ts index 773e619..b6769a7 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -45,46 +45,20 @@ import { pendingTodos, } from "./opencode.ts"; -/** - * Strip XML-like tags from text for Markdown-neutral rendering. - * Converts `` blocks to plain "Workspace memory:" sections. - */ -function stripXmlTags(text: string): string { - if (!text) return ""; - - // Replace XML tag pairs with Markdown headers - return text - .replace(/\n?/gi, "## Workspace Memory\n") - .replace(/<\/workspace_memory>\n?/gi, "") - .replace(/\n?/gi, "## Hot Session State\n") - .replace(/<\/hot_session_state>\n?/gi, "") - .replace(/\n?/gi, "## Pending Todos\n") - .replace(/<\/pending_todos>\n?/gi, ""); -} - /** * Generate instructions for the compaction model. - * IMPORTANT: These instructions make clear what should NOT be in the final output. */ function compactionContextHeader(): string { return ` -[PRIVATE COMPACTION CONTEXT - DO NOT OUTPUT] -The following sections are PRIVATE INPUT for updating the compaction summary. -DO NOT copy these sections, their headings, or their contents verbatim. -Use the facts only to update the normal summary sections (Goal, Progress, etc.). +Background context for memory extraction (do not output verbatim): +- Use facts to update Goal/Progress/Key Decisions/Next Steps sections +- At the end, emit durable future-session memories in this format: -At the VERY END of your summary, if there are durable memory candidates, include this hidden block: - - - -Only include truly durable information useful across FUTURE sessions. -If nothing qualifies, omit the block entirely. -DO NOT use XML tags like . -DO NOT start with "---" frontmatter delimiters. - -[END PRIVATE COMPACTION CONTEXT] +Memory candidates: +- [feedback] content +- [project] content +- [decision] content +- [reference] content `.trim(); } @@ -97,11 +71,11 @@ function memoryCandidateInstruction(): string { } /** - * Render todos for compaction context (Markdown-neutral format). + * 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"]; + const lines = ["Pending todos:"]; for (const todo of todos) { const priority = todo.priority ? ` [${todo.priority}]` : ""; const status = todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "→" : "○"; @@ -329,22 +303,22 @@ export const MemoryV2Plugin: Plugin = async (input) => { // Sub-agents don't need compaction support if (await isSubAgent(sessionID)) return; - // Build private context with Markdown-neutral format - const contextParts: string[] = []; +// Build private context with Markdown-neutral format +const contextParts: string[] = []; - // 1. Frozen workspace memory (strip XML tags for compaction context) - const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID); - const workspacePrompt = renderWorkspaceMemory(workspaceMemory); - if (workspacePrompt) { - contextParts.push(stripXmlTags(workspacePrompt)); - } +// 1. Frozen workspace memory +const workspaceMemory = await getFrozenWorkspaceMemory(directory, sessionID); +const workspacePrompt = renderWorkspaceMemory(workspaceMemory); +if (workspacePrompt) { + contextParts.push(workspacePrompt); +} - // 2. Hot session state (strip XML tags for compaction context) - const sessionState = await loadSessionState(directory, sessionID); - const hotPrompt = renderHotSessionState(sessionState, directory); - if (hotPrompt) { - contextParts.push(stripXmlTags(hotPrompt)); - } +// 2. Hot session state +const sessionState = await loadSessionState(directory, sessionID); +const hotPrompt = renderHotSessionState(sessionState, directory); +if (hotPrompt) { + contextParts.push(hotPrompt); +} // 3. Pending todos from OpenCode (Markdown-neutral format) const todos = await pendingTodos(client, sessionID); diff --git a/src/session-state.ts b/src/session-state.ts index 4cc29b2..db0614b 100644 --- a/src/session-state.ts +++ b/src/session-state.ts @@ -180,7 +180,7 @@ export function renderHotSessionState(state: SessionState, workspaceRoot: string if (activeFiles.length === 0 && openErrors.length === 0 && decisions.length === 0) return ""; - const lines: string[] = [""]; + const lines: string[] = ["Hot session state (current session):"]; if (activeFiles.length > 0) { lines.push("active_files:"); @@ -204,7 +204,6 @@ export function renderHotSessionState(state: SessionState, workspaceRoot: string } } - lines.push(""); return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars); } diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 3a4c5e6..4631abf 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -136,10 +136,8 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string { // If maxChars smaller than minimum envelope, return empty string if (maxChars < MIN_ENVELOPE_LENGTH) return ""; - const closing = ""; const lines: string[] = [ - "", - "Persistent workspace memory. Use as background; verify stale or code-related claims.", + "Workspace memory (cross-session, verify if stale):", ]; for (const type of ["feedback", "project", "decision", "reference"] as const) { @@ -150,17 +148,16 @@ export function renderWorkspaceMemory(store: WorkspaceMemoryStore): string { for (const item of items) { const line = `- ${renderEntry(item)}`; - if (wouldFit([...lines, ...sectionLines], line, closing, maxChars)) { + if ([...lines, ...sectionLines, line].join("\n").length <= maxChars) { sectionLines.push(line); } } - if (sectionLines.length > 1 && wouldFit(lines, sectionLines[0], closing, maxChars)) { + if (sectionLines.length > 1) { lines.push(...sectionLines); } } - lines.push(closing); return lines.join("\n"); } diff --git a/tests/extractors.test.ts b/tests/extractors.test.ts index 21426c1..28cd039 100644 --- a/tests/extractors.test.ts +++ b/tests/extractors.test.ts @@ -133,9 +133,8 @@ import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; test("parseWorkspaceMemoryCandidates rejects short text", () => { const summary = ` - +## Memory Candidates - [decision] short text - `; const items = parseWorkspaceMemoryCandidates(summary); assert.equal(items.length, 0); @@ -143,9 +142,8 @@ test("parseWorkspaceMemoryCandidates rejects short text", () => { test("parseWorkspaceMemoryCandidates rejects git commit hash", () => { const summary = ` - +## Memory Candidates - [project] abc123def456 is the commit hash - `; const items = parseWorkspaceMemoryCandidates(summary); assert.equal(items.length, 0); @@ -153,9 +151,8 @@ test("parseWorkspaceMemoryCandidates rejects git commit hash", () => { test("parseWorkspaceMemoryCandidates rejects raw error", () => { const summary = ` - +## Memory Candidates - [feedback] TypeError: Cannot read property 'x' of undefined - `; const items = parseWorkspaceMemoryCandidates(summary); assert.equal(items.length, 0); @@ -163,9 +160,8 @@ test("parseWorkspaceMemoryCandidates rejects raw error", () => { test("parseWorkspaceMemoryCandidates rejects stack trace", () => { const summary = ` - +## Memory Candidates - [reference] at foo (bar.ts:10:5) - `; const items = parseWorkspaceMemoryCandidates(summary); assert.equal(items.length, 0); @@ -173,9 +169,8 @@ test("parseWorkspaceMemoryCandidates rejects stack trace", () => { test("parseWorkspaceMemoryCandidates rejects commit prefix", () => { const summary = ` - +## Memory Candidates - [project] fix: add new feature - `; const items = parseWorkspaceMemoryCandidates(summary); assert.equal(items.length, 0); @@ -183,9 +178,8 @@ test("parseWorkspaceMemoryCandidates rejects commit prefix", () => { test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => { const summary = ` - +## Memory Candidates - [project] files at /src/a.ts /src/b.ts /src/c.ts are important - `; const items = parseWorkspaceMemoryCandidates(summary); assert.equal(items.length, 0); @@ -193,9 +187,8 @@ test("parseWorkspaceMemoryCandidates rejects path-heavy facts", () => { test("parseWorkspaceMemoryCandidates accepts valid decision", () => { const summary = ` - +## Memory Candidates - [decision] Use pnpm instead of npm for package management - `; const items = parseWorkspaceMemoryCandidates(summary); assert.equal(items.length, 1); @@ -205,11 +198,22 @@ test("parseWorkspaceMemoryCandidates accepts valid decision", () => { test("parseWorkspaceMemoryCandidates accepts valid project info", () => { const summary = ` - +## Memory Candidates - [project] This project uses TypeScript for all source files - `; const items = parseWorkspaceMemoryCandidates(summary); assert.equal(items.length, 1); assert.equal(items[0].type, "project"); +}); + +test("parseWorkspaceMemoryCandidates accepts plain text label format (no Markdown)", () => { + const summary = ` +Memory candidates: +- [decision] Use plain text labels to avoid purple Markdown headers +- [project] This repo uses pnpm for package management +`; + const items = parseWorkspaceMemoryCandidates(summary); + assert.equal(items.length, 2); + assert.equal(items[0].type, "decision"); + assert.equal(items[1].type, "project"); }); \ No newline at end of file diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 4261068..58a6633 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -235,13 +235,13 @@ test("experimental.session.compacting: context contains no XML tags for compacti assert.equal(contextText.includes(""), false, "Context should not contain tag"); - // Verify: context should contain the private context header - assert.equal(contextText.includes("[PRIVATE COMPACTION CONTEXT - DO NOT OUTPUT]"), true, - "Context should contain private context header"); + // Verify: context should NOT contain square bracket markers + assert.equal(contextText.includes("[PRIVATE COMPACTION CONTEXT"), false, + "Context should not contain square bracket markers"); - // Verify: context should contain Markdown headers instead - assert.equal(contextText.includes("## Workspace Memory") || contextText.includes("## Hot Session State") || contextText.includes("## Pending Todos"), true, - "Context should use Markdown headers instead of XML tags"); + // Verify: context should contain plain text headers + assert.equal(contextText.includes("Workspace memory") || contextText.includes("Hot session state") || contextText.includes("Pending todos"), true, + "Context should use plain text headers instead of XML tags"); } finally { await rm(tmpDir, { recursive: true, force: true }); @@ -264,48 +264,34 @@ test("compactionContextHeader does not require XML output", async () => { const header = output.context[0] || ""; - // Should instruct HTML comment format - assert.equal(header.includes(" Next steps: continue development. `; const candidates = parseWorkspaceMemoryCandidates(summary); - assert.equal(candidates.length, 2, "Should parse HTML comment format"); + assert.equal(candidates.length, 2, "Should parse Markdown section format"); assert.equal(candidates[0].type, "decision"); assert.equal(candidates[1].type, "project"); }); -test("parseWorkspaceMemoryCandidates accepts Markdown section format", async () => { +test("parseWorkspaceMemoryCandidates accepts legacy Workspace Memory Candidates section", async () => { const summary = ` ## Summary Progress made on testing. @@ -318,7 +304,7 @@ Continue development. `; const candidates = parseWorkspaceMemoryCandidates(summary); - assert.equal(candidates.length, 1, "Should parse Markdown section format"); + assert.equal(candidates.length, 1, "Should parse legacy section format"); assert.equal(candidates[0].type, "reference"); }); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index 095491e..e0a7545 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -21,7 +21,7 @@ function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "de // Task 2: renderWorkspaceMemory tests // ============================================ -test("renderWorkspaceMemory never truncates closing XML tag", () => { +test("renderWorkspaceMemory respects budget and fits entries", () => { const entries = Array.from({ length: 28 }, (_, i) => entry(`mem_${i}`, `Long durable memory entry ${i} `.repeat(20)) ); @@ -36,8 +36,8 @@ test("renderWorkspaceMemory never truncates closing XML tag", () => { const rendered = renderWorkspaceMemory(store); - assert.ok(rendered.endsWith(""), - `Rendered memory must end with closing tag. Got: ...${rendered.slice(-50)}`); + assert.ok(!rendered.includes(""), + "Should not contain XML tags"); assert.ok(rendered.length <= 700, `Rendered memory must not exceed maxChars. Got: ${rendered.length}`); }); @@ -56,7 +56,7 @@ test("renderWorkspaceMemory returns empty string when maxChars too small", () => "When maxChars too small for even minimal envelope, return empty string"); }); -test("renderWorkspaceMemory respects budget and fits entries", () => { +test("renderWorkspaceMemory respects small budget", () => { // Create entries that would overflow a small budget const entries = [ entry("a", "First memory entry that is reasonably long"), @@ -74,8 +74,8 @@ test("renderWorkspaceMemory respects budget and fits entries", () => { const rendered = renderWorkspaceMemory(store); - assert.ok(rendered.endsWith(""), - "Must end with closing tag even when truncating entries"); + assert.ok(!rendered.includes(""), + "Should not contain XML tags"); assert.ok(rendered.length <= 200, `Must respect maxChars limit. Got: ${rendered.length}`); });