pi-mega-compact 0.8.23 → 0.8.25

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 (157) hide show
  1. package/README.md +26 -0
  2. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +8 -0
  3. package/dist/extensions/dashboard-server/api-contracts/game-types.js +7 -0
  4. package/dist/extensions/mega-compact-s38.test.js +263 -14
  5. package/dist/extensions/mega-compact.js +15 -0
  6. package/dist/extensions/mega-config.js +3 -0
  7. package/dist/extensions/mega-events/agent-handlers.js +211 -26
  8. package/dist/extensions/mega-events/context-handler.js +45 -7
  9. package/dist/extensions/mega-events/error-classifier.js +125 -18
  10. package/dist/extensions/mega-pipeline/compact.js +24 -13
  11. package/dist/extensions/mega-pipeline/recall.js +31 -2
  12. package/dist/extensions/mega-runtime/append-event.js +24 -0
  13. package/dist/extensions/mega-runtime/bind-repo.js +65 -0
  14. package/dist/extensions/mega-runtime/capture-model.js +87 -0
  15. package/dist/extensions/mega-runtime/dashboard-snapshot.js +122 -0
  16. package/dist/extensions/mega-runtime/effects.js +86 -0
  17. package/dist/extensions/mega-runtime/engine-view.js +11 -0
  18. package/dist/extensions/mega-runtime/game-state.js +116 -0
  19. package/dist/extensions/mega-runtime/get-state-dir.js +10 -0
  20. package/dist/extensions/mega-runtime/perf.js +49 -0
  21. package/dist/extensions/mega-runtime/pressure-getters.js +64 -0
  22. package/dist/extensions/mega-runtime/render-widget.js +17 -0
  23. package/dist/extensions/mega-runtime/reset-runtime.js +50 -0
  24. package/dist/extensions/mega-runtime/runtime-helpers.js +73 -0
  25. package/dist/extensions/mega-runtime/runtime-snapshot.js +208 -0
  26. package/dist/extensions/mega-runtime/runtime.js +405 -0
  27. package/dist/extensions/mega-runtime/snapshot.js +142 -0
  28. package/dist/extensions/mega-runtime/state.js +5 -1151
  29. package/dist/extensions/mega-runtime/status.js +11 -0
  30. package/dist/extensions/mega-runtime/widget-ansi.js +207 -0
  31. package/dist/extensions/mega-runtime/widget-types.js +8 -0
  32. package/dist/extensions/mega-runtime/widget.js +15 -204
  33. package/dist/extensions/openclaw-mega-compact.js +291 -0
  34. package/dist/src/boundary.js +79 -43
  35. package/dist/src/boundary.test.js +119 -2
  36. package/dist/src/canary.js +10 -0
  37. package/dist/src/config/dedup.js +14 -0
  38. package/dist/src/config.js +3 -1
  39. package/dist/src/dedup/raptor/buildHistory.js +164 -0
  40. package/dist/src/dedup/raptor/buildHistory.test.js +292 -0
  41. package/dist/src/dedup/raptor/index.js +38 -0
  42. package/dist/src/dedup/raptor/multilevel-serve.test.js +229 -0
  43. package/dist/src/dedup/raptor/multilevel.js +17 -5
  44. package/dist/src/dedup/raptor/multilevel.test.js +36 -1
  45. package/dist/src/dedup/raptor/raptor.test.js +43 -0
  46. package/dist/src/dedup/raptor/retrieval.js +14 -2
  47. package/dist/src/dedup/raptor/retrieval.test.js +95 -0
  48. package/dist/src/dedup/raptor/serve-gate.test.js +298 -0
  49. package/dist/src/dedup/raptor/summarizer.js +1 -0
  50. package/dist/src/dedup/raptor/tree.js +16 -2
  51. package/dist/src/engine.js +18 -2
  52. package/dist/src/httpEmbedder.js +96 -6
  53. package/dist/src/httpEmbedder.test.js +277 -0
  54. package/dist/src/mechanical-fix.test.js +65 -0
  55. package/dist/src/minilm.js +92 -0
  56. package/dist/src/raptor-inject-summaries.test.js +155 -0
  57. package/dist/src/recall.js +135 -21
  58. package/dist/src/recall.test.js +179 -4
  59. package/dist/src/store/sqlite/dedup-mirror.js +32 -15
  60. package/dist/src/store/sqlite/maintenance.js +2 -2
  61. package/dist/src/store/sqlite/mechanical-fix.test.js +146 -0
  62. package/dist/src/store/sqlite/memories.js +5 -5
  63. package/dist/src/store/sqlite/meta.js +1 -1
  64. package/dist/src/store/sqlite/raptor.js +56 -17
  65. package/dist/src/store/sqlite/raptor.test.js +106 -0
  66. package/dist/src/store/sqlite/schema.js +90 -1
  67. package/dist/src/store/sqlite/session-state.js +9 -3
  68. package/dist/src/store/sqlite/stats.js +9 -5
  69. package/dist/src/store/sqlite/turns.js +181 -0
  70. package/dist/src/store/sqlite/turns.test.js +183 -0
  71. package/dist/src/store/sqlite/utils.js +15 -4
  72. package/dist/src/store/sqlite.js +1 -0
  73. package/dist/src/store.js +2 -2
  74. package/dist/src/vector-search-cache.test.js +157 -0
  75. package/dist/src/vector-search.js +107 -15
  76. package/dist/src/vectorStore.js +36 -8
  77. package/dist/src/wordpiece.js +129 -0
  78. package/extensions/dashboard-client/dist/assets/index-D_WtU2TV.js.map +1 -1
  79. package/extensions/dashboard-server/api-contracts/endpoints.ts +30 -155
  80. package/extensions/dashboard-server/api-contracts/game-types.ts +172 -0
  81. package/extensions/mega-compact-s38.test.ts +259 -14
  82. package/extensions/mega-compact.ts +15 -0
  83. package/extensions/mega-config.ts +18 -0
  84. package/extensions/mega-dashboard.ts +10 -1
  85. package/extensions/mega-events/agent-handlers.ts +211 -26
  86. package/extensions/mega-events/context-handler.ts +43 -7
  87. package/extensions/mega-events/error-classifier.ts +125 -17
  88. package/extensions/mega-pipeline/compact.ts +28 -16
  89. package/extensions/mega-pipeline/recall.ts +34 -2
  90. package/extensions/mega-runtime/DECOMPOSITION.md +180 -0
  91. package/extensions/mega-runtime/README.md +38 -0
  92. package/extensions/mega-runtime/append-event.ts +40 -0
  93. package/extensions/mega-runtime/bind-repo.ts +81 -0
  94. package/extensions/mega-runtime/capture-model.ts +101 -0
  95. package/extensions/mega-runtime/dashboard-snapshot.ts +181 -0
  96. package/extensions/mega-runtime/effects.ts +129 -0
  97. package/extensions/mega-runtime/engine-view.ts +17 -0
  98. package/extensions/mega-runtime/game-state.ts +149 -0
  99. package/extensions/mega-runtime/get-state-dir.ts +19 -0
  100. package/extensions/mega-runtime/helpers.ts +25 -1
  101. package/extensions/mega-runtime/perf.ts +60 -0
  102. package/extensions/mega-runtime/pressure-getters.ts +96 -0
  103. package/extensions/mega-runtime/render-widget.ts +41 -0
  104. package/extensions/mega-runtime/runtime-helpers.ts +119 -0
  105. package/extensions/mega-runtime/runtime-snapshot.ts +293 -0
  106. package/extensions/mega-runtime/runtime.ts +483 -0
  107. package/extensions/mega-runtime/snapshot.ts +230 -0
  108. package/extensions/mega-runtime/state.ts +5 -1268
  109. package/extensions/mega-runtime/status.ts +26 -0
  110. package/extensions/mega-runtime/widget-ansi.ts +217 -0
  111. package/extensions/mega-runtime/widget-types.ts +80 -0
  112. package/extensions/mega-runtime/widget.ts +34 -285
  113. package/package.json +1 -1
  114. package/src/boundary.test.ts +128 -2
  115. package/src/boundary.ts +75 -39
  116. package/src/canary.ts +10 -0
  117. package/src/config/dedup.ts +25 -0
  118. package/src/config.ts +3 -1
  119. package/src/dedup/raptor/buildHistory.test.ts +353 -0
  120. package/src/dedup/raptor/buildHistory.ts +259 -0
  121. package/src/dedup/raptor/index.ts +38 -0
  122. package/src/dedup/raptor/multilevel-serve.test.ts +273 -0
  123. package/src/dedup/raptor/multilevel.test.ts +47 -0
  124. package/src/dedup/raptor/multilevel.ts +18 -8
  125. package/src/dedup/raptor/raptor.test.ts +59 -0
  126. package/src/dedup/raptor/retrieval.test.ts +118 -0
  127. package/src/dedup/raptor/retrieval.ts +14 -2
  128. package/src/dedup/raptor/serve-gate.test.ts +348 -0
  129. package/src/dedup/raptor/summarizer.ts +1 -0
  130. package/src/dedup/raptor/tree.ts +17 -2
  131. package/src/engine.ts +32 -3
  132. package/src/httpEmbedder.test.ts +286 -0
  133. package/src/httpEmbedder.ts +98 -8
  134. package/src/mechanical-fix.test.ts +70 -0
  135. package/src/raptor-inject-summaries.test.ts +212 -0
  136. package/src/recall.test.ts +220 -4
  137. package/src/recall.ts +151 -22
  138. package/src/store/sqlite/dedup-mirror.ts +35 -18
  139. package/src/store/sqlite/maintenance.ts +2 -2
  140. package/src/store/sqlite/mechanical-fix.test.ts +162 -0
  141. package/src/store/sqlite/memories.ts +5 -5
  142. package/src/store/sqlite/meta.ts +1 -1
  143. package/src/store/sqlite/raptor.test.ts +139 -0
  144. package/src/store/sqlite/raptor.ts +135 -81
  145. package/src/store/sqlite/schema.ts +90 -1
  146. package/src/store/sqlite/session-state.ts +9 -3
  147. package/src/store/sqlite/stats.ts +10 -8
  148. package/src/store/sqlite/turns.test.ts +218 -0
  149. package/src/store/sqlite/turns.ts +292 -0
  150. package/src/store/sqlite/utils.ts +14 -4
  151. package/src/store/sqlite.ts +1 -0
  152. package/src/store.ts +9 -2
  153. package/src/vector-search-cache.test.ts +190 -0
  154. package/src/vector-search.ts +273 -156
  155. package/src/vectorStore.ts +443 -382
  156. package/dist/extensions/dashboard-client/src/hooks/useApi.js +0 -51
  157. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +0 -63
