pi-mega-compact 0.8.24 → 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.
Files changed (111) hide show
  1. package/README.md +26 -0
  2. package/dist/extensions/mega-compact-s38.test.js +263 -14
  3. package/dist/extensions/mega-compact.js +15 -0
  4. package/dist/extensions/mega-config.js +3 -0
  5. package/dist/extensions/mega-events/agent-handlers.js +211 -26
  6. package/dist/extensions/mega-events/context-handler.js +45 -7
  7. package/dist/extensions/mega-events/error-classifier.js +125 -18
  8. package/dist/extensions/mega-pipeline/compact.js +24 -13
  9. package/dist/extensions/mega-pipeline/recall.js +31 -2
  10. package/dist/extensions/mega-runtime/dashboard-snapshot.js +4 -0
  11. package/dist/extensions/mega-runtime/runtime-snapshot.js +4 -0
  12. package/dist/extensions/mega-runtime/runtime.js +58 -5
  13. package/dist/src/boundary.js +79 -43
  14. package/dist/src/boundary.test.js +119 -2
  15. package/dist/src/canary.js +10 -0
  16. package/dist/src/config/dedup.js +14 -0
  17. package/dist/src/config.js +3 -1
  18. package/dist/src/dedup/raptor/buildHistory.js +164 -0
  19. package/dist/src/dedup/raptor/buildHistory.test.js +292 -0
  20. package/dist/src/dedup/raptor/index.js +38 -0
  21. package/dist/src/dedup/raptor/multilevel-serve.test.js +229 -0
  22. package/dist/src/dedup/raptor/multilevel.js +17 -5
  23. package/dist/src/dedup/raptor/multilevel.test.js +36 -1
  24. package/dist/src/dedup/raptor/raptor.test.js +43 -0
  25. package/dist/src/dedup/raptor/retrieval.js +14 -2
  26. package/dist/src/dedup/raptor/retrieval.test.js +95 -0
  27. package/dist/src/dedup/raptor/serve-gate.test.js +298 -0
  28. package/dist/src/dedup/raptor/summarizer.js +1 -0
  29. package/dist/src/dedup/raptor/tree.js +16 -2
  30. package/dist/src/engine.js +18 -2
  31. package/dist/src/httpEmbedder.js +96 -6
  32. package/dist/src/httpEmbedder.test.js +277 -0
  33. package/dist/src/mechanical-fix.test.js +65 -0
  34. package/dist/src/raptor-inject-summaries.test.js +162 -0
  35. package/dist/src/recall.js +153 -24
  36. package/dist/src/recall.test.js +179 -4
  37. package/dist/src/store/sqlite/dedup-mirror.js +32 -15
  38. package/dist/src/store/sqlite/maintenance.js +2 -2
  39. package/dist/src/store/sqlite/mechanical-fix.test.js +146 -0
  40. package/dist/src/store/sqlite/memories.js +5 -5
  41. package/dist/src/store/sqlite/meta.js +1 -1
  42. package/dist/src/store/sqlite/raptor.js +56 -17
  43. package/dist/src/store/sqlite/raptor.test.js +106 -0
  44. package/dist/src/store/sqlite/schema.js +90 -1
  45. package/dist/src/store/sqlite/session-state.js +9 -3
  46. package/dist/src/store/sqlite/stats.js +9 -5
  47. package/dist/src/store/sqlite/turns.js +179 -0
  48. package/dist/src/store/sqlite/turns.test.js +183 -0
  49. package/dist/src/store/sqlite/utils.js +15 -4
  50. package/dist/src/store/sqlite.js +1 -0
  51. package/dist/src/store.js +2 -2
  52. package/dist/src/vector-search-cache.test.js +157 -0
  53. package/dist/src/vector-search.js +107 -15
  54. package/dist/src/vectorStore.js +36 -8
  55. package/extensions/mega-compact-s38.test.ts +259 -14
  56. package/extensions/mega-compact.ts +15 -0
  57. package/extensions/mega-config.ts +18 -0
  58. package/extensions/mega-dashboard.ts +10 -1
  59. package/extensions/mega-events/agent-handlers.ts +211 -26
  60. package/extensions/mega-events/context-handler.ts +43 -7
  61. package/extensions/mega-events/error-classifier.ts +125 -17
  62. package/extensions/mega-pipeline/compact.ts +28 -16
  63. package/extensions/mega-pipeline/recall.ts +34 -2
  64. package/extensions/mega-runtime/dashboard-snapshot.ts +8 -0
  65. package/extensions/mega-runtime/helpers.ts +25 -1
  66. package/extensions/mega-runtime/runtime-snapshot.ts +4 -0
  67. package/extensions/mega-runtime/runtime.ts +69 -23
  68. package/package.json +1 -1
  69. package/src/boundary.test.ts +128 -2
  70. package/src/boundary.ts +75 -39
  71. package/src/canary.ts +10 -0
  72. package/src/config/dedup.ts +25 -0
  73. package/src/config.ts +3 -1
  74. package/src/dedup/raptor/buildHistory.test.ts +353 -0
  75. package/src/dedup/raptor/buildHistory.ts +259 -0
  76. package/src/dedup/raptor/index.ts +38 -0
  77. package/src/dedup/raptor/multilevel-serve.test.ts +273 -0
  78. package/src/dedup/raptor/multilevel.test.ts +47 -0
  79. package/src/dedup/raptor/multilevel.ts +18 -8
  80. package/src/dedup/raptor/raptor.test.ts +59 -0
  81. package/src/dedup/raptor/retrieval.test.ts +118 -0
  82. package/src/dedup/raptor/retrieval.ts +14 -2
  83. package/src/dedup/raptor/serve-gate.test.ts +348 -0
  84. package/src/dedup/raptor/summarizer.ts +1 -0
  85. package/src/dedup/raptor/tree.ts +17 -2
  86. package/src/engine.ts +32 -3
  87. package/src/httpEmbedder.test.ts +286 -0
  88. package/src/httpEmbedder.ts +98 -8
  89. package/src/mechanical-fix.test.ts +70 -0
  90. package/src/raptor-inject-summaries.test.ts +228 -0
  91. package/src/recall.test.ts +220 -4
  92. package/src/recall.ts +462 -265
  93. package/src/store/sqlite/dedup-mirror.ts +35 -18
  94. package/src/store/sqlite/maintenance.ts +2 -2
  95. package/src/store/sqlite/mechanical-fix.test.ts +162 -0
  96. package/src/store/sqlite/memories.ts +5 -5
  97. package/src/store/sqlite/meta.ts +1 -1
  98. package/src/store/sqlite/raptor.test.ts +139 -0
  99. package/src/store/sqlite/raptor.ts +135 -81
  100. package/src/store/sqlite/schema.ts +90 -1
  101. package/src/store/sqlite/session-state.ts +9 -3
  102. package/src/store/sqlite/stats.ts +10 -8
  103. package/src/store/sqlite/turns.test.ts +218 -0
  104. package/src/store/sqlite/turns.ts +302 -0
  105. package/src/store/sqlite/utils.ts +14 -4
  106. package/src/store/sqlite.ts +1 -0
  107. package/src/store.ts +9 -2
  108. package/src/vector-search-cache.test.ts +190 -0
  109. package/src/vector-search.ts +273 -156
  110. package/src/vectorStore.ts +443 -382
  111. package/extensions/mega-runtime/reset-runtime.ts +0 -80
