pi-mega-compact 0.8.26 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +12 -9
  2. package/dist/extensions/mega-compact.js +16 -1
  3. package/dist/extensions/mega-config.js +1 -0
  4. package/dist/extensions/mega-pipeline/compact.js +2 -1
  5. package/dist/extensions/mega-runtime/reset-runtime.js +8 -0
  6. package/dist/extensions/mega-runtime/runtime.js +12 -50
  7. package/dist/extensions/mega-shutdown-widget.test.js +121 -0
  8. package/dist/src/compact.js +4 -2
  9. package/dist/src/dedup/raptor/tree.js +11 -0
  10. package/dist/src/memory.test.js +29 -0
  11. package/dist/src/memoryOps.js +4 -19
  12. package/dist/src/memoryRecall.test.js +27 -0
  13. package/dist/src/memoryRoundtrip.test.js +137 -0
  14. package/dist/src/recall.js +5 -4
  15. package/dist/src/sprint4x-rag-verification.test.js +93 -0
  16. package/dist/src/store/memoryIndex.js +29 -7
  17. package/dist/src/store/pgOpenGuard.js +83 -0
  18. package/dist/src/store/pgOpenGuard.test.js +74 -0
  19. package/dist/src/store/repoKey.js +45 -0
  20. package/dist/src/store/vectorIndex.js +30 -8
  21. package/dist/src/store/vectorIndex.test.js +25 -1
  22. package/dist/src/vector-search.js +11 -5
  23. package/dist/src/vectorStore.js +4 -1
  24. package/extensions/mega-compact.ts +16 -1
  25. package/extensions/mega-config.ts +8 -0
  26. package/extensions/mega-pipeline/compact.ts +2 -1
  27. package/extensions/mega-runtime/reset-runtime.ts +88 -0
  28. package/extensions/mega-runtime/runtime.ts +27 -59
  29. package/extensions/mega-shutdown-widget.test.ts +141 -0
  30. package/package.json +1 -1
  31. package/src/compact.ts +209 -174
  32. package/src/dedup/raptor/tree.ts +11 -0
  33. package/src/memory.test.ts +47 -1
  34. package/src/memoryOps.ts +4 -19
  35. package/src/memoryRecall.test.ts +36 -0
  36. package/src/memoryRoundtrip.test.ts +155 -0
  37. package/src/recall.ts +6 -3
  38. package/src/sprint4x-rag-verification.test.ts +119 -0
  39. package/src/store/memoryIndex.ts +34 -8
  40. package/src/store/pgOpenGuard.test.ts +89 -0
  41. package/src/store/pgOpenGuard.ts +93 -0
  42. package/src/store/repoKey.ts +50 -0
  43. package/src/store/vectorIndex.test.ts +25 -1
  44. package/src/store/vectorIndex.ts +35 -9
  45. package/src/vector-search.ts +10 -5
  46. package/src/vectorStore.ts +4 -1
package/src/compact.ts CHANGED
@@ -14,30 +14,31 @@ const INTERESTING_EXT = new Set(["rs", "ts", "tsx", "js", "json", "md"]);
14
14
  const PENDING_WORDS = ["todo", "next", "pending", "follow up", "remaining"];
15
15
 
16
16
  const COMPACT_PREAMBLE =
17
- "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n";
17
+ "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n";
18
18
  const RECENT_NOTE = "Recent messages are preserved verbatim.";
19
- const DIRECT_RESUME = "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
19
+ const DIRECT_RESUME =
20
+ "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
20
21
 
21
22
  function truncate(s: string, max: number): string {
22
- return s.length <= max ? s : `${s.slice(0, max)}…`;
23
+ return s.length <= max ? s : `${s.slice(0, max)}…`;
23
24
  }
24
25
 
25
26
  function firstText(m: EngineMessage): string | undefined {
26
- // PREVENT crash: pi can hand us a message with text: undefined (pure
27
- // tool-call/tool-result). Guard the trim so the legacy summarizeMessages
28
- // path can't throw the same undefined-text crash the extractive path did.
29
- const raw = m.text ?? "";
30
- const t = raw.trim();
31
- return t.length > 0 ? t : undefined;
27
+ // PREVENT crash: pi can hand us a message with text: undefined (pure
28
+ // tool-call/tool-result). Guard the trim so the legacy summarizeMessages
29
+ // path can't throw the same undefined-text crash the extractive path did.
30
+ const raw = m.text ?? "";
31
+ const t = raw.trim();
32
+ return t.length > 0 ? t : undefined;
32
33
  }
33
34
 
34
35
  /** Heuristic: does this text look like chatty filler we can collapse? */
35
36
  export function isChatty(text: string): boolean {
36
- const low = text.toLowerCase();
37
- if (low.includes("hello") || low.includes("thanks") || low.includes("great") || low.includes("ok")) {
38
- return true;
39
- }
40
- return text.length < 40 && !/(\/|\.|\{|import |def |function )/.test(text);
37
+ const low = text.toLowerCase();
38
+ if (/\b(hello|thanks|great|ok)\b/i.test(low)) {
39
+ return true;
40
+ }
41
+ return text.length < 40 && !/(\/|\.|\{|import |def |function )/.test(text);
41
42
  }
42
43
 
43
44
  /** Extract plausible file paths (contain '/' + an interesting extension).
@@ -45,101 +46,110 @@ export function isChatty(text: string): boolean {
45
46
  * a message whose `text`/`input`/`output` is undefined (e.g. a pure tool-call
46
47
  * or tool-result message), and `.split` on undefined throws and takes down the
47
48
  * whole compaction. Guard once at the source so every caller is safe. */
48
- export function extractFileCandidates(content: string | undefined | null): string[] {
49
- if (!content) return [];
50
- const out: string[] = [];
51
- for (const raw of content.split(/\s+/)) {
52
- // Trim surrounding punctuation only — do NOT strip internal dots, or we
53
- // would erase the extension separator (src/server.ts -> src/server/ts).
54
- const token = raw.replace(/^[^A-Za-z0-9/]+|[^A-Za-z0-9/]+$/g, "");
55
- if (!token.includes("/") || !token.includes(".")) continue;
56
- const ext = token.split(".").pop()?.toLowerCase() ?? "";
57
- if (INTERESTING_EXT.has(ext)) out.push(token);
58
- }
59
- return out;
49
+ export function extractFileCandidates(
50
+ content: string | undefined | null,
51
+ ): string[] {
52
+ if (!content) return [];
53
+ const out: string[] = [];
54
+ for (const raw of content.split(/\s+/)) {
55
+ // Trim surrounding punctuation only — do NOT strip internal dots, or we
56
+ // would erase the extension separator (src/server.ts -> src/server/ts).
57
+ const token = raw.replace(/^[^A-Za-z0-9/]+|[^A-Za-z0-9/]+$/g, "");
58
+ if (!token.includes("/") || !token.includes(".")) continue;
59
+ const ext = token.split(".").pop()?.toLowerCase() ?? "";
60
+ if (INTERESTING_EXT.has(ext)) out.push(token);
61
+ }
62
+ return out;
60
63
  }
61
64
 
62
65
  /** Collect unique key files referenced across a set of messages. */
63
66
  export function collectKeyFiles(messages: EngineMessage[]): string[] {
64
- const files = new Set<string>();
65
- for (const m of messages) {
66
- for (const c of [m.text, m.input, m.output]) {
67
- if (!c) continue;
68
- for (const f of extractFileCandidates(c)) files.add(f);
69
- }
70
- }
71
- return [...files].slice(0, 8);
67
+ const files = new Set<string>();
68
+ for (const m of messages) {
69
+ for (const c of [m.text, m.input, m.output]) {
70
+ if (!c) continue;
71
+ for (const f of extractFileCandidates(c)) files.add(f);
72
+ }
73
+ }
74
+ return [...files].slice(0, 8);
72
75
  }
73
76
 
74
77
  /** Infer pending work from recent messages via keyword scan. */
75
78
  export function inferPendingWork(messages: EngineMessage[]): string[] {
76
- const out: string[] = [];
77
- for (const m of [...messages].reverse()) {
78
- const t = firstText(m);
79
- if (!t) continue;
80
- const low = t.toLowerCase();
81
- if (PENDING_WORDS.some((w) => low.includes(w))) {
82
- out.push(truncate(t, 160));
83
- if (out.length >= 3) break;
84
- }
85
- }
86
- return out.reverse();
79
+ const out: string[] = [];
80
+ for (const m of [...messages].reverse()) {
81
+ const t = firstText(m);
82
+ if (!t) continue;
83
+ const low = t.toLowerCase();
84
+ if (PENDING_WORDS.some((w) => low.includes(w))) {
85
+ out.push(truncate(t, 160));
86
+ if (out.length >= 3) break;
87
+ }
88
+ }
89
+ return out.reverse();
87
90
  }
88
91
 
89
92
  /** Latest user request (for "current work" line). */
90
- export function inferCurrentWork(messages: EngineMessage[]): string | undefined {
91
- for (const m of [...messages].reverse()) {
92
- const t = firstText(m);
93
- if (t && m.role === "user") return truncate(t, 200);
94
- }
95
- return undefined;
93
+ export function inferCurrentWork(
94
+ messages: EngineMessage[],
95
+ ): string | undefined {
96
+ for (const m of [...messages].reverse()) {
97
+ const t = firstText(m);
98
+ if (t && m.role === "user") return truncate(t, 200);
99
+ }
100
+ return undefined;
96
101
  }
97
102
 
98
103
  /** Last N user requests, in original order. */
99
- export function collectRecentUserRequests(messages: EngineMessage[], limit: number): string[] {
100
- const reqs = messages
101
- .filter((m) => m.role === "user")
102
- .map((m) => firstText(m))
103
- .filter((t): t is string => Boolean(t))
104
- .map((t) => truncate(t, 160));
105
- return reqs.slice(-limit);
104
+ export function collectRecentUserRequests(
105
+ messages: EngineMessage[],
106
+ limit: number,
107
+ ): string[] {
108
+ const reqs = messages
109
+ .filter((m) => m.role === "user")
110
+ .map((m) => firstText(m))
111
+ .filter((t): t is string => Boolean(t))
112
+ .map((t) => truncate(t, 160));
113
+ return reqs.slice(-limit);
106
114
  }
107
115
 
108
116
  /** Summarize a block to a one-line description. */
109
117
  function summarizeBlock(m: EngineMessage): string {
110
- if (m.role === "tool") return `tool_result ${m.toolName ?? "?"}: ${truncate(m.output ?? m.text, 160)}`;
111
- if (m.toolName) return `tool_use ${m.toolName}(${truncate(m.input ?? "", 160)})`;
112
- return truncate(m.text, 160);
118
+ if (m.role === "tool")
119
+ return `tool_result ${m.toolName ?? "?"}: ${truncate(m.output ?? m.text, 160)}`;
120
+ if (m.toolName)
121
+ return `tool_use ${m.toolName}(${truncate(m.input ?? "", 160)})`;
122
+ return truncate(m.text, 160);
113
123
  }
114
124
 
115
125
  function stripTag(block: string, tag: string): string {
116
- const start = `<${tag}>`;
117
- const end = `</${tag}>`;
118
- const s = block.indexOf(start);
119
- const e = block.indexOf(end);
120
- if (s === -1 || e === -1) return block;
121
- return block.slice(0, s) + block.slice(e + end.length);
126
+ const start = `<${tag}>`;
127
+ const end = `</${tag}>`;
128
+ const s = block.indexOf(start);
129
+ const e = block.indexOf(end);
130
+ if (s === -1 || e === -1) return block;
131
+ return block.slice(0, s) + block.slice(e + end.length);
122
132
  }
123
133
 
124
134
  function extractTag(block: string, tag: string): string | undefined {
125
- const s = block.indexOf(`<${tag}>`);
126
- const e = block.indexOf(`</${tag}>`);
127
- if (s === -1 || e === -1) return undefined;
128
- return block.slice(s + `<${tag}>`.length, e);
135
+ const s = block.indexOf(`<${tag}>`);
136
+ const e = block.indexOf(`</${tag}>`);
137
+ if (s === -1 || e === -1) return undefined;
138
+ return block.slice(s + `<${tag}>`.length, e);
129
139
  }
130
140
 
131
141
  /** Normalize a raw summary into user-facing "Summary: ..." text. */
132
142
  export function formatCompactSummary(summary: string): string {
133
- const withoutAnalysis = stripTag(summary, "analysis");
134
- let formatted = withoutAnalysis;
135
- const content = extractTag(withoutAnalysis, "summary");
136
- if (content !== undefined) {
137
- formatted = withoutAnalysis.replace(
138
- `<summary>${content}</summary>`,
139
- `Summary:\n${content.trim()}`,
140
- );
141
- }
142
- return formatted.replace(/\n{3,}/g, "\n\n").trim();
143
+ const withoutAnalysis = stripTag(summary, "analysis");
144
+ let formatted = withoutAnalysis;
145
+ const content = extractTag(withoutAnalysis, "summary");
146
+ if (content !== undefined) {
147
+ formatted = withoutAnalysis.replace(
148
+ `<summary>${content}</summary>`,
149
+ `Summary:\n${content.trim()}`,
150
+ );
151
+ }
152
+ return formatted.replace(/\n{3,}/g, "\n\n").trim();
143
153
  }
144
154
 
145
155
  /**
@@ -147,125 +157,150 @@ export function formatCompactSummary(summary: string): string {
147
157
  * Mirrors claw-code summarize_messages.
148
158
  */
149
159
  export function summarizeMessages(messages: EngineMessage[]): string {
150
- const users = messages.filter((m) => m.role === "user").length;
151
- const assistants = messages.filter((m) => m.role === "assistant").length;
152
- const tools = messages.filter((m) => m.role === "tool").length;
160
+ const users = messages.filter((m) => m.role === "user").length;
161
+ const assistants = messages.filter((m) => m.role === "assistant").length;
162
+ const tools = messages.filter((m) => m.role === "tool").length;
153
163
 
154
- const toolNames = [...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : [])))].sort();
164
+ const toolNames = [
165
+ ...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : []))),
166
+ ].sort();
155
167
 
