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
@@ -14,9 +14,12 @@
14
14
  * extension decides where it lands.
15
15
  */
16
16
  import { recall as searchRecall } from "./engine.js";
17
- import { vectorWasInjected, vectorMarkInjected, vectorSearchAsync } from "./vectorStore.js";
17
+ import { vectorWasInjected, vectorMarkInjected, vectorSearchAsync, } from "./vectorStore.js";
18
18
  import { estimateBlockTokens } from "./tokens.js";
19
19
  import { defaultEmbedder, cosineSimilarity } from "./embedder.js";
20
+ import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
21
+ import { maxCheckpointTimestamp } from "./store/sqlite.js";
22
+ import { normalizeSessionId } from "./store.js";
20
23
  /** Wrap a recall block so the model reads it as restored compacted context. */
21
24
  export function formatRecallBlock(hits) {
22
25
  if (hits.length === 0)
@@ -26,7 +29,16 @@ export function formatRecallBlock(hits) {
26
29
  // S17: label a cross-repo hit with its source repo (the repoId doubles as
27
30
  // that repo's stateDir, so the last path segment is the repo's display
28
31
  // name). Same-repo hits (no repoId) stay unlabeled.
29
- const repoName = h.repoId ? ` (from repo ${h.repoId.split("/").filter(Boolean).pop() ?? h.repoId})` : "";
32
+ const repoName = h.repoId
33
+ ? ` (from repo ${h.repoId.split("/").filter(Boolean).pop() ?? h.repoId})`
34
+ : "";
35
+ // S42B: a RAPTOR cluster node hit (not a stored checkpoint) is labeled as a
36
+ // hierarchical summary and uses raptorSummary as its body. No Key files line
37
+ // (cluster nodes carry no file list).
38
+ if (h.raptorLevel !== undefined) {
39
+ return (`### Recalled cluster summary [${i + 1}] (level ${h.raptorLevel}, relevance ${score}%)${repoName}\n` +
40
+ `${(h.raptorSummary ?? h.checkpoint.summary).trim()}\n`);
41
+ }
30
42
  return (`### Recalled context [${i + 1}] (relevance ${score}%)${repoName}\n` +
31
43
  `${h.checkpoint.summary.trim()}\n` +
32
44
  (h.checkpoint.filesModified.length
@@ -37,6 +49,29 @@ export function formatRecallBlock(hits) {
37
49
  "and is relevant to the current request. Treat it as background you already know:\n\n" +
38
50
  parts.join("\n"));
39
51
  }
52
+ /**
53
+ * S25 Phase-2: format the RAPTOR tree's top-level summary nodes (root + level-1
54
+ * clusters) as a hierarchical overview HEADER. Surfacing the high-level topical
55
+ * structure before the detailed checkpoint hits gives the model a map of what
56
+ * the session has covered. `nodes` are the RAPTOR summary nodes to surface,
57
+ * highest level first (root → level-1 clusters). Returns "" when empty.
58
+ */
59
+ export function formatRaptorBlock(nodes) {
60
+ if (nodes.length === 0)
61
+ return "";
62
+ const parts = nodes.map((n, i) => {
63
+ const score = n.score !== undefined
64
+ ? ` (relevance ${(n.score * 100).toFixed(0)}%)`
65
+ : "";
66
+ const label = n.level === 0
67
+ ? `Session overview [${i + 1}]${score}`
68
+ : `Cluster summary [${i + 1}] (level ${n.level})${score}`;
69
+ return `### ${label}\n${n.summary.trim()}\n`;
70
+ });
71
+ return ("The following hierarchical overview summarizes the structure of this " +
72
+ "session so far. Use it as a map of what has been covered:\n\n" +
73
+ parts.join("\n"));
74
+ }
40
75
  /**
41
76
  * Run the unified recall+dudupe+prepare-inject pipeline. Does NOT touch pi;
42
77
  * it records injections via `markInjected` so the next call dedupes. The
@@ -85,47 +120,115 @@ export function recallAndInline(opts, store) {
85
120
  const maxTokens = opts.recallMaxTokens ?? 0; // 0 = unbounded (legacy behavior)
86
121
  const doWindowDedupe = opts.windowDedupe ?? false;
87
122
  const dedupSim = opts.dedupSim ?? 0.9;
88
- const { hits } = searchRecall({ sessionId: opts.sessionId, query: opts.query, limit, skipInjected: false }, store);
123
+ // F4: thread skipInjected through to searchRecall instead of hardcoding false
124
+ // and re-implementing the filter here. newHits is already deduped when skip
125
+ // is true (default); equals hits when skip is false (openclaw command path).
126
+ const { newHits } = searchRecall({ sessionId: opts.sessionId, query: opts.query, limit, skipInjected: skip }, store);
127
+ // F1: hoist one embedder instance for inline dedupe (matches the async path).
128
+ // defaultEmbedder() is deterministic but creating it per hit wastes allocations.
129
+ const embedder = defaultEmbedder();
89
130
  // Precompute live-window embeddings once for inline dedupe (Fix C). Trigram
90
131
  // embedder is local + cheap; never a network call (PREVENT-PI-004).
91
132
  let liveEmbeddings = [];
92
133
  if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
93
- const embedder = defaultEmbedder();
94
134
  liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
95
135
  }
96
- // Shared dedup + bounded/inline block assembly. We build the block
97
- // incrementally so the token cap can stop mid-stream (Fix C).
136
+ // F3: build the hit list first; format ONCE at the end so the block carries
137
+ // exactly one preamble and [1..n] numbering, and the token cap counts body
138
+ // tokens (one preamble at format time, not N). We accumulate summaries and
139
+ // break mid-stream when the cap would be exceeded.
98
140
  const toInject = [];
99
- const parts = [];
100
141
  let blockTokens = 0;
101
- for (const h of hits) {
102
- if (skip && vectorWasInjected(store, opts.sessionId, h.checkpoint.checkpointId))
103
- continue;
142
+ for (const h of newHits) {
104
143
  // Inline dedupe: skip a hit already resident in the live window (Fix C).
105
144
  if (doWindowDedupe && liveEmbeddings.length > 0) {
106
- const hitVec = defaultEmbedder().embed(h.checkpoint.summary);
145
+ const hitVec = embedder.embed(h.checkpoint.summary);
107
146
  if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim))
108
147
  continue;
109
148
  }
110
- const part = formatRecallBlock([h]);
111
- const partTokens = estimateBlockTokens(part);
149
+ const partTokens = estimateBlockTokens(h.checkpoint.summary);
112
150
  // Token cap: never push a chunk that would overrun the ceiling.
113
151
  if (maxTokens > 0 && blockTokens + partTokens > maxTokens)
114
152
  break;
115
- parts.push(part);
116
153
  toInject.push(h);
117
154
  blockTokens += partTokens;
118
155
  vectorMarkInjected(store, opts.sessionId, h.checkpoint.checkpointId);
119
156
  }
120
- const block = parts.join("\n");
157
+ // F3: format once — one preamble, correct [1..n] numbering.
158
+ const recallBlock = toInject.length > 0 ? formatRecallBlock(toInject) : "";
121
159
  const report = toInject.map((h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`);
160
+ // S25 Phase-2 (RAPTOR_INJECT_SUMMARIES): prepend a hierarchical overview
161
+ // header built from the tree's top-level summary nodes (root + the
162
+ // highest-scoring level-1 cluster summaries). This gives the model a topical
163
+ // map of the session before the detailed checkpoint hits. Default ON via
164
+ // the store's config; `opts.raptorSummaries` (when explicitly set) overrides.
165
+ // Skipped when no tree exists, the tree is stale/timedOut, or shadow mode is on.
166
+ let overview = "";
167
+ const injectSummaries = opts.raptorSummaries ?? store.cfg.RAPTOR_INJECT_SUMMARIES;
168
+ if (injectSummaries && recallBlock) {
169
+ overview = raptorOverviewBlock(store, opts.sessionId, opts.query);
170
+ }
171
+ const block = overview && recallBlock
172
+ ? overview + "\n" + recallBlock
173
+ : overview || recallBlock;
122
174
  return {
123
175
  toInject,
124
176
  report,
125
177
  block,
126
- empty: toInject.length === 0,
178
+ empty: block.length === 0,
127
179
  };
128
180
  }
181
+ /**
182
+ * S25 Phase-2: build the hierarchical overview header for a session. Rehydrates
183
+ * the persisted RAPTOR tree, picks the root + top level-1 cluster nodes by
184
+ * cosine similarity to the query, and formats them via formatRaptorBlock.
185
+ * Returns "" when no tree, stale tree, timedOut tree, or shadow mode. Non-fatal
186
+ * (wrapped in try/catch) — the overview is a bonus; a failure must never block
187
+ * the detailed recall block.
188
+ */
189
+ function raptorOverviewBlock(store, sessionId, query) {
190
+ try {
191
+ const sid = normalizeSessionId(sessionId);
192
+ // S25 gate: the overview header is part of the RAPTOR serve surface, so it
193
+ // must honor the same contract as raptorSearchHits — shadow mode is
194
+ // logging-only building, not injection.
195
+ if (isShadowMode())
196
+ return "";
197
+ const tree = rehydrateRaptorTree(sid, store.stateDir);
198
+ if (!tree || !tree.rootId || tree.timedOut)
199
+ return "";
200
+ // Freshness: a tree built before the session's latest checkpoint is stale.
201
+ const maxTs = maxCheckpointTimestamp(sid, store.stateDir);
202
+ if (tree.builtAt && tree.builtAt < maxTs)
203
+ return "";
204
+ const root = tree.nodes.get(tree.rootId);
205
+ if (!root)
206
+ return "";
207
+ const qv = store.embedder.embed(query);
208
+ // Root (level 0) first, then the top level-1 clusters by cosine to the query.
209
+ const nodes = [
210
+ {
211
+ summary: root.summary,
212
+ level: root.level,
213
+ score: cosineSimilarity(qv, root.embedding),
214
+ },
215
+ ];
216
+ const level1 = [...tree.nodes.values()]
217
+ .filter((n) => n.level === 1 && n.summary)
218
+ .map((n) => ({
219
+ summary: n.summary,
220
+ level: n.level,
221
+ score: cosineSimilarity(qv, n.embedding),
222
+ }))
223
+ .sort((a, b) => b.score - a.score)
224
+ .slice(0, 3);
225
+ nodes.push(...level1);
226
+ return formatRaptorBlock(nodes);
227
+ }
228
+ catch {
229
+ return ""; // non-fatal: overview is a bonus
230
+ }
231
+ }
129
232
  /** Format one memory hit for the recall block. Category + score for traceability. */
130
233
  export function formatMemoryRecallBlock(hits) {
131
234
  if (hits.length === 0)
@@ -219,16 +322,38 @@ export async function recallAndInlineAsync(opts, store) {
219
322
  catch {
220
323
  hits = [];
221
324
  }
325
+ // F1: hoist one embedder instance for inline dedupe. defaultEmbedder() is
326
+ // deterministic but creating it per call wastes allocations on large hit sets.
327
+ // (recallAndInline already hoisted this; applying the same fix here.)
328
+ const embedder = defaultEmbedder();
222
329
  let liveEmbeddings = [];
223
330
  if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
224
- const embedder = defaultEmbedder();
225
331
  liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
226
332
  }
227
333
  const toInject = [];
228
- const parts = [];
229
334
  let blockTokens = 0;
335
+ // F2: when cross-repo is on but no global index dir could be resolved, skip
336
+ // foreign hits rather than injecting them undeduped — otherwise a foreign
337
+ // checkpoint with no machine-wide injected-set to consult would re-inject in
338
+ // every new session. Same-repo hits (no repoId) are unaffected. Warn once so
339
+ // the silent degradation is observable. (The extension resolver normally
340
+ // supplies a default globalIndexDir, so this is belt-and-braces.)
341
+ const skipCrossRepoHits = !!opts.crossRepo && !opts.globalIndexDir;
342
+ if (skipCrossRepoHits) {
343
+ try {
344
+ console.warn("[mega-compact:recall] cross-repo recall enabled but globalIndexDir is unset — " +
345
+ "skipping cross-repo injection to avoid re-injecting undeduped foreign checkpoints");
346
+ }
347
+ catch {
348
+ /* ignore */
349
+ }
350
+ }
230
351
  for (const h of hits) {
231
- if (skip && vectorWasInjected(store, opts.sessionId, h.checkpoint.checkpointId))
352
+ // F2: skip foreign hits when we can't dedup them machine-wide.
353
+ if (skipCrossRepoHits && h.repoId)
354
+ continue;
355
+ if (skip &&
356
+ vectorWasInjected(store, opts.sessionId, h.checkpoint.checkpointId))
232
357
  continue;
233
358
  // S18: machine-wide injected-set — a foreign checkpoint already injected
234
359
  // (in any session) is never re-injected. Only applies to cross-repo hits
@@ -243,16 +368,17 @@ export async function recallAndInlineAsync(opts, store) {
243
368
  /* non-fatal: degrade to per-session injected-set only */
244
369
  }
245
370
  }
371
+ // Inline dedupe: skip a hit already resident in the live window (F1: hoisted embedder).
246
372
  if (doWindowDedupe && liveEmbeddings.length > 0) {
247
- const hitVec = defaultEmbedder().embed(h.checkpoint.summary);
373
+ const hitVec = embedder.embed(h.checkpoint.summary);
248
374
  if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim))
249
375
  continue;
250
376
  }
251
- const part = formatRecallBlock([h]);
252
- const partTokens = estimateBlockTokens(part);
377
+ // F3: build the hit list first; format ONCE at the end so the block carries
378
+ // exactly one preamble and numbering [1..n] rather than one per hit.
379
+ const partTokens = estimateBlockTokens(h.checkpoint.summary);
253
380
  if (maxTokens > 0 && blockTokens + partTokens > maxTokens)
254
381
  break;
255
- parts.push(part);
256
382
  toInject.push(h);
257
383
  blockTokens += partTokens;
258
384
  vectorMarkInjected(store, opts.sessionId, h.checkpoint.checkpointId);
@@ -268,7 +394,10 @@ export async function recallAndInlineAsync(opts, store) {
268
394
  }
269
395
  }
270
396
  }