package/src/recall.ts CHANGED
@@ -15,68 +15,122 @@
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";
27
+ import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
28
+ import { maxCheckpointTimestamp } from "./store/sqlite.js";
29
+ import { normalizeSessionId } from "./store.js";
21
30
 
22
31
  export type RecallSource = "resume" | "command" | "sentinel";
23
32
 
24
33
  export interface RecallInjectOptions {
25
- sessionId: string;
26
- query: string;
27
- limit?: number;
28
- source: RecallSource;
29
- /** Skip checkpoints already injected this session (recall dedup). */
30
- skipInjected?: boolean;
31
- /** Token ceiling for the re-injected block (Fix C). Recall stops adding once
32
- * the block would exceed this, so the read path can never net-inflate. */
33
- recallMaxTokens?: number;
34
- /** Inline-dedupe hits against the live window (Fix C): drop a hit whose
35
- * summary is ≥ `dedupSim` similar to a live message. */
36
- windowDedupe?: boolean;
37
- /** Live window text (from the session manager) used for inline dedupe. */
38
- liveWindow?: string[];
39
- /** Similarity threshold for inline dedupe (defaults to 0.9). */
40
- dedupSim?: number;
41
- /** S18: index dir of the machine-wide injected-set. When set on a cross-repo
42
- * recall, a foreign checkpoint already injected (in any session) is skipped
43
- * and a fresh injection is recorded globally. */
44
- globalIndexDir?: string;
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;
45
59
  }
