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.
- package/README.md +12 -9
- package/dist/extensions/mega-compact.js +16 -1
- package/dist/extensions/mega-config.js +1 -0
- package/dist/extensions/mega-pipeline/compact.js +2 -1
- package/dist/extensions/mega-runtime/reset-runtime.js +8 -0
- package/dist/extensions/mega-runtime/runtime.js +12 -50
- package/dist/extensions/mega-shutdown-widget.test.js +121 -0
- package/dist/src/compact.js +4 -2
- package/dist/src/dedup/raptor/tree.js +11 -0
- package/dist/src/memory.test.js +29 -0
- package/dist/src/memoryOps.js +4 -19
- package/dist/src/memoryRecall.test.js +27 -0
- package/dist/src/memoryRoundtrip.test.js +137 -0
- package/dist/src/recall.js +5 -4
- package/dist/src/sprint4x-rag-verification.test.js +93 -0
- package/dist/src/store/memoryIndex.js +29 -7
- package/dist/src/store/pgOpenGuard.js +83 -0
- package/dist/src/store/pgOpenGuard.test.js +74 -0
- package/dist/src/store/repoKey.js +45 -0
- package/dist/src/store/vectorIndex.js +30 -8
- package/dist/src/store/vectorIndex.test.js +25 -1
- package/dist/src/vector-search.js +11 -5
- package/dist/src/vectorStore.js +4 -1
- package/extensions/mega-compact.ts +16 -1
- package/extensions/mega-config.ts +8 -0
- package/extensions/mega-pipeline/compact.ts +2 -1
- package/extensions/mega-runtime/reset-runtime.ts +88 -0
- package/extensions/mega-runtime/runtime.ts +27 -59
- package/extensions/mega-shutdown-widget.test.ts +141 -0
- package/package.json +1 -1
- package/src/compact.ts +209 -174
- package/src/dedup/raptor/tree.ts +11 -0
- package/src/memory.test.ts +47 -1
- package/src/memoryOps.ts +4 -19
- package/src/memoryRecall.test.ts +36 -0
- package/src/memoryRoundtrip.test.ts +155 -0
- package/src/recall.ts +6 -3
- package/src/sprint4x-rag-verification.test.ts +119 -0
- package/src/store/memoryIndex.ts +34 -8
- package/src/store/pgOpenGuard.test.ts +89 -0
- package/src/store/pgOpenGuard.ts +93 -0
- package/src/store/repoKey.ts +50 -0
- package/src/store/vectorIndex.test.ts +25 -1
- package/src/store/vectorIndex.ts +35 -9
- package/src/vector-search.ts +10 -5
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
23
|
+
return s.length <= max ? s : `${s.slice(0, max)}…`;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
function firstText(m: EngineMessage): string | undefined {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
164
|
+
const toolNames = [
|
|
165
|
+
...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : []))),
|
|
166
|
+
].sort();
|
|
155
167
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
188
|
+
const files = collectKeyFiles(messages);
|
|
189
|
+
if (files.length) lines.push(`- Key files referenced: ${files.join(", ")}.`);
|
|
177
190
|
|
|
178
|
-
|
|
179
|
-
|
|
191
|
+
const current = inferCurrentWork(messages);
|
|
192
|
+
if (current) lines.push(`- Current work: ${current}`);
|
|
180
193
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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(
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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(
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
}
|
package/src/dedup/raptor/tree.ts
CHANGED
|
@@ -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;
|
package/src/memory.test.ts
CHANGED
|
@@ -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
|
|
45
|
-
* repos; falls back to the state dir
|
|
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 =
|
|
33
|
+
const repoId = repoKey(stateDir);
|
|
49
34
|
try {
|
|
50
35
|
const vec = defaultEmbedder().embed(content);
|
|
51
36
|
void upsertMemoryEmbedding(repoId, memoryId, content, vec);
|
package/src/memoryRecall.test.ts
CHANGED
|
@@ -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";
|