chore: ignore superpowers plans and update architecture doc

- Add docs/superpowers/plans/ to .gitignore
- Remove tracked plan files from git
- Update docs/architecture.md:
  - Change primary extraction format from XML to 'Memory candidates:'
  - Mark XML format as legacy/deprecated
  - Fix hot session state injection example
This commit is contained in:
Ralph Chang
2026-04-27 14:53:07 +08:00
parent ca68b7f55c
commit 1c748f3ee2
8 changed files with 17 additions and 6048 deletions
+3
View File
@@ -48,3 +48,6 @@ pnpm-lock.yaml
.opencode/
.opencode-agenthub/
.opencode-agenthub.user.json
# Superpowers local planning artifacts
docs/superpowers/plans/
+14 -8
View File
@@ -73,15 +73,16 @@ Long-term memory that persists across sessions within the same workspace. Perfec
### Memory Extraction
During compaction, the plugin scans for `<workspace_memory_candidates>` blocks:
During compaction, the plugin scans for `Memory candidates:` sections:
```
<workspace_memory_candidates>
Memory candidates:
- [decision] Use npm cache for plugin loading
- [project] This repo uses TypeScript with strict mode
</workspace_memory_candidates>
```
**Legacy Format**: The plugin also accepts `<workspace_memory_candidates>` XML blocks for backward compatibility, but this format is deprecated.
**Quality Gate**: Not all candidates become memories. The plugin rejects:
- Git commit hashes (e.g., `abc1234`)
- Raw errors (e.g., `Error: something failed`)
@@ -180,15 +181,20 @@ Hot session state is injected after workspace memory:
```
---
<workspace_memory_candidates>
- [project] This repo uses TypeScript with strict mode
</workspace_memory_candidates>
Active Files:
Hot session state (current session):
active_files:
- src/plugin.ts (edit, 18x)
- tests/plugin.test.ts (edit, 5x)
Open Errors: (none)
open_errors: (none)
recent_decisions:
- Use frozen workspace memory snapshots for cache stability
pending_memories:
- [decision] Parser supports 3 candidate formats
```
## Layer 3: Native OpenCode State
@@ -1,976 +0,0 @@
# 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.
@@ -1,815 +0,0 @@
# Memory Deduplication and Staleness Analysis
Date: 2026-04-26
## Executive recommendation
Fix this at storage time first, then tighten ingestion prompts.
Storage is the safety net. Every memory entry, whether from compaction, explicit user instruction, or future manual editing, already flows through `normalizeWorkspaceMemory()` in `src/workspace-memory.ts`. That is the right architectural choke point for deduplication, supersession, and lifecycle pruning.
Prompt changes are still useful, but only as a quality reducer. They cannot be the source of truth because model output will drift, multilingual phrasing will vary, and old stores already contain bad entries.
Do not add embeddings yet. This repo has 22 entries, a limit of 28, and all current failures are simple lexical/category problems. Embeddings would add latency, dependencies, nondeterminism, and storage shape questions for a problem that can be solved with boring code.
## Current data flow
```text
OpenCode session.compacted event
latestCompactionSummary(client, sessionID)
parseWorkspaceMemoryCandidates(summary)
│ src/extractors.ts
│ - validates shape and basic quality
│ - assigns type/source/confidence/staleAfterDays
updateWorkspaceMemory(directory, store => {
store.entries.push(...candidates)
})
normalizeWorkspaceMemory(root, store)
│ src/workspace-memory.ts
│ - exact canonical dedupe only
│ - maxEntries trim
workspace-memory.json
```
The broken boundary is clear: ingestion appends all candidates, and normalization only dedupes exact normalized text per type.
## Problem 1: near-duplicate accumulation
### Diagnosis
`canonicalMemoryText()` catches only exact matches after NFKC, lowercase, and punctuation/whitespace collapse. It does not catch:
- same fact with extra location detail
- same path with slightly different label text
- same decision revised from version 3 to version 4
- bilingual restatements of the same project fact
- new fix superseding an older fix for the same issue
This is not one dedupe problem. It is three different classes wearing the same hat.
```text
Near duplicate classes
────────────────────────────────────────────
project/reference → entity identity problem
feedback → topic preference/result problem
decision → supersession/history problem
```
Treating all of these with one fuzzy text threshold will either miss real duplicates or delete useful distinct decisions.
### Ingestion time vs storage time
Use both, with different jobs.
#### Storage time, required
Add deterministic memory normalization in `src/workspace-memory.ts`:
1. exact canonical dedupe, keep existing behavior
2. type-specific identity keys for obvious entities
3. simple lexical similarity for same-type candidates
4. explicit supersession rules for versioned/solution-style decisions
5. lifecycle pruning before `maxEntries` trim
Why storage first:
- one code path for compaction, explicit, manual, and tests
- fixes existing stores on next load/save
- deterministic and unit-testable
- does not depend on model behavior
#### Ingestion time, useful but secondary
Improve `buildCompactionPrompt()` in `src/plugin.ts` so compaction receives existing memory and is told to emit only new or replacing facts.
The current prompt already passes rendered workspace memory as background context and says "Do not output this context verbatim." That is not strong enough. Add a small rule near `Memory candidates:`:
```text
Before emitting a memory candidate, compare it to Background context.
Do not emit a candidate that repeats an existing memory.
If a new candidate replaces an older one, write only the newer statement.
Prefer one canonical statement per project fact, reference path, user feedback topic, or implementation decision.
```
This will reduce noise. It will not eliminate it. Models repeat themselves. Software should expect this.
### Recommended deduplication strategy
Use deterministic, type-aware dedupe. Avoid embeddings. Avoid global fuzzy dedupe as the main rule.
#### 1. Keep exact canonical dedupe
Current logic is good as the first pass.
```ts
dedup key = `${entry.type}:${canonicalMemoryText(text)}`
```
Keep source/confidence tie-breaking.
#### 2. Add type-specific identity extraction
For `project` and `reference`, dedupe by identifiable anchors, not prose.
Examples:
- repo/plugin system facts: normalized phrase key like `opencode-agenthub plugin system`
- file paths: normalized path key, with backticks stripped
- URLs/domains if they appear later
For the current data:
```text
reference:path:.opencode-agenthub/current/xdg/opencode/opencode.json
project:phrase:opencode-agenthub plugin system
```
When two entries share the same identity key, merge them by keeping the more useful text:
1. explicit source beats manual beats compaction
2. higher confidence beats lower confidence
3. more specific text beats vague text, usually longer but cap this to avoid keeping rambles
4. newer beats older if specificity/source/confidence tie
This directly fixes:
- `OpenCode plugin config location: ...` vs `OpenCode plugin config: ...`
- Chinese and English variants that both mention `opencode-agenthub plugin system`
#### 3. Add conservative lexical similarity only inside same type
Use token Jaccard or Dice similarity over normalized tokens after stopword removal. No new dependencies.
Suggested thresholds:
```text
project/reference: >= 0.72 duplicate
feedback: >= 0.70 possible duplicate if same topic anchor exists
decision: do not use fuzzy deletion by default
```
This should be a fallback after identity keys, not the primary system.
Risk: fuzzy matching can delete nearby but distinct decisions. Example: "Markdown headers cause purple text" and "Plain text labels avoid special markup" are related but both useful in the history of the bug.
Keep fuzzy matching conservative and type-scoped.
#### 4. Use explicit supersession for decisions
Decision duplication is fundamentally different. Decisions often form a timeline. Some are still valuable context, some are obsolete.
The pair below is supersession, not duplication:
```text
Parser supports 3 formats: HTML comment, Markdown section, legacy XML
Parser supports 4 formats: plain text label, Markdown section, legacy section name, legacy XML
```
The right model is: newer active decision supersedes older active decision on the same topic.
Keep this simple. Do not build a knowledge graph.
Add a small `decisionTopicKey(text)` heuristic:
```text
parser supports <n> formats → decision:parser-supported-formats
solution: use ... → decision:purple-italic-output-format, if text contains purple/italic/markup/markdown/xml/html/comment/label
use output.prompt ... template → decision:compaction-template-replacement
opencode plugin load/config facts → decision:plugin-loading-config
```
That sounds bespoke, but that is acceptable here. The repo is small, the memory types are product-specific, and the current bad entries are product-specific. Boring beats clever.
When same decision topic appears:
- keep the newest active entry as active
- optionally mark the older entry `status: "superseded"` if the type supports it, or drop it during normalization if old status values are not preserved
- do not render superseded entries
If preserving history matters later, add `supersededBy?: string` and `supersededAt?: string` to the type. Not needed for the first fix.
### Type-specific policy
| Type | Nature | Recommended dedupe | Keep history? |
|---|---|---|---|
| `project` | stable facts about repo/system | identity key + conservative similarity | no, keep one canonical fact |
| `reference` | pointer to path/URL/config | path/URL/entity key | no, keep one canonical pointer |
| `feedback` | user preference or resolved issue | topic key + newer wins for same issue | usually no |
| `decision` | implementation choice over time | topic supersession, not fuzzy duplicate deletion | sometimes, but render only active latest |
## Problem 2: stale entries never cleaned
### Diagnosis
`staleAfterDays` exists, but only `renderEntry()` uses it to append `[Xd old, verify]`. Nothing removes or demotes stale entries. As a result, the store is monotonic until `maxEntries` forces a priority trim.
That trim is the wrong cleanup mechanism. It sorts by type/source/confidence, not usefulness. A stale high-priority decision can beat a fresh low-priority reference.
### When to prune
Prune during storage normalization, not render.
`normalizeWorkspaceMemory()` is already called by `load/save/updateWorkspaceMemory()`. That gives one central place to enforce lifecycle rules.
```text
load/update/save
normalizeWorkspaceMemory()
├─ drop inactive/superseded from active set
├─ exact dedupe
├─ identity dedupe
├─ supersession
├─ stale lifecycle pruning
└─ maxEntries trim
```
Do not prune only on render. Render is presentation. If render hides or labels stale entries while the JSON keeps growing, the system still rots.
Do not require explicit cleanup as the only path. It will not run often enough. An explicit cleanup command can be added later for manual inspection, but automatic normalization should handle the common case.
### Should `staleAfterDays` be enforced?
Yes, but not uniformly as immediate deletion for every type.
`staleAfterDays` means "this should be revalidated after this age." It does not always mean "delete at this age."
Use a two-tier lifecycle:
```text
fresh age <= staleAfterDays
stale staleAfterDays < age <= staleAfterDays + grace
prunable age > staleAfterDays + grace
```
Suggested grace periods:
| Type | Current staleAfterDays | Grace | Auto-prune? | Rationale |
|---|---:|---:|---|---|
| `feedback` | none | none | no age-based prune | User preference can remain valid indefinitely. Prune only by supersession/topic replacement. |
| `decision` | 45 | 15 | yes if compaction/manual and not explicit | Implementation decisions age fast. Supersession should remove most earlier. |
| `project` | 60 | 30 | yes if compaction/manual and no strong identity/path | Project facts change slower. Keep explicit project facts unless replaced. |
| `reference` | 90 | 30 | yes if path no longer exists or prunable age exceeded | References are rediscoverable and can become stale. |
For the first implementation, a simpler rule is enough:
```text
Never age-prune feedback.
Never age-prune explicit entries automatically.
Drop compaction/manual entries when age > staleAfterDays + 30 days.
Drop superseded entries immediately from the active set.
```
This keeps user-owned memory safe while preventing compaction sludge.
### Explicit vs implicit contradiction detection
Use explicit supersession for known memory shapes. Do not try general contradiction detection.
General contradiction detection without LLM or embeddings is brittle. With an LLM it is nondeterministic and adds another model-quality surface. The current problem does not need that.
Recommended model:
- explicit supersession for same decision topic, same reference path, same project entity, same feedback topic
- newer entry wins inside the same topic unless older has higher source priority
- if `source === "explicit"`, require a newer explicit entry to replace it, or keep both
This gives predictable behavior and avoids deleting user instructions because a compaction guessed a replacement.
## Concrete implementation plan
### P0: centralize deterministic cleanup in `src/workspace-memory.ts`
Add helpers near `canonicalMemoryText()`:
```text
normalizedTokens(text)
extractPathKeys(text)
memoryIdentityKeys(entry)
decisionTopicKey(text)
feedbackTopicKey(text)
isPrunableByAge(entry, now)
chooseBetterMemory(existing, candidate)
```
Then change `enforceLongTermLimits(entries)` to run in phases:
```text
1. keep active entries only
2. truncate text
3. drop entries prunable by age, except feedback and explicit
4. exact canonical dedupe
5. identity-key dedupe for project/reference/feedback
6. decision-topic supersession
7. sort by priority with freshness as a tie-breaker
8. slice to maxEntries
```
Add freshness to `priority()` or to the final sort tie-breaker. Do not let 90-day-old compaction entries beat fresh entries just because type weight is higher.
Minimal version:
```text
priority desc, source priority desc, freshness desc, updatedAt desc
```
### P1: improve compaction prompt
Update `buildCompactionPrompt()` with dedupe instructions before the `Memory candidates:` examples.
Keep this short. Long prompts invite drift.
### P1: add tests before changing behavior
Use `tests/workspace-memory.test.ts` for normalization behavior.
Required regression tests:
```text
CODE PATH COVERAGE
==================
[+] enforceLongTermLimits(entries)
├── [GAP] exact canonical duplicate still dedupes
├── [GAP] project opencode-agenthub bilingual/long-short variants collapse to one
├── [GAP] reference same config path variants collapse to one
├── [GAP] decision parser 4 formats supersedes parser 3 formats
├── [GAP] feedback purple/italic newer fix supersedes older fix
├── [GAP] stale compaction decision older than staleAfterDays + grace is pruned
├── [GAP] stale explicit decision is retained
└── [GAP] maxEntries trim runs after dedupe/prune
[+] renderWorkspaceMemory(store)
└── [GAP] does not render superseded/pruned entries
```
No E2E needed. These are pure functions and deterministic store normalization paths.
### P2: optional explicit cleanup command
Later, add a manual cleanup/report command that prints:
- duplicates removed
- superseded decisions
- stale entries pruned
- entries retained because explicit
Not needed for the first fix. Useful for trust once memory stores grow.
## Why not embeddings
Embeddings are the wrong tool at this scale.
Costs:
- new dependency/API or local model decision
- cache/versioning problem for embedding vectors
- nondeterministic thresholds
- hard-to-debug deletions
- privacy and offline behavior questions
The current store has 22 entries. The failures are obvious strings, paths, topics, and versioned decisions. Use deterministic rules now. Reconsider embeddings only if stores grow into hundreds of entries and lexical/topic rules fail in real usage.
## Risks and tradeoffs
### Risk: deleting useful historical decisions
Mitigation: do not apply broad fuzzy dedupe to `decision`. Use topic-specific supersession only for known patterns. Keep explicit entries unless explicitly replaced.
### Risk: bespoke topic keys become a pile of regexes
Mitigation: keep the first version tiny and test-driven. Add keys only for observed failures. If this grows past roughly 10 topic rules, revisit the model.
### Risk: prompt-only fix gives false confidence
Mitigation: prompt change is P1, storage normalization is P0. The store must protect itself.
### Risk: stale pruning removes something still useful
Mitigation: no age pruning for feedback, no automatic age pruning for explicit entries, and grace periods for compaction/manual entries.
### Risk: normalization mutates existing stores unexpectedly
Mitigation: add tests with fixtures from the current store. Consider logging cleanup counts in development if a logging channel exists. The output should be deterministic.
## NOT in scope
- Embedding similarity, too much machinery for 22 entries.
- LLM-based contradiction detection, nondeterministic and hard to test.
- Full memory history graph with `supersededBy`, useful later but not required for current rendering quality.
- New cleanup UI or CLI, optional P2 after deterministic normalization lands.
- Changing `LongTermMemoryEntry` schema, avoid migration unless history preservation becomes required.
## Prioritized steps
1. **P0: Add tests in `tests/workspace-memory.test.ts` using the concrete duplicate examples from the current store.** This locks the desired behavior before touching cleanup logic.
2. **P0: Implement storage-time cleanup in `enforceLongTermLimits()`.** Exact dedupe, identity-key dedupe, decision supersession, stale pruning, then max-entry trim.
3. **P0: Make stale lifecycle enforceable but conservative.** No age pruning for feedback or explicit entries. Prune compaction/manual entries after `staleAfterDays + 30`.
4. **P1: Tighten `buildCompactionPrompt()` to avoid re-emitting existing memories and emit only replacing facts.** This reduces future noise but is not trusted as the only defense.
5. **P1: Add regression fixtures matching the real `workspace-memory.json` problem set.** Assert resulting entries are below the current 22 and contain the newer/canonical facts.
6. **P2: Add a cleanup report command only if users need visibility.** Defer until after the automatic path proves itself.
## Final architecture decision
The memory store should be self-cleaning at its storage boundary.
Use prompt engineering to reduce bad candidates, but make `src/workspace-memory.ts` the authority for what persists. Use deterministic, type-aware dedupe instead of embeddings. Treat `project` and `reference` as entity identity problems, `feedback` as topic replacement, and `decision` as explicit supersession.
That is the smallest design that solves the real failures without turning a 28-entry JSON file into a search platform.
## Addendum: bracketless memory candidate format from real compaction
Date: 2026-04-26
### Summary table
| Issue | Severity | Fix | Priority |
|-------|----------|-----|----------|
| Parser silently drops `- project text` bracketless candidates | High | Accept both `- [type] text` and `- type text` | P0 |
| Prompt examples imply brackets but do not explicitly require exact syntax | Medium | Add "Use exactly this format, including square brackets" plus a negative example | P0, same small patch |
| No regression test for bracketless candidate lines | High | Add parser test covering all four types in bracketless form | P0 |
| Future compactions may re-extract useful facts with changed counts or wording | Medium | Keep storage-time type-aware dedupe/staleness plan | P0, unchanged |
### 1. Parser fix
Accept `- type text` with no brackets.
Also strengthen the prompt. Do both.
The parser is the product boundary. Model output is not a contract, it is an input from an unreliable narrator with excellent vibes. If the model emits a plainly parseable, semantically valid candidate, dropping it silently is a data loss bug.
The prompt should still ask for the preferred bracketed format because bracketed type markers are less ambiguous. But prompt enforcement alone is not enough. The new evidence proves the model sometimes drops brackets even when examples include them.
Recommended parser behavior:
- preferred: `- [project] pathology-playground 後端健康改進計劃已完成 Phase 1-4`
- accepted fallback: `- project pathology-playground 後端健康改進計劃已完成 Phase 1-4`
- still reject unknown types
- still run `shouldAcceptWorkspaceMemoryCandidate()`
- still require body length and existing quality gates
### 2. Prompt format enforcement
Yes, add explicit syntax instructions.
Current prompt shows examples, but examples are not a hard enough constraint. Add one sentence before the examples:
```text
Use exactly this candidate format, including square brackets around the type:
```
Then keep the examples:
```text
Memory candidates:
- [feedback] content
- [project] content
- [decision] content
- [reference] content
```
Optionally add one short warning:
```text
Do not write `- project content`; write `- [project] content`.
```
Keep this short. Long formatting lectures increase prompt surface area and make the summary worse. One positive instruction plus one negative example is enough.
### 3. Impact on dedup plan
Parser robustness moves to P0, before storage dedup/staleness cleanup.
This changes sequencing, not the architecture.
Updated P0 order:
1. **P0a: Fix parser format tolerance and add regression tests.** Lost memory is worse than duplicate memory. A deduper cannot dedupe entries that never made it into the store.
2. **P0b: Implement storage-time dedupe and stale pruning.** Still the main long-term quality fix.
3. **P0c: Tighten prompt format instruction in the same small patch as parser tolerance.** Cheap and reduces fallback-parser usage.
The earlier recommendation still stands: storage normalization remains the authority for duplicates and staleness. This new evidence adds a more basic ingestion reliability bug in front of it.
### 4. Concrete implementation recommendation
#### Regex change
Replace the current parser line in `src/extractors.ts:parseWorkspaceMemoryCandidates()`:
```ts
const item = line.trim().match(/^-\s*\[(feedback|project|decision|reference)\]\s*(.+)$/i);
```
with a single regex that accepts bracketed and bracketless forms:
```ts
const item = line.trim().match(
/^-\s*(?:\[(feedback|project|decision|reference)\]|(feedback|project|decision|reference)\b)\s+(.+)$/i,
);
if (!item) continue;
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
const body = item[3].trim();
```
Why this shape:
- `(?:[type]|type\b)` accepts both formats
- `\b` prevents `projectile` from being parsed as `project`
- `\s+(.+)` requires real content after the type
- unknown types still fail
Even better for readability, avoid duplicate type alternation with a named group if the runtime target supports it cleanly:
```ts
const item = line.trim().match(
/^-\s*(?:\[(?<bracketed>feedback|project|decision|reference)\]|(?<plain>feedback|project|decision|reference)\b)\s+(?<body>.+)$/i,
);
if (!item?.groups) continue;
const type = (item.groups.bracketed ?? item.groups.plain).toLowerCase() as LongTermType;
const body = item.groups.body.trim();
```
Recommendation: use the non-named-group version. It is uglier, but it is maximally boring and consistent with the existing code style.
Add tests in `tests/extractors.test.ts`:
```ts
test("parseWorkspaceMemoryCandidates accepts bracketless candidate format", () => {
const summary = `
Memory candidates:
- project pathology-playground 後端健康改進計劃已完成 Phase 1-4
- reference Scrypt 參數必須是 N=16384, r=8, p=1
- feedback 端口 9473 可能被舊進程佔用,需殺掉後重啟
- decision Use output.prompt to replace the default compaction template
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 4);
assert.deepEqual(items.map(item => item.type), [
"project",
"reference",
"feedback",
"decision",
]);
});
```
Also add a guard test:
```ts
test("parseWorkspaceMemoryCandidates rejects unknown bracketless candidate type", () => {
const summary = `
Memory candidates:
- note this should not be parsed as memory
`;
const items = parseWorkspaceMemoryCandidates(summary);
assert.equal(items.length, 0);
});
```
#### Prompt change
In `src/plugin.ts:buildCompactionPrompt()`, change this block:
```ts
"At the end of the summary, extract durable memory entries for future",
"sessions using these labels:",
"",
"Memory candidates:",
"- [feedback] content",
"- [project] content",
"- [decision] content",
"- [reference] content",
```
to:
```ts
"At the end of the summary, extract durable memory entries for future",
"sessions using exactly this candidate format, including square brackets around the type:",
"",
"Memory candidates:",
"- [feedback] content",
"- [project] content",
"- [decision] content",
"- [reference] content",
"",
"Do not write '- project content'; write '- [project] content'.",
```
This gives the model a crisp positive format and a concrete anti-pattern. The parser still accepts the anti-pattern because users need data capture more than format purity.
### Final addendum decision
Parser tolerance is now P0.
The architecture stays the same: make the storage layer self-cleaning, and make ingestion defensive. But the implementation sequence changes because silent data loss beats duplicate accumulation in severity. First capture valid candidates reliably. Then dedupe and prune them.
## Addendum 2: content quality guidance
Date: 2026-04-26
### Summary table
| Issue | Severity | Fix | Priority |
|-------|----------|-----|----------|
| Model extracts low-durability progress snapshots as `project` memory | High | Add durable-content guidance to compaction prompt | P0 |
| Exact counts like `1237 tests pass` and `37 files` churn across sessions | High | Add parser quality filter for obvious snapshot patterns | P0 |
| Stable config values are useful and should still pass | Medium | Keep `reference` guidance permissive for config/crypto/PIN values | P0 |
| Environment issues like occupied ports may be useful briefly but not long-term | Medium | Prompt says unresolved issues only; storage staleness handles aging | P1 with staleness work |
### 1. Architecture fit
This belongs in both the prompt and the parser, with different responsibilities.
The prompt should teach the model what "durable" means. The model is choosing what to extract, so it needs product semantics:
- stable configuration values are good memory
- unresolved bugs can be useful memory
- exact test counts, file counts, and phase progress are usually bad long-term memory
The parser should still reject obvious low-durability snapshots as a backstop. The parser already has `shouldAcceptWorkspaceMemoryCandidate()` in `src/extractors.ts`; this is exactly where simple content-quality gates belong.
Do not put subtle semantic judgment in the parser. Do put obvious anti-patterns there.
Recommended split:
```text
Prompt
└─ positive/negative guidance for durable memory selection
Parser quality gate
└─ deterministic rejection of obvious snapshots
- exact test counts
- exact file counts
- completed Phase N-M progress lines
- temporary port/process cleanup notes when phrased as resolved/current env state
Storage normalization
└─ dedupe, supersession, age-based pruning
```
This is the same design principle as the bracketless parser addendum: ask the model nicely, then make the code defensive.
### 2. Specificity vs risk
The proposed guidance is specific, but not too specific.
It names examples from the observed failure mode, but the rule underneath is general: facts should stay true across sessions. Exact counts and phase numbers are classic snapshot smell in almost every codebase.
Potential risk: sometimes an exact count is genuinely durable. Example: "USB sync protocol expects exactly 37 manifest entries" could be a stable contract, not a snapshot.
Mitigation: word the guidance around "session-specific progress" rather than banning all numbers. Keep config values explicitly allowed.
Good distinction:
```text
Bad: 1237 tests pass today
Good: Test suite is expected to pass before handoff
Bad: USB sync currently has 37 files
Good: USB sync covers bundles, server, frontend, tests, and docs
Bad: Phase 1-4 completed
Good: Backend health work is organized into phased improvements
Good: Scrypt parameters are N=16384, r=8, p=1
```
The first three are progress snapshots. The Scrypt value is a stable configuration contract. Numbers are not the problem. Temporary state is the problem.
### 3. Prompt length concern
Adding four lines is worth it.
This prompt is already making the model do extraction. Without guidance, the model optimizes for "important-looking facts," and progress snapshots look important. That creates churn, duplicates, and stale memory. Four lines preventing bad memory at the source are cheap.
If trimming is needed, trim redundant formatting language before removing quality guidance. Formatting mistakes lose entries or require parser tolerance. Content mistakes pollute the store. Both matter, but the durable-content guidance carries more product value than repeated Markdown formatting reminders.
Recommended trim posture:
- keep one concise formatting instruction
- keep one concise candidate syntax instruction
- add one concise durable-content block
- avoid long examples or taxonomy tables in the prompt
The prompt should not become a memory policy document. It just needs the model to stop writing "1237 tests pass" into long-term storage. Wild that we have to say this, but we do.
### 4. Concrete prompt recommendation
In `src/plugin.ts:buildCompactionPrompt()`, replace the candidate instruction block with this final version:
```ts
"At the end of the summary, extract durable memory entries for future sessions.",
"Only extract facts that are likely to stay true across sessions.",
"Do not extract session-specific progress like exact test counts, file counts, or phase numbers.",
"For progress, extract the stable goal or durable milestone, not the current number.",
"For references, extract configuration values that do not usually change between sessions.",
"For feedback, extract unresolved issues or user preferences that future sessions need to know.",
"Use exactly this candidate format, including square brackets around the type:",
"",
"Memory candidates:",
"- [feedback] content",
"- [project] content",
"- [decision] content",
"- [reference] content",
"",
"Do not write '- project content'; write '- [project] content'.",
```
This is slightly longer than the lead's proposal, but it avoids an overbroad ban on numbers by saying "session-specific progress." It also gives a positive replacement behavior: stable goal or durable milestone.
If a shorter version is required, use this:
```ts
"At the end of the summary, extract durable memory entries for future sessions.",
"Only extract facts likely to stay true across sessions; skip exact test counts, file counts, phase numbers, and temporary environment state.",
"References may include stable configuration values. Feedback should be unresolved issues or user preferences future sessions need.",
"Use exactly this candidate format, including square brackets around the type:",
```
Recommendation: use the longer block. The extra three lines buy clarity and reduce accidental over-filtering.
### Parser quality gate recommendation
Add deterministic snapshot rejection to `shouldAcceptWorkspaceMemoryCandidate()`.
Keep this conservative. Reject obvious snapshots, not every number.
Suggested first-pass rules:
```ts
// Session-specific progress snapshots, not durable memory.
if (entry.type === "project") {
if (/\b\d+\s+tests?\s+pass(?:ed)?\b/i.test(text)) return false;
if (/\b\d+\s+suites?\b/i.test(text)) return false;
if (/\b\d+\s+(?:files?|文件)\b/i.test(text)) return false;
if (/\bphase\s*\d+(?:\s*[-]\s*\d+)?\s+(?:completed|done|finished)\b/i.test(text)) return false;
if (/已完成\s*Phase\s*\d+(?:\s*[-]\s*\d+)?/i.test(text)) return false;
}
```
Do not reject stable `reference` values containing numbers. These must pass:
```text
Admin PIN 是 456123
Scrypt 參數必須是 N=16384, r=8, p=1
```
For `feedback`, do not broadly reject ports yet. A port issue can be useful if it explains a recurring failure. Let staleness prune it, unless the text clearly says the issue was resolved. A future parser rule can reject resolved temporary env notes, but the current evidence is not enough to safely block all port-related feedback.
### 5. Integration with storage-time dedup/staleness
Prompt-level guidance and staleness solve different problems.
Staleness is cleanup after bad or aging facts are already stored. Prompt guidance prevents low-value facts from entering the store in the first place. Parser filtering catches obvious misses when the prompt fails.
Do not rely on staleness for exact counts.
Why:
- `maxEntries` is 28, so a few bad snapshots can evict useful facts before they age out
- exact counts will churn every compaction and create near-duplicates
- stale labels still consume render budget until pruning runs
- users see noisy memory and trust the feature less
Storage-time dedup/staleness remains required for facts that were good when written but later become outdated. Example: a config path that moves, a decision superseded by a better decision, or an unresolved bug that later gets fixed.
Use this mental model:
```text
Prompt guidance → prevent bad candidates
Parser quality gate → reject obvious bad candidates
Storage dedupe → merge repeated good candidates
Storage staleness → retire once-good candidates that aged out
```
### Updated priority
The new content-quality evidence adds another P0 ingestion fix.
Updated sequence:
1. **P0a: Parser accepts bracketless candidate format and tests it.** Prevent silent data loss.
2. **P0b: Prompt durable-content guidance.** Stop obvious snapshots at the source.
3. **P0c: Parser rejects obvious low-durability `project` snapshots.** Backstop the prompt with deterministic filters.
4. **P0d: Storage-time dedupe and staleness.** Still required for duplicate accumulation and lifecycle cleanup.
### Final addendum 2 decision
Add the durable-content guidance to the prompt and add conservative parser filters for obvious `project` snapshots.
This does not replace storage-time dedupe or staleness. It reduces garbage before it reaches that layer. The store still needs to clean itself, but it should not be used as a trash compactor for facts we already know are temporary.
File diff suppressed because it is too large Load Diff
@@ -1,702 +0,0 @@
# Workspace Memory Cleanup Migration Plan (v2)
## Status: APPROVED (v3)
## Problem Statement
Audit of recent workspace memories found quality issues in pre-v1.2.1 stores:
### Issue 1: Snapshot Violations (P0)
| Workspace | Entry | Type |
|-----------|-------|------|
| opencode-record | `測試套件:1237 tests pass, 226 suites` | Test count |
| opencode-record | `USB 同步:37 個文件(...` | File count (Chinese) |
| opencode-record | `pathology-playground...已完成 Phase 1-4` | Phase progress |
| pathology-agent-reports | `Waves 1-5, 7 已完成,Wave 6 deferred` | Wave progress |
**Root Cause**: These entries were created before P0c/P0d fix (08:02:32). Current code would reject them.
**Risk**: Medium. Pollutes long-term memory, wastes tokens.
### Issue 2: Sensitive Credentials (P0)
| Workspace | Entry | Risk |
|-----------|-------|------|
| opencode-record | `Admin PIN 是 456123` | **High** - Raw credential |
| Pre-cancer-atlas | `測試用戶名:shihlab,密碼:sushi` | **High** - Raw credential |
**Root Cause**: No credential redaction in compaction extraction or storage normalization.
**Risk**: High. Credentials sent to model in every compaction prompt.
### Issue 3: Wave/Sprint Not Filtered (P0)
| Pattern | Status |
|---------|--------|
| `Phase 1-4 已完成` | ✅ Filtered by P0c |
| `Wave 1-5 已完成` | ❌ Not filtered |
**Root Cause**: P0c filter only covers `Phase`, not `Wave/Sprint/Milestone/Task`.
**Risk**: Medium. New snapshots still enter memory.
### Issue 4: Duplicates (P1)
| Workspace | Entry | Issue |
|-----------|-------|-------|
| Pre-cancer-atlas | `認證使用 Basic Auth...` x2 | Exact duplicate |
| Pre-cancer-atlas | `IP 隱私...` x2 | Semantic duplicate |
| Pre-cancer-atlas | `Cloud Run...` project + reference | Cross-type duplicate |
**Root Cause**: `extractEntityKey()` only recognizes `opencode-agenthub`. Natural canonical dedup handles exact duplicates.
**Risk**: Low. Wastes tokens but not dangerous.
---
## Architect Review Failures (v1, v2)
### v1 Failures
| Issue | Problem |
|-------|---------|
| Regex | `Waves` not matched, Chinese `\b` unreliable |
| Superseded entries | Would be deleted by `enforceLongTermLimits()` |
| Credential redaction | Was migration-gated, must be always-on |
| Wave filter | Deferred to future, must be now |
| Over-broad | `Upload limit is 10 files` would be flagged |
| Rationale | Only redacted `text`, not `rationale` |
### v2 Failures
| Issue | Problem |
|-------|---------|
| File context | `upload` matches `Upload limit`, false positive |
| Explicit check | Missing `source === "explicit"` check before marking |
| Credential regex | `\S+` captures through Chinese comma tail |
| Filter location | Don't filter in `getFrozenWorkspaceMemory()` |
---
## Proposed Solution (v3)
### Architecture Principle
```
┌─────────────────────────────────┐
│ normalizeWorkspaceMemory() │
│ │
│ 1. ALWAYS redact credentials │
│ (not migration-gated) │
│ │
│ 2. Mark legacy snapshots as │
│ superseded (migration-gated)│
│ │
│ 3. Preserve superseded entries │
│ in storage, exclude from │
│ render │
└─────────────────────────────────┘
```
### Key Design Decisions
1. **Credential redaction is always-on** - runs on every normalize, independent of migration ID
2. **Snapshot marking is migration-gated** - one-time cleanup for legacy entries
3. **Superseded entries preserved in storage** - but excluded from render
4. **Type restriction for snapshots** - only `project` type, avoid false positives
5. **Wave/Sprint/Milestone filter added now** - not deferred
---
## Implementation
### 1. Add Migration Tracking to Type
```typescript
// src/types.ts
interface WorkspaceMemoryStore {
version: number;
workspace: { root: string; key: string };
limits: { maxRenderedChars: number; maxEntries: number };
entries: LongTermMemoryEntry[];
migrations?: string[]; // NEW: track applied migrations
updatedAt: string;
}
const MIGRATION_ID = "2026-04-26-p0-cleanup";
```
### 2. Snapshot Detection (Revised Regex)
```typescript
// src/workspace-memory.ts
/**
* Detect snapshot violations in text.
* Only apply to 'project' type entries with source !== 'explicit'.
*/
function isProjectSnapshotViolation(text: string): boolean {
// Test/suite counts
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
// File counts (Chinese/English) - require sync/completion context
// And must NOT be a limit/maximum statement
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
if (hasSnapshotContext && !hasLimitContext) return true;
}
// Phase/Wave/Sprint/Milestone progress
// English: Phase 1-4 completed, Waves 1-5 done
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-]\s*\d+)?/i.test(text)) {
if (/completed|done|finished|完成/i.test(text)) return true;
}
// Chinese: 已完成 Phase 1-4
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
return false;
}
```
### 3. Credential Redaction (Always-On)
```typescript
// src/workspace-memory.ts
/**
* Bounded secret value pattern - stops at delimiters and Chinese punctuation.
* Avoids capturing through Chinese commas: 密碼:sushi,用於測試
*/
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,\s]+`;
/**
* Multilingual credential labels.
* These are used in both detection and redaction patterns.
*/
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
/**
* Prefix patterns that capture label + delimiter together.
* This preserves the delimiter in output: 密碼:secret → 密碼:[REDACTED]
*/
const PASSWORD_PREFIX = String.raw`(${PASSWORD_LABELS.source}\s*(?:是|=|:|)?\s*)`;
const USERNAME_PREFIX = String.raw`(${USERNAME_LABELS.source}\s*(?:是|=|:|)?\s*)`;
/**
* Redact sensitive credentials from text.
* This runs on EVERY normalize, not just migration.
* Idempotent - [REDACTED] doesn't match patterns again.
*
* Order matters:
* 1. PIN (standalone)
* 2. Username+password pairs (must run before standalone password)
* 3. Standalone password
*/
function redactCredentials(text: string): string {
let result = text;
// 1. PIN patterns (language-neutral, supports 是, =, :, )
result = result.replace(
new RegExp(String.raw`\b(PIN|pin)\s*(?:是|=|:|)?\s*[`'"]?(${SECRET_VALUE})`, 'gi'),
'$1 [REDACTED]'
);
// 2. Username+Password pairs (multilingual)
// Must run BEFORE standalone password to match full pairs.
// 測試用戶名:xxx,密碼:yyy
// username: xxx, password: yyy
result = result.replace(
new RegExp(
String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`,
'gi'
),
'$1[REDACTED]$3$4[REDACTED]'
);
// 3. Standalone password patterns (multilingual)
// Matches: password: secret, 密碼:secret, パスワード: secret, etc.
result = result.replace(
new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, 'gi'),
'$1[REDACTED]'
);
return result;
}
```
### 4. Migration Function (One-Time)
```typescript
// src/workspace-memory.ts
function runMigrationP0Cleanup(
store: WorkspaceMemoryStore,
nowIso: string
): WorkspaceMemoryStore {
// Check if already run
if (store.migrations?.includes(MIGRATION_ID)) {
return store;
}
const entries = store.entries.map(entry => {
// Skip explicit entries - user-added memories are preserved
if (entry.source === "explicit") {
return entry;
}
// Skip non-project types for snapshot marking
// (Only project entries had snapshot pollution)
if (entry.type !== "project") {
return entry;
}
// Mark legacy snapshot violations as superseded
if (isProjectSnapshotViolation(entry.text)) {
return {
...entry,
status: "superseded" as const,
updatedAt: nowIso,
};
}
return entry;
});
return {
...store,
entries,
migrations: [...(store.migrations || []), MIGRATION_ID],
updatedAt: nowIso,
};
}
```
### 5. Normalize with Always-On Credential Redaction
```typescript
// src/workspace-memory.ts
// Preserve existing normalization behavior
async function normalizeWorkspaceMemory(
root: string,
store: WorkspaceMemoryStore,
): Promise<WorkspaceMemoryStore> {
const nowIso = new Date().toISOString();
// Start with existing store normalization
let result: WorkspaceMemoryStore = {
...store,
workspace: { root, key: await workspaceKey(root) },
limits: {
maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
},
entries: Array.isArray(store.entries) ? store.entries : [],
updatedAt: nowIso,
};
// ALWAYS-ON: Redact credentials in all entries
// This must run regardless of migration status
result.entries = result.entries.map(entry => {
const text = redactCredentials(entry.text);
const rationale = entry.rationale
? redactCredentials(entry.rationale)
: undefined;
if (text === entry.text && rationale === entry.rationale) {
return entry;
}
return {
...entry,
text,
rationale,
updatedAt: nowIso,
};
});
// ONE-TIME: Mark legacy snapshots as superseded
result = runMigrationP0Cleanup(result, nowIso);
// Remove superseded from active rendering
const activeEntries = result.entries.filter(e => e.status !== "superseded");
// Apply dedup and limits to active entries only
const processed = enforceLongTermLimits(activeEntries);
// Merge back: active entries + superseded entries (preserved in storage)
const superseded = result.entries.filter(e => e.status === "superseded");
return {
...result,
entries: [...processed, ...superseded],
updatedAt: nowIso,
};
}
```
### 6. Extend P0c Snapshot Filter (Not Deferred)
```typescript
// src/extractors.ts
// Add to isProjectSnapshotViolation() or equivalent filter
// File counts - require snapshot context AND NOT limit context
const FILE_COUNT_PATTERN = /\d+\s*(?:個|个)?\s*(?:files?|文件)/i;
const FILE_SNAPSHOT_CONTEXT = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i;
const FILE_LIMIT_CONTEXT = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i;
if (FILE_COUNT_PATTERN.test(text)) {
if (FILE_SNAPSHOT_CONTEXT.test(text) && !FILE_LIMIT_CONTEXT.test(text)) {
return true; // snapshot violation
}
}
// Test/suite counts
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
// Phase/Wave/Sprint/Milestone progress
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-]\s*\d+)?/i.test(text)) {
if (/completed|done|finished|完成/i.test(text)) return true;
}
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
```
**Note**: Do NOT use bare `upload|download` as context. Use past-tense verbs or process states.
---
## Test Cases
### Credential Redaction (Always-On)
| Input | Expected Output |
|-------|-----------------|
| `Admin PIN 是 456123` | `Admin PIN 是 [REDACTED]` |
| `Admin PIN = 456123` | `Admin PIN = [REDACTED]` |
| `Admin PIN 456123` | `Admin PIN [REDACTED]` |
| `密碼:sushi` | `密碼:[REDACTED]` |
| `密码:sushi` | `密码:[REDACTED]` |
| `password: abc-123!` | `password: [REDACTED]` |
| `パスワード:secret` | `パスワード:[REDACTED]` |
| `비밀번호: secret` | `비밀번호: [REDACTED]` |
| `測試用戶名:shihlab,密碼:sushi` | `測試用戶名:[REDACTED],密碼:[REDACTED]` |
| `密碼:sushi,用於測試` | `密碼:[REDACTED],用於測試` |
| Credential in rationale | Redacted in both text and rationale |
| Explicit entry with PIN | Redacted, preserved |
| `[REDACTED]` in text | No change (idempotent) |
### Snapshot Detection
| Input | type | source | Is Violation? |
|-------|------|--------|---------------|
| `1237 tests pass, 226 suites` | project | compaction | ✅ Yes |
| `USB 同步:37 個文件` | project | compaction | ✅ Yes |
| `Phase 1-4 已完成` | project | compaction | ✅ Yes |
| `Waves 1-5 已完成` | project | compaction | ✅ Yes |
| `Upload limit is 10 files` | project | compaction | ❌ No (has "limit" context) |
| `Project supports 5 test suites` | project | compaction | ❌ No (no pass/fail) |
| `Phase 1-4 已完成` | project | explicit | ❌ No (explicit preserved) |
| Snapshot text | feedback | compaction | ❌ No (only project type) |
| Snapshot text | decision | compaction | ❌ No (only project type) |
### Migration Behavior
| Test | Description |
|------|-------------|
| Run once | Migration ID added |
| Run twice | No duplicate ID, entries unchanged |
| Non-project entry | Not marked superseded |
| Project snapshot | Marked superseded |
| Explicit project snapshot | Not marked (source check before type) |
| Credential in snapshot | Redacted, then marked superseded |
### Integration Tests
| Test | Description |
|------|-------------|
| `saveWorkspaceMemory()` | Superseded entries preserved in JSON |
| `updateWorkspaceMemory()` | Credential redaction runs on second normalize |
| New entry with PIN | Redacted on save (always-on) |
| `normalizeWorkspaceMemory()` | Preserves workspace root/key, limits, updatedAt |
| Memory render | Superseded entries excluded via `enforceLongTermLimits()` |
### Extractor Tests
| Input | Expected |
|-------|----------|
| `Upload limit is 10 files` | NOT a snapshot violation (has "limit" context) |
| `USB uploaded 37 files` | Snapshot violation (has "uploaded" process context) |
| `Project supports 5 test suites` | NOT a snapshot violation (no pass/fail context) |
| `1237 tests passed` | Snapshot violation (test count with pass) |
---
## Edge Cases
| Case | Handling |
|------|----------|
| Entry is explicit + snapshot | Not marked (source check before type check) |
| Entry has both snapshot + credential | Credential redacted, snapshot marked |
| Entry is already superseded | Keep status, still redact credentials |
| Migration runs twice | Skip if ID present |
| Store has no migrations field | Create empty array |
| `Upload limit is 10 files` | Not marked (has "limit" context) |
| Password with punctuation `abc-123!` | Captured by bounded pattern |
| Chinese comma after credential `密碼:sushi,用於測試` | Redact preserves `,用於測試` |
| Simplified Chinese `密码` | Preserved as `密码:[REDACTED]` |
---
## Implementation Order
1. Add `migrations` field to `WorkspaceMemoryStore` type
2. Add snapshot patterns to `src/extractors.ts` (not deferred)
3. Add `isProjectSnapshotViolation()` to `src/workspace-memory.ts`
4. Add `redactCredentials()` to `src/workspace-memory.ts`
5. Add `runMigrationP0Cleanup()` to `src/workspace-memory.ts`
6. Update `normalizeWorkspaceMemory()` with always-on redaction + migration
7. Do NOT add filtering to `getFrozenWorkspaceMemory()` - filtering happens in `enforceLongTermLimits()`
8. Add test cases for all patterns
---
## What We Will NOT Do
### Do NOT Add Project-Specific Entity Keys
Cloud Run, Basic Auth, IP privacy — these are project-specific. Natural canonical dedup handles exact duplicates.
### Do NOT Delete Superseded Entries
Mark as `status: "superseded"`, preserve in storage, exclude from render.
### Do NOT Gate Credential Redaction on Migration
Credential redaction is always-on. Migration only marks legacy snapshots.
---
## Summary
| Issue | Priority | Solution |
|-------|----------|----------|
| Sensitive credentials | P0 | Always-on redaction |
| Snapshot violations | P0 | Migration-gated marking (project type only) |
| Wave progress not filtered | P0 | Add to extractors.ts now |
| Project-specific duplicates | N/A | Natural dedup |
**Credential redaction runs on every normalize.**
**Snapshot marking is one-time migration for legacy entries.**
**Superseded entries preserved in storage, excluded from render.**
**Wave/Sprint/Milestone filter added now, not deferred.**
---
## Multilingual Scope
### Snapshot Detection: Chinese + English Only
Do **not** add Japanese/Korean/Spanish/French/German snapshot regexes now.
Reasons:
- False positives silently suppress valid durable memories
- Audit evidence only shows Chinese and English pollution
- Words like "completed", "terminé", "abgeschlossen" can appear in durable process descriptions
- Extraction is always-on, so every false positive becomes permanent blind spot
Add languages only after seeing real polluted memories in those languages.
### Credential Redaction: Add Multilingual Labels
For credentials, false negatives leak secrets. Add high-signal multilingual labels now.
**Password labels:**
```typescript
const PASSWORD_LABELS =
/password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
```
**Username labels:**
```typescript
const USERNAME_LABELS =
/username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
```
PIN remains language-neutral: `/\bPIN\b/i`
### Memory Trigger Patterns: Add Chinese Expansion + Japanese + Korean
#### Chinese Expansion
Add common phrases:
```typescript
// Current: 记住/記住
// Add: 记得/記得, 记下来/記下來
/(?:^|\n)\s*(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[:,]?\s*(.+)$/gim
```
#### Japanese Positive Triggers
```typescript
/(?:^|\n)\s*(?:覚えておいて|覚えて|忘れないで|メモして)[:,]?\s*(.+)$/gim
```
Note: `覚えておいて` must come before `覚えて` to prevent partial match in body.
Note: `忘れないで` ("don't forget") is a positive memory request despite negative morphology.
#### Japanese Negation
```typescript
/(?:覚えないで|記憶しないで|メモしないで)\s*$/u
```
#### Korean Positive Triggers
```typescript
/(?:^|\n)\s*(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[:,]?\s*(.+)$/gim
```
Note: `기억해줘` must come before `기억해`, `메모해줘` must come before `메모해` to prevent partial match in body.
Note: `잊지 마` ("don't forget") is a positive memory request despite negative morphology.
#### Korean Negation
```typescript
/(?:기억하지\s*마|기억하지마|메모하지\s*마|메모하지마)\s*$/u
```
#### Priority
1. Chinese: `记得/記得`, `记下来/記下來` (small expansion)
2. Japanese (full patterns + negation)
3. Korean (full patterns + negation)
4. Defer: Spanish/German/French (higher collision risk with normal text)
### Tests Required
**Credential redaction:**
```text
パスワード:secret → [REDACTED]
비밀번호: secret → [REDACTED]
contraseña: secret → [REDACTED]
mot de passe: secret → [REDACTED]
Passwort: secret → [REDACTED]
```
**Memory triggers (positive):**
```text
记得:这个项目使用 pnpm
記下來:这个项目使用 pnpm
覚えて: このプロジェクトは pnpm を使う
覚えておいて: このプロジェクトは pnpm を使う
忘れないで: このプロジェクトは pnpm を使う
メモして: このプロジェクトは pnpm を使う
기억해: 이 프로젝트는 pnpm을 사용한다
기억해줘: 이 프로젝트는 pnpm을 사용한다
잊지 마: 이 프로젝트는 pnpm을 사용한다
메모해: 이 프로젝트는 pnpm을 사용한다
메모해줘: 이 프로젝트는 pnpm을 사용한다
```
**Memory triggers (body extraction - must not include trigger suffix):**
```text
覚えておいて: このプロジェクトは pnpm を使う
→ body is "このプロジェクトは pnpm を使う" (not "おいて: この...")
기억해줘: 이 프로젝트는 pnpm을 사용한다
→ body is "이 프로젝트는 pnpm을 사용한다" (not "줘: 이...")
메모해줘: 이 프로젝트는 pnpm을 사용한다
→ body is "이 프로젝트는 pnpm을 사용한다" (not "줘: 이...")
```
**Memory triggers (negation - should NOT trigger):**
```text
覚えないで 覚えて: temporary note only
メモしないで メモして: temporary note only
기억하지 마 기억해: temporary note only
메모하지 마 메모해: temporary note only
```
---
## Memory Quality Bar (Prompt Improvement)
### Problem
Current extraction accepts "facts that were mentioned" instead of "facts that will change future behavior."
Examples of low-value trivia:
- `Cloud Run revision: pre-cancer-atlas-website-00066-j8c` — transient deployment state
- `UI 要統一風格:兩個表格都要 scrollable,約 20 rows` — local implementation detail
- Paths observed from code/logs without stable contract
### Solution: Prompt Quality Bar
Add to compaction memory extraction prompt:
```text
Memory quality bar:
Extract only durable facts that will change future behavior: user preferences, decisions with rationale, stable constraints, or hard-to-rediscover references.
Do not extract trivia: transient IDs/revisions, task progress, test/file counts, bare status updates, local UI details, or facts easily rediscovered from the repo.
When unsure, skip it. Fewer high-signal memories are better than many low-value ones.
```
### Example Pair (Optional)
If model still stores junk, add one example:
```text
Bad: Cloud Run revision: xyz-00066
Good: Revision xyz-00066 is the last known good deploy before the auth regression.
```
### What This Captures
| Keep | Reject |
|------|--------|
| User preferences | Transient IDs/revisions |
| Decisions with rationale | Task progress, test/file counts |
| Stable constraints | Bare status updates |
| Hard-to-rediscover references | Local UI details |
| | Rediscoverable facts |
### Why Prompt Instead of Code Filters
- Context matters: "Cloud Run revision" might be useful if framed as "last known good before regression"
- Avoid regex whack-a-mole for every trivia pattern
- Model can judge wording and context
- Easier to iterate on prompt than code
### Code Filters (Stay Minimal)
Keep only hard invariants:
- Credentials (security)
- Obvious snapshots (test counts, phase progress)
Do NOT add new filters for deployment revisions, status updates, or UI trivia. Let prompt handle those.
---
## Summary
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff