pi-mega-compact 0.8.21 → 0.8.22

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 (76) hide show
  1. package/dist/extensions/dashboard-server/dashboard-client-core.js +201 -0
  2. package/dist/extensions/dashboard-server/dashboard-client-game.js +241 -0
  3. package/dist/extensions/dashboard-server/dashboard-client-repos.js +212 -0
  4. package/dist/extensions/dashboard-server/dashboard-client.js +19 -0
  5. package/dist/extensions/dashboard-server/html.js +2 -621
  6. package/dist/extensions/dashboard-server/routes-core.js +62 -0
  7. package/dist/extensions/dashboard-server/routes-game.js +323 -0
  8. package/dist/extensions/dashboard-server/routes-repo.js +170 -0
  9. package/dist/extensions/dashboard-server/routes-sessions.js +159 -0
  10. package/dist/extensions/dashboard-server/routes.js +10 -0
  11. package/dist/extensions/dashboard-server/server.js +26 -623
  12. package/dist/extensions/mega-commands.js +4 -3
  13. package/dist/extensions/mega-events/agent-handlers.js +2 -1
  14. package/dist/extensions/mega-events/compact-handlers.js +26 -0
  15. package/dist/extensions/mega-events/session-handlers.js +2 -1
  16. package/dist/extensions/mega-pipeline/compact.js +3 -2
  17. package/dist/extensions/mega-runtime/state.js +7 -7
  18. package/dist/src/dedup/raptor/promote.test.js +5 -5
  19. package/dist/src/dedup/sprint12.test.js +7 -7
  20. package/dist/src/dedup-engine.test.js +29 -29
  21. package/dist/src/e2e.test.js +38 -38
  22. package/dist/src/engine.js +3 -3
  23. package/dist/src/engine.test.js +6 -6
  24. package/dist/src/importance.js +197 -0
  25. package/dist/src/importance.test.js +372 -0
  26. package/dist/src/ratio.bench.test.js +18 -18
  27. package/dist/src/recall.js +6 -5
  28. package/dist/src/recall.test.js +85 -27
  29. package/dist/src/sprint14.test.js +2 -2
  30. package/dist/src/store/migrate.test.js +5 -5
  31. package/dist/src/store/sprint10.test.js +5 -5
  32. package/dist/src/store/sqlite/global-index.js +5 -174
  33. package/dist/src/store/sqlite/global-sessions.js +190 -0
  34. package/dist/src/vector-read.js +168 -0
  35. package/dist/src/vector-search.js +191 -0
  36. package/dist/src/vectorStore.js +10 -297
  37. package/dist/src/vectorStore.test.js +32 -32
  38. package/extensions/dashboard-server/dashboard-client-core.ts +202 -0
  39. package/extensions/dashboard-server/dashboard-client-game.ts +242 -0
  40. package/extensions/dashboard-server/dashboard-client-repos.ts +213 -0
  41. package/extensions/dashboard-server/dashboard-client.ts +21 -0
  42. package/extensions/dashboard-server/html.ts +2 -621
  43. package/extensions/dashboard-server/routes-core.ts +113 -0
  44. package/extensions/dashboard-server/routes-game.ts +386 -0
  45. package/extensions/dashboard-server/routes-repo.ts +212 -0
  46. package/extensions/dashboard-server/routes-sessions.ts +195 -0
  47. package/extensions/dashboard-server/routes.ts +13 -0
  48. package/extensions/dashboard-server/server.ts +37 -700
  49. package/extensions/mega-commands.ts +4 -3
  50. package/extensions/mega-events/agent-handlers.ts +2 -1
  51. package/extensions/mega-events/compact-handlers.ts +28 -0
  52. package/extensions/mega-events/session-handlers.ts +2 -1
  53. package/extensions/mega-pipeline/compact.ts +3 -2
  54. package/extensions/mega-runtime/state.ts +7 -7
  55. package/extensions/openclaw-mega-compact.ts +2 -2
  56. package/package.json +1 -1
  57. package/src/dedup/raptor/promote.test.ts +5 -5
  58. package/src/dedup/sprint12.test.ts +7 -7
  59. package/src/dedup-engine.test.ts +30 -30
  60. package/src/e2e.test.ts +38 -38
  61. package/src/engine.test.ts +6 -6
  62. package/src/engine.ts +3 -3
  63. package/src/importance.test.ts +538 -0
  64. package/src/importance.ts +312 -0
  65. package/src/ratio.bench.test.ts +18 -18
  66. package/src/recall.test.ts +101 -29
  67. package/src/recall.ts +9 -9
  68. package/src/sprint14.test.ts +2 -2
  69. package/src/store/migrate.test.ts +5 -5
  70. package/src/store/sprint10.test.ts +5 -5
  71. package/src/store/sqlite/global-index.ts +18 -290
  72. package/src/store/sqlite/global-sessions.ts +291 -0
  73. package/src/vector-read.ts +237 -0
  74. package/src/vector-search.ts +231 -0
  75. package/src/vectorStore.test.ts +32 -32
  76. package/src/vectorStore.ts +29 -356
@@ -9,7 +9,7 @@ import assert from "node:assert/strict";
9
9
  import { mkdtempSync, rmSync } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
- import { VectorStore, computeRegionHash } from "./vectorStore.js";
12
+ import { VectorStore, computeRegionHash, vectorList, vectorSemDedup, vectorWasInjected, vectorMarkInjected, vectorTopSimilar, vectorStats, vectorSearch } from "./vectorStore.js";
13
13
  import { compactSession, recall, supersededCount } from "./engine.js";
14
14
  import { loadDedupConfig } from "./config/dedup.js";
15
15
  import { defaultEmbedder } from "./embedder.js";
@@ -101,8 +101,8 @@ test("1. Full compaction pipeline: SUPERSEDE → COLLAPSE → CLUSTER", () => {
101
101
  // Verify: checkpoint persisted
102
102
  assert.ok(r.checkpointId, "checkpointId should be set");
103
103
  assert.match(r.checkpointId, /^chkpt_001$/);
104
- // Verify: checkpoint is searchable via store.search()
105
- const hits = s.search(SESS, "server.ts memory leak JWT auth", 5);
104
+ // Verify: checkpoint is searchable via vectorSearch(store, )
105
+ const hits = vectorSearch(s, SESS, "server.ts memory leak JWT auth", 5);
106
106
  assert.ok(hits.length > 0, "checkpoint should be searchable");
107
107
  assert.equal(hits[0].checkpoint.checkpointId, r.checkpointId);
108
108
  });
@@ -133,7 +133,7 @@ test("2. Multi-session dedup: identical content in same session detected by L0",
133
133
  assert.equal(rB.deduped, true, "second identical compaction should be deduped by L0");
134
134
  assert.ok(rB.dedupReason === "regionHash" || rB.dedupReason === "contentHash", `dedup reason should be L0 (regionHash or contentHash), got ${rB.dedupReason}`);
135
135
  // Search should still return the checkpoint
136
- const hits = s.search("sess_multi_a", "database connection pooling", 5);
136
+ const hits = vectorSearch(s, "sess_multi_a", "database connection pooling", 5);
137
137
  assert.ok(hits.length > 0, "search returns the deduped result");
138
138
  assert.equal(hits[0].checkpoint.checkpointId, rA.checkpointId);
139
139
  });
@@ -162,7 +162,7 @@ test("3. Near-duplicate detection: L1 MinHash/LSH catches one-word edits that L0
162
162
  // L1 should catch the near-dup
163
163
  assert.equal(r2.deduped, true, "L1 should catch the near-duplicate");
164
164
  assert.equal(r2.reason, "l1MinHash", "dedup reason should be l1MinHash");
165
- assert.equal(s.list(SESS).length, 1, "only one checkpoint stored");
165
+ assert.equal(vectorList(s, SESS).length, 1, "only one checkpoint stored");
166
166
  });
167
167
  test("3b. Negative: L1 does NOT collapse genuinely different content", () => {
168
168
  const s = store({ L2_ENABLED: false });
@@ -180,7 +180,7 @@ test("3b. Negative: L1 does NOT collapse genuinely different content", () => {
180
180
  timestamp: 2,
181
181
  });
182
182
  assert.equal(r2.deduped, false, "distinct content should not be deduped");
183
- assert.equal(s.list(SESS).length, 2, "both checkpoints stored");
183
+ assert.equal(vectorList(s, SESS).length, 2, "both checkpoints stored");
184
184
  });
185
185
  // ---------------------------------------------------------------------------
186
186
  // 4. Semantic Dedup (L2 cosine)
@@ -208,7 +208,7 @@ test("4. Semantic dedup: L2 cosine catches highly similar content that L0/L1 mis
208
208
  // L2 semantic cosine should catch this high-similarity paraphrase
209
209
  assert.equal(r2.deduped, true, "L2 should catch the high-similarity paraphrase");
210
210
  assert.ok(r2.reason === "contentSimilarity" || r2.reason === "l1MinHash", `dedup reason should be L2 contentSimilarity or L1 l1MinHash, got ${r2.reason}`);
211
- assert.equal(s.list(SESS).length, 1, "only one checkpoint after semantic dedup");
211
+ assert.equal(vectorList(s, SESS).length, 1, "only one checkpoint after semantic dedup");
212
212
  });
213
213
  test("4b. MMR diversifies search results", () => {
214
214
  const s = store();
@@ -230,7 +230,7 @@ test("4b. MMR diversifies search results", () => {
230
230
  });
231
231
  }
232
232
  // Search for parser-related content — should return relevant results
233
- const hits = s.search(SESS, "parser tokenization source code files", 4);
233
+ const hits = vectorSearch(s, SESS, "parser tokenization source code files", 4);
234
234
  assert.ok(hits.length >= 1, "should return at least one result");
235
235
  // The top hit should be the parser checkpoint
236
236
  assert.ok(hits[0].checkpoint.summary.includes("topic_0"), "top hit is the parser topic");
@@ -255,20 +255,20 @@ test("5. SemDeDup offline cleanup: redundant rows marked removed, not deleted",
255
255
  { id: "chkpt_003", text: "the frontend uses a virtualized list for rendering large datasets efficiently", tok: 500, ts: 3 },
256
256
  ]);
257
257
  // Run SemDeDup with threshold that catches the near-identical pair
258
- const removed = s.semDedup(SESS, 0.85);
258
+ const removed = vectorSemDedup(s, SESS, 0.85);
259
259
  assert.equal(removed, 1, "one redundant row should be marked removed");
260
260
  // Verify: the lower-tokenEstimate row is the one removed
261
- const all = s.list(SESS);
261
+ const all = vectorList(s, SESS);
262
262
  const dropped = all.find((c) => c.dedupStatus === "removed");
263
263
  assert.ok(dropped, "a row should have dedup_status='removed'");
264
264
  assert.equal(dropped.checkpointId, "chkpt_001", "lower tokenEstimate row removed");
265
265
  assert.equal(dropped.dedupStatus, "removed");
266
266
  // Verify: search excludes removed rows (3 total, 1 removed → 2 active)
267
- const hits = s.search(SESS, "cache parsed ast nodes frontend virtualized list", 10);
267
+ const hits = vectorSearch(s, SESS, "cache parsed ast nodes frontend virtualized list", 10);
268
268
  assert.equal(hits.length, 2, "search excludes the removed row, returns 2 active");
269
269
  assert.ok(hits.every((h) => h.checkpoint.dedupStatus !== "removed"), "no removed rows in search");
270
270
  // Verify: idempotent re-run removes nothing new
271
- const secondRun = s.semDedup(SESS, 0.85);
271
+ const secondRun = vectorSemDedup(s, SESS, 0.85);
272
272
  assert.equal(secondRun, 0, "idempotent re-run removes nothing new");
273
273
  closeStore(dir);
274
274
  });
@@ -293,15 +293,15 @@ test("6. Recall + inline with dedup sentinel: injected checkpoints are skipped",
293
293
  assert.equal(first.hits.length, 1);
294
294
  // Mark it injected
295
295
  const cpId = first.hits[0].checkpoint.checkpointId;
296
- s.markInjected(SESS, cpId);
296
+ vectorMarkInjected(s, SESS, cpId);
297
297
  // Recall again — should skip the injected checkpoint
298
298
  const second = recall({ sessionId: SESS, query: "vectorStore dedup cascade", limit: 5, skipInjected: true }, s);
299
299
  assert.equal(second.newHits.length, 0, "injected checkpoint should be skipped (dedup sentinel)");
300
300
  // Without skipInjected, hits still surface
301
301
  assert.equal(second.hits.length, 1, "hits still surface without skipInjected");
302
302
  // Verify wasInjected
303
- assert.equal(s.wasInjected(SESS, cpId), true, "wasInjected returns true for injected checkpoint");
304
- assert.equal(s.wasInjected(SESS, "chkpt_999"), false, "wasInjected returns false for non-injected");
303
+ assert.equal(vectorWasInjected(s, SESS, cpId), true, "wasInjected returns true for injected checkpoint");
304
+ assert.equal(vectorWasInjected(s, SESS, "chkpt_999"), false, "wasInjected returns false for non-injected");
305
305
  });
306
306
  // ---------------------------------------------------------------------------
307
307
  // 7. topSimilar
@@ -326,11 +326,11 @@ test("7. topSimilar: returns n most cosine-similar checkpoints", () => {
326
326
  });
327
327
  }
328
328
  // Call topSimilar(3)
329
- const hits = s.topSimilar(SESS, 3);
329
+ const hits = vectorTopSimilar(s, SESS, 3);
330
330
  assert.ok(hits.length <= 3, "should respect n limit");
331
331
  assert.ok(hits.length > 0, "should return results");
332
332
  // Verify self-exclusion: the most recent checkpoint should not be in results
333
- const all = s.list(SESS);
333
+ const all = vectorList(s, SESS);
334
334
  const ordered = [...all].sort((a, b) => a.checkpointId.localeCompare(b.checkpointId));
335
335
  const current = ordered[ordered.length - 1];
336
336
  assert.ok(!hits.some((h) => h.checkpoint.checkpointId === current.checkpointId), "current checkpoint excluded");
@@ -358,14 +358,14 @@ test("7b. topSimilar respects n limit strictly", () => {
358
358
  timestamp: i + 1,
359
359
  });
360
360
  }
361
- const hits = s.topSimilar(SESS, 2);
361
+ const hits = vectorTopSimilar(s, SESS, 2);
362
362
  assert.equal(hits.length, 2, "n limit respected");
363
363
  });