46
60
 
47
61
  export interface RecallInjectResult {
48
- /** Blocks that are ready to inline (already deduped against the window). */
49
- toInject: SearchHit[];
50
- /** Human-readable lines for status/notify reporting. */
51
- report: string[];
52
- /** The concatenated, model-visible recall block (empty when nothing new). */
53
- block: string;
54
- /** True when nothing new was inlined. */
55
- 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;
56
70
  }
57
71
 
58
72
  /** Wrap a recall block so the model reads it as restored compacted context. */
59
73
  export function formatRecallBlock(hits: SearchHit[]): string {
60
- if (hits.length === 0) return "";
61
- const parts = hits.map((h, i) => {
62
- const score = (h.score * 100).toFixed(0);
63
- // S17: label a cross-repo hit with its source repo (the repoId doubles as
64
- // that repo's stateDir, so the last path segment is the repo's display
65
- // name). Same-repo hits (no repoId) stay unlabeled.
66
- const repoName = h.repoId ? ` (from repo ${h.repoId.split("/").filter(Boolean).pop() ?? h.repoId})` : "";
67
- return (
68
- `### Recalled context [${i + 1}] (relevance ${score}%)${repoName}\n` +
69
- `${h.checkpoint.summary.trim()}\n` +
70
- (h.checkpoint.filesModified.length
71
- ? `Key files: ${h.checkpoint.filesModified.join(", ")}.\n`
72
- : "")
73
- );
74
- });
75
- return (
76
- "The following compacted context was recalled from earlier in this session " +
77
- "and is relevant to the current request. Treat it as background you already know:\n\n" +
78
- parts.join("\n")
79
- );
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
+ );
105
+ }
106
+
107
+ /**
108
+ * S25 Phase-2: format the RAPTOR tree's top-level summary nodes (root + level-1
109
+ * clusters) as a hierarchical overview HEADER. Surfacing the high-level topical
110
+ * structure before the detailed checkpoint hits gives the model a map of what
111
+ * the session has covered. `nodes` are the RAPTOR summary nodes to surface,
112
+ * highest level first (root → level-1 clusters). Returns "" when empty.
113
+ */
114
+ export function formatRaptorBlock(
115
+ nodes: { summary: string; level: number; score?: number }[],
116
+ ): string {
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
+ );
80
134
  }
81
135
 
82
136
  /**
@@ -106,82 +160,155 @@ export function formatRecallBlock(hits: SearchHit[]): string {
106
160
  * Pi-agnostic: no pi runtime imports (src/ invariant).
107
161
  */
