pi-mega-compact 0.8.25 → 0.8.26
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/dist/src/raptor-inject-summaries.test.js +10 -3
- package/dist/src/recall.js +24 -9
- package/dist/src/store/sqlite/turns.js +1 -3
- package/package.json +1 -1
- package/src/raptor-inject-summaries.test.ts +22 -6
- package/src/recall.ts +436 -368
- package/src/store/sqlite/turns.ts +177 -167
package/src/recall.ts
CHANGED
|
@@ -15,7 +15,13 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { recall as searchRecall } from "./engine.js";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
vectorWasInjected,
|
|
20
|
+
vectorMarkInjected,
|
|
21
|
+
type SearchHit,
|
|
22
|
+
type VectorStore,
|
|
23
|
+
vectorSearchAsync,
|
|
24
|
+
} from "./vectorStore.js";
|
|
19
25
|
import { estimateBlockTokens } from "./tokens.js";
|
|
20
26
|
import { defaultEmbedder, cosineSimilarity } from "./embedder.js";
|
|
21
27
|
import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
|
|
@@ -25,75 +31,77 @@ import { normalizeSessionId } from "./store.js";
|
|
|
25
31
|
export type RecallSource = "resume" | "command" | "sentinel";
|
|
26
32
|
|
|
27
33
|
export interface RecallInjectOptions {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
34
|
+
sessionId: string;
|
|
35
|
+
query: string;
|
|
36
|
+
limit?: number;
|
|
37
|
+
source: RecallSource;
|
|
38
|
+
/** Skip checkpoints already injected this session (recall dedup). */
|
|
39
|
+
skipInjected?: boolean;
|
|
40
|
+
/** Token ceiling for the re-injected block (Fix C). Recall stops adding once
|
|
41
|
+
* the block would exceed this, so the read path can never net-inflate. */
|
|
42
|
+
recallMaxTokens?: number;
|
|
43
|
+
/** Inline-dedupe hits against the live window (Fix C): drop a hit whose
|
|
44
|
+
* summary is ≥ `dedupSim` similar to a live message. */
|
|
45
|
+
windowDedupe?: boolean;
|
|
46
|
+
/** Live window text (from the session manager) used for inline dedupe. */
|
|
47
|
+
liveWindow?: string[];
|
|
48
|
+
/** Similarity threshold for inline dedupe (defaults to 0.9). */
|
|
49
|
+
dedupSim?: number;
|
|
50
|
+
/** S18: index dir of the machine-wide injected-set. When set on a cross-repo
|
|
51
|
+
* recall, a foreign checkpoint already injected (in any session) is skipped
|
|
52
|
+
* and a fresh injection is recorded globally. */
|
|
53
|
+
globalIndexDir?: string;
|
|
54
|
+
/** S25 Phase-2: also inject top-level RAPTOR summary nodes (root + level-1
|
|
55
|
+
* clusters) as a hierarchical overview HEADER on the recall block. Defaults
|
|
56
|
+
* to the `RAPTOR_INJECT_SUMMARIES` config flag. The overview helps the model
|
|
57
|
+
* see the session's topical structure before the detailed checkpoint hits. */
|
|
58
|
+
raptorSummaries?: boolean;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
export interface RecallInjectResult {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
/** Blocks that are ready to inline (already deduped against the window). */
|
|
63
|
+
toInject: SearchHit[];
|
|
64
|
+
/** Human-readable lines for status/notify reporting. */
|
|
65
|
+
report: string[];
|
|
66
|
+
/** The concatenated, model-visible recall block (empty when nothing new). */
|
|
67
|
+
block: string;
|
|
68
|
+
/** True when nothing new was inlined. */
|
|
69
|
+
empty: boolean;
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
/** Wrap a recall block so the model reads it as restored compacted context. */
|
|
67
73
|
export function formatRecallBlock(hits: SearchHit[]): string {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
74
|
+
if (hits.length === 0) return "";
|
|
75
|
+
const parts = hits.map((h, i) => {
|
|
76
|
+
const score = (h.score * 100).toFixed(0);
|
|
77
|
+
// S17: label a cross-repo hit with its source repo (the repoId doubles as
|
|
78
|
+
// that repo's stateDir, so the last path segment is the repo's display
|
|
79
|
+
// name). Same-repo hits (no repoId) stay unlabeled.
|
|
80
|
+
const repoName = h.repoId
|
|
81
|
+
? ` (from repo ${h.repoId.split("/").filter(Boolean).pop() ?? h.repoId})`
|
|
82
|
+
: "";
|
|
83
|
+
// S42B: a RAPTOR cluster node hit (not a stored checkpoint) is labeled as a
|
|
84
|
+
// hierarchical summary and uses raptorSummary as its body. No Key files line
|
|
85
|
+
// (cluster nodes carry no file list).
|
|
86
|
+
if (h.raptorLevel !== undefined) {
|
|
87
|
+
return (
|
|
88
|
+
`### Recalled cluster summary [${i + 1}] (level ${h.raptorLevel}, relevance ${score}%)${repoName}\n` +
|
|
89
|
+
`${(h.raptorSummary ?? h.checkpoint.summary).trim()}\n`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
`### Recalled context [${i + 1}] (relevance ${score}%)${repoName}\n` +
|
|
94
|
+
`${h.checkpoint.summary.trim()}\n` +
|
|
95
|
+
(h.checkpoint.filesModified.length
|
|
96
|
+
? `Key files: ${h.checkpoint.filesModified.join(", ")}.\n`
|
|
97
|
+
: "")
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
return (
|
|
101
|
+
"The following compacted context was recalled from earlier in this session " +
|
|
102
|
+
"and is relevant to the current request. Treat it as background you already know:\n\n" +
|
|
103
|
+
parts.join("\n")
|
|
104
|
+
);
|
|
97
105
|
}
|
|
98
106
|
|
|
99
107
|
/**
|
|
@@ -104,23 +112,25 @@ export function formatRecallBlock(hits: SearchHit[]): string {
|
|
|
104
112
|
* highest level first (root → level-1 clusters). Returns "" when empty.
|
|
105
113
|
*/
|
|
106
114
|
export function formatRaptorBlock(
|
|
107
|
-
|
|
115
|
+
nodes: { summary: string; level: number; score?: number }[],
|
|
108
116
|
): string {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
117
|
+
if (nodes.length === 0) return "";
|
|
118
|
+
const parts = nodes.map((n, i) => {
|
|
119
|
+
const score =
|
|
120
|
+
n.score !== undefined
|
|
121
|
+
? ` (relevance ${(n.score * 100).toFixed(0)}%)`
|
|
122
|
+
: "";
|
|
123
|
+
const label =
|
|
124
|
+
n.level === 0
|
|
125
|
+
? `Session overview [${i + 1}]${score}`
|
|
126
|
+
: `Cluster summary [${i + 1}] (level ${n.level})${score}`;
|
|
127
|
+
return `### ${label}\n${n.summary.trim()}\n`;
|
|
128
|
+
});
|
|
129
|
+
return (
|
|
130
|
+
"The following hierarchical overview summarizes the structure of this " +
|
|
131
|
+
"session so far. Use it as a map of what has been covered:\n\n" +
|
|
132
|
+
parts.join("\n")
|
|
133
|
+
);
|
|
124
134
|
}
|
|
125
135
|
|
|
126
136
|
/**
|
|
@@ -150,100 +160,104 @@ export function formatRaptorBlock(
|
|
|
150
160
|
* Pi-agnostic: no pi runtime imports (src/ invariant).
|
|
151
161
|
*/
|
|
152
162
|
export function recallAndInline(
|
|
153
|
-
|
|
154
|
-
|
|
163
|
+
opts: RecallInjectOptions,
|
|
164
|
+
store: VectorStore,
|
|
155
165
|
): RecallInjectResult {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
166
|
+
// ── S27 Recall Demotion ─────────────────────────────────────────────
|
|
167
|
+
//
|
|
168
|
+
// When MEGACOMPACT_DB_MIRROR is ON, the raw_transcript + dedup_mirror
|
|
169
|
+
// tables are preferred for byte-stable reconstruction. The current
|
|
170
|
+
// recall path (VectorStore search → format → inject) is unaffected —
|
|
171
|
+
// it provides fast semantic search over checkpoint summaries.
|
|
172
|
+
//
|
|
173
|
+
// If full transcript reconstruction is ever needed (replay, export,
|
|
174
|
+
// debug), call reconstructFromMirror(db, sessionId, fromSeq, toSeq)
|
|
175
|
+
// from src/mirror/dedup.ts instead of reading from the legacy JSON
|
|
176
|
+
// checkpoint. Falls back to legacy checkpoint if mirror is empty
|
|
177
|
+
// (pre-migration sessions).
|
|
178
|
+
//
|
|
179
|
+
// Invariant: raw_transcript + dedup_mirror are additive and never
|
|
180
|
+
// lose data. The legacy JSON checkpoint remains as a DR snapshot.
|
|
181
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
const limit = opts.limit ?? 3;
|
|
184
|
+
const skip = opts.skipInjected ?? true;
|
|
185
|
+
const maxTokens = opts.recallMaxTokens ?? 0; // 0 = unbounded (legacy behavior)
|
|
186
|
+
const doWindowDedupe = opts.windowDedupe ?? false;
|
|
187
|
+
const dedupSim = opts.dedupSim ?? 0.9;
|
|
188
|
+
|
|
189
|
+
// F4: thread skipInjected through to searchRecall instead of hardcoding false
|
|
190
|
+
// and re-implementing the filter here. newHits is already deduped when skip
|
|
191
|
+
// is true (default); equals hits when skip is false (openclaw command path).
|
|
192
|
+
const { newHits } = searchRecall(
|
|
193
|
+
{ sessionId: opts.sessionId, query: opts.query, limit, skipInjected: skip },
|
|
194
|
+
store,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// F1: hoist one embedder instance for inline dedupe (matches the async path).
|
|
198
|
+
// defaultEmbedder() is deterministic but creating it per hit wastes allocations.
|
|
199
|
+
const embedder = defaultEmbedder();
|
|
200
|
+
// Precompute live-window embeddings once for inline dedupe (Fix C). Trigram
|
|
201
|
+
// embedder is local + cheap; never a network call (PREVENT-PI-004).
|
|
202
|
+
let liveEmbeddings: number[][] = [];
|
|
203
|
+
if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
|
|
204
|
+
liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// F3: build the hit list first; format ONCE at the end so the block carries
|
|
208
|
+
// exactly one preamble and [1..n] numbering, and the token cap counts body
|
|
209
|
+
// tokens (one preamble at format time, not N). We accumulate summaries and
|
|
210
|
+
// break mid-stream when the cap would be exceeded.
|
|
211
|
+
const toInject: SearchHit[] = [];
|
|
212
|
+
let blockTokens = 0;
|
|
213
|
+
|
|
214
|
+
for (const h of newHits) {
|
|
215
|
+
// Inline dedupe: skip a hit already resident in the live window (Fix C).
|
|
216
|
+
if (doWindowDedupe && liveEmbeddings.length > 0) {
|
|
217
|
+
const hitVec = embedder.embed(h.checkpoint.summary);
|
|
218
|
+
if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim))
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const partTokens = estimateBlockTokens(h.checkpoint.summary);
|
|
223
|
+
// Token cap: never push a chunk that would overrun the ceiling.
|
|
224
|
+
if (maxTokens > 0 && blockTokens + partTokens > maxTokens) break;
|
|
225
|
+
|
|
226
|
+
toInject.push(h);
|
|
227
|
+
blockTokens += partTokens;
|
|
228
|
+
vectorMarkInjected(store, opts.sessionId, h.checkpoint.checkpointId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// F3: format once — one preamble, correct [1..n] numbering.
|
|
232
|
+
const recallBlock = toInject.length > 0 ? formatRecallBlock(toInject) : "";
|
|
233
|
+
const report = toInject.map(
|
|
234
|
+
(h) =>
|
|
235
|
+
` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// S25 Phase-2 (RAPTOR_INJECT_SUMMARIES): prepend a hierarchical overview
|
|
239
|
+
// header built from the tree's top-level summary nodes (root + the
|
|
240
|
+
// highest-scoring level-1 cluster summaries). This gives the model a topical
|
|
241
|
+
// map of the session before the detailed checkpoint hits. Default ON via
|
|
242
|
+
// the store's config; `opts.raptorSummaries` (when explicitly set) overrides.
|
|
243
|
+
// Skipped when no tree exists, the tree is stale/timedOut, or shadow mode is on.
|
|
244
|
+
let overview = "";
|
|
245
|
+
const injectSummaries =
|
|
246
|
+
opts.raptorSummaries ?? store.cfg.RAPTOR_INJECT_SUMMARIES;
|
|
247
|
+
if (injectSummaries && recallBlock) {
|
|
248
|
+
overview = raptorOverviewBlock(store, opts.sessionId, opts.query);
|
|
249
|
+
}
|
|
250
|
+
const block =
|
|
251
|
+
overview && recallBlock
|
|
252
|
+
? overview + "\n" + recallBlock
|
|
253
|
+
: overview || recallBlock;
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
toInject,
|
|
257
|
+
report,
|
|
258
|
+
block,
|
|
259
|
+
empty: block.length === 0,
|
|
260
|
+
};
|
|
247
261
|
}
|
|
248
262
|
|
|
249
263
|
/**
|
|
@@ -255,38 +269,46 @@ export function recallAndInline(
|
|
|
255
269
|
* the detailed recall block.
|
|
256
270
|
*/
|
|
257
271
|
function raptorOverviewBlock(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
272
|
+
store: VectorStore,
|
|
273
|
+
sessionId: string,
|
|
274
|
+
query: string,
|
|
261
275
|
): string {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
276
|
+
try {
|
|
277
|
+
const sid = normalizeSessionId(sessionId);
|
|
278
|
+
// S25 gate: the overview header is part of the RAPTOR serve surface, so it
|
|
279
|
+
// must honor the same contract as raptorSearchHits — shadow mode is
|
|
280
|
+
// logging-only building, not injection.
|
|
281
|
+
if (isShadowMode()) return "";
|
|
282
|
+
const tree = rehydrateRaptorTree(sid, store.stateDir);
|
|
283
|
+
if (!tree || !tree.rootId || tree.timedOut) return "";
|
|
284
|
+
// Freshness: a tree built before the session's latest checkpoint is stale.
|
|
285
|
+
const maxTs = maxCheckpointTimestamp(sid, store.stateDir);
|
|
286
|
+
if (tree.builtAt && tree.builtAt < maxTs) return "";
|
|
287
|
+
const root = tree.nodes.get(tree.rootId);
|
|
288
|
+
if (!root) return "";
|
|
289
|
+
const qv = store.embedder.embed(query);
|
|
290
|
+
// Root (level 0) first, then the top level-1 clusters by cosine to the query.
|
|
291
|
+
const nodes: { summary: string; level: number; score: number }[] = [
|
|
292
|
+
{
|
|
293
|
+
summary: root.summary,
|
|
294
|
+
level: root.level,
|
|
295
|
+
score: cosineSimilarity(qv, root.embedding),
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
const level1 = [...tree.nodes.values()]
|
|
299
|
+
.filter((n) => n.level === 1 && n.summary)
|
|
300
|
+
.map((n) => ({
|
|
301
|
+
summary: n.summary,
|
|
302
|
+
level: n.level,
|
|
303
|
+
score: cosineSimilarity(qv, n.embedding),
|
|
304
|
+
}))
|
|
305
|
+
.sort((a, b) => b.score - a.score)
|
|
306
|
+
.slice(0, 3);
|
|
307
|
+
nodes.push(...level1);
|
|
308
|
+
return formatRaptorBlock(nodes);
|
|
309
|
+
} catch {
|
|
310
|
+
return ""; // non-fatal: overview is a bonus
|
|
311
|
+
}
|
|
290
312
|
}
|
|
291
313
|
|
|
292
314
|
// --- S21: memory recall ----------------------------------------------------
|
|
@@ -295,87 +317,113 @@ function raptorOverviewBlock(
|
|
|
295
317
|
// a token cap so it can never net-inflate the system prompt.
|
|
296
318
|
|
|
297
319
|
export interface MemoryRecallInjectOptions {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
320
|
+
query: string;
|
|
321
|
+
stateDir: string;
|
|
322
|
+
limit?: number;
|
|
323
|
+
/** Token ceiling; defaults to the same `recallMaxTokens` used for checkpoints. */
|
|
324
|
+
recallMaxTokens?: number;
|
|
325
|
+
/** Cosine threshold; default 0.2. */
|
|
326
|
+
minSimilarity?: number;
|
|
327
|
+
/** When true, augment same-repo recall with cross-repo PGlite NN (S24). */
|
|
328
|
+
crossRepo?: boolean;
|
|
329
|
+
/** Stricter cosine floor for cross-repo memory hits (S24). Default 0.3. */
|
|
330
|
+
crossRepoCosine?: number;
|
|
309
331
|
}
|
|
310
332
|
|
|
311
333
|
/** Format one memory hit for the recall block. Category + score for traceability. */
|
|
312
334
|
export function formatMemoryRecallBlock(
|
|
313
|
-
|
|
335
|
+
hits: Array<{ content: string; category: string | null; score: number }>,
|
|
314
336
|
): string {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
337
|
+
if (hits.length === 0) return "";
|
|
338
|
+
const parts = hits.map((h, i) => {
|
|
339
|
+
const pct = (h.score * 100).toFixed(0);
|
|
340
|
+
const cat = h.category ? `[${h.category}] ` : "";
|
|
341
|
+
return `### Recalled memory [${i + 1}] (relevance ${pct}%)\n${cat}${h.content.trim()}`;
|
|
342
|
+
});
|
|
343
|
+
return (
|
|
344
|
+
"The following facts about this project were saved from earlier turns " +
|
|
345
|
+
"and are relevant to the current request. Treat them as established:\n\n" +
|
|
346
|
+
parts.join("\n")
|
|
347
|
+
);
|
|
326
348
|
}
|
|
327
349
|
|
|
328
350
|
/** Recall top-k durable memories, format into a token-capped block. */
|
|
329
351
|
export async function recallMemoriesAndInline(
|
|
330
|
-
|
|
352
|
+
opts: MemoryRecallInjectOptions,
|
|
331
353
|
): Promise<{ empty: boolean; block: string; report: string[] }> {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
354
|
+
const limit = opts.limit ?? 5;
|
|
355
|
+
const maxTokens = opts.recallMaxTokens ?? 0;
|
|
356
|
+
const { recallMemories, recallMemoriesCrossRepo } = await import(
|
|
357
|
+
"./memoryRecall.js"
|
|
358
|
+
);
|
|
359
|
+
const hits = await recallMemories(opts.query, opts.stateDir, {
|
|
360
|
+
topK: limit,
|
|
361
|
+
minSimilarity: opts.minSimilarity ?? 0.2,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// S24 cross-repo augmentation: if same-repo recall is thin, pull additional
|
|
365
|
+
// memories from OTHER repos via the PGlite HNSW index. Non-fatal: a failure
|
|
366
|
+
// degrades to the same-repo hits only.
|
|
367
|
+
const crossHits: Array<{ memory: any; score: number; repoId: string }> = [];
|
|
368
|
+
if (opts.crossRepo && hits.length < limit) {
|
|
369
|
+
try {
|
|
370
|
+
const x = await recallMemoriesCrossRepo(opts.query, opts.stateDir, {
|
|
371
|
+
repo: null,
|
|
372
|
+
limit: limit - hits.length,
|
|
373
|
+
crossRepoCosine: opts.crossRepoCosine ?? 0.3,
|
|
374
|
+
});
|
|
375
|
+
for (const h of x) crossHits.push(h);
|
|
376
|
+
} catch {
|
|
377
|
+
/* non-fatal — cross-repo failure → same-repo only */
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (hits.length === 0 && crossHits.length === 0)
|
|
381
|
+
return { empty: true, block: "", report: [] };
|
|
382
|
+
|
|
383
|
+
// Same incremental token cap pattern as checkpoint recall.
|
|
384
|
+
const parts: string[] = [];
|
|
385
|
+
const report: string[] = [];
|
|
386
|
+
let blockTokens = 0;
|
|
387
|
+
const pushHit = (
|
|
388
|
+
content: string,
|
|
389
|
+
category: string | null,
|
|
390
|
+
score: number,
|
|
391
|
+
label: string,
|
|
392
|
+
) => {
|
|
393
|
+
const part = formatMemoryRecallBlock([{ content, category, score }]);
|
|
394
|
+
const partTokens = estimateBlockTokens(part);
|
|
395
|
+
if (maxTokens > 0 && blockTokens + partTokens > maxTokens) return false;
|
|
396
|
+
parts.push(part);
|
|
397
|
+
report.push(
|
|
398
|
+
` • ${label} (${(score * 100).toFixed(0)}%): ${content.slice(0, 60).replace(/\n/g, " ")}…`,
|
|
399
|
+
);
|
|
400
|
+
blockTokens += partTokens;
|
|
401
|
+
return true;
|
|
402
|
+
};
|
|
403
|
+
for (const h of hits) {
|
|
404
|
+
if (
|
|
405
|
+
!pushHit(
|
|
406
|
+
h.memory.content,
|
|
407
|
+
h.memory.category,
|
|
408
|
+
h.score,
|
|
409
|
+
`memory#${h.memory.id}`,
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
for (const h of crossHits) {
|
|
415
|
+
const repoLabel = h.repoId.split(/[\\/]/).filter(Boolean).pop() ?? h.repoId;
|
|
416
|
+
if (
|
|
417
|
+
!pushHit(
|
|
418
|
+
h.memory.content,
|
|
419
|
+
h.memory.category,
|
|
420
|
+
h.score,
|
|
421
|
+
`memory#${h.memory.id} (from ${repoLabel})`,
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
return { empty: parts.length === 0, block: parts.join("\n"), report };
|
|
379
427
|
}
|
|
380
428
|
|
|
381
429
|
/**
|
|
@@ -389,99 +437,119 @@ export async function recallMemoriesAndInline(
|
|
|
389
437
|
* back to an empty result — recall is a bonus, never a hard dependency.
|
|
390
438
|
*/
|
|
391
439
|
export async function recallAndInlineAsync(
|
|
392
|
-
|
|
393
|
-
|
|
440
|
+
opts: RecallInjectOptions & { crossRepo?: boolean; repoId?: string },
|
|
441
|
+
store: VectorStore,
|
|
394
442
|
): Promise<RecallInjectResult> {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
443
|
+
const limit = opts.limit ?? 3;
|
|
444
|
+
const skip = opts.skipInjected ?? true;
|
|
445
|
+
const maxTokens = opts.recallMaxTokens ?? 0;
|
|
446
|
+
const doWindowDedupe = opts.windowDedupe ?? false;
|
|
447
|
+
const dedupSim = opts.dedupSim ?? 0.9;
|
|
448
|
+
|
|
449
|
+
let hits: SearchHit[] = [];
|
|
450
|
+
try {
|
|
451
|
+
hits = await vectorSearchAsync(store, opts.sessionId, opts.query, limit, {
|
|
452
|
+
crossRepo: opts.crossRepo,
|
|
453
|
+
repoId: opts.repoId,
|
|
454
|
+
});
|
|
455
|
+
} catch {
|
|
456
|
+
hits = [];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// F1: hoist one embedder instance for inline dedupe. defaultEmbedder() is
|
|
460
|
+
// deterministic but creating it per call wastes allocations on large hit sets.
|
|
461
|
+
// (recallAndInline already hoisted this; applying the same fix here.)
|
|
462
|
+
const embedder = defaultEmbedder();
|
|
463
|
+
let liveEmbeddings: number[][] = [];
|
|
464
|
+
if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
|
|
465
|
+
liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const toInject: SearchHit[] = [];
|
|
469
|
+
let blockTokens = 0;
|
|
470
|
+
|
|
471
|
+
// F2: when cross-repo is on but no global index dir could be resolved, skip
|
|
472
|
+
// foreign hits rather than injecting them undeduped — otherwise a foreign
|
|
473
|
+
// checkpoint with no machine-wide injected-set to consult would re-inject in
|
|
474
|
+
// every new session. Same-repo hits (no repoId) are unaffected. Warn once so
|
|
475
|
+
// the silent degradation is observable. (The extension resolver normally
|
|
476
|
+
// supplies a default globalIndexDir, so this is belt-and-braces.)
|
|
477
|
+
const skipCrossRepoHits = !!opts.crossRepo && !opts.globalIndexDir;
|
|
478
|
+
if (skipCrossRepoHits) {
|
|
479
|
+
try {
|
|
480
|
+
console.warn(
|
|
481
|
+
"[mega-compact:recall] cross-repo recall enabled but globalIndexDir is unset — " +
|
|
482
|
+
"skipping cross-repo injection to avoid re-injecting undeduped foreign checkpoints",
|
|
483
|
+
);
|
|
484
|
+
} catch {
|
|
485
|
+
/* ignore */
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
for (const h of hits) {
|
|
490
|
+
// F2: skip foreign hits when we can't dedup them machine-wide.
|
|
491
|
+
if (skipCrossRepoHits && h.repoId) continue;
|
|
492
|
+
if (
|
|
493
|
+
skip &&
|
|
494
|
+
vectorWasInjected(store, opts.sessionId, h.checkpoint.checkpointId)
|
|
495
|
+
)
|
|
496
|
+
continue;
|
|
497
|
+
// S18: machine-wide injected-set — a foreign checkpoint already injected
|
|
498
|
+
// (in any session) is never re-injected. Only applies to cross-repo hits
|
|
499
|
+
// (same-repo hits have no repoId and are handled by the per-session set).
|
|
500
|
+
if (opts.globalIndexDir && h.repoId) {
|
|
501
|
+
try {
|
|
502
|
+
const { wasInjectedGlobal } = await import("./store/sqlite.js");
|
|
503
|
+
if (
|
|
504
|
+
wasInjectedGlobal(
|
|
505
|
+
h.checkpoint.checkpointId,
|
|
506
|
+
opts.sessionId,
|
|
507
|
+
opts.globalIndexDir,
|
|
508
|
+
)
|
|
509
|
+
)
|
|
510
|
+
continue;
|
|
511
|
+
} catch {
|
|
512
|
+
/* non-fatal: degrade to per-session injected-set only */
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// Inline dedupe: skip a hit already resident in the live window (F1: hoisted embedder).
|
|
516
|
+
if (doWindowDedupe && liveEmbeddings.length > 0) {
|
|
517
|
+
const hitVec = embedder.embed(h.checkpoint.summary);
|
|
518
|
+
if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim))
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
// F3: build the hit list first; format ONCE at the end so the block carries
|
|
522
|
+
// exactly one preamble and numbering [1..n] rather than one per hit.
|
|
523
|
+
const partTokens = estimateBlockTokens(h.checkpoint.summary);
|
|
524
|
+
if (maxTokens > 0 && blockTokens + partTokens > maxTokens) break;
|
|
525
|
+
toInject.push(h);
|
|
526
|
+
blockTokens += partTokens;
|
|
527
|
+
vectorMarkInjected(store, opts.sessionId, h.checkpoint.checkpointId);
|
|
528
|
+
// S18: record the cross-repo injection machine-wide so it's not re-injected
|
|
529
|
+
// by a later recall (same or different session).
|
|
530
|
+
if (opts.globalIndexDir && h.repoId) {
|
|
531
|
+
try {
|
|
532
|
+
const { markInjectedGlobal } = await import("./store/sqlite.js");
|
|
533
|
+
markInjectedGlobal(
|
|
534
|
+
h.checkpoint.checkpointId,
|
|
535
|
+
h.repoId,
|
|
536
|
+
opts.sessionId,
|
|
537
|
+
opts.globalIndexDir,
|
|
538
|
+
);
|
|
539
|
+
} catch {
|
|
540
|
+
/* non-fatal */
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// F3: format once — one preamble, correct [1..n] numbering, token cap counted
|
|
546
|
+
// against one preamble (not N). Pass the full toInject array so formatRecallBlock
|
|
547
|
+
// has repoId + score for proper labeling.
|
|
548
|
+
const block = toInject.length > 0 ? formatRecallBlock(toInject) : "";
|
|
549
|
+
const report = toInject.map(
|
|
550
|
+
(h) =>
|
|
551
|
+
` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`,
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
return { toInject, report, block, empty: toInject.length === 0 };
|
|
487
555
|
}
|