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.
This commit is contained in:
Ralph Chang
2026-04-26 15:13:58 +08:00
parent 32fa2bd454
commit 721544e7a8
8 changed files with 1056 additions and 116 deletions
@@ -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 Nodes 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<string> {
const resolved = await realpath(root).catch(() => root);
return createHash("sha256").update(resolved).digest("hex").slice(0, 16);
}
export async function memoryRoot(root: string): Promise<string> {
return join(dataHome(), "opencode-working-memory", "workspaces", await workspaceKey(root));
}
export async function workspaceMemoryPath(root: string): Promise<string> {
return join(await memoryRoot(root), "workspace-memory.json");
}
export async function sessionStatePath(root: string, sessionID: string): Promise<string> {
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<T>(path: string, fallback: () => T): Promise<T> {
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<void> {
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
<workspace_memory_candidates>
- [decision] Use JSON as canonical storage because it is easier to validate.
- [reference] External design notes are in Notion.
</workspace_memory_candidates>`);
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<string, unknown>,
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(/<workspace_memory_candidates>([\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<WorkspaceMemoryStore> {
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<WorkspaceMemoryStore> {
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<void> {
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<string, LongTermMemoryEntry>();
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 = [
"<workspace_memory>",
"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("</workspace_memory>");
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<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 !== "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<Array<{ content: string; status: string; priority?: string }>> {
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:
<workspace_memory_candidates>
- [feedback] ...
- [project] ...
- [decision] ...
- [reference] ...
</workspace_memory_candidates>
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/<hash>/`.
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.
+11 -7
View File
@@ -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(/<!--\s*workspace_memory_candidates\s*\n([\s\S]*?)-->/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(/<workspace_memory_candidates>([\s\S]*?)<\/workspace_memory_candidates>/i);
if (xmlMatch) return xmlMatch[1];
+24 -50
View File
@@ -45,46 +45,20 @@ import {
pendingTodos,
} from "./opencode.ts";
/**
* Strip XML-like tags from text for Markdown-neutral rendering.
* Converts `<workspace_memory>` blocks to plain "Workspace memory:" sections.
*/
function stripXmlTags(text: string): string {
if (!text) return "";
// Replace XML tag pairs with Markdown headers
return text
.replace(/<workspace_memory>\n?/gi, "## Workspace Memory\n")
.replace(/<\/workspace_memory>\n?/gi, "")
.replace(/<hot_session_state>\n?/gi, "## Hot Session State\n")
.replace(/<\/hot_session_state>\n?/gi, "")
.replace(/<pending_todos>\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:
<!-- workspace_memory_candidates
- [type] content (types: feedback, project, decision, reference)
-->
Only include truly durable information useful across FUTURE sessions.
If nothing qualifies, omit the block entirely.
DO NOT use XML tags like <workspace_memory_candidates>.
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);
+1 -2
View File
@@ -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[] = ["<hot_session_state>"];
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("</hot_session_state>");
return lines.join("\n").slice(0, HOT_STATE_LIMITS.maxRenderedChars);
}
+3 -6
View File
@@ -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 = "</workspace_memory>";
const lines: string[] = [
"<workspace_memory>",
"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");
}
+20 -16
View File
@@ -133,9 +133,8 @@ import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
test("parseWorkspaceMemoryCandidates rejects short text", () => {
const summary = `
<workspace_memory_candidates>
## Memory Candidates
- [decision] short text
</workspace_memory_candidates>
`;
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 = `
<workspace_memory_candidates>
## Memory Candidates
- [project] abc123def456 is the commit hash
</workspace_memory_candidates>
`;
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 = `
<workspace_memory_candidates>
## Memory Candidates
- [feedback] TypeError: Cannot read property 'x' of undefined
</workspace_memory_candidates>
`;
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 = `
<workspace_memory_candidates>
## Memory Candidates
- [reference] at foo (bar.ts:10:5)
</workspace_memory_candidates>
`;
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 = `
<workspace_memory_candidates>
## Memory Candidates
- [project] fix: add new feature
</workspace_memory_candidates>
`;
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 = `
<workspace_memory_candidates>
## Memory Candidates
- [project] files at /src/a.ts /src/b.ts /src/c.ts are important
</workspace_memory_candidates>
`;
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 = `
<workspace_memory_candidates>
## Memory Candidates
- [decision] Use pnpm instead of npm for package management
</workspace_memory_candidates>
`;
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 = `
<workspace_memory_candidates>
## Memory Candidates
- [project] This project uses TypeScript for all source files
</workspace_memory_candidates>
`;
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");
});
+15 -29
View File
@@ -235,13 +235,13 @@ test("experimental.session.compacting: context contains no XML tags for compacti
assert.equal(contextText.includes("<pending_todos>"), false,
"Context should not contain <pending_todos> 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("<!-- workspace_memory_candidates"), true,
"Header should instruct HTML comment format");
// Should NOT instruct legacy XML format as primary
// Note: We check that the instruction for HTML comments comes before any XML mention
const htmlCommentIndex = header.indexOf("<!-- workspace_memory_candidates");
const xmlTagIndex = header.indexOf("<workspace_memory_candidates>\n");
if (xmlTagIndex !== -1) {
assert.equal(htmlCommentIndex < xmlTagIndex, true,
"HTML comment format should be the primary instruction, not XML");
}
// Should explicitly forbid XML
assert.equal(header.includes("DO NOT use XML tags"), true,
"Header should forbid XML tags");
// Should instruct plain text label format (not Markdown headers)
assert.equal(header.includes("Memory candidates:"), true,
"Header should use plain text 'Memory candidates:' label");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("parseWorkspaceMemoryCandidates accepts HTML comment format", async () => {
test("parseWorkspaceMemoryCandidates accepts Markdown section format", async () => {
const summary = `
## Summary
Progress made on testing.
<!-- workspace_memory_candidates
- [decision] Use HTML comments for candidates
## Memory Candidates
- [decision] Use Markdown sections for candidates
- [project] This repo uses Markdown for docs
-->
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");
});
+6 -6
View File
@@ -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("</workspace_memory>"),
`Rendered memory must end with closing tag. Got: ...${rendered.slice(-50)}`);
assert.ok(!rendered.includes("<workspace_memory>"),
"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("</workspace_memory>"),
"Must end with closing tag even when truncating entries");
assert.ok(!rendered.includes("<workspace_memory>"),
"Should not contain XML tags");
assert.ok(rendered.length <= 200,
`Must respect maxChars limit. Got: ${rendered.length}`);
});