108
162
  export function recallAndInline(
109
- opts: RecallInjectOptions,
110
- store: VectorStore,
163
+ opts: RecallInjectOptions,
164
+ store: VectorStore,
111
165
  ): RecallInjectResult {
112
- // ── S27 Recall Demotion ─────────────────────────────────────────────
113
- //
114
- // When MEGACOMPACT_DB_MIRROR is ON, the raw_transcript + dedup_mirror
115
- // tables are preferred for byte-stable reconstruction. The current
116
- // recall path (VectorStore search → format → inject) is unaffected —
117
- // it provides fast semantic search over checkpoint summaries.
118
- //
119
- // If full transcript reconstruction is ever needed (replay, export,
120
- // debug), call reconstructFromMirror(db, sessionId, fromSeq, toSeq)
121
- // from src/mirror/dedup.ts instead of reading from the legacy JSON
122
- // checkpoint. Falls back to legacy checkpoint if mirror is empty
123
- // (pre-migration sessions).
124
- //
125
- // Invariant: raw_transcript + dedup_mirror are additive and never
126
- // lose data. The legacy JSON checkpoint remains as a DR snapshot.
127
- // ─────────────────────────────────────────────────────────────────────
128
-
129
- const limit = opts.limit ?? 3;
130
- const skip = opts.skipInjected ?? true;
131
- const maxTokens = opts.recallMaxTokens ?? 0; // 0 = unbounded (legacy behavior)
132
- const doWindowDedupe = opts.windowDedupe ?? false;
133
- const dedupSim = opts.dedupSim ?? 0.9;
134
-
135
- const { hits } = searchRecall(
136
- { sessionId: opts.sessionId, query: opts.query, limit, skipInjected: false },
137
- store,
138
- );
139
-
140
- // Precompute live-window embeddings once for inline dedupe (Fix C). Trigram
141
- // embedder is local + cheap; never a network call (PREVENT-PI-004).
142
- let liveEmbeddings: number[][] = [];
143
- if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
144
- const embedder = defaultEmbedder();
145
- liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
146
- }
147
-
148
- // Shared dedup + bounded/inline block assembly. We build the block
149
- // incrementally so the token cap can stop mid-stream (Fix C).
150
- const toInject: SearchHit[] = [];
151
- const parts: string[] = [];
152
- let blockTokens = 0;
153
-
154
- for (const h of hits) {
155
- if (skip && vectorWasInjected(store, opts.sessionId, h.checkpoint.checkpointId)) continue;
156
-
157
- // Inline dedupe: skip a hit already resident in the live window (Fix C).
158
- if (doWindowDedupe && liveEmbeddings.length > 0) {
159
- const hitVec = defaultEmbedder().embed(h.checkpoint.summary);
160
- if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim)) continue;
161
- }
162
-
163
- const part = formatRecallBlock([h]);
164
- const partTokens = estimateBlockTokens(part);
165
- // Token cap: never push a chunk that would overrun the ceiling.
166
- if (maxTokens > 0 && blockTokens + partTokens > maxTokens) break;
167
-
168
- parts.push(part);
169
- toInject.push(h);
170
- blockTokens += partTokens;
171
- vectorMarkInjected(store, opts.sessionId, h.checkpoint.checkpointId);
172
- }
173
-
174
- const block = parts.join("\n");
175
- const report = toInject.map(
176
- (h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`,
177
- );
178
-
179
- return {
180
- toInject,
181
- report,
182
- block,
183
- empty: toInject.length === 0,
184
- };
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
+ };
261
+ }
262
+
263
+ /**
264
+ * S25 Phase-2: build the hierarchical overview header for a session. Rehydrates
265
+ * the persisted RAPTOR tree, picks the root + top level-1 cluster nodes by
266
+ * cosine similarity to the query, and formats them via formatRaptorBlock.
267
+ * Returns "" when no tree, stale tree, timedOut tree, or shadow mode. Non-fatal
268
+ * (wrapped in try/catch) — the overview is a bonus; a failure must never block
269
+ * the detailed recall block.
270
+ */
271
+ function raptorOverviewBlock(
272
+ store: VectorStore,
273
+ sessionId: string,
274
+ query: string,
275
+ ): string {
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
+ }
185
312
  }
186
313
 
187
314
  // --- S21: memory recall ----------------------------------------------------
@@ -190,87 +317,113 @@ export function recallAndInline(
190
317
  // a token cap so it can never net-inflate the system prompt.
191
318
 
192
319
  export interface MemoryRecallInjectOptions {
193
- query: string;
194
- stateDir: string;
195
- limit?: number;
196
- /** Token ceiling; defaults to the same `recallMaxTokens` used for checkpoints. */
197
- recallMaxTokens?: number;
198
- /** Cosine threshold; default 0.2. */
199
- minSimilarity?: number;
200
- /** When true, augment same-repo recall with cross-repo PGlite NN (S24). */
201
- crossRepo?: boolean;
202
- /** Stricter cosine floor for cross-repo memory hits (S24). Default 0.3. */
203
- 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;
204
331
  }
205
332
 
206
333
  /** Format one memory hit for the recall block. Category + score for traceability. */
207
334
  export function formatMemoryRecallBlock(
208
- hits: Array<{ content: string; category: string | null; score: number }>,
335
+ hits: Array<{ content: string; category: string | null; score: number }>,
209
336
  ): string {
210
- if (hits.length === 0) return "";
211
- const parts = hits.map((h, i) => {
212
- const pct = (h.score * 100).toFixed(0);
213
- const cat = h.category ? `[${h.category}] ` : "";
214
- return `### Recalled memory [${i + 1}] (relevance ${pct}%)\n${cat}${h.content.trim()}`;
215
- });
216
- return (
217
- "The following facts about this project were saved from earlier turns " +
218
- "and are relevant to the current request. Treat them as established:\n\n" +
219
- parts.join("\n")
220
- );
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
+ );
221
348
  }
