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