364
364
  test("7c. topSimilar returns empty for sessions with 0 or 1 checkpoints", () => {
365
365
  const s = store();
366
- assert.deepEqual(s.topSimilar("sess_empty", 5), []);
366
+ assert.deepEqual(vectorTopSimilar(s, "sess_empty", 5), []);
367
367
  s.add({ sessionId: "sess_one", summary: "solo", regionText: "only checkpoint here", timestamp: 1 });
368
- assert.deepEqual(s.topSimilar("sess_one", 5), []);
368
+ assert.deepEqual(vectorTopSimilar(s, "sess_one", 5), []);
369
369
  });
370
370
  // ---------------------------------------------------------------------------
371
371
  // 8. Compression Round-Trip
@@ -390,7 +390,7 @@ test("8. Compression round-trip: decompression produces original content", () =>
390
390
  timestamp: 1,
391
391
  });
392
392
  // Retrieve and verify decompression
393
- const cp = s.list(SESS)[0];
393
+ const cp = vectorList(s, SESS)[0];
394
394
  assert.ok(cp.compressedOriginal instanceof Buffer, "compressedOriginal is a Buffer");
395
395
  const restored = decompressSmart(cp.compressedOriginal).toString("utf-8");
396
396
  assert.equal(restored, originalText, "decompression produces the original content");