156
- const lines: string[] = [
157
- "<summary>",
158
- "Conversation summary:",
159
- `- Scope: ${messages.length} earlier messages compacted (user=${users}, assistant=${assistants}, tool=${tools}).`,
160
- ];
161
- if (toolNames.length) lines.push(`- Tools mentioned: ${toolNames.join(", ")}.`);
168
+ const lines: string[] = [
169
+ "<summary>",
170
+ "Conversation summary:",
171
+ `- Scope: ${messages.length} earlier messages compacted (user=${users}, assistant=${assistants}, tool=${tools}).`,
172
+ ];
173
+ if (toolNames.length)
174
+ lines.push(`- Tools mentioned: ${toolNames.join(", ")}.`);
162
175
 
163
- const recent = collectRecentUserRequests(messages, 3);
164
- if (recent.length) {
165
- lines.push("- Recent user requests:");
166
- recent.forEach((r) => lines.push(` - ${r}`));
167
- }
176
+ const recent = collectRecentUserRequests(messages, 3);
177
+ if (recent.length) {
178
+ lines.push("- Recent user requests:");
179
+ recent.forEach((r) => lines.push(` - ${r}`));
180
+ }
168
181
 
169
- const pending = inferPendingWork(messages);
170
- if (pending.length) {
171
- lines.push("- Pending work:");
172
- pending.forEach((p) => lines.push(` - ${p}`));
173
- }
182
+ const pending = inferPendingWork(messages);
183
+ if (pending.length) {
184
+ lines.push("- Pending work:");
185
+ pending.forEach((p) => lines.push(` - ${p}`));
186
+ }
174
187
 
175
- const files = collectKeyFiles(messages);
176
- if (files.length) lines.push(`- Key files referenced: ${files.join(", ")}.`);
188
+ const files = collectKeyFiles(messages);
189
+ if (files.length) lines.push(`- Key files referenced: ${files.join(", ")}.`);
177
190
 
178
- const current = inferCurrentWork(messages);
179
- if (current) lines.push(`- Current work: ${current}`);
191
+ const current = inferCurrentWork(messages);
192
+ if (current) lines.push(`- Current work: ${current}`);
180
193
 
181
- lines.push("- Key timeline:");
182
- for (const m of messages) {
183
- const role = m.role;
184
- lines.push(` - ${role}: ${summarizeBlock(m)}`);
185
- }
186
- lines.push("</summary>");
187
- return lines.join("\n");
194
+ lines.push("- Key timeline:");
195
+ for (const m of messages) {
196
+ const role = m.role;
197
+ lines.push(` - ${role}: ${summarizeBlock(m)}`);
198
+ }
199
+ lines.push("</summary>");
200
+ return lines.join("\n");
188
201
  }
189
202
 
190
203
  /** Extract the prior "highlights" + "timeline" sections from an existing summary. */
191
204
  function extractSummaryHighlights(summary: string): string[] {
192
- const lines = formatCompactSummary(summary).split("\n");
193
- const out: string[] = [];
194
- let inTimeline = false;
195
- for (const line of lines) {
196
- const t = line.trimEnd();
197
- if (!t || t === "Summary:" || t === "Conversation summary:") continue;
198
- if (t === "- Key timeline:") { inTimeline = true; continue; }
199
- if (inTimeline) continue;
200
- out.push(t);
201
- }
202
- return out;
205
+ const lines = formatCompactSummary(summary).split("\n");
206
+ const out: string[] = [];
207
+ let inTimeline = false;
208
+ for (const line of lines) {
209
+ const t = line.trimEnd();
210
+ if (!t || t === "Summary:" || t === "Conversation summary:") continue;
211
+ if (t === "- Key timeline:") {
212
+ inTimeline = true;
213
+ continue;
214
+ }
215
+ if (inTimeline) continue;
216
+ out.push(t);
217
+ }
218
+ return out;
203
219
  }