222
349
 
223
350
  /** Recall top-k durable memories, format into a token-capped block. */
224
351
  export async function recallMemoriesAndInline(
225
- opts: MemoryRecallInjectOptions,
352
+ opts: MemoryRecallInjectOptions,
226
353
  ): Promise<{ empty: boolean; block: string; report: string[] }> {
227
- const limit = opts.limit ?? 5;
228
- const maxTokens = opts.recallMaxTokens ?? 0;
229
- const { recallMemories, recallMemoriesCrossRepo } = await import("./memoryRecall.js");
230
- const hits = await recallMemories(opts.query, opts.stateDir, {
231
- topK: limit,
232
- minSimilarity: opts.minSimilarity ?? 0.2,
233
- });
234
-
235
- // S24 cross-repo augmentation: if same-repo recall is thin, pull additional
236
- // memories from OTHER repos via the PGlite HNSW index. Non-fatal: a failure
237
- // degrades to the same-repo hits only.
238
- const crossHits: Array<{ memory: any; score: number; repoId: string }> = [];
239
- if (opts.crossRepo && hits.length < limit) {
240
- try {
241
- const x = await recallMemoriesCrossRepo(opts.query, opts.stateDir, {
242
- repo: null,
243
- limit: limit - hits.length,
244
- crossRepoCosine: opts.crossRepoCosine ?? 0.3,
245
- });
246
- for (const h of x) crossHits.push(h);
247
- } catch {
248
- /* non-fatal cross-repo failure → same-repo only */
249
- }
250
- }
251
- if (hits.length === 0 && crossHits.length === 0) return { empty: true, block: "", report: [] };
252
-
253
- // Same incremental token cap pattern as checkpoint recall.
254
- const parts: string[] = [];
255
- const report: string[] = [];
256
- let blockTokens = 0;
257
- const pushHit = (content: string, category: string | null, score: number, label: string) => {
258
- const part = formatMemoryRecallBlock([{ content, category, score }]);
259
- const partTokens = estimateBlockTokens(part);
260
- if (maxTokens > 0 && blockTokens + partTokens > maxTokens) return false;
261
- parts.push(part);
262
- report.push(` • ${label} (${(score * 100).toFixed(0)}%): ${content.slice(0, 60).replace(/\n/g, " ")}…`);
263
- blockTokens += partTokens;
264
- return true;
265
- };
266
- for (const h of hits) {
267
- if (!pushHit(h.memory.content, h.memory.category, h.score, `memory#${h.memory.id}`)) break;
268
- }
269
- for (const h of crossHits) {
270
- const repoLabel = h.repoId.split(/[\\/]/).filter(Boolean).pop() ?? h.repoId;
271
- if (!pushHit(h.memory.content, h.memory.category, h.score, `memory#${h.memory.id} (from ${repoLabel})`)) break;
272
- }
273
- 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 };
274
427
  }