@@ -407,7 +407,7 @@ test("8b. Compression round-trip with different content sizes", () => {
407
407
  // Large text (>4KB → gzip level 6 or brotli)
408
408
  const large = "detailed region text with lots of context about the codebase. ".repeat(200);
409
409
  s.add({ sessionId: SESS, summary: "large", regionText: large, timestamp: 3 });
410
- const checkpoints = s.list(SESS);
410
+ const checkpoints = vectorList(s, SESS);
411
411
  assert.equal(checkpoints.length, 3);
412
412
  // Verify each round-trips correctly
413
413
  const texts = [small, medium, large];
@@ -444,7 +444,7 @@ test("9. Store stats and metrics reflect actual state after compactions", () =>
444
444
  const r3 = compactSession({ sessionId: SESS, messages: conv3, keepFrom: 2, timestamp: 3 }, s);
445
445
  assert.equal(r3.deduped, false);
446
446
  // Get stats
447
- const st = s.stats(SESS);
447
+ const st = vectorStats(s, SESS);
448
448
  assert.equal(st.checkpointCount, 3, "three checkpoints stored");
449
449
  assert.ok(st.totalTokenEstimate > 0, "totalTokenEstimate should be positive");
450
450
  assert.equal(st.lastCheckpointId, "chkpt_003", "last checkpoint id correct");
@@ -452,14 +452,14 @@ test("9. Store stats and metrics reflect actual state after compactions", () =>
452
452
  assert.equal(st.injectedCount, 0, "no injections yet");
453
453
  assert.equal(st.dedupHitRate, 0, "dedupHitRate is 0 with no injections");
454
454
  // Mark one injected and re-check
455
- s.markInjected(SESS, "chkpt_001");
456
- const st2 = s.stats(SESS);
455
+ vectorMarkInjected(s, SESS, "chkpt_001");
456
+ const st2 = vectorStats(s, SESS);
457
457
  assert.equal(st2.injectedCount, 1, "one injection tracked");
458
458
  assert.ok(Math.abs(st2.dedupHitRate - 1 / 3) < 1e-9, "dedupHitRate = injected/checkpoints");
459
459
  });
460
460
  test("9b. Stats on empty session returns zeros", () => {
461
461
  const s = store();
462
- const st = s.stats("sess_nothing");
462
+ const st = vectorStats(s, "sess_nothing");
463
463
  assert.equal(st.checkpointCount, 0);
464
464
  assert.equal(st.totalTokenEstimate, 0);
465
465
  assert.equal(st.lastCheckpointId, undefined);
@@ -496,15 +496,15 @@ test("10. Concurrent sessions: no cross-contamination", () => {
496
496
  });
497
497
  // Verify: each session has exactly one checkpoint
498
498
  sessions.forEach((sess) => {
499
- const cps = s.list(sess.id);
499
+ const cps = vectorList(s, sess.id);
500
500
  assert.equal(cps.length, 1, `${sess.id} should have 1 checkpoint`);
501
501
  });
502
502
  // Verify: search in one session doesn't return another session's checkpoints
503
- const hitsA = s.search("sess_concurrent_a", "authentication JWT", 5);
503
+ const hitsA = vectorSearch(s, "sess_concurrent_a", "authentication JWT", 5);
504
504
  assert.ok(hitsA.every((h) => h.checkpoint.sessionId === "sess_concurrent_a"), "search in A returns only A's checkpoints");
505
- const hitsB = s.search("sess_concurrent_b", "database queries indexes", 5);
505
+ const hitsB = vectorSearch(s, "sess_concurrent_b", "database queries indexes", 5);
506
506
  assert.ok(hitsB.every((h) => h.checkpoint.sessionId === "sess_concurrent_b"), "search in B returns only B's checkpoints");
507
- const hitsC = s.search("sess_concurrent_c", "canvas rendering bug", 5);
507
+ const hitsC = vectorSearch(s, "sess_concurrent_c", "canvas rendering bug", 5);
508
508
  assert.ok(hitsC.every((h) => h.checkpoint.sessionId === "sess_concurrent_c"), "search in C returns only C's checkpoints");
509
509
  });
510
510
  // ---------------------------------------------------------------------------
@@ -516,7 +516,7 @@ test("11a. Empty session (no messages) — compactSession should skip", () => {
516
516
  assert.equal(r.skipped, true, "empty session should be skipped");
517
517
  assert.equal(r.summary, "");
518
518
  assert.equal(r.regionHash, "");
519
- assert.equal(s.list("sess_empty_msgs").length, 0);
519
+ assert.equal(vectorList(s, "sess_empty_msgs").length, 0);
520
520
  });
521
521
  test("11b. Single message session — should skip or handle gracefully", () => {
522
522
  const s = store();
@@ -549,7 +549,7 @@ test("11c. Very large region text — should still compact and store", () => {
549
549
  assert.ok(r.checkpointId, "checkpoint created");
550
550
  assert.ok(r.summary.length > 0, "summary produced");
551
551
  // Verify it's searchable
552
- const hits = s.search(SESS, "module feature fix review", 3);
552
+ const hits = vectorSearch(s, SESS, "module feature fix review", 3);
553
553
  assert.ok(hits.length > 0, "large checkpoint is searchable");
554
554
  });
555
555
  test("11d. Unicode and emoji content — should normalize and hash correctly", () => {
@@ -565,7 +565,7 @@ test("11d. Unicode and emoji content — should normalize and hash correctly", (
565
565
  assert.equal(r.skipped, false, "unicode conversation should compact");
566
566
  assert.ok(r.regionHash.length > 0, "regionHash computed for unicode content");
567
567
  // Verify the checkpoint exists and is searchable
568
- const hits = s.search(SESS, "中文 translation 本地化", 3);
568
+ const hits = vectorSearch(s, SESS, "中文 translation 本地化", 3);
569
569
  assert.ok(hits.length > 0, "unicode checkpoint is searchable");
570
570
  // Verify regionHash is deterministic for the same unicode content
571
571
  const r2 = compactSession({ sessionId: SESS, messages, keepFrom: 4, timestamp: 2 }, s);
@@ -588,7 +588,7 @@ test("11e. All messages in preserve-recent window — nothing to compact", () =>
588
588
  timestamp: 1,
589
589
  }, s);
590
590
  assert.equal(r.skipped, true, "should skip when keepFrom=0 (nothing to compact)");
591
- assert.equal(s.list("sess_all_preserved").length, 0, "no checkpoints stored");
591
+ assert.equal(vectorList(s, "sess_all_preserved").length, 0, "no checkpoints stored");
592
592
  });
593
593
  // ---------------------------------------------------------------------------
594
594
  // 12. Feature Flag Toggling
@@ -616,7 +616,7 @@ test("12a. L0_ENABLED=false — L0 does not collapse exact dups", () => {
616
616
  assert.equal(r1.deduped, false);
617
617
  // With L0 off (and L1/L2 off too), near-dup should NOT be collapsed
618
618
  assert.equal(r2.deduped, false, "L0 disabled → near-dup not collapsed");
619
- assert.equal(s.list(SESS).length, 2, "both checkpoints stored when L0 is off");
619
+ assert.equal(vectorList(s, SESS).length, 2, "both checkpoints stored when L0 is off");
620
620
  });
621
621
  test("12b. MARK_ONLY_L1=true — L1 records but does not collapse", () => {
622
622
  // Disable L2 to isolate L1 behavior
@@ -637,9 +637,9 @@ test("12b. MARK_ONLY_L1=true — L1 records but does not collapse", () => {
637
637
  assert.equal(r1.deduped, false);
638
638
  // MARK_ONLY_L1 → L1 detects the near-dup but does NOT collapse
639
639
  assert.equal(r2.deduped, false, "MARK_ONLY_L1 → not collapsed");
640
- assert.equal(s.list(SESS).length, 2, "both checkpoints stored (mark only)");
640
+ assert.equal(vectorList(s, SESS).length, 2, "both checkpoints stored (mark only)");
641
641
  // Both should be active (not removed)
642
- const all = s.list(SESS);
642
+ const all = vectorList(s, SESS);
643
643
  assert.ok(all.every((c) => c.dedupStatus === "active"), "both rows active under MARK_ONLY");
644
644
  });
645
645
  test("12c. L2_ENABLED=false — L2 skipped but L0/L1 still work", () => {
@@ -688,7 +688,7 @@ test("12d. All tiers disabled — nothing deduped, everything stored", () => {
688
688
  });
689
689
  assert.equal(r1.deduped, false);
690
690
  assert.equal(r2.deduped, false, "all tiers off → nothing deduped");
691
- assert.equal(s.list(SESS).length, 2, "both stored");
691
+ assert.equal(vectorList(s, SESS).length, 2, "both stored");
692
692
  });
693
693
  // ---------------------------------------------------------------------------
694
694
  // Cleanup
@@ -15,7 +15,7 @@ import { findSuperseded, supersede } from "./supersede.js";
15
15
  import { summarizeMessages, mergeCompactSummaries, formatCompactSummary } from "./compact.js";
16
16
  import { extractiveSummarize } from "./extractive.js";
17
17
  import { estimateSessionTokens, estimateBlockTokens } from "./tokens.js";
18
- import { computeRegionHash, VectorStore } from "./vectorStore.js";
18
+ import { computeRegionHash, vectorWasInjected, vectorSearch, VectorStore } from "./vectorStore.js";
19
19
  /** Default store used by the convenience `compactSession`. */
20
20
  let defaultStore;
21
21
  export function getDefaultStore(stateDir) {
@@ -125,8 +125,8 @@ export function compactSession(input, store = getDefaultStore()) {
125
125
  * inject (Sprint 4 wires injection); this module only does the deduped search.
126
126
  */
127
127
  export function recall(input, store = getDefaultStore()) {
128
- const hits = store.search(input.sessionId, input.query, input.limit ?? 3);
129
- const newHits = input.skipInjected === false ? hits : hits.filter((h) => !store.wasInjected(input.sessionId, h.checkpoint.checkpointId));
128
+ const hits = vectorSearch(store, input.sessionId, input.query, input.limit ?? 3);
129
+ const newHits = input.skipInjected === false ? hits : hits.filter((h) => !vectorWasInjected(store, input.sessionId, h.checkpoint.checkpointId));
130
130
  return { hits, newHits };
131
131
  }
132
132
  /** Merge a freshly compacted summary into the prior persisted summary text. */
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { VectorStore } from "./vectorStore.js";
6
+ import { VectorStore, vectorMarkInjected, vectorSearch } from "./vectorStore.js";
7
7
  import { compactSession, recall, mergeSummary, supersededCount } from "./engine.js";
8
8
  const baseTmp = mkdtempSync(join(tmpdir(), "mc-engine-"));
9
9
  let counter = 0;
@@ -35,7 +35,7 @@ test("compactSession supersedes then persists a checkpoint", () => {
35
35
  assert.equal(supersededCount(messages.slice(0, 4)), 1);
36
36
  assert.equal(r.compactedFrom, 4);
37
37
  // The persisted checkpoint is searchable.
38
- assert.equal(s.search(SESS, "bug src/server.ts", 5).length, 1);
38
+ assert.equal(vectorSearch(s, SESS, "bug src/server.ts", 5).length, 1);
39
39
  });
40
40
  test("compactSession is idempotent on identical region (dedup sentinel)", () => {
41
41
  const s = store();
@@ -50,20 +50,20 @@ test("compactSession is idempotent on identical region (dedup sentinel)", () =>
50
50
  assert.equal(r1.deduped, false);
51
51
  assert.equal(r2.deduped, true);
52
52
  assert.equal(r1.checkpointId, r2.checkpointId);
53
- assert.equal(s.search(SESS, "parser", 10).length, 1);
53
+ assert.equal(vectorSearch(s, SESS, "parser", 10).length, 1);
54
54
  });
55
55
  test("compactSession skipped when slice is empty", () => {
56
56
  const s = store();
57
57
  const r = compactSession({ sessionId: SESS, messages: [msg("user", "only tail")], keepFrom: 0 }, s);
58
58
  assert.equal(r.skipped, true);
59
- assert.equal(s.search(SESS, "x", 5).length, 0);
59
+ assert.equal(vectorSearch(s, SESS, "x", 5).length, 0);
60
60
  });
61
61
  test("recall drops already-injected checkpoints", () => {
62
62
  const s = store();
63
63
  compactSession({ sessionId: SESS, messages: [msg("user", "investigated src/vectorStore.ts"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
64
64
  const first = recall({ sessionId: SESS, query: "vectorStore", limit: 5, skipInjected: true }, s);
65
65
  assert.equal(first.newHits.length, 1);
66
- s.markInjected(SESS, first.hits[0].checkpoint.checkpointId);
66
+ vectorMarkInjected(s, SESS, first.hits[0].checkpoint.checkpointId);
67
67
  const second = recall({ sessionId: SESS, query: "vectorStore", limit: 5, skipInjected: true }, s);
68
68
  assert.equal(second.newHits.length, 0);
69
69
  // Without the skip flag, both hits still surface.
@@ -100,7 +100,7 @@ test("compactSession with useExtractive produces topicSummary on checkpoint", ()
100
100
  assert.equal(r.skipped, false);
101
101
  assert.ok(r.checkpointId, "checkpoint created");
102
102
  // The checkpoint should have topicSummary populated
103
- const hits = s.search("sess_extr", "auth refactor", 5);
103
+ const hits = vectorSearch(s, "sess_extr", "auth refactor", 5);
104
104
  assert.ok(hits.length > 0);
105
105
  // topicSummary should be present on the stored checkpoint (via extractive path)
106
106
  assert.ok(hits[0].checkpoint.topicSummary, "topicSummary should be populated when useExtractive is true");
@@ -0,0 +1,197 @@
1
+ /** The 8 content/item types that drive the type multiplier. */
2
+ export var ContextItemType;
3
+ (function (ContextItemType) {
4
+ ContextItemType["UserMessage"] = "user_message";
5
+ ContextItemType["AssistantMessage"] = "assistant_message";
6
+ ContextItemType["SystemMessage"] = "system_message";
7
+ ContextItemType["CodeBlock"] = "code_block";
8
+ ContextItemType["Error"] = "error";
9
+ ContextItemType["Decision"] = "decision";
10
+ ContextItemType["FileModification"] = "file_modification";
11
+ ContextItemType["ToolExecution"] = "tool_execution";
12
+ })(ContextItemType || (ContextItemType = {}));
13
+ /**
14
+ * Default type multipliers (ported from the Rust reference
15
+ * `router/src/context/importance.rs`). Decisions (2.5x) and errors (2.0x)
16
+ * dominate; system/filler (0.5x) sinks.
17
+ */
18
+ export const DEFAULT_MULTIPLIERS = {
19
+ [ContextItemType.UserMessage]: 1.5,
20
+ [ContextItemType.AssistantMessage]: 1.0,
21
+ [ContextItemType.SystemMessage]: 0.5,
22
+ [ContextItemType.CodeBlock]: 1.2,
23
+ [ContextItemType.Error]: 2.0,
24
+ [ContextItemType.Decision]: 2.5,
25
+ [ContextItemType.FileModification]: 1.8,
26
+ [ContextItemType.ToolExecution]: 1.3,
27
+ };
28
+ /**
29
+ * Age decay: fraction to SUBTRACT from the score.
30
+ * Formula: `min(maxDecay, (ageMs / 3_600_000) * decayRatePerHour)`.
31
+ * 0 = fresh, 0.7 = very old. At 14h with 0.05/hr: 14 * 0.05 = 0.70 (capped).
32
+ */
33
+ export function ageDecay(itemAgeMs, decayRatePerHour = 0.05, maxDecay = 0.7) {
34
+ if (itemAgeMs <= 0)
35
+ return 0;
36
+ const hours = itemAgeMs / 3_600_000;
37
+ return Math.min(maxDecay, hours * decayRatePerHour);
38
+ }
39
+ /** Recency boost: 1.2 if `ageMs < thresholdMs` (default 5min), else 1.0. */
40
+ export function recencyBoost(ageMs, thresholdMs = 300_000) {
41
+ return ageMs < thresholdMs ? 1.2 : 1.0;
42
+ }
43
+ /**
44
+ * Retention boost: 3.0 if the user flagged the item, else 1.0.
45
+ * User-flagged items are also inferred by `detectItemType` returning
46
+ * Decision or Error.
47
+ */
48
+ export function retentionBoost(userFlagged) {
49
+ return userFlagged ? 3.0 : 1.0;
50
+ }
51
+ /**
52
+ * Classify a content blob into a `ContextItemType`.
53
+ *
54
+ * Rules are ordered by priority — first match wins:
55
+ * 1. role === "tool" → ToolExecution
56
+ * 2. role === "custom" → SystemMessage
57
+ * 3. error-ish content → Error
58
+ * 4. decision-ish content → Decision
59
+ * 5. fenced code block (≥20 chars) → CodeBlock
60
+ * 6. file-modification verbs → FileModification
61
+ * 7. role === "user" → UserMessage
62
+ * 8. role === "assistant" → AssistantMessage
63
+ * 9. Fallback → AssistantMessage
64
+ */
65
+ export function detectItemType(content, role) {
66
+ const c = content ?? "";
67
+ if (role === "tool")
68
+ return ContextItemType.ToolExecution;
69
+ if (role === "custom")
70
+ return ContextItemType.SystemMessage;
71
+ // (3) errors — traceback / panic / E#### / exception / failure / crash.
72
+ if (/error|exception|failure|crash|panic|traceback|E\d{4}/i.test(c)) {
73
+ return ContextItemType.Error;
74
+ }
75
+ // (4) decisions — "we decided", "going with", "switching to", etc.
76
+ if (/decided|we chose|going with|switching to|using .* instead|final decision|let's go with/i.test(c)) {
77
+ return ContextItemType.Decision;
78
+ }
79
+ // (5) fenced code block ≥20 chars.
80
+ if (/```[\s\S]{20,}/.test(c)) {
81
+ return ContextItemType.CodeBlock;
82
+ }
83
+ // (6) file modification verbs.
84
+ if (/(wrote|edited|created|modified|updated|patched)\s+\S+\.\w+/i.test(c)) {
85
+ return ContextItemType.FileModification;
86
+ }
87
+ if (role === "user")
88
+ return ContextItemType.UserMessage;
89
+ if (role === "assistant")
90
+ return ContextItemType.AssistantMessage;
91
+ // (9) Fallback.
92
+ return ContextItemType.AssistantMessage;
93
+ }
94
+ /**
95
+ * Composite score for a single item.
96
+ *
97
+ * Formula:
98
+ * type = detectItemType(content, role)
99
+ * rawMult = multipliers[type] ?? DEFAULT_MULTIPLIERS[type]
100
+ * decay = ageDecay(now - timestamp, decayRatePerHour, maxDecay)
101
+ * recency = recencyBoost(now - timestamp, recencyThresholdMs)
102
+ * retention = retentionBoost(userFlagged)
103
+ * finalScore = rawMult * (1 - decay) * recency * retention
104
+ *
105
+ * Clamps `finalScore` to a minimum of 0.01 (never zero, so an item is
106
+ * always a candidate unless explicitly filtered).
107
+ */
108
+ export function score(item, now, multipliers, opts) {
109
+ const content = item.content ?? "";
110
+ // detectItemType expects the 4-role union; narrow defensively.
111
+ const role = (["user", "assistant", "tool", "custom"].includes(item.role)
112
+ ? item.role
113
+ : "assistant");
114
+ const type = detectItemType(content, role);
115
+ const rawMultiplier = multipliers?.[type] ?? DEFAULT_MULTIPLIERS[type];
116
+ const ageMs = Math.max(0, now - item.timestamp);
117
+ const decay = ageDecay(ageMs, opts?.decayRatePerHour, opts?.maxDecay);
118
+ const recency = recencyBoost(ageMs, opts?.recencyThresholdMs);
119
+ const retention = retentionBoost(item.userFlagged === true);
120
+ const finalScore = Math.max(0.01, rawMultiplier * (1 - decay) * recency * retention);
121
+ return {
122
+ id: item.id,
123
+ type,
124
+ content,
125
+ role,
126
+ timestamp: item.timestamp,
127
+ rawMultiplier,
128
+ ageDecay: decay,
129
+ recencyBoost: recency,
130
+ retentionBoost: retention,
131
+ finalScore,
132
+ };
133
+ }
134
+ /**
135
+ * Score cutoff at the `preserveRatio` percentile boundary.
136
+ *
137
+ * Sorts items by `finalScore` descending; returns the score at the
138
+ * `preserveRatio` quantile. Items with `finalScore >= threshold` are
139
+ * preserved.
140
+ *
141
+ * - `ratio = 1.0` → return 0 (preserve all)
142
+ * - `ratio = 0` → return Infinity (preserve none)
143
+ * - empty input → return Infinity
144
+ */
145
+ export function preservationCutoff(items, preserveRatio) {
146
+ if (items.length === 0)
147
+ return Infinity;
148
+ if (preserveRatio >= 1)
149
+ return 0;
150
+ if (preserveRatio <= 0)
151
+ return Infinity;
152
+ // Sort descending; the top `preserveRatio` fraction is preserved.
153
+ const sorted = [...items].sort((a, b) => b.finalScore - a.finalScore);
154
+ // Index of the lowest-scoring item in the preserved set.
155
+ // ceil(N * ratio) - 1, clamped to [0, N-1].
156
+ const cutoffIdx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * preserveRatio) - 1));
157
+ return sorted[cutoffIdx].finalScore;
158
+ }
159
+ /**
160
+ * Select which items to preserve verbatim based on `preserveRatio`.
161
+ *
162
+ * Returns the IDs of items with `finalScore >= threshold`. The caller
163
+ * is responsible for excluding items inside the anchor window (the
164
+ * boundary guard already protects those).
165
+ */
166
+ export function itemsToPreserve(items, preserveRatio) {
167
+ const threshold = preservationCutoff(items, preserveRatio);
168
+ const preservedIds = new Set(items.filter((i) => i.finalScore >= threshold).map((i) => i.id));
169
+ return {
170
+ preservedIds,
171
+ threshold,
172
+ totalScored: items.length,
173
+ totalPreserved: preservedIds.size,
174
+ };
175
+ }
176
+ /**
177
+ * Convenience: score a list of `EngineMessage`s in position order using
178
+ * approximate ages from their index (1 minute per position, oldest first).
179
+ * Real timestamps should be threaded through the extension adapter when
180
+ * available; this is the position-based fallback documented in S40B-2.
181
+ *
182
+ * The returned `ScoredItem[]` is in the SAME ORDER as the input — callers
183
+ * index back into `messages` by `id` (which is the stringified index).
184
+ */
185
+ export function scoreEngineMessages(messages, now, multipliers, opts) {
186
+ return messages.map((m, i) => {
187
+ // Oldest message (i=0) is `messages.length` minutes ago; newest is 1 min.
188
+ const timestamp = now - (messages.length - i) * 60_000;
189
+ return score({
190
+ id: String(i),
191
+ content: m.text ?? "",
192
+ role: m.role,
193
+ timestamp,
194
+ userFlagged: false,
195
+ }, now, multipliers, opts);
196
+ });
197
+ }