204
220
 
205
221
  function extractSummaryTimeline(summary: string): string[] {
206
- const lines = formatCompactSummary(summary).split("\n");
207
- const out: string[] = [];
208
- let inTimeline = false;
209
- for (const line of lines) {
210
- const t = line.trimEnd();
211
- if (t === "- Key timeline:") { inTimeline = true; continue; }
212
- if (!inTimeline) continue;
213
- if (!t) break;
214
- out.push(t);
215
- }
216
- return out;
222
+ const lines = formatCompactSummary(summary).split("\n");
223
+ const out: string[] = [];
224
+ let inTimeline = false;
225
+ for (const line of lines) {
226
+ const t = line.trimEnd();
227
+ if (t === "- Key timeline:") {
228
+ inTimeline = true;
229
+ continue;
230
+ }
231
+ if (!inTimeline) continue;
232
+ if (!t) break;
233
+ out.push(t);
234
+ }
235
+ return out;
217
236
  }
218
237
 
219
238
  /** Merge an existing compact summary with a new one (accumulate, don't overwrite). */
220
- export function mergeCompactSummaries(existing: string | undefined, newSummary: string): string {
221
- if (!existing) return newSummary;
222
- const prevHighlights = extractSummaryHighlights(existing);
223
- const newHighlights = extractSummaryHighlights(formatCompactSummary(newSummary));
224
- const newTimeline = extractSummaryTimeline(formatCompactSummary(newSummary));
239
+ export function mergeCompactSummaries(
240
+ existing: string | undefined,
241
+ newSummary: string,
242
+ ): string {
243
+ if (!existing) return newSummary;
244
+ const prevHighlights = extractSummaryHighlights(existing);
245
+ const newHighlights = extractSummaryHighlights(
246
+ formatCompactSummary(newSummary),
247
+ );
248
+ const newTimeline = extractSummaryTimeline(formatCompactSummary(newSummary));
225
249
 
226
- const lines = ["<summary>", "Conversation summary:"];
227
- if (prevHighlights.length) {
228
- lines.push("- Previously compacted context:");
229
- prevHighlights.forEach((l) => lines.push(` ${l}`));
230
- }
231
- if (newHighlights.length) {
232
- lines.push("- Newly compacted context:");
233
- newHighlights.forEach((l) => lines.push(` ${l}`));
234
- }
235
- if (newTimeline.length) {
236
- lines.push("- Key timeline:");
237
- newTimeline.forEach((l) => lines.push(` ${l}`));
238
- }
239
- lines.push("</summary>");
240
- return lines.join("\n");
250
+ const lines = ["<summary>", "Conversation summary:"];
251
+ if (prevHighlights.length) {
252
+ lines.push("- Previously compacted context:");
253
+ prevHighlights.forEach((l) => lines.push(` ${l}`));
254
+ }
255
+ if (newHighlights.length) {
256
+ lines.push("- Newly compacted context:");
257
+ newHighlights.forEach((l) => lines.push(` ${l}`));
258
+ }
259
+ if (newTimeline.length) {
260
+ lines.push("- Key timeline:");
261
+ newTimeline.forEach((l) => lines.push(` ${l}`));
262
+ }
263
+ lines.push("</summary>");
264
+ return lines.join("\n");
241
265
  }
242
266
 
243
267
  /** True when the compactable portion exceeds the budget. */
244
- export function shouldCompact(messages: EngineMessage[], maxEstimatedTokens: number, preserveRecent: number): boolean {
245
- if (messages.length <= preserveRecent) return false;
246
- const compactable = messages.slice(0, messages.length - preserveRecent);
247
- return estimateSessionTokens(compactable) >= maxEstimatedTokens;
268
+ export function shouldCompact(
269
+ messages: EngineMessage[],
270
+ maxEstimatedTokens: number,
271
+ preserveRecent: number,
272
+ ): boolean {
273
+ if (messages.length <= preserveRecent) return false;
274
+ const compactable = messages.slice(0, messages.length - preserveRecent);
275
+ return estimateSessionTokens(compactable) >= maxEstimatedTokens;
248
276
  }
249
277
 
250
278
  /** Local reimplementation of memory-mcp auto_compact_check. */
251
- export function autoCompactCheck(currentTokens: number, threshold = 50000): {
252
- shouldCompact: boolean;
253
- currentTokens: number;
254
- threshold: number;
255
- utilizationPct: number;
279
+ export function autoCompactCheck(
280
+ currentTokens: number,
281
+ threshold = 50000,
282
+ ): {
283
+ shouldCompact: boolean;
284
+ currentTokens: number;
285
+ threshold: number;
286
+ utilizationPct: number;
256
287
  } {
257
- return {
258
- shouldCompact: currentTokens >= threshold,
259
- currentTokens,
260
- threshold,
261
- utilizationPct: Math.round((currentTokens / threshold) * 1000) / 10,
262
- };
288
+ return {
289
+ shouldCompact: currentTokens >= threshold,
290
+ currentTokens,
291
+ threshold,
292
+ utilizationPct: Math.round((currentTokens / threshold) * 1000) / 10,
293
+ };
263
294
  }
264
295
 
265
296
  /** Build the synthetic continuation message (system-prompt prepend form). */
266
- export function getContinuationMessage(summary: string, suppressFollowUp: boolean, recentPreserved: boolean): string {
267
- let base = COMPACT_PREAMBLE + formatCompactSummary(summary);
268
- if (recentPreserved) base += `\n\n${RECENT_NOTE}`;
269
- if (suppressFollowUp) base += `\n${DIRECT_RESUME}`;
270
- return base;
297
+ export function getContinuationMessage(
298
+ summary: string,
299
+ suppressFollowUp: boolean,
300
+ recentPreserved: boolean,
301
+ ): string {
302
+ let base = COMPACT_PREAMBLE + formatCompactSummary(summary);
303
+ if (recentPreserved) base += `\n\n${RECENT_NOTE}`;
304
+ if (suppressFollowUp) base += `\n${DIRECT_RESUME}`;
305
+ return base;
271
306
  }
@@ -230,6 +230,17 @@ export function buildRaptorTree(leaves: Leaf[], opts: BuildOptions): RaptorTree
230
230
  qualityMarker,
231
231
  tokenEstimate,
232
232
  });
233
+ // Populate parentId for the internal nodes being absorbed into this
234
+ // parent summary. Group members with ids in `nodes` are internal summary
235
+ // nodes (level >= 1); raw leaf ids are not in `nodes` (per-leaf wrappers
236
+ // are intentionally absent) and are correctly skipped — leaf→summary
237
+ // walks go through the parent's `children` list instead.
238
+ for (const c of group) {
239
+ const child = nodes.get(c.id);
240
+ if (child && child.id !== merged.id) {
241
+ child.parentId = merged.id;
242
+ }
243
+ }
233
244
  nextLevel.push(merged);
234
245
  }
235
246
  currentLevel = nextLevel;
@@ -1,6 +1,7 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { reviewConversation } from "./memory.js";
3
+ import { reviewConversation, type MemoryOp } from "./memory.js";
4
+ import type { EngineMessage } from "./types.js";
4
5
 
5
6
  test("reviewConversation: yields an ADD op for a stated decision", () => {
6
7
  const msgs = [
@@ -44,3 +45,48 @@ test("reviewConversation: REMOVE requires topic overlap (no accidental drop)", (
44
45
  const ops = reviewConversation(msgs, existing);
45
46
  assert.equal(ops.filter((o) => o.op === "remove").length, 0, "vague 'drop it' with no topic overlap does not remove anything");
46
47
  });
48
+
49
+ // ---- E5 (docs/specs/s25-memory-db-roundtrip.md): hallucination-guard pins ----
50
+
51
+ test("E5.3 — truncation pin: long decision truncates to 160 chars and stays message-grounded", () => {
52
+ // collectRecentUserRequests truncates user text at 160 chars before review.
53
+ // A long decision is silently clipped — undocumented before S25; this pins
54
+ // the boundary.
55
+ const long =
56
+ "we decided to use node:sqlite for the authoritative store backend after evaluating better-sqlite3, pglite and libsql and rejecting all three";
57
+ const msgs = [{ role: "user", text: long }] as any;
58
+ const ops = reviewConversation(msgs);
59
+ const add = ops.find((o) => o.op === "add") as Extract<MemoryOp, { op: "add" }> | undefined;
60
+ assert.ok(add, "a decision inside a long user message produces an add");
61
+ assert.ok(
62
+ add!.memory.content.length <= 160,
63
+ "stored content is 160-char truncated",
64
+ );
65
+ assert.ok(
66
+ long.includes(add!.memory.content),
67
+ "truncated content is still verbatim-grounded in the message",
68
+ );
69
+ });
70
+
71
+ test("E5.1 — hallucination guard: every surviving add/replace is verbatim from a real message", () => {
72
+ const msgs = [{ role: "user", text: "the pipeline uses dagster for orchestration" }] as EngineMessage[];
73
+ const ops = reviewConversation(msgs, [{ content: "we use better-sqlite3 for the store" }]);
74
+ for (const o of ops) {
75
+ if (o.op === "remove") continue; // REMOVE is exempt by design (:70-74)
76
+ assert.ok(
77
+ msgs.some((m) => String(m.text ?? "").includes(o.memory.content)),
78
+ "every add/replace content is verbatim from a real message",
79
+ );
80
+ }
81
+ assert.equal(ops.filter((o) => o.op !== "remove").length, 0, "non-decision text produces no add/replace");
82
+ });
83
+
84
+ test("E5.4 — REMOVE over-match pin: single-token topic overlap fires REMOVE", () => {
85
+ const existing = [{ content: "we use redis for the cache" }];
86
+ const msgs = [{ role: "user", text: "stop using redis" }] as any;
87
+ const ops = reviewConversation(msgs, existing);
88
+ assert.ok(
89
+ ops.some((o) => o.op === "remove" && /redis/i.test(o.content)),
90
+ "single-token overlap removes the matching memory (current behavior — KNOWN: weak topic match)",
91
+ );
92
+ });
package/src/memoryOps.ts CHANGED
@@ -13,23 +13,8 @@ import {
13
13
  type MemoryRecord,
14
14
  } from "./store/sqlite.js";
15
15
  import { defaultEmbedder } from "./embedder.js";
16
+ import { repoKey } from "./store/repoKey.js";
16
17
  import { upsertMemoryEmbedding } from "./store/memoryIndex.js";
17
- import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the memory index per-repo
18
-
19
- /** Resolve the current repo's git root (mirrors extensions/mega-config.ts but
20
- * kept local so src/ stays pi-agnostic — no extension-layer import). */
21
- function resolveRepoRootLocal(cwd: string): string | undefined {
22
- try {
23
- const out = execSync("git rev-parse --show-toplevel", {
24
- cwd,
25
- encoding: "utf-8",
26
- stdio: ["ignore", "pipe", "ignore"],
27
- }).trim();
28
- return out || undefined;
29
- } catch {
30
- return undefined;
31
- }
32
- }
33
18
 
34
19
  /** Find a memory row whose content exactly matches (case-insensitive). */
35
20
  function findByContent(memories: MemoryRecord[], content: string): MemoryRecord | undefined {
@@ -41,11 +26,11 @@ function findByContent(memories: MemoryRecord[], content: string): MemoryRecord
41
26
  * Fire-and-forget mirror of a memory write into the cross-repo PGlite index
42
27
  * (S24 optional memory-RAG mirror). Best-effort + non-fatal: never blocks the
43
28
  * SQLite write and degrades to the same-repo scan if the index is disabled or
44
- * fails. `repoId` is the resolved git root so the memory is findable from other
45
- * repos; falls back to the state dir when outside git.
29
+ * fails. `repoId` is the unified S25 repoKey (git root) so the memory is
30
+ * findable from other repos; falls back to the state dir outside git.
46
31
  */
47
32
  function indexMemoryWrite(stateDir: string, memoryId: number, content: string): void {
48
- const repoId = resolveRepoRootLocal(stateDir) ?? stateDir;
33
+ const repoId = repoKey(stateDir);
49
34
  try {
50
35
  const vec = defaultEmbedder().embed(content);
51
36
  void upsertMemoryEmbedding(repoId, memoryId, content, vec);
@@ -131,6 +131,42 @@ test("recallMemoriesAndInline: surfaces a memory saved in ANOTHER repo via cross
131
131
  }
132
132
  });
133
133
 
134
+ // ---- S25 §3.3: content de-dup in the cross-repo memory path -----------------
135
+ // recallMemoriesCrossRepo (memoryRecall.ts:114) must NOT surface a memory the
136
+ // local repo ALREADY has — same-repo authoritative store wins over the index.
137
+ test("recallMemoriesCrossRepo: dedupes content the local repo already has", async () => {
138
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index-dedup");
139
+ const repoA = join(baseTmp, "dedup-a");
140
+ const repoB = join(baseTmp, "dedup-b");
141
+ try {
142
+ const { applyMemoryOps } = await import("./memoryOps.js");
143
+ const shared = "we standardized on node:sqlite for the store backend";
144
+ await applyMemoryOps(
145
+ [{ op: "add", memory: { content: shared, category: "decision", sourceTurn: 0 } }],
146
+ repoA,
147
+ );
148
+ await applyMemoryOps(
149
+ [{ op: "add", memory: { content: shared, category: "decision", sourceTurn: 0 } }],
150
+ repoB,
151
+ );
152
+ // The PGlite index now has repoA's copy; repoB ALSO has it locally. The
153
+ // cross-repo path for repoB must drop repoA's duplicate.
154
+ const { recallMemoriesCrossRepo } = await import("./memoryRecall.js");
155
+ const hits = await recallMemoriesCrossRepo("what store backend do we use?", repoB, {
156
+ crossRepoCosine: 0.0, // floor at 0: would match everything if dedup fails
157
+ limit: 5,
158
+ });
159
+ assert.ok(
160
+ hits.every((h) => h.memory.content.trim().toLowerCase() !== shared.toLowerCase()),
161
+ "cross-repo hit with content the local repo already has is dropped",
162
+ );
163
+ } finally {
164
+ const { closeMemoryIndex } = await import("./store/memoryIndex.js");
165
+ await closeMemoryIndex();
166
+ delete process.env.MEGACOMPACT_INDEX_DIR;
167
+ }
168
+ });
169
+
134
170
  test("recallMemoriesAndInline: cross-repo disabled when MEGACOMPACT_PGLITE_DISABLED", async () => {
135
171
  process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "xrepo-index-off");
136
172
  process.env.MEGACOMPACT_PGLITE_DISABLED = "true";