-
Notifications
You must be signed in to change notification settings - Fork 41
Fix #34 conservative pi-dream consolidation #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 7 commits
1b87ad9
fbaa32f
68b3b73
6bc72c7
8071ae6
34de05a
a32eec4
1b01142
8565e66
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -724,6 +724,202 @@ 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 = | ||
| /^<!-- (?:(?:last updated: )?(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})|HANDOFF (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})) /; | ||
|
|
||
| /** Parse MEMORY.md content into stamped/unstamped blocks (same unit as forgetBlocks removes). */ | ||
| export function parseMemoryBlocks(content: string): DreamBlock[] { | ||
| const normalized = content.replace(/\r\n?/g, "\n").replace(/^\uFEFF/, ""); | ||
| const rawBlocks: string[] = []; | ||
| let currentLines: string[] = []; | ||
| let currentIsStamped = false; | ||
| const flushCurrent = () => { | ||
| const current = currentLines.join("\n").trim(); | ||
| if (!current) return; | ||
| if (currentIsStamped) { | ||
| rawBlocks.push(current); | ||
| } else { | ||
| rawBlocks.push( | ||
| ...current | ||
| .split(/\n{2,}/) | ||
| .map((block) => block.trim()) | ||
| .filter(Boolean), | ||
| ); | ||
| } | ||
| }; | ||
| for (const line of normalized.split("\n")) { | ||
| if (MEMORY_ENTRY_META_COMMENT_REGEX.test(line)) { | ||
| flushCurrent(); | ||
| currentLines = [line]; | ||
| currentIsStamped = true; | ||
| } else { | ||
| currentLines.push(line); | ||
| } | ||
| } | ||
| flushCurrent(); | ||
|
|
||
| return rawBlocks.map((raw) => { | ||
| const lines = raw.split("\n"); | ||
| const first = lines[0] ?? ""; | ||
| if (MEMORY_ENTRY_META_COMMENT_REGEX.test(first)) { | ||
| const match = DREAM_TIMESTAMP_REGEX.exec(first); | ||
| const ts = match ? (match[1] ?? match[2]) : undefined; | ||
| return { | ||
| meta: first, | ||
| body: lines.slice(1).join("\n").trim() || raw, | ||
| timestamp: ts ? new Date(ts.replace(" ", "T")) : null, | ||
| }; | ||
| } | ||
| return { meta: "", body: raw, timestamp: null }; | ||
| }); | ||
| } | ||
|
|
||
| function dreamTokenSet(text: string): Set<string> { | ||
| const tokens = text | ||
| .toLowerCase() | ||
| .split(/[^a-z0-9]+/) | ||
| .filter((word) => word.length >= 3); | ||
| return new Set(tokens); | ||
| } | ||
|
|
||
| /** Jaccard similarity over lowercase word tokens (words < 3 chars ignored). */ | ||
| export function dreamSimilarity(a: string, b: string): number { | ||
| const setA = dreamTokenSet(a); | ||
| const setB = dreamTokenSet(b); | ||
| if (setA.size === 0 && setB.size === 0) return 1; | ||
| let intersection = 0; | ||
| for (const token of setA) { | ||
| if (setB.has(token)) intersection++; | ||
| } | ||
| const union = setA.size + setB.size - intersection; | ||
| return union === 0 ? 0 : intersection / union; | ||
| } | ||
|
|
||
| function dreamDaysBetween(a: Date, b: Date): number { | ||
| return Math.abs(a.getTime() - b.getTime()) / 86_400_000; | ||
| } | ||
|
|
||
| /** 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 = 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; | ||
|
|
||
| const duplicateGroups: number[][] = []; | ||
| const assigned = new Set<number>(); | ||
| 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<string>(); | ||
| 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); | ||
| const ti = blocks[i].timestamp; | ||
| const tj = blocks[j].timestamp; | ||
| if (sim >= supersedeThreshold && ti && tj && dreamDaysBetween(ti, tj) >= supersedeMinAgeDays) { | ||
| const olderIsFirst = ti <= tj; | ||
| superseded.push(olderIsFirst ? { olderIndex: i, newerIndex: j } : { olderIndex: j, newerIndex: i }); | ||
| } | ||
| } | ||
| } | ||
| return { blocks, duplicateGroups, superseded }; | ||
| } | ||
|
|
||
| /** Compute which block indices pi-dream would remove: older members of duplicate groups plus superseded entries. */ | ||
| export function dreamDropIndices(analysis: DreamAnalysis): number[] { | ||
| const drops = new Set<number>(); | ||
| for (const group of analysis.duplicateGroups) { | ||
| 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); | ||
| } | ||
|
|
||
| /** Apply consolidation to content: returns surviving content plus complete removed blocks. */ | ||
| export function dreamApply( | ||
| content: string, | ||
| opts: DreamAnalysisOptions = {}, | ||
| ): { keptContent: string; removed: string[] } { | ||
| const newline = content.includes("\r\n") ? "\r\n" : "\n"; | ||
| const analysis = dreamAnalyze(content, opts); | ||
| const drops = dreamDropIndices(analysis); | ||
| if (drops.length === 0) return { keptContent: content, removed: [] }; | ||
| const removed: string[] = []; | ||
| const kept: string[] = []; | ||
| analysis.blocks.forEach((block, index) => { | ||
| const raw = block.meta ? `${block.meta}\n${block.body}` : block.body; | ||
| if (drops.includes(index)) removed.push(raw.replace(/\n/g, newline)); | ||
| else kept.push(raw.replace(/\n/g, newline)); | ||
| }); | ||
| return { keptContent: kept.length ? `${kept.join("\n\n")}\n` : "", removed }; | ||
| } | ||
|
|
||
| function recoveryPath(recoveryId: string): string | null { | ||
| if (!RECOVERY_ID_REGEX.test(recoveryId)) return null; | ||
| return path.join(RECOVERY_DIR, `${recoveryId}.json`); | ||
|
|
@@ -2149,6 +2345,150 @@ export default function (pi: ExtensionAPI) { | |
| }, | ||
| }); | ||
|
|
||
| // --- memory_dream (pi-dream) tool --- | ||
| pi.registerTool({ | ||
| name: "memory_dream", | ||
| label: "Memory Dream", | ||
| description: [ | ||
| "Consolidate long-term memory (MEMORY.md): detect near-duplicate entries and older entries", | ||
| "superseded by newer ones about the same topic. mode='report' (default) analyzes only and", | ||
| "returns findings; mode='apply' removes redundant older entries via the standard recovery-record", | ||
| "pipeline (undo with memory_restore). Run this when MEMORY.md has grown bloated or contradictory", | ||
| "after many sessions.", | ||
| ].join("\n"), | ||
| parameters: Type.Object({ | ||
| mode: Type.Optional( | ||
| StringEnum(["report", "apply"] as const, { | ||
| description: "'report' (default) analyzes without changing files; 'apply' removes redundant entries", | ||
| }), | ||
| ), | ||
| duplicateSimilarity: Type.Optional( | ||
| 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)" }), | ||
| ), | ||
| }), | ||
| async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { | ||
| ensureDirs(); | ||
| const existing = readFileSafe(MEMORY_FILE); | ||
| if (!existing?.trim()) { | ||
| return { | ||
| content: [{ type: "text", text: `Nothing stored in ${MEMORY_FILE} — nothing to dream about.` }], | ||
| details: { path: MEMORY_FILE, removed: 0 }, | ||
| }; | ||
| } | ||
| const opts: DreamAnalysisOptions = {}; | ||
| if (params.duplicateSimilarity !== undefined) opts.duplicateSimilarity = params.duplicateSimilarity; | ||
| if (params.supersedeSimilarity !== undefined) opts.supersedeSimilarity = params.supersedeSimilarity; | ||
|
|
||
| const analysis = dreamAnalyze(existing, opts); | ||
| const dropCount = dreamDropIndices(analysis).length; | ||
| if (dropCount === 0) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `pi-dream: no duplicates or superseded entries found in ${MEMORY_FILE} (${analysis.blocks.length} entries analyzed). Memory looks healthy.`, | ||
| }, | ||
| ], | ||
| details: { path: MEMORY_FILE, analyzed: analysis.blocks.length, removed: 0 }, | ||
| }; | ||
| } | ||
|
|
||
| const previewBlock = (index: number): string => { | ||
| const block = analysis.blocks[index]; | ||
| const raw = block.meta ? `${block.meta}\n${block.body}` : block.body; | ||
| const preview = buildPreview(raw, { maxLines: 4, maxChars: 300, mode: "start" }); | ||
| return preview.preview; | ||
| }; | ||
| const findings: string[] = []; | ||
| for (const group of analysis.duplicateGroups) { | ||
| findings.push(`Duplicate group (keeping newest #${Math.max(...group)}):`); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When duplicate blocks are not stored chronologically, this reports the largest file index as the retained entry, while Useful? React with 👍 / 👎. |
||
| for (const index of group) findings.push(` [#${index}] ${previewBlock(index)}`); | ||
| } | ||
| for (const pair of analysis.superseded) { | ||
| findings.push(`Superseded: [#${pair.olderIndex}] replaced by newer [#${pair.newerIndex}]:`); | ||
| findings.push(` [#${pair.olderIndex}] ${previewBlock(pair.olderIndex)}`); | ||
| } | ||
| const findingsText = findings.join("\n"); | ||
|
|
||
| if ((params.mode ?? "report") === "report") { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: | ||
| `pi-dream report for ${MEMORY_FILE}: ${dropCount} of ${analysis.blocks.length} entries are removable ` + | ||
| `(${analysis.duplicateGroups.length} duplicate group(s), ${analysis.superseded.length} superseded).\n\n` + | ||
| `${findingsText}\n\n` + | ||
| `Review the findings, then re-run with mode='apply' to remove them (recovery record included).`, | ||
| }, | ||
| ], | ||
| details: { | ||
| path: MEMORY_FILE, | ||
| analyzed: analysis.blocks.length, | ||
| removable: dropCount, | ||
| duplicateGroups: analysis.duplicateGroups.length, | ||
| superseded: analysis.superseded.length, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| const applied = dreamApply(existing, opts); | ||
| const recovery = writeRecoveryRecord("long_term", undefined, applied.removed); | ||
| fs.writeFileSync(MEMORY_FILE, applied.keptContent, "utf-8"); | ||
| snapshotDirty = true; | ||
| await ensureQmdAvailableForUpdate(); | ||
| scheduleQmdUpdate(); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: | ||
| `pi-dream: consolidated ${MEMORY_FILE}. Removed ${applied.removed.length} redundant entr${applied.removed.length === 1 ? "y" : "ies"} ` + | ||
| `(was ${analysis.blocks.length}, now ${analysis.blocks.length - applied.removed.length}). ` + | ||
| `Recovery ID: ${recovery.id}. To undo this consolidation, call memory_restore with that ID.\n\n` + | ||
| `Removed:\n${findingsText}`, | ||
| }, | ||
| ], | ||
| details: { | ||
| path: MEMORY_FILE, | ||
| analyzed: analysis.blocks.length, | ||
| removed: applied.removed.length, | ||
| recoveryId: recovery.id, | ||
| recoveryPath: recoveryPath(recovery.id), | ||
| }, | ||
| }; | ||
| }, | ||
| }); | ||
|
|
||
| // --- /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 is report-only; apply must be explicit)", | ||
| 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; | ||
| } | ||
| 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 === "" ? "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." | ||
| : effective === "auto" | ||
| ? "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.", | ||
| ); | ||
| }, | ||
| }); | ||
|
|
||
| // --- memory_restore tool --- | ||
| pi.registerTool({ | ||
| name: "memory_restore", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When entries contain only non-ASCII text or words shorter than three characters, such as Chinese/Japanese memories or short technical notes like “Use Go” and “Use AI,” the tokenizer produces empty or effectively identical sets and this branch assigns them similarity 1.
memory_dreamtherefore groups unrelated entries as duplicates, andmode='apply'deletes all but one of them. Treat entries without sufficient comparable tokens as non-duplicates or fall back to a Unicode-aware/text comparison.Useful? React with 👍 / 👎.