@@ -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 {
12
12
  closeVectorIndex,
@@ -88,10 +88,12 @@ test("Fix C: recallMaxTokens caps the injected block", () => {
88
88
  compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
89
89
  compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
90
90
 
91
- // A ceiling of 100 tokens fits the first checkpoint (~82) but stops before the
92
- // second (~163 cumulative) — proving the cap bites mid-stream.
91
+ // A ceiling of 50 tokens fits the first checkpoint (~33 body tokens) but
92
+ // stops before the second (~65 cumulative) — proving the cap bites mid-stream.
93
+ // (F3: the cap now counts body tokens only — one preamble at format time, not
94
+ // N — matching the async path. The old per-hit preamble counting needed ~100.)
93
95
  const r = recallAndInline(
94
- { sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", recallMaxTokens: 100, skipInjected: false },
96
+ { sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", recallMaxTokens: 50, skipInjected: false },
95
97
  s as any,
96
98
  );
97
99
  assert.ok(r.toInject.length >= 1, "at least one injected under the cap");
@@ -246,6 +248,220 @@ test("S18: a fresh foreign checkpoint is injected AND recorded globally", async
246
248
  }
247
249
  });
248
250
 
251
+ // ---- F3: format-once — one preamble, [1..n] numbering, cap respected ----
252
+ //
253
+ // The fix (landed async, extended to sync here) accumulates the hit list and
254
+ // calls formatRecallBlock ONCE at the end, so a multi-hit block carries exactly
255
+ // one preamble and contiguous [1..n] labels instead of one preamble per hit.
256
+
257
+ test("F3 (sync): multi-hit injection has exactly one preamble and [1..n] numbering", () => {
258
+ const s = store();
259
+ compactSession({ sessionId: SESS, messages: [msg("user", "alpha module wiring and bootstrap sequence"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
260
+ compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
261
+ compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
262
+
263
+ const r = recallAndInline(
264
+ { sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", skipInjected: false },
265
+ s as any,
266
+ );
267
+ assert.ok(r.toInject.length >= 2, "at least two hits injected (got " + r.toInject.length + ")");
268
+ // Exactly one preamble (the old per-hit format produced one per hit).
269
+ const preamble = "The following compacted context was recalled";
270
+ assert.equal(r.block.split(preamble).length - 1, 1, "exactly one preamble for a multi-hit block");
271
+ // Contiguous [1..n] numbering.
272
+ for (let i = 1; i <= r.toInject.length; i++) {
273
+ assert.ok(r.block.includes(`[${i}]`), `block includes [${i}]`);
274
+ }
275
+ // No out-of-range label (e.g. [n+1]) leaks in.
276
+ assert.ok(!r.block.includes(`[${r.toInject.length + 1}]`), "no extra-numbered label");
277
+ });
278
+
279
+ test("F3 (sync): recallMaxTokens caps mid-stream with exactly one preamble", () => {
280
+ const s = store();
281
+ compactSession({ sessionId: SESS, messages: [msg("user", "alpha module wiring and bootstrap sequence"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
282
+ compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
283
+ compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
284
+
285
+ // A tight ceiling that fits the first checkpoint (~33 body tokens) but stops
286
+ // before the second (~65 cumulative). F3: body-only counting (one preamble at
287
+ // format time, not per hit), matching the async path.
288
+ const r = recallAndInline(
289
+ { sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", skipInjected: false, recallMaxTokens: 50 },
290
+ s as any,
291
+ );
292
+ assert.ok(r.toInject.length < 3, "cap stopped before all three injected");
293
+ assert.ok(r.toInject.length >= 1, "at least one injected under the cap");
294
+ assert.equal(
295
+ r.block.split("The following compacted context was recalled").length - 1,
296
+ 1,
297
+ "still exactly one preamble under the cap",
298
+ );
299
+ });
300
+
301
+ test("F3 (async): multi-hit cross-repo injection has exactly one preamble and [1..n] numbering", async () => {
302
+ if (process.env.MEGACOMPACT_PGLITE_DISABLED === "true") { return; } // skip when WASM index is off
303
+ const indexDir = mkdtempSync(join(tmpdir(), "mc-f3a-"));
304
+ const foreignStateDir = mkdtempSync(join(tmpdir(), "mc-f3a-foreign-"));
305
+ const selfStateDir = mkdtempSync(join(tmpdir(), "mc-f3a-self-"));
306
+ process.env.MEGACOMPACT_VECTOR_INDEX_DIR = mkdtempSync(join(tmpdir(), "mc-f3a-vidx-"));
307
+ try {
308
+ await closeVectorIndex();
309
+ // Seed multiple distinct foreign checkpoints so the cross-repo query yields
310
+ // more than one hit (needed to assert [1..n] numbering).
311
+ const foreign = new VectorStore({ stateDir: foreignStateDir, dedupSim: 0.9 });
312
+ const fsess = "sess_foreign_f3";
313
+ const summaries = [
314
+ "foreign repo authentication jwt token validation",
315
+ "foreign repo database connection pooling and retry",
316
+ "foreign repo logging telemetry and tracing spans",
317
+ ];
318
+ const cids: string[] = [];
319
+ for (let i = 0; i < summaries.length; i++) {
320
+ const res = compactSession(
321
+ { sessionId: fsess, messages: [msg("user", summaries[i]), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: i + 1 },
322
+ foreign,
323
+ );
324
+ if (res.checkpointId) cids.push(res.checkpointId);
325
+ }
326
+ await rebuildFromSqlite(
327
+ () => [{ repoId: foreignStateDir, stateDir: foreignStateDir }],
328
+ (sd) => {
329
+ const st = new VectorStore({ stateDir: sd, dedupSim: 0.9 });
330
+ return vectorList(st, fsess).map((cp) => ({
331
+ sessionId: fsess, checkpointId: cp.checkpointId, embedding: cp.embedding,
332
+ }));
333
+ },
334
+ );
335
+ const pg = await initVectorIndex();
336
+ assert.ok(pg, "PGlite index should initialize");
337
+
338
+ const selfStore = new VectorStore({ stateDir: selfStateDir, dedupSim: 0.9 });
339
+ const r = await recallAndInlineAsync(
340
+ { sessionId: "sess_f3", query: "foreign repo", limit: 5, source: "command", crossRepo: true, skipInjected: false, globalIndexDir: indexDir },
341
+ selfStore,
342
+ );
343
+ assert.ok(r.toInject.length >= 2, "at least two cross-repo hits (got " + r.toInject.length + ")");
344
+ assert.equal(
345
+ r.block.split("The following compacted context was recalled").length - 1,
346
+ 1,
347
+ "exactly one preamble for a multi-hit async block",
348
+ );
349
+ for (let i = 1; i <= r.toInject.length; i++) {
350
+ assert.ok(r.block.includes(`[${i}]`), `async block includes [${i}]`);
351
+ }
352
+ assert.ok(!r.block.includes(`[${r.toInject.length + 1}]`), "no extra-numbered label (async)");
353
+ } finally {
354
+ await closeVectorIndex();
355
+ closeIndexStore();
356
+ delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
357
+ rmSync(indexDir, { recursive: true, force: true });
358
+ rmSync(foreignStateDir, { recursive: true, force: true });
359
+ rmSync(selfStateDir, { recursive: true, force: true });
360
+ }
361
+ });
362
+
363
+ // ---- F2: cross-repo injected-set bypass ----------------------------------
364
+ //
365
+ // When the same session resumes in a DIFFERENT repo, the per-repo session_state
366
+ // has no injection marker for the foreign checkpoint (different stateDir), so
367
+ // only the machine-wide injected-set (shared globalIndexDir) can block a
368
+ // re-inject. The F2 fix ensures globalIndexDir is always resolved (via
369
+ // getIndexDir in the extension), so wasInjectedGlobal is consulted; without
370
+ // the fix, globalIndexDir was undefined and foreign checkpoints re-injected
371
+ // every resume. This test passes globalIndexDir explicitly (simulating the
372
+ // resolver default) and does NOT set MEGACOMPACT_INDEX_DIR.
373
+
374
+ test("F2: cross-repo hit is not re-injected when resuming the same session in a different repo (shared index dir)", async () => {
375
+ if (process.env.MEGACOMPACT_PGLITE_DISABLED === "true") { return; } // skip when WASM index is off
376
+ const indexDir = mkdtempSync(join(tmpdir(), "mc-f2-"));
377
+ const foreignStateDir = mkdtempSync(join(tmpdir(), "mc-f2-foreign-"));
378
+ const selfStateDir1 = mkdtempSync(join(tmpdir(), "mc-f2-self1-"));
379
+ const selfStateDir2 = mkdtempSync(join(tmpdir(), "mc-f2-self2-"));
380
+ process.env.MEGACOMPACT_VECTOR_INDEX_DIR = mkdtempSync(join(tmpdir(), "mc-f2-vidx-"));
381
+ try {
382
+ await closeVectorIndex();
383
+ const seed = await seedForeignRepo(foreignStateDir);
384
+ await rebuildFromSqlite(
385
+ () => [{ repoId: foreignStateDir, stateDir: foreignStateDir }],
386
+ (sd) => {
387
+ const st = new VectorStore({ stateDir: sd, dedupSim: 0.9 });
388
+ return vectorList(st, seed.sessionId).map((cp) => ({
389
+ sessionId: seed.sessionId, checkpointId: cp.checkpointId, embedding: cp.embedding,
390
+ }));
391
+ },
392
+ );
393
+ const pg = await initVectorIndex();
394
+ assert.ok(pg, "PGlite index should initialize");
395
+
396
+ const sess = "sess_resume";
397
+ // Repo B: first recall — fresh foreign checkpoint is injected AND recorded
398
+ // in the shared machine-wide index.
399
+ const storeB = new VectorStore({ stateDir: selfStateDir1, dedupSim: 0.9 });
400
+ const r1 = await recallAndInlineAsync(
401
+ { sessionId: sess, query: "foreign repo authentication jwt", limit: 3, source: "command", crossRepo: true, globalIndexDir: indexDir },
402
+ storeB,
403
+ );
404
+ assert.equal(r1.toInject.length, 1, "first recall (repo B) injects the foreign checkpoint");
405
+ assert.equal(wasInjectedGlobal(seed.checkpointId, sess, indexDir), true, "recorded in the shared index");
406
+
407
+ // Repo C: SAME session resumed in a DIFFERENT repo. The per-session injected
408
+ // set in repo C's store is empty (different stateDir), so only the shared
409
+ // global injected-set can block re-injection.
410
+ const storeC = new VectorStore({ stateDir: selfStateDir2, dedupSim: 0.9 });
411
+ assert.equal(vectorWasInjected(storeC, sess, seed.checkpointId), false, "repo C per-session set is empty (different stateDir)");
412
+ const r2 = await recallAndInlineAsync(
413
+ { sessionId: sess, query: "foreign repo authentication jwt", limit: 3, source: "command", crossRepo: true, globalIndexDir: indexDir },
414
+ storeC,
415
+ );
416
+ assert.equal(r2.toInject.length, 0, "second recall (repo C) does NOT re-inject (machine-wide global dedup)");
417
+ } finally {
418
+ await closeVectorIndex();
419
+ closeIndexStore();
420
+ delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
421
+ rmSync(indexDir, { recursive: true, force: true });
422
+ rmSync(foreignStateDir, { recursive: true, force: true });
423
+ rmSync(selfStateDir1, { recursive: true, force: true });
424
+ rmSync(selfStateDir2, { recursive: true, force: true });
425
+ }
426
+ });
427
+
428
+ test("F2: without globalIndexDir, cross-repo hits are skipped (not injected undeduped)", async () => {
429
+ if (process.env.MEGACOMPACT_PGLITE_DISABLED === "true") { return; } // skip when WASM index is off
430
+ const foreignStateDir = mkdtempSync(join(tmpdir(), "mc-f2b-foreign-"));
431
+ const selfStateDir = mkdtempSync(join(tmpdir(), "mc-f2b-self-"));
432
+ process.env.MEGACOMPACT_VECTOR_INDEX_DIR = mkdtempSync(join(tmpdir(), "mc-f2b-vidx-"));
433
+ try {
434
+ await closeVectorIndex();
435
+ const seed = await seedForeignRepo(foreignStateDir);
436
+ await rebuildFromSqlite(
437
+ () => [{ repoId: foreignStateDir, stateDir: foreignStateDir }],
438
+ (sd) => {
439
+ const st = new VectorStore({ stateDir: sd, dedupSim: 0.9 });
440
+ return vectorList(st, seed.sessionId).map((cp) => ({
441
+ sessionId: seed.sessionId, checkpointId: cp.checkpointId, embedding: cp.embedding,
442
+ }));
443
+ },
444
+ );
445
+ const pg = await initVectorIndex();
446
+ assert.ok(pg, "PGlite index should initialize");
447
+
448
+ // No globalIndexDir → the F2 guard skips foreign hits rather than injecting
449
+ // them undeduped. Same-repo hits (none here) would still pass.
450
+ const selfStore = new VectorStore({ stateDir: selfStateDir, dedupSim: 0.9 });
451
+ const r = await recallAndInlineAsync(
452
+ { sessionId: "sess_nodir", query: "foreign repo authentication jwt", limit: 3, source: "command", crossRepo: true },
453
+ selfStore,
454
+ );
455
+ assert.equal(r.toInject.length, 0, "foreign hit skipped when globalIndexDir is unset (no undeduped injection)");
456
+ } finally {
457
+ await closeVectorIndex();
458
+ closeIndexStore();
459
+ delete process.env.MEGACOMPACT_VECTOR_INDEX_DIR;
460
+ rmSync(foreignStateDir, { recursive: true, force: true });
461
+ rmSync(selfStateDir, { recursive: true, force: true });
462
+ }
463
+ });
464
+
249
465
  test("cleanup", () => {
250
466
  rmSync(baseTmp, { recursive: true, force: true });
251
467
  });
package/src/recall.ts CHANGED
@@ -18,6 +18,9 @@ import { recall as searchRecall } from "./engine.js";
18
18
  import { vectorWasInjected, vectorMarkInjected, type SearchHit, type VectorStore, vectorSearchAsync } from "./vectorStore.js";
19
19
  import { estimateBlockTokens } from "./tokens.js";
20
20
  import { defaultEmbedder, cosineSimilarity } from "./embedder.js";
21
+ import { rehydrateRaptorTree, isShadowMode } from "./dedup/raptor/index.js";
22
+ import { maxCheckpointTimestamp } from "./store/sqlite.js";
23
+ import { normalizeSessionId } from "./store.js";
21
24
 
22
25
  export type RecallSource = "resume" | "command" | "sentinel";
23
26
 
@@ -42,6 +45,11 @@ export interface RecallInjectOptions {
42
45
  * recall, a foreign checkpoint already injected (in any session) is skipped
43
46
  * and a fresh injection is recorded globally. */
44
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;
45
53
  }
46
54
 
47
55
  export interface RecallInjectResult {
@@ -64,6 +72,15 @@ export function formatRecallBlock(hits: SearchHit[]): string {
64
72
  // that repo's stateDir, so the last path segment is the repo's display
65
73
  // name). Same-repo hits (no repoId) stay unlabeled.
66
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
+ }
67
84
  return (
68
85
  `### Recalled context [${i + 1}] (relevance ${score}%)${repoName}\n` +
69
86
  `${h.checkpoint.summary.trim()}\n` +
@@ -79,6 +96,33 @@ export function formatRecallBlock(hits: SearchHit[]): string {
79
96
  );
80
97
  }
81
98
 
99
+ /**
100
+ * S25 Phase-2: format the RAPTOR tree's top-level summary nodes (root + level-1
101
+ * clusters) as a hierarchical overview HEADER. Surfacing the high-level topical
102
+ * structure before the detailed checkpoint hits gives the model a map of what
103
+ * the session has covered. `nodes` are the RAPTOR summary nodes to surface,
104
+ * highest level first (root → level-1 clusters). Returns "" when empty.
105
+ */
106
+ export function formatRaptorBlock(
107
+ nodes: { summary: string; level: number; score?: number }[],
108
+ ): 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
+ );
124
+ }
125
+
82
126
  /**
83
127
  * Run the unified recall+dudupe+prepare-inject pipeline. Does NOT touch pi;
84
128
  * it records injections via `markInjected` so the next call dedupes. The
@@ -132,58 +176,119 @@ export function recallAndInline(
132
176
  const doWindowDedupe = opts.windowDedupe ?? false;
133
177
  const dedupSim = opts.dedupSim ?? 0.9;
134
178
 
135
- const { hits } = searchRecall(
136
- { sessionId: opts.sessionId, query: opts.query, limit, skipInjected: false },
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 },
137
184
  store,
138
185
  );
139
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();
140
190
  // Precompute live-window embeddings once for inline dedupe (Fix C). Trigram
141
191
  // embedder is local + cheap; never a network call (PREVENT-PI-004).
142
192
  let liveEmbeddings: number[][] = [];
143
193
  if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
144
- const embedder = defaultEmbedder();
145
194
  liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
146
195
  }
147
196
 
148
- // Shared dedup + bounded/inline block assembly. We build the block
149
- // incrementally so the token cap can stop mid-stream (Fix C).
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.
150
201
  const toInject: SearchHit[] = [];
151
- const parts: string[] = [];
152
202
  let blockTokens = 0;
153
203
 
154
- for (const h of hits) {
155
- if (skip && vectorWasInjected(store, opts.sessionId, h.checkpoint.checkpointId)) continue;
156
-
204
+ for (const h of newHits) {
157
205
  // Inline dedupe: skip a hit already resident in the live window (Fix C).
158
206
  if (doWindowDedupe && liveEmbeddings.length > 0) {
159
- const hitVec = defaultEmbedder().embed(h.checkpoint.summary);
207
+ const hitVec = embedder.embed(h.checkpoint.summary);
160
208
  if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim)) continue;
161
209
  }
162
210
 
163
- const part = formatRecallBlock([h]);
164
- const partTokens = estimateBlockTokens(part);
211
+ const partTokens = estimateBlockTokens(h.checkpoint.summary);
165
212
  // Token cap: never push a chunk that would overrun the ceiling.
166
213
  if (maxTokens > 0 && blockTokens + partTokens > maxTokens) break;
167
214
 
168
- parts.push(part);
169
215
  toInject.push(h);
170
216
  blockTokens += partTokens;
171
217
  vectorMarkInjected(store, opts.sessionId, h.checkpoint.checkpointId);
172
218
  }
173
219
 
174
- const block = parts.join("\n");
220
+ // F3: format once — one preamble, correct [1..n] numbering.
221
+ const recallBlock = toInject.length > 0 ? formatRecallBlock(toInject) : "";
175
222
  const report = toInject.map(
176
223
  (h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`,
177
224
  );
178
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
+
179
241
  return {
180
242
  toInject,
181
243
  report,
182
244
  block,
183
- empty: toInject.length === 0,
245
+ empty: block.length === 0,
184
246
  };
185
247
  }
186
248
 
249
+ /**
250
+ * S25 Phase-2: build the hierarchical overview header for a session. Rehydrates
251
+ * the persisted RAPTOR tree, picks the root + top level-1 cluster nodes by
252
+ * cosine similarity to the query, and formats them via formatRaptorBlock.
253
+ * Returns "" when no tree, stale tree, timedOut tree, or shadow mode. Non-fatal
254
+ * (wrapped in try/catch) — the overview is a bonus; a failure must never block
255
+ * the detailed recall block.
256
+ */
257
+ function raptorOverviewBlock(
258
+ store: VectorStore,
259
+ sessionId: string,
260
+ query: string,
261
+ ): 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
+ }
290
+ }
291
+
187
292
  // --- S21: memory recall ----------------------------------------------------
188
293
  // Durables (decisions, rules, user-saved facts) live in the `memories` table.
189
294
  // We mirror the checkpoint recall path: rank by cosine, format a block, respect
@@ -303,17 +408,37 @@ export async function recallAndInlineAsync(
303
408
  hits = [];
304
409
  }
305
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();
306
415
  let liveEmbeddings: number[][] = [];
307
416
  if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
308
- const embedder = defaultEmbedder();
309
417
  liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
310
418
  }
311
419
 
312
420
  const toInject: SearchHit[] = [];
313
- const parts: string[] = [];
314
421
  let blockTokens = 0;
315
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
+
316
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;
317
442
  if (skip && vectorWasInjected(store, opts.sessionId, h.checkpoint.checkpointId)) continue;
318
443
  // S18: machine-wide injected-set — a foreign checkpoint already injected
319
444
  // (in any session) is never re-injected. Only applies to cross-repo hits
@@ -326,14 +451,15 @@ export async function recallAndInlineAsync(
326
451
  /* non-fatal: degrade to per-session injected-set only */
327
452
  }
328
453
  }
454
+ // Inline dedupe: skip a hit already resident in the live window (F1: hoisted embedder).
329
455
  if (doWindowDedupe && liveEmbeddings.length > 0) {
330
- const hitVec = defaultEmbedder().embed(h.checkpoint.summary);
456
+ const hitVec = embedder.embed(h.checkpoint.summary);
331
457
  if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim)) continue;
332
458
  }
333
- const part = formatRecallBlock([h]);
334
- const partTokens = estimateBlockTokens(part);
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);
335
462
  if (maxTokens > 0 && blockTokens + partTokens > maxTokens) break;
336
- parts.push(part);
337
463
  toInject.push(h);
338
464
  blockTokens += partTokens;
339
465
  vectorMarkInjected(store, opts.sessionId, h.checkpoint.checkpointId);
@@ -349,7 +475,10 @@ export async function recallAndInlineAsync(
349
475
  }
350
476
  }
351
477
 
352
- const block = parts.join("\n");
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) : "";
353
482
  const report = toInject.map(
354
483
  (h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`,
355
484
  );
@@ -21,6 +21,9 @@ export interface DedupMirrorRowDB {
21
21
  /**
22
22
  * Upsert a row into dedup_mirror. If the hash already exists, increment ref_count.
23
23
  * Returns true if this was a NEW unique content (first insert), false if it was a duplicate.
24
+ *
25
+ * F3 fix: uses INSERT ... ON CONFLICT DO UPDATE (single atomic statement) instead of
26
+ * a check-then-act race-prone SELECT + UPDATE/INSERT sequence.
24
27
  */
25
28
  export function upsertDedupMirror(
26
29
  db: DatabaseSync,
@@ -29,29 +32,35 @@ export function upsertDedupMirror(
29
32
  seq: number,
30
33
  ): boolean {
31
34
  const now = Date.now();
32
- const existing = db
33
- .prepare(`SELECT content_hash FROM dedup_mirror WHERE content_hash = @hash`)
34
- .get({ "@hash": contentHash }) as { content_hash: string } | undefined;
35
- if (existing) {
36
- db.prepare(`UPDATE dedup_mirror SET ref_count = ref_count + 1 WHERE content_hash = @hash`).run({
37
- "@hash": contentHash,
38
- });
39
- return false;
40
- }
41
- db.prepare(
35
+ // Atomic upsert: on conflict, increment ref_count in-place (no check-then-act
36
+ // race). RETURNING ref_count distinguishes the two paths in one statement:
37
+ // inserted rows report ref_count=1, conflict-updated rows report ref_count>1.
38
+ const row = db.prepare(
42
39
  `INSERT INTO dedup_mirror (content_hash, content_bytes, ref_count, first_seen_seq, created_at)
43
- VALUES (@hash, @bytes, 1, @seq, @now)`,
44
- ).run({
40
+ VALUES (@hash, @bytes, 1, @seq, @now)
41
+ ON CONFLICT(content_hash) DO UPDATE SET
42
+ ref_count = ref_count + 1,
43
+ content_bytes = excluded.content_bytes
44
+ RETURNING ref_count`,
45
+ ).get({
45
46
  "@hash": contentHash,
46
47
  "@bytes": contentBytes,
47
48
  "@seq": seq,
48
49
  "@now": now,
49
- });
50
- return true;
50
+ }) as { ref_count: number } | undefined;
51
+ return (row?.ref_count ?? 1) === 1;
51
52
  }
52
53
 
53
54
  /**
54
55
  * Get dedup ratio for a session: total bytes vs unique bytes.
56
+ *
57
+ * F2 fix: both total and unique bytes are now scoped to the session, via a JOIN
58
+ * of raw_transcript.content_ref → dedup_mirror. The ratio is meaningful: how much
59
+ * smaller the session's storage footprint is compared to naive inline storage.
60
+ *
61
+ * NOTE: for sessions with NO dedup pipeline runs yet (all content_ref NULL),
62
+ * uniqueBytes falls back to the raw_transcript bytes (ratio=1), which is correct
63
+ * since nothing has been deduplicated yet.
55
64
  */
56
65
  export function getDedupRatio(
57
66
  db: DatabaseSync,
@@ -64,12 +73,20 @@ export function getDedupRatio(
64
73
  WHERE session_id = @session_id`,
65
74
  )
66
75
  .get({ "@session_id": sessionId }) as { total: number };
76
+ // F2 fix: session-scoped unique bytes via JOIN on content_ref.
77
+ // A row contributes its dedup_mirror bytes exactly once even when content_ref
78
+ // is NULL (fallback: use the raw_transcript bytes for that row, which is
79
+ // accurate when dedup hasn't run yet for the session).
67
80
  const uniqueRow = db
68
81
  .prepare(
69
- `SELECT COALESCE(SUM(LENGTH(content_bytes)), 0) AS unique_bytes
70
- FROM dedup_mirror`,
82
+ `SELECT COALESCE(SUM(LENGTH(
83
+ COALESCE(dm.content_bytes, rt.content_bytes)
84
+ )), 0) AS unique_bytes
85
+ FROM raw_transcript rt
86
+ LEFT JOIN dedup_mirror dm ON rt.content_ref = dm.content_hash
87
+ WHERE rt.session_id = @session_id`,
71
88
  )
72
- .get() as { unique_bytes: number };
89
+ .get({ "@session_id": sessionId }) as { unique_bytes: number };
73
90
  const totalBytes = totalRow.total;
74
91
  const uniqueBytes = uniqueRow.unique_bytes;
75
92
  const ratio = uniqueBytes > 0 ? totalBytes / uniqueBytes : 1;
@@ -111,4 +128,4 @@ export function updateRawTranscriptRef(
111
128
  "@sid": sessionId,
112
129
  "@seq": seq,
113
130
  });
114
- }
131
+ }
@@ -36,7 +36,7 @@ const DB_TABLE_NAMES = [
36
36
  "checkpoint_epochs",
37
37
  "dedup_mirror",
38
38
  "memories",
39
- "dedup_stats",
39
+ "meta",
40
40
  "daily_log",
41
41
  ] as const;
42
42
 
@@ -136,7 +136,7 @@ export function pruneOldRows(stateDir: string = getStateDir(), daysOld = 30): Ma
136
136
  // dedup_mirror: cascade-delete orphan rows whose ref_count has dropped to 0
137
137
  // after the raw_transcript deletes. Safe even if FK is off (raw_transcript has
138
138
  // no FK to dedup_mirror; ref_count is maintained by the dedup pipeline).
139
- const delDedup = db.prepare(`DELETE FROM dedup_mirror WHERE ref_count <= 0`).run() as {
139
+ 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() as {
140
140
  changes?: number;
141
141
  } | undefined;
142
142
  const dedupDeleted = delDedup?.changes ?? 0;