271
- const block = parts.join("\n");
397
+ // F3: format once — one preamble, correct [1..n] numbering, token cap counted
398
+ // against one preamble (not N). Pass the full toInject array so formatRecallBlock
399
+ // has repoId + score for proper labeling.
400
+ const block = toInject.length > 0 ? formatRecallBlock(toInject) : "";
272
401
  const report = toInject.map((h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`);
273
402
  return { toInject, report, block, empty: toInject.length === 0 };
274
403
  }
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import { VectorStore } from "./vectorStore.js";
7
7
  import { compactSession } from "./engine.js";
8
8
  import { recallAndInline, recallAndInlineAsync, formatRecallBlock } from "./recall.js";
9
- import { vectorList } from "./vectorStore.js";
9
+ import { vectorList, vectorWasInjected } from "./vectorStore.js";
10
10
  import { markInjectedGlobal, wasInjectedGlobal, closeIndexStore } from "./store/sqlite.js";
11
11
  import { closeVectorIndex, initVectorIndex, rebuildFromSqlite, } from "./store/vectorIndex.js";
12
12
  const baseTmp = mkdtempSync(join(tmpdir(), "mc-recall-"));
@@ -72,9 +72,11 @@ test("Fix C: recallMaxTokens caps the injected block", () => {
72
72
  compactSession({ sessionId: SESS, messages: [msg("user", "alpha module wiring and bootstrap sequence"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
73
73
  compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
74
74
  compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
75
- // A ceiling of 100 tokens fits the first checkpoint (~82) but stops before the
76
- // second (~163 cumulative) — proving the cap bites mid-stream.
77
- const r = recallAndInline({ sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", recallMaxTokens: 100, skipInjected: false }, s);
75
+ // A ceiling of 50 tokens fits the first checkpoint (~33 body tokens) but
76
+ // stops before the second (~65 cumulative) — proving the cap bites mid-stream.
77
+ // (F3: the cap now counts body tokens only one preamble at format time, not
78
+ // N — matching the async path. The old per-hit preamble counting needed ~100.)
79
+ const r = recallAndInline({ sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", recallMaxTokens: 50, skipInjected: false }, s);
78
80
  assert.ok(r.toInject.length >= 1, "at least one injected under the cap");
79
81
  assert.ok(r.toInject.length < 3, "cap prevented all three from injecting");
80
82
  assert.ok(r.block.length > 0, "block non-empty");
@@ -199,6 +201,179 @@ test("S18: a fresh foreign checkpoint is injected AND recorded globally", async
199
201
  rmSync(selfStateDir, { recursive: true, force: true });
200
202
  }
201
203
  });
204
+ // ---- F3: format-once — one preamble, [1..n] numbering, cap respected ----
205
+ //
206
+ // The fix (landed async, extended to sync here) accumulates the hit list and
207
+ // calls formatRecallBlock ONCE at the end, so a multi-hit block carries exactly
208
+ // one preamble and contiguous [1..n] labels instead of one preamble per hit.
209
+ test("F3 (sync): multi-hit injection has exactly one preamble and [1..n] numbering", () => {
210
+ const s = store();
211
+ compactSession({ sessionId: SESS, messages: [msg("user", "alpha module wiring and bootstrap sequence"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
212
+ compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
213
+ compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
214
+ const r = recallAndInline({ sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", skipInjected: false }, s);
215
+ assert.ok(r.toInject.length >= 2, "at least two hits injected (got " + r.toInject.length + ")");
216
+ // Exactly one preamble (the old per-hit format produced one per hit).
217
+ const preamble = "The following compacted context was recalled";
218
+ assert.equal(r.block.split(preamble).length - 1, 1, "exactly one preamble for a multi-hit block");
219
+ // Contiguous [1..n] numbering.
220
+ for (let i = 1; i <= r.toInject.length; i++) {
221
+ assert.ok(r.block.includes(`[${i}]`), `block includes [${i}]`);
222
+ }
223
+ // No out-of-range label (e.g. [n+1]) leaks in.
224
+ assert.ok(!r.block.includes(`[${r.toInject.length + 1}]`), "no extra-numbered label");
225
+ });
226
+ test("F3 (sync): recallMaxTokens caps mid-stream with exactly one preamble", () => {
227
+ const s = store();
228
+ compactSession({ sessionId: SESS, messages: [msg("user", "alpha module wiring and bootstrap sequence"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
229
+ compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
230
+ compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
231
+ // A tight ceiling that fits the first checkpoint (~33 body tokens) but stops
232
+ // before the second (~65 cumulative). F3: body-only counting (one preamble at
233
+ // format time, not per hit), matching the async path.
234
+ const r = recallAndInline({ sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", skipInjected: false, recallMaxTokens: 50 }, s);
235
+ assert.ok(r.toInject.length < 3, "cap stopped before all three injected");
236
+ assert.ok(r.toInject.length >= 1, "at least one injected under the cap");
237
+ assert.equal(r.block.split("The following compacted context was recalled").length - 1, 1, "still exactly one preamble under the cap");
238
+ });
239
+ test("F3 (async): multi-hit cross-repo injection has exactly one preamble and [1..n] numbering", async () => {
240
+ if (process.env.MEGACOMPACT_PGLITE_DISABLED === "true") {
241
+ return;
242
+ } // skip when WASM index is off
243
+ const indexDir = mkdtempSync(join(tmpdir(), "mc-f3a-"));
244
+ const foreignStateDir = mkdtempSync(join(tmpdir(), "mc-f3a-foreign-"));
245
+ const selfStateDir = mkdtempSync(join(tmpdir(), "mc-f3a-self-"));
246
+ process.env.MEGACOMPACT_VECTOR_INDEX_DIR = mkdtempSync(join(tmpdir(), "mc-f3a-vidx-"));
247
+ try {
248
+ await closeVectorIndex();
249
+ // Seed multiple distinct foreign checkpoints so the cross-repo query yields
250
+ // more than one hit (needed to assert [1..n] numbering).
251
+ const foreign = new VectorStore({ stateDir: foreignStateDir, dedupSim: 0.9 });
252
+ const fsess = "sess_foreign_f3";
253
+ const summaries = [
254
+ "foreign repo authentication jwt token validation",
255
+ "foreign repo database connection pooling and retry",
256
+ "foreign repo logging telemetry and tracing spans",
257
+ ];
258
+ const cids = [];
259
+ for (let i = 0; i < summaries.length; i++) {
260
+ const res = compactSession({ sessionId: fsess, messages: [msg("user", summaries[i]), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: i + 1 }, foreign);
261
+ if (res.checkpointId)
262
+ cids.push(res.checkpointId);
263
+ }
264
+ await rebuildFromSqlite(() => [{ repoId: foreignStateDir, stateDir: foreignStateDir }], (sd) => {
265
+ const st = new VectorStore({ stateDir: sd, dedupSim: 0.9 });
266
+ return vectorList(st, fsess).map((cp) => ({
267
+ sessionId: fsess, checkpointId: cp.checkpointId, embedding: cp.embedding,
268
+ }));
269
+ });
270
+ const pg = await initVectorIndex();
271
+ assert.ok(pg, "PGlite index should initialize");
272
+ const selfStore = new VectorStore({ stateDir: selfStateDir, dedupSim: 0.9 });
273
+ const r = await recallAndInlineAsync({ sessionId: "sess_f3", query: "foreign repo", limit: 5, source: "command", crossRepo: true, skipInjected: false, globalIndexDir: indexDir }, selfStore);
274
+ assert.ok(r.toInject.length >= 2, "at least two cross-repo hits (got " + r.toInject.length + ")");
275
+ assert.equal(r.block.split("The following compacted context was recalled").length - 1, 1, "exactly one preamble for a multi-hit async block");
276
+ for (let i = 1; i <= r.toInject.length; i++) {
277
+ assert.ok(r.block.includes(`[${i}]`), `async block includes [${i}]`);
278
+ }
279
+ assert.ok(!r.block.includes(`[${r.toInject.length + 1}]`), "no extra-numbered label (async)");
280
+ }
281
+ finally {
282
+ await closeVectorIndex();
283
+ closeIndexStore();
284
+ delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
285
+ rmSync(indexDir, { recursive: true, force: true });
286
+ rmSync(foreignStateDir, { recursive: true, force: true });
287
+ rmSync(selfStateDir, { recursive: true, force: true });
288
+ }
289
+ });
290
+ // ---- F2: cross-repo injected-set bypass ----------------------------------
291
+ //
292
+ // When the same session resumes in a DIFFERENT repo, the per-repo session_state
293
+ // has no injection marker for the foreign checkpoint (different stateDir), so
294
+ // only the machine-wide injected-set (shared globalIndexDir) can block a
295
+ // re-inject. The F2 fix ensures globalIndexDir is always resolved (via
296
+ // getIndexDir in the extension), so wasInjectedGlobal is consulted; without
297
+ // the fix, globalIndexDir was undefined and foreign checkpoints re-injected
298
+ // every resume. This test passes globalIndexDir explicitly (simulating the
299
+ // resolver default) and does NOT set MEGACOMPACT_INDEX_DIR.
300
+ test("F2: cross-repo hit is not re-injected when resuming the same session in a different repo (shared index dir)", async () => {
301
+ if (process.env.MEGACOMPACT_PGLITE_DISABLED === "true") {
302
+ return;
303
+ } // skip when WASM index is off
304
+ const indexDir = mkdtempSync(join(tmpdir(), "mc-f2-"));
305
+ const foreignStateDir = mkdtempSync(join(tmpdir(), "mc-f2-foreign-"));
306
+ const selfStateDir1 = mkdtempSync(join(tmpdir(), "mc-f2-self1-"));
307
+ const selfStateDir2 = mkdtempSync(join(tmpdir(), "mc-f2-self2-"));
308
+ process.env.MEGACOMPACT_VECTOR_INDEX_DIR = mkdtempSync(join(tmpdir(), "mc-f2-vidx-"));
309
+ try {
310
+ await closeVectorIndex();
311
+ const seed = await seedForeignRepo(foreignStateDir);
312
+ await rebuildFromSqlite(() => [{ repoId: foreignStateDir, stateDir: foreignStateDir }], (sd) => {
313
+ const st = new VectorStore({ stateDir: sd, dedupSim: 0.9 });
314
+ return vectorList(st, seed.sessionId).map((cp) => ({
315
+ sessionId: seed.sessionId, checkpointId: cp.checkpointId, embedding: cp.embedding,
316
+ }));
317
+ });
318
+ const pg = await initVectorIndex();
319
+ assert.ok(pg, "PGlite index should initialize");
320
+ const sess = "sess_resume";
321
+ // Repo B: first recall — fresh foreign checkpoint is injected AND recorded
322
+ // in the shared machine-wide index.
323
+ const storeB = new VectorStore({ stateDir: selfStateDir1, dedupSim: 0.9 });
324
+ const r1 = await recallAndInlineAsync({ sessionId: sess, query: "foreign repo authentication jwt", limit: 3, source: "command", crossRepo: true, globalIndexDir: indexDir }, storeB);
325
+ assert.equal(r1.toInject.length, 1, "first recall (repo B) injects the foreign checkpoint");
326
+ assert.equal(wasInjectedGlobal(seed.checkpointId, sess, indexDir), true, "recorded in the shared index");
327
+ // Repo C: SAME session resumed in a DIFFERENT repo. The per-session injected
328
+ // set in repo C's store is empty (different stateDir), so only the shared
329
+ // global injected-set can block re-injection.
330
+ const storeC = new VectorStore({ stateDir: selfStateDir2, dedupSim: 0.9 });
331
+ assert.equal(vectorWasInjected(storeC, sess, seed.checkpointId), false, "repo C per-session set is empty (different stateDir)");
332
+ const r2 = await recallAndInlineAsync({ sessionId: sess, query: "foreign repo authentication jwt", limit: 3, source: "command", crossRepo: true, globalIndexDir: indexDir }, storeC);
333
+ assert.equal(r2.toInject.length, 0, "second recall (repo C) does NOT re-inject (machine-wide global dedup)");
334
+ }
335
+ finally {
336
+ await closeVectorIndex();
337
+ closeIndexStore();
338
+ delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
339
+ rmSync(indexDir, { recursive: true, force: true });
340
+ rmSync(foreignStateDir, { recursive: true, force: true });
341
+ rmSync(selfStateDir1, { recursive: true, force: true });
342
+ rmSync(selfStateDir2, { recursive: true, force: true });
343
+ }
344
+ });
345
+ test("F2: without globalIndexDir, cross-repo hits are skipped (not injected undeduped)", async () => {
346
+ if (process.env.MEGACOMPACT_PGLITE_DISABLED === "true") {
347
+ return;
348
+ } // skip when WASM index is off
349
+ const foreignStateDir = mkdtempSync(join(tmpdir(), "mc-f2b-foreign-"));
350
+ const selfStateDir = mkdtempSync(join(tmpdir(), "mc-f2b-self-"));
351
+ process.env.MEGACOMPACT_VECTOR_INDEX_DIR = mkdtempSync(join(tmpdir(), "mc-f2b-vidx-"));
352
+ try {
353
+ await closeVectorIndex();
354
+ const seed = await seedForeignRepo(foreignStateDir);
355
+ await rebuildFromSqlite(() => [{ repoId: foreignStateDir, stateDir: foreignStateDir }], (sd) => {
356
+ const st = new VectorStore({ stateDir: sd, dedupSim: 0.9 });
357
+ return vectorList(st, seed.sessionId).map((cp) => ({
358
+ sessionId: seed.sessionId, checkpointId: cp.checkpointId, embedding: cp.embedding,
359
+ }));
360
+ });
361
+ const pg = await initVectorIndex();
362
+ assert.ok(pg, "PGlite index should initialize");
363
+ // No globalIndexDir → the F2 guard skips foreign hits rather than injecting
364
+ // them undeduped. Same-repo hits (none here) would still pass.
365
+ const selfStore = new VectorStore({ stateDir: selfStateDir, dedupSim: 0.9 });
366
+ const r = await recallAndInlineAsync({ sessionId: "sess_nodir", query: "foreign repo authentication jwt", limit: 3, source: "command", crossRepo: true }, selfStore);
367
+ assert.equal(r.toInject.length, 0, "foreign hit skipped when globalIndexDir is unset (no undeduped injection)");
368
+ }
369
+ finally {
370
+ await closeVectorIndex();
371
+ closeIndexStore();
372
+ delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
373
+ rmSync(foreignStateDir, { recursive: true, force: true });
374
+ rmSync(selfStateDir, { recursive: true, force: true });
375
+ }
376
+ });
202
377
  test("cleanup", () => {
203
378
  rmSync(baseTmp, { recursive: true, force: true });
204
379
  });
@@ -1,29 +1,38 @@
1
1
  /**
2
2
  * Upsert a row into dedup_mirror. If the hash already exists, increment ref_count.
3
3
  * Returns true if this was a NEW unique content (first insert), false if it was a duplicate.
4
+ *
5
+ * F3 fix: uses INSERT ... ON CONFLICT DO UPDATE (single atomic statement) instead of
6
+ * a check-then-act race-prone SELECT + UPDATE/INSERT sequence.
4
7
  */
5
8
  export function upsertDedupMirror(db, contentHash, contentBytes, seq) {
6
9
  const now = Date.now();
7
- const existing = db
8
- .prepare(`SELECT content_hash FROM dedup_mirror WHERE content_hash = @hash`)
9
- .get({ "@hash": contentHash });
10
- if (existing) {
11
- db.prepare(`UPDATE dedup_mirror SET ref_count = ref_count + 1 WHERE content_hash = @hash`).run({
12
- "@hash": contentHash,
13
- });
14
- return false;
15
- }
16
- db.prepare(`INSERT INTO dedup_mirror (content_hash, content_bytes, ref_count, first_seen_seq, created_at)
17
- VALUES (@hash, @bytes, 1, @seq, @now)`).run({
10
+ // Atomic upsert: on conflict, increment ref_count in-place (no check-then-act
11
+ // race). RETURNING ref_count distinguishes the two paths in one statement:
12
+ // inserted rows report ref_count=1, conflict-updated rows report ref_count>1.
13
+ const row = db.prepare(`INSERT INTO dedup_mirror (content_hash, content_bytes, ref_count, first_seen_seq, created_at)
14
+ VALUES (@hash, @bytes, 1, @seq, @now)
15
+ ON CONFLICT(content_hash) DO UPDATE SET
16
+ ref_count = ref_count + 1,
17
+ content_bytes = excluded.content_bytes
18
+ RETURNING ref_count`).get({
18
19
  "@hash": contentHash,
19
20
  "@bytes": contentBytes,
20
21
  "@seq": seq,
21
22
  "@now": now,
22
23
  });
23
- return true;
24
+ return (row?.ref_count ?? 1) === 1;
24
25
  }
25
26
  /**
26
27
  * Get dedup ratio for a session: total bytes vs unique bytes.
28
+ *
29
+ * F2 fix: both total and unique bytes are now scoped to the session, via a JOIN
30
+ * of raw_transcript.content_ref → dedup_mirror. The ratio is meaningful: how much
31
+ * smaller the session's storage footprint is compared to naive inline storage.
32
+ *
33
+ * NOTE: for sessions with NO dedup pipeline runs yet (all content_ref NULL),
34
+ * uniqueBytes falls back to the raw_transcript bytes (ratio=1), which is correct
35
+ * since nothing has been deduplicated yet.
27
36
  */
28
37
  export function getDedupRatio(db, sessionId) {
29
38
  const totalRow = db
@@ -31,10 +40,18 @@ export function getDedupRatio(db, sessionId) {
31
40
  FROM raw_transcript
32
41
  WHERE session_id = @session_id`)
33
42
  .get({ "@session_id": sessionId });
43
+ // F2 fix: session-scoped unique bytes via JOIN on content_ref.
44
+ // A row contributes its dedup_mirror bytes exactly once even when content_ref
45
+ // is NULL (fallback: use the raw_transcript bytes for that row, which is
46
+ // accurate when dedup hasn't run yet for the session).
34
47
  const uniqueRow = db
35
- .prepare(`SELECT COALESCE(SUM(LENGTH(content_bytes)), 0) AS unique_bytes
36
- FROM dedup_mirror`)
37
- .get();
48
+ .prepare(`SELECT COALESCE(SUM(LENGTH(
49
+ COALESCE(dm.content_bytes, rt.content_bytes)
50
+ )), 0) AS unique_bytes
51
+ FROM raw_transcript rt
52
+ LEFT JOIN dedup_mirror dm ON rt.content_ref = dm.content_hash
53
+ WHERE rt.session_id = @session_id`)
54
+ .get({ "@session_id": sessionId });
38
55
  const totalBytes = totalRow.total;
39
56
  const uniqueBytes = uniqueRow.unique_bytes;
40
57
  const ratio = uniqueBytes > 0 ? totalBytes / uniqueBytes : 1;
@@ -15,7 +15,7 @@ const DB_TABLE_NAMES = [
15
15
  "checkpoint_epochs",
16
16
  "dedup_mirror",
17
17
  "memories",
18
- "dedup_stats",
18
+ "meta",
19
19
  "daily_log",
20
20
  ];
21
21
  function fileSizeIfExists(path) {
@@ -102,7 +102,7 @@ export function pruneOldRows(stateDir = getStateDir(), daysOld = 30) {
102
102
  // dedup_mirror: cascade-delete orphan rows whose ref_count has dropped to 0
103
103
  // after the raw_transcript deletes. Safe even if FK is off (raw_transcript has
104
104
  // no FK to dedup_mirror; ref_count is maintained by the dedup pipeline).
105
- const delDedup = db.prepare(`DELETE FROM dedup_mirror WHERE ref_count <= 0`).run();
105
+ const delDedup = db.prepare(`DELETE FROM dedup_mirror WHERE content_hash NOT IN (SELECT DISTINCT content_ref FROM raw_transcript WHERE content_ref IS NOT NULL)`).run();
106
106
  const dedupDeleted = delDedup?.changes ?? 0;
107
107
  const afterBytes = fileSizeIfExists(join(stateDir, "sqlite.db"));
108
108
  const total = rtDeleted + epDeleted + dedupDeleted;