275
428
 
276
429
  /**
@@ -284,75 +437,119 @@ export async function recallMemoriesAndInline(
284
437
  * back to an empty result — recall is a bonus, never a hard dependency.
285
438
  */
286
439
  export async function recallAndInlineAsync(
287
- opts: RecallInjectOptions & { crossRepo?: boolean; repoId?: string },
288
- store: VectorStore,
440
+ opts: RecallInjectOptions & { crossRepo?: boolean; repoId?: string },
441
+ store: VectorStore,
289
442
  ): Promise<RecallInjectResult> {
290
- const limit = opts.limit ?? 3;
291
- const skip = opts.skipInjected ?? true;
292
- const maxTokens = opts.recallMaxTokens ?? 0;
293
- const doWindowDedupe = opts.windowDedupe ?? false;
294
- const dedupSim = opts.dedupSim ?? 0.9;
295
-
296
- let hits: SearchHit[] = [];
297
- try {
298
- hits = await vectorSearchAsync(store, opts.sessionId, opts.query, limit, {
299
- crossRepo: opts.crossRepo,
300
- repoId: opts.repoId,
301
- });
302
- } catch {
303
- hits = [];
304
- }
305
-
306
- let liveEmbeddings: number[][] = [];
307
- if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
308
- const embedder = defaultEmbedder();
309
- liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
310
- }
311
-
312
- const toInject: SearchHit[] = [];
313
- const parts: string[] = [];
314
- let blockTokens = 0;
315
-
316
- for (const h of hits) {
317
- if (skip && vectorWasInjected(store, opts.sessionId, h.checkpoint.checkpointId)) continue;
318
- // S18: machine-wide injected-set a foreign checkpoint already injected
319
- // (in any session) is never re-injected. Only applies to cross-repo hits
320
- // (same-repo hits have no repoId and are handled by the per-session set).
321
- if (opts.globalIndexDir && h.repoId) {
322
- try {
323
- const { wasInjectedGlobal } = await import("./store/sqlite.js");
324
- if (wasInjectedGlobal(h.checkpoint.checkpointId, opts.sessionId, opts.globalIndexDir)) continue;
325
- } catch {
326
- /* non-fatal: degrade to per-session injected-set only */
327
- }
328
- }
329
- if (doWindowDedupe && liveEmbeddings.length > 0) {
330
- const hitVec = defaultEmbedder().embed(h.checkpoint.summary);
331
- if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim)) continue;
332
- }
333
- const part = formatRecallBlock([h]);
334
- const partTokens = estimateBlockTokens(part);
335
- if (maxTokens > 0 && blockTokens + partTokens > maxTokens) break;
336
- parts.push(part);
337
- toInject.push(h);
338
- blockTokens += partTokens;
339
- vectorMarkInjected(store, opts.sessionId, h.checkpoint.checkpointId);
340
- // S18: record the cross-repo injection machine-wide so it's not re-injected
341
- // by a later recall (same or different session).
342
- if (opts.globalIndexDir && h.repoId) {
343
- try {
344
- const { markInjectedGlobal } = await import("./store/sqlite.js");
345
- markInjectedGlobal(h.checkpoint.checkpointId, h.repoId, opts.sessionId, opts.globalIndexDir);
346
- } catch {
347
- /* non-fatal */
348
- }
349
- }
350
- }
351
-
352
- const block = parts.join("\n");
353
- const report = toInject.map(
354
- (h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`,
355
- );
356
-
357
- 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 };
358
555
  }