From 1b87ad96a8e794e1680e02a7b7aa0969ba2697c6 Mon Sep 17 00:00:00 2001 From: KrissTos Date: Mon, 24 Aug 2026 17:35:26 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20pi-dream=20=E2=80=94=20memory=20con?= =?UTF-8?q?solidation=20(memory=5Fdream=20tool=20+=20/pi-dream=20command)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseMemoryBlocks/dreamSimilarity/dreamAnalyze/dreamDropIndices/dreamApply pure functions - memory_dream tool: report mode (read-only findings) + apply mode (removes duplicate older copies and superseded entries via existing recovery-record pipeline, undoable with memory_restore; refreshes snapshot + qmd index) - /pi-dream command: quick read-only health check with notification summary - 10 new unit tests (192 total pass), tsc + biome clean Signed-off-by: KrissTos --- README.md | 6 +- index.ts | 318 ++++++++++++++++++++++++++++++++++++++++++++++ test/unit.test.ts | 149 +++++++++++++++++++++- 3 files changed, 469 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 819156e..a6b658c 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ pi install npm:pi-memory pi install ./pi-memory ``` -That's it — the six core tools (`memory_write`, `memory_forget`, `memory_restore`, -`memory_read`, `scratchpad`, `memory_status`) work immediately with no other setup. +That's it — the core tools (`memory_write`, `memory_forget`, `memory_restore`, +`memory_dream`, `memory_read`, `scratchpad`, `memory_status`) work immediately with no other setup. Search is opt-in below. ### Optional: enable search with qmd @@ -80,6 +80,7 @@ Without qmd, the core tools still work fully — only `memory_search` and select | `memory_write` | Write to MEMORY.md (long-term) or daily log | | `memory_forget` | Delete matching entries and create a durable recovery record | | `memory_restore` | Restore a deletion using the recovery ID returned by `memory_forget` | +| `memory_dream` | Consolidate MEMORY.md: report/apply near-duplicate and superseded entries (pi-dream) | | `memory_read` | Read any memory file or list daily logs | | `scratchpad` | Add/done/undo/clear/list checklist items | | `memory_search` | Search across all memory files (requires qmd) | @@ -175,6 +176,7 @@ This ensures in-progress context survives compaction and is visible in the next - **Persistence**: Memory files are plain markdown on disk — readable, editable, and git-friendly. - **Recoverable deletion**: `memory_forget` stores complete deleted entries under `recovery/` before changing memory and returns a recovery ID that `memory_restore` can use. Recovery JSON is outside qmd's `**/*.md` index. +- **pi-dream consolidation** (`memory_dream` tool, `/pi-dream` command): detects near-duplicate entries (Jaccard similarity over word tokens) and older entries superseded by newer ones about the same topic. `mode='report'` analyzes without touching files; `mode='apply'` removes redundant older entries through the same recovery-record pipeline as `memory_forget`, so consolidation is undoable. Thresholds are tunable via `duplicateSimilarity` / `supersedeSimilarity` parameters. - **Tool response previews**: Write/scratchpad tools return size-capped previews instead of full file contents. - **qmd auto-setup**: On first session start with qmd available, the extension creates the collection and path contexts automatically. - **qmd re-indexing**: After every write, a debounced `qmd update` runs in the background (fire-and-forget, non-blocking) unless disabled via `PI_MEMORY_QMD_UPDATE`. diff --git a/index.ts b/index.ts index 3ad496d..f469e9a 100644 --- a/index.ts +++ b/index.ts @@ -724,6 +724,187 @@ export function forgetBlocks(content: string, match: string): { content: string; }; } +// --------------------------------------------------------------------------- +// pi-dream: memory consolidation analysis +// --------------------------------------------------------------------------- + +export interface DreamBlock { + /** Timestamp meta comment line ("" for unstamped paragraph blocks). */ + meta: string; + body: string; + timestamp: Date | null; +} + +export interface DreamAnalysisOptions { + /** Jaccard similarity at/below which two entries are considered duplicates. Default 0.75. */ + duplicateSimilarity?: number; + /** Jaccard similarity at which an older entry counts as superseded by a newer one. Default 0.6. */ + supersedeSimilarity?: number; + /** Minimum age gap in days between a superseded entry and its newer replacement. Default 7. */ + supersedeMinAgeDays?: number; +} + +export interface DreamAnalysis { + blocks: DreamBlock[]; + /** Groups of block indices (length > 1) whose text is near-identical. */ + duplicateGroups: number[][]; + /** Pairs where the older entry is considered superseded by the newer one. */ + superseded: Array<{ olderIndex: number; newerIndex: number }>; +} + +const DREAM_TIMESTAMP_REGEX = + /^`; + + test("parseMemoryBlocks splits stamped entries and unstamped paragraphs", () => { + const content = [ + "# Heading", + "", + stamp("2026-01-01 10:00:00"), + "first entry body", + "", + stamp("2026-02-01 10:00:00"), + "second entry body line one", + "second entry body line two", + "", + "unstamped paragraph one", + "", + "unstamped paragraph two", + ].join("\n"); + const blocks = parseMemoryBlocks(content); + expect(blocks.length).toBe(3); // "# Heading" + 2 stamped entries (trailing unstamped lines belong to last entry) + expect(blocks[1].meta).toContain("2026-01-01"); + expect(blocks[2].body).toContain("second entry body line two"); + expect(blocks[blocks.length - 1].body).toContain("unstamped paragraph two"); + }); + + test("dreamSimilarity is high for near-duplicates and low for unrelated text", () => { + const a = "User prefers bun test over vitest for the memory package"; + const b = "user prefers bun test over vitest for the memory package!"; + const c = "Deploy pipeline runs on Fridays via GitHub Actions"; + expect(dreamSimilarity(a, b)).toBeGreaterThan(0.9); + expect(dreamSimilarity(a, c)).toBeLessThan(0.2); + }); + + test("dreamAnalyze groups near-identical entries as duplicates", () => { + const body = "API key rotation happens monthly using rotate-keys script"; + const content = [ + stamp("2026-01-01 10:00:00"), + body, + "", + stamp("2026-03-01 10:00:00"), + `${body} (unchanged)`, + ].join("\n"); + const analysis = dreamAnalyze(content); + expect(analysis.duplicateGroups.length).toBe(1); + expect(dreamDropIndices(analysis)).toEqual([0]); // older copy dropped + }); + + test("dreamAnalyze flags older entries superseded by newer ones", () => { + const content = [ + stamp("2026-01-01 10:00:00"), + "deployment target is staging.example.com with manual approval step before release", + "", + stamp("2026-06-01 10:00:00"), + "deployment target is staging.example.com with automated approval gate before release", + ].join("\n"); + const analysis = dreamAnalyze(content); + expect(analysis.duplicateGroups.length).toBe(0); + expect(analysis.superseded).toEqual([{ olderIndex: 0, newerIndex: 1 }]); + }); + + test("supersede detection requires minimum age gap", () => { + const content = [ + stamp("2026-06-01 10:00:00"), + "database host is db-primary.internal port 5432 with connection pool of twenty", + "", + stamp("2026-06-03 10:00:00"), + "database host is db-replica.internal port 5432 with connection pool of twenty", + ].join("\n"); + const analysis = dreamAnalyze(content, { supersedeSimilarity: 0.5 }); + // Only 2 days apart — below default min age gap; also below duplicate threshold. + expect(analysis.superseded.length).toBe(0); + }); + + test("dreamApply removes older copies, keeps newest, returns full removed blocks", () => { + const body = "release checklist lives in docs/release.md and is updated quarterly"; + const content = [stamp("2026-01-01 10:00:00"), body, "", stamp("2026-05-01 10:00:00"), body].join("\n"); + const result = dreamApply(content); + expect(result.removed.length).toBe(1); + expect(result.removed[0]).toContain("2026-01-01"); + expect(result.keptContent).toContain("2026-05-01"); + expect(result.keptContent).not.toContain("2026-01-01"); + }); + + let dreamTools: Record; + + beforeEach(() => { + setupTmpDir(); + const mockPi = createMockPi(); + dreamTools = mockPi.tools; + registerExtension(mockPi.pi as any); + }); + + test("memory_dream registers with correct name", () => { + expect(dreamTools.memory_dream).toBeDefined(); + }); + + test("memory_dream report mode leaves file untouched", async () => { + fs.writeFileSync( + path.join(tmpDir, "MEMORY.md"), + `${stamp("2026-01-01 10:00:00")}\ndup body shared words here\n\n${stamp("2026-02-01 10:00:00")}\ndup body shared words here\n`, + "utf-8", + ); + const result = await dreamTools.memory_dream.execute("c1", { mode: "report" }, null, null, {}); + expect(result.content[0].text).toContain("pi-dream report"); + expect(fs.readFileSync(path.join(tmpDir, "MEMORY.md"), "utf-8")).toContain("dup body shared words"); // unchanged + }); + + test("memory_dream apply mode removes duplicates with recovery id", async () => { + fs.writeFileSync( + path.join(tmpDir, "MEMORY.md"), + `${stamp("2026-01-01 10:00:00")}\ndup body shared words here\n\n${stamp("2026-02-01 10:00:00")}\ndup body shared words here\n`, + "utf-8", + ); + const result = await dreamTools.memory_dream.execute("c1", { mode: "apply" }, null, null, {}); + expect(result.content[0].text).toContain("Removed 1 redundant entry"); + expect(result.details.recoveryId).toBeDefined(); + const remaining = fs.readFileSync(path.join(tmpDir, "MEMORY.md"), "utf-8"); + expect(remaining).toContain("2026-02-01"); + expect(remaining).not.toContain("2026-01-01"); + + // Recovery record must restore the removed entry. + const restored = await dreamTools.memory_restore.execute( + "c2", + { recoveryId: result.details.recoveryId }, + null, + null, + {}, + ); + expect(fs.readFileSync(path.join(tmpDir, "MEMORY.md"), "utf-8")).toContain("2026-01-01"); + void restored; + }); + + test("memory_dream reports healthy memory when nothing removable", async () => { + fs.writeFileSync(path.join(tmpDir, "MEMORY.md"), "one unique fact about bun test runner config\n", "utf-8"); + const result = await dreamTools.memory_dream.execute("c1", {}, null, null, {}); + expect(result.content[0].text).toContain("Memory looks healthy"); + }); +}); From fbaa32fa8d31010691a6a2a0789ff317b88a22c8 Mon Sep 17 00:00:00 2001 From: KrissTos Date: Mon, 24 Aug 2026 17:47:43 +0200 Subject: [PATCH 2/9] feat: /pi-dream drives the agent via pi.sendUserMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /pi-dream (or /pi-dream report) → sends user message asking agent to run memory_dream report mode and show full findings in conversation - /pi-dream apply → runs apply mode, shows removed entries + recovery ID - replaces cramped notify-only health check Signed-off-by: KrissTos --- index.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/index.ts b/index.ts index f469e9a..b27bf15 100644 --- a/index.ts +++ b/index.ts @@ -2448,21 +2448,25 @@ export default function (pi: ExtensionAPI) { }, }); - // --- /pi-dream command: quick health check without touching files --- + // --- /pi-dream command: drives the agent to run the memory_dream tool --- pi.registerCommand("pi-dream", { - description: "Check MEMORY.md for duplicates/superseded entries (read-only report)", - handler: async (_args, ctx) => { + description: "Memory consolidation: /pi-dream [report|apply] (default: report)", + handler: async (args, ctx) => { + const mode = (args ?? "").trim().toLowerCase(); const existing = readFileSafe(MEMORY_FILE); if (!existing?.trim()) { ctx.ui.notify("pi-dream: memory is empty — nothing to consolidate.", "info"); return; } - const analysis = dreamAnalyze(existing); - const removable = dreamDropIndices(analysis).length; - ctx.ui.notify( - `pi-dream: ${analysis.blocks.length} entries, ${analysis.duplicateGroups.length} duplicate group(s), ` + - `${analysis.superseded.length} superseded → ${removable} removable. Ask the agent to run memory_dream (mode='apply') to consolidate.`, - removable > 0 ? "warning" : "info", + if (mode !== "" && mode !== "report" && mode !== "apply") { + ctx.ui.notify("pi-dream: unknown argument. Usage: /pi-dream [report|apply]", "warning"); + return; + } + const effective = mode === "" ? "report" : mode; + pi.sendUserMessage( + effective === "apply" + ? "Run the memory_dream tool with mode='apply' to consolidate MEMORY.md. Show me what was removed and the recovery ID." + : "Run the memory_dream tool in report mode and show me the full findings for MEMORY.md — duplicate groups and superseded entries with previews. Do not modify anything.", ); }, }); From 68b3b738ead21205d9a7cdc64c3090b2a85eced9 Mon Sep 17 00:00:00 2001 From: KrissTos Date: Mon, 24 Aug 2026 18:13:12 +0200 Subject: [PATCH 3/9] feat: /pi-dream default = auto (apply pure duplicates, ask on superseded) Signed-off-by: KrissTos --- index.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index b27bf15..c521b64 100644 --- a/index.ts +++ b/index.ts @@ -2450,7 +2450,8 @@ export default function (pi: ExtensionAPI) { // --- /pi-dream command: drives the agent to run the memory_dream tool --- pi.registerCommand("pi-dream", { - description: "Memory consolidation: /pi-dream [report|apply] (default: report)", + description: + "Memory consolidation: /pi-dream [auto|report|apply] (default auto: applies pure duplicates, asks about superseded)", handler: async (args, ctx) => { const mode = (args ?? "").trim().toLowerCase(); const existing = readFileSafe(MEMORY_FILE); @@ -2458,15 +2459,17 @@ export default function (pi: ExtensionAPI) { ctx.ui.notify("pi-dream: memory is empty — nothing to consolidate.", "info"); return; } - if (mode !== "" && mode !== "report" && mode !== "apply") { + if (mode !== "" && mode !== "report" && mode !== "apply" && mode !== "auto") { ctx.ui.notify("pi-dream: unknown argument. Usage: /pi-dream [report|apply]", "warning"); return; } - const effective = mode === "" ? "report" : mode; + const effective = mode === "" ? "auto" : mode; pi.sendUserMessage( effective === "apply" ? "Run the memory_dream tool with mode='apply' to consolidate MEMORY.md. Show me what was removed and the recovery ID." - : "Run the memory_dream tool in report mode and show me the full findings for MEMORY.md — duplicate groups and superseded entries with previews. Do not modify anything.", + : effective === "auto" + ? "Run the memory_dream tool in report mode on MEMORY.md. If ALL removable entries are near-duplicates of kept newer versions (zero unique content would be lost), immediately re-run with mode='apply' and show me what was removed plus the recovery ID. If any finding involves superseded entries where older content differs meaningfully from its newer replacement, do NOT apply — present those findings and ask me first." + : "Run the memory_dream tool in report mode and show me the full findings for MEMORY.md — duplicate groups and superseded entries with previews. Do not modify anything.", ); }, }); From 6bc72c770fb02afa8783f1ec3b6fe513d95cc8c7 Mon Sep 17 00:00:00 2001 From: KrissTos Date: Mon, 24 Aug 2026 18:14:47 +0200 Subject: [PATCH 4/9] docs: add local pi-dream reference (PI-DREAM.md) Signed-off-by: KrissTos --- PI-DREAM.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 PI-DREAM.md diff --git a/PI-DREAM.md b/PI-DREAM.md new file mode 100644 index 0000000..2fd5217 --- /dev/null +++ b/PI-DREAM.md @@ -0,0 +1,89 @@ +# pi-dream — memory consolidation for pi-memory + +Local working notes for the `feat/pi-dream-consolidation` branch (PR: https://github.com/jayzeng/pi-memory/pull/34). + +> This file is local-only documentation. The upstream-facing docs live in `README.md`. + +## What it does + +After many sessions, `MEMORY.md` accumulates near-duplicate entries and older entries superseded by newer ones about the same topic. Bloat is injected into every session start — wasted context tokens + stale-recall risk. + +pi-dream detects both patterns and removes redundant older copies through the standard recovery-record pipeline (fully reversible). + +## Detection + +| Pattern | Method | Default threshold | +|---|---|---| +| Near-duplicates | Jaccard similarity over word tokens (≥3 chars) | ≥ 0.75 | +| Superseded | Similar older/newer pair on same topic | ≥ 0.6 similarity AND ≥ 7 days age gap | + +Entry unit = timestamped block (`` until next meta comment). Trailing unstamped lines after the last stamp belong to that entry (same semantics as `forgetBlocks`). + +## Commands & tools + +### `/pi-dream [auto|report|apply]` + +Drives the agent via `pi.sendUserMessage`. Modes: + +| Mode | Behavior | +|---|---| +| *(none)* = **auto** | Report → if ALL findings are pure duplicates (zero content loss) → applies immediately, shows recovery ID. If superseded entries found → stops, presents findings, asks first | +| `report` | Read-only full findings with previews | +| `apply` | Apply everything found, show removed entries + recovery ID | +| bad arg | Usage hint | + +### `memory_dream` tool (agent-invocable) + +``` +memory_dream {} # report mode (default) +memory_dream { mode: "report" } # read-only findings +memory_dream { mode: "apply" } # consolidate, returns recovery ID +memory_dream { duplicateSimilarity: 0.8 } # stricter dup threshold +memory_dream { supersedeSimilarity: 0.5 } # looser supersede detection +``` + +Returns: findings with previews / removed count + `recoveryId`. + +### Undo + +``` +memory_restore { recoveryId: "" } +``` + +Recovery records live in `~/.pi/agent/memory/recovery/.json` before any file mutation. + +## Files + +| Path | Role | +|---|---| +| `index.ts` | All logic: `parseMemoryBlocks`, `dreamSimilarity`, `dreamAnalyze`, `dreamDropIndices`, `dreamApply` (exported pure functions) + tool/command registration (~line 670 analysis, ~line 2355 tool) | +| `test/unit.test.ts` | `describe("pi-dream consolidation")` — 10 tests | + +Pipeline on apply: `dreamAnalyze` → `dreamDropIndices` → `dreamApply` → `writeRecoveryRecord("long_term")` → write file → `snapshotDirty = true` → `scheduleQmdUpdate()`. + +## Install (this machine) + +`~/.pi/agent/settings.json`: + +```json +"git:github.com/KrissTos/pi-memory@feat/pi-dream-consolidation" +``` + +Note: branch separator is `@`, not `#`. After merge upstream, switch back to `"npm:pi-memory"` and run `pi update --extensions`. + +## Dev workflow + +```bash +cd ~/Projects/pi-memory-pr +bun test test/unit.test.ts # 192 tests +bunx tsc --noEmit # typecheck +bunx biome check index.ts # lint/format (pre-commit hook enforces all three) +git push origin feat/pi-dream-consolidation # auto-updates PR #34 +``` + +Pre-commit hooks (`.githooks/`) run tests + lint on every commit — commit fails if format drifts; fix with `bunx biome check --write index.ts`. + +## Roadmap ideas + +- Weekly scheduled run via `pi-schedule-prompt`: recurring prompt "Run memory_dream report; apply if pure duplicates only" (validate judgment on real runs first) +- Phase 2: semantic contradiction merging (needs LLM call, e.g. exit-summary-style model access) From 8071ae65f9354f983d09867e69d37679163e7830 Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Sun, 20 Sep 2026 19:00:56 -0700 Subject: [PATCH 5/9] fix: make pi-dream consolidation conservative --- index.ts | 87 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 51 insertions(+), 36 deletions(-) diff --git a/index.ts b/index.ts index c521b64..abe678f 100644 --- a/index.ts +++ b/index.ts @@ -828,49 +828,58 @@ function dreamDaysBetween(a: Date, b: Date): number { } /** Analyze MEMORY.md content for duplicate groups and superseded older entries. */ +function dreamThreshold(value: number | undefined, fallback: number, label: string): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value) || value < 0 || value > 1) throw new Error(`${label} must be between 0 and 1`); + return value; +} + export function dreamAnalyze(content: string, opts: DreamAnalysisOptions = {}): DreamAnalysis { - const duplicateThreshold = opts.duplicateSimilarity ?? 0.75; - const supersedeThreshold = opts.supersedeSimilarity ?? 0.6; + const duplicateThreshold = dreamThreshold(opts.duplicateSimilarity, 0.75, "duplicateSimilarity"); + const supersedeThreshold = dreamThreshold(opts.supersedeSimilarity, 0.6, "supersedeSimilarity"); const supersedeMinAgeDays = opts.supersedeMinAgeDays ?? 7; + if (!Number.isFinite(supersedeMinAgeDays) || supersedeMinAgeDays < 0) + throw new Error("supersedeMinAgeDays must be a non-negative number"); const blocks = parseMemoryBlocks(content); const n = blocks.length; - // Union-find over near-identical pairs. - const parent = Array.from({ length: n }, (_, i) => i); - const find = (i: number): number => { - if (parent[i] !== i) parent[i] = find(parent[i]); - return parent[i]; - }; - const unionPair = (i: number, j: number) => { - parent[find(i)] = find(j); - }; + const duplicateGroups: number[][] = []; + const assigned = new Set(); + for (let i = 0; i < n; i++) { + if (assigned.has(i)) continue; + const group = [i]; + for (let j = i + 1; j < n; j++) { + if (assigned.has(j)) continue; + if (group.every((member) => dreamSimilarity(blocks[member].body, blocks[j].body) >= duplicateThreshold)) { + group.push(j); + } + } + if (group.length > 1) { + duplicateGroups.push(group); + for (const member of group) assigned.add(member); + } + } + + const duplicatePairs = new Set(); + for (const group of duplicateGroups) { + for (let i = 0; i < group.length; i++) { + for (let j = i + 1; j < group.length; j++) duplicatePairs.add(`${group[i]}:${group[j]}`); + } + } + const superseded: Array<{ olderIndex: number; newerIndex: number }> = []; for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { + if (duplicatePairs.has(`${i}:${j}`)) continue; const sim = dreamSimilarity(blocks[i].body, blocks[j].body); - if (sim >= duplicateThreshold) { - unionPair(i, j); - continue; - } const ti = blocks[i].timestamp; const tj = blocks[j].timestamp; - if (sim >= supersedeThreshold && ti && tj) { - if (dreamDaysBetween(ti, tj) >= supersedeMinAgeDays) { - const olderIsFirst = ti <= tj; - superseded.push(olderIsFirst ? { olderIndex: i, newerIndex: j } : { olderIndex: j, newerIndex: i }); - } + if (sim >= supersedeThreshold && ti && tj && dreamDaysBetween(ti, tj) >= supersedeMinAgeDays) { + const olderIsFirst = ti <= tj; + superseded.push(olderIsFirst ? { olderIndex: i, newerIndex: j } : { olderIndex: j, newerIndex: i }); } } } - - const groupMap = new Map(); - for (let i = 0; i < n; i++) { - const root = find(i); - const group = groupMap.get(root); - if (group) group.push(i); - else groupMap.set(root, [i]); - } - const duplicateGroups = [...groupMap.values()].filter((group) => group.length > 1); return { blocks, duplicateGroups, superseded }; } @@ -878,9 +887,15 @@ export function dreamAnalyze(content: string, opts: DreamAnalysisOptions = {}): export function dreamDropIndices(analysis: DreamAnalysis): number[] { const drops = new Set(); for (const group of analysis.duplicateGroups) { - // Keep the newest member (largest index = latest position); drop earlier copies. - const sorted = [...group].sort((a, b) => a - b); - for (const index of sorted.slice(0, -1)) drops.add(index); + const keep = [...group].sort((a, b) => { + const ta = analysis.blocks[a].timestamp?.getTime(); + const tb = analysis.blocks[b].timestamp?.getTime(); + if (ta !== undefined && tb !== undefined && ta !== tb) return tb - ta; + if (ta !== undefined && tb === undefined) return -1; + if (ta === undefined && tb !== undefined) return 1; + return b - a; + })[0]!; + for (const index of group) if (index !== keep) drops.add(index); } for (const pair of analysis.superseded) drops.add(pair.olderIndex); return [...drops].sort((a, b) => a - b); @@ -2348,10 +2363,10 @@ export default function (pi: ExtensionAPI) { }), ), duplicateSimilarity: Type.Optional( - Type.Number({ description: "Jaccard threshold for duplicates (0-1, default 0.75)" }), + Type.Number({ minimum: 0, maximum: 1, description: "Jaccard threshold for duplicates (0-1, default 0.75)" }), ), supersedeSimilarity: Type.Optional( - Type.Number({ description: "Jaccard threshold for superseded entries (0-1, default 0.6)" }), + Type.Number({ minimum: 0, maximum: 1, description: "Jaccard threshold for superseded entries (0-1, default 0.6)" }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { @@ -2451,7 +2466,7 @@ export default function (pi: ExtensionAPI) { // --- /pi-dream command: drives the agent to run the memory_dream tool --- pi.registerCommand("pi-dream", { description: - "Memory consolidation: /pi-dream [auto|report|apply] (default auto: applies pure duplicates, asks about superseded)", + "Memory consolidation: /pi-dream [auto|report|apply] (default auto is report-only; apply must be explicit)", handler: async (args, ctx) => { const mode = (args ?? "").trim().toLowerCase(); const existing = readFileSafe(MEMORY_FILE); @@ -2468,7 +2483,7 @@ export default function (pi: ExtensionAPI) { effective === "apply" ? "Run the memory_dream tool with mode='apply' to consolidate MEMORY.md. Show me what was removed and the recovery ID." : effective === "auto" - ? "Run the memory_dream tool in report mode on MEMORY.md. If ALL removable entries are near-duplicates of kept newer versions (zero unique content would be lost), immediately re-run with mode='apply' and show me what was removed plus the recovery ID. If any finding involves superseded entries where older content differs meaningfully from its newer replacement, do NOT apply — present those findings and ask me first." + ? "Run the memory_dream tool in report mode on MEMORY.md and show me the findings. Do not modify anything. If consolidation looks useful, tell me to re-run /pi-dream apply explicitly." : "Run the memory_dream tool in report mode and show me the full findings for MEMORY.md — duplicate groups and superseded entries with previews. Do not modify anything.", ); }, From 34de05a427927fdd76768cdff664ff75d9b74d54 Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Sun, 20 Sep 2026 19:01:08 -0700 Subject: [PATCH 6/9] test: cover conservative pi-dream behavior --- test/unit.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/unit.test.ts b/test/unit.test.ts index 616f0c9..ee6b295 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -2488,6 +2488,46 @@ describe("pi-dream consolidation", () => { expect(result.keptContent).not.toContain("2026-01-01"); }); + test("duplicate clustering is not transitive", () => { + const content = [ + stamp("2026-01-01 10:00:00"), + "alpha beta gamma delta epsilon", + "", + stamp("2026-02-01 10:00:00"), + "alpha beta gamma delta zeta", + "", + stamp("2026-03-01 10:00:00"), + "alpha beta gamma zeta eta", + ].join("\n"); + const analysis = dreamAnalyze(content, { duplicateSimilarity: 0.65, supersedeSimilarity: 1 }); + for (const group of analysis.duplicateGroups) { + for (let i = 0; i < group.length; i++) { + for (let j = i + 1; j < group.length; j++) { + expect(dreamSimilarity(analysis.blocks[group[i]].body, analysis.blocks[group[j]].body)).toBeGreaterThanOrEqual(0.65); + } + } + } + expect(dreamDropIndices(analysis)).not.toContain(1); + }); + + test("duplicate retention prefers newest timestamp over file position", () => { + const body = "same durable fact with enough shared words"; + const content = [ + stamp("2026-06-01 10:00:00"), + body, + "", + stamp("2026-01-01 10:00:00"), + body, + ].join("\n"); + const analysis = dreamAnalyze(content); + expect(dreamDropIndices(analysis)).toEqual([1]); + }); + + test("rejects out-of-range consolidation thresholds", () => { + expect(() => dreamAnalyze("one block", { duplicateSimilarity: 1.1 })).toThrow("between 0 and 1"); + expect(() => dreamAnalyze("one block", { supersedeSimilarity: -0.1 })).toThrow("between 0 and 1"); + }); + let dreamTools: Record; beforeEach(() => { From a32eec405b109f439f0b95d0500299ce9021663d Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Sun, 20 Sep 2026 19:01:11 -0700 Subject: [PATCH 7/9] chore: remove local-only pi-dream notes --- PI-DREAM.md | 89 ----------------------------------------------------- 1 file changed, 89 deletions(-) delete mode 100644 PI-DREAM.md diff --git a/PI-DREAM.md b/PI-DREAM.md deleted file mode 100644 index 2fd5217..0000000 --- a/PI-DREAM.md +++ /dev/null @@ -1,89 +0,0 @@ -# pi-dream — memory consolidation for pi-memory - -Local working notes for the `feat/pi-dream-consolidation` branch (PR: https://github.com/jayzeng/pi-memory/pull/34). - -> This file is local-only documentation. The upstream-facing docs live in `README.md`. - -## What it does - -After many sessions, `MEMORY.md` accumulates near-duplicate entries and older entries superseded by newer ones about the same topic. Bloat is injected into every session start — wasted context tokens + stale-recall risk. - -pi-dream detects both patterns and removes redundant older copies through the standard recovery-record pipeline (fully reversible). - -## Detection - -| Pattern | Method | Default threshold | -|---|---|---| -| Near-duplicates | Jaccard similarity over word tokens (≥3 chars) | ≥ 0.75 | -| Superseded | Similar older/newer pair on same topic | ≥ 0.6 similarity AND ≥ 7 days age gap | - -Entry unit = timestamped block (`` until next meta comment). Trailing unstamped lines after the last stamp belong to that entry (same semantics as `forgetBlocks`). - -## Commands & tools - -### `/pi-dream [auto|report|apply]` - -Drives the agent via `pi.sendUserMessage`. Modes: - -| Mode | Behavior | -|---|---| -| *(none)* = **auto** | Report → if ALL findings are pure duplicates (zero content loss) → applies immediately, shows recovery ID. If superseded entries found → stops, presents findings, asks first | -| `report` | Read-only full findings with previews | -| `apply` | Apply everything found, show removed entries + recovery ID | -| bad arg | Usage hint | - -### `memory_dream` tool (agent-invocable) - -``` -memory_dream {} # report mode (default) -memory_dream { mode: "report" } # read-only findings -memory_dream { mode: "apply" } # consolidate, returns recovery ID -memory_dream { duplicateSimilarity: 0.8 } # stricter dup threshold -memory_dream { supersedeSimilarity: 0.5 } # looser supersede detection -``` - -Returns: findings with previews / removed count + `recoveryId`. - -### Undo - -``` -memory_restore { recoveryId: "" } -``` - -Recovery records live in `~/.pi/agent/memory/recovery/.json` before any file mutation. - -## Files - -| Path | Role | -|---|---| -| `index.ts` | All logic: `parseMemoryBlocks`, `dreamSimilarity`, `dreamAnalyze`, `dreamDropIndices`, `dreamApply` (exported pure functions) + tool/command registration (~line 670 analysis, ~line 2355 tool) | -| `test/unit.test.ts` | `describe("pi-dream consolidation")` — 10 tests | - -Pipeline on apply: `dreamAnalyze` → `dreamDropIndices` → `dreamApply` → `writeRecoveryRecord("long_term")` → write file → `snapshotDirty = true` → `scheduleQmdUpdate()`. - -## Install (this machine) - -`~/.pi/agent/settings.json`: - -```json -"git:github.com/KrissTos/pi-memory@feat/pi-dream-consolidation" -``` - -Note: branch separator is `@`, not `#`. After merge upstream, switch back to `"npm:pi-memory"` and run `pi update --extensions`. - -## Dev workflow - -```bash -cd ~/Projects/pi-memory-pr -bun test test/unit.test.ts # 192 tests -bunx tsc --noEmit # typecheck -bunx biome check index.ts # lint/format (pre-commit hook enforces all three) -git push origin feat/pi-dream-consolidation # auto-updates PR #34 -``` - -Pre-commit hooks (`.githooks/`) run tests + lint on every commit — commit fails if format drifts; fix with `bunx biome check --write index.ts`. - -## Roadmap ideas - -- Weekly scheduled run via `pi-schedule-prompt`: recurring prompt "Run memory_dream report; apply if pure duplicates only" (validate judgment on real runs first) -- Phase 2: semantic contradiction merging (needs LLM call, e.g. exit-summary-style model access) From 1b011423906a34c89bc02d09f88b84b2d612b7d0 Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Sun, 20 Sep 2026 19:08:41 -0700 Subject: [PATCH 8/9] style: format pi-dream threshold schema --- index.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index abe678f..3ee6b6f 100644 --- a/index.ts +++ b/index.ts @@ -2363,10 +2363,18 @@ export default function (pi: ExtensionAPI) { }), ), duplicateSimilarity: Type.Optional( - Type.Number({ minimum: 0, maximum: 1, description: "Jaccard threshold for duplicates (0-1, default 0.75)" }), + Type.Number({ + minimum: 0, + maximum: 1, + description: "Jaccard threshold for duplicates (0-1, default 0.75)", + }), ), supersedeSimilarity: Type.Optional( - Type.Number({ minimum: 0, maximum: 1, description: "Jaccard threshold for superseded entries (0-1, default 0.6)" }), + Type.Number({ + minimum: 0, + maximum: 1, + description: "Jaccard threshold for superseded entries (0-1, default 0.6)", + }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { From 8565e66026395025d65a5a740c49bd4b9648fb8d Mon Sep 17 00:00:00 2001 From: Jay Zeng Date: Sun, 20 Sep 2026 19:08:45 -0700 Subject: [PATCH 9/9] style: format pi-dream tests --- test/unit.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/test/unit.test.ts b/test/unit.test.ts index ee6b295..78131dd 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -2503,7 +2503,9 @@ describe("pi-dream consolidation", () => { for (const group of analysis.duplicateGroups) { for (let i = 0; i < group.length; i++) { for (let j = i + 1; j < group.length; j++) { - expect(dreamSimilarity(analysis.blocks[group[i]].body, analysis.blocks[group[j]].body)).toBeGreaterThanOrEqual(0.65); + expect( + dreamSimilarity(analysis.blocks[group[i]].body, analysis.blocks[group[j]].body), + ).toBeGreaterThanOrEqual(0.65); } } } @@ -2512,13 +2514,7 @@ describe("pi-dream consolidation", () => { test("duplicate retention prefers newest timestamp over file position", () => { const body = "same durable fact with enough shared words"; - const content = [ - stamp("2026-06-01 10:00:00"), - body, - "", - stamp("2026-01-01 10:00:00"), - body, - ].join("\n"); + const content = [stamp("2026-06-01 10:00:00"), body, "", stamp("2026-01-01 10:00:00"), body].join("\n"); const analysis = dreamAnalyze(content); expect(dreamDropIndices(analysis)).toEqual([1]); });