pi-mega-compact 0.8.21 → 0.8.23

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 (84) hide show
  1. package/LICENSE +6 -2
  2. package/README.md +1 -1
  3. package/dist/extensions/dashboard-server/dashboard-client-core.js +201 -0
  4. package/dist/extensions/dashboard-server/dashboard-client-game.js +241 -0
  5. package/dist/extensions/dashboard-server/dashboard-client-repos.js +212 -0
  6. package/dist/extensions/dashboard-server/dashboard-client.js +19 -0
  7. package/dist/extensions/dashboard-server/html.js +2 -621
  8. package/dist/extensions/dashboard-server/routes-core.js +62 -0
  9. package/dist/extensions/dashboard-server/routes-game.js +323 -0
  10. package/dist/extensions/dashboard-server/routes-repo.js +170 -0
  11. package/dist/extensions/dashboard-server/routes-sessions.js +159 -0
  12. package/dist/extensions/dashboard-server/routes.js +10 -0
  13. package/dist/extensions/dashboard-server/server.js +26 -623
  14. package/dist/extensions/mega-commands.js +4 -3
  15. package/dist/extensions/mega-events/agent-handlers.js +2 -1
  16. package/dist/extensions/mega-events/compact-handlers.js +26 -0
  17. package/dist/extensions/mega-events/session-handlers.js +2 -1
  18. package/dist/extensions/mega-pipeline/compact.js +3 -2
  19. package/dist/extensions/mega-runtime/state.js +7 -7
  20. package/dist/src/dedup/raptor/multilevel.js +172 -0
  21. package/dist/src/dedup/raptor/multilevel.test.js +203 -0
  22. package/dist/src/dedup/raptor/promote.test.js +5 -5
  23. package/dist/src/dedup/raptor/retrieval.js +1 -1
  24. package/dist/src/dedup/sprint12.test.js +7 -7
  25. package/dist/src/dedup-engine.test.js +29 -29
  26. package/dist/src/e2e.test.js +38 -38
  27. package/dist/src/engine.js +3 -3
  28. package/dist/src/engine.test.js +6 -6
  29. package/dist/src/importance.js +197 -0
  30. package/dist/src/importance.test.js +372 -0
  31. package/dist/src/ratio.bench.test.js +18 -18
  32. package/dist/src/recall.js +6 -5
  33. package/dist/src/recall.test.js +85 -27
  34. package/dist/src/sprint14.test.js +2 -2
  35. package/dist/src/store/migrate.test.js +5 -5
  36. package/dist/src/store/sprint10.test.js +5 -5
  37. package/dist/src/store/sqlite/global-index.js +5 -174
  38. package/dist/src/store/sqlite/global-sessions.js +190 -0
  39. package/dist/src/vector-read.js +168 -0
  40. package/dist/src/vector-search.js +191 -0
  41. package/dist/src/vectorStore.js +10 -297
  42. package/dist/src/vectorStore.test.js +32 -32
  43. package/extensions/dashboard-server/dashboard-client-core.ts +202 -0
  44. package/extensions/dashboard-server/dashboard-client-game.ts +242 -0
  45. package/extensions/dashboard-server/dashboard-client-repos.ts +213 -0
  46. package/extensions/dashboard-server/dashboard-client.ts +21 -0
  47. package/extensions/dashboard-server/html.ts +2 -621
  48. package/extensions/dashboard-server/routes-core.ts +113 -0
  49. package/extensions/dashboard-server/routes-game.ts +386 -0
  50. package/extensions/dashboard-server/routes-repo.ts +212 -0
  51. package/extensions/dashboard-server/routes-sessions.ts +195 -0
  52. package/extensions/dashboard-server/routes.ts +13 -0
  53. package/extensions/dashboard-server/server.ts +37 -700
  54. package/extensions/mega-commands.ts +4 -3
  55. package/extensions/mega-events/agent-handlers.ts +2 -1
  56. package/extensions/mega-events/compact-handlers.ts +28 -0
  57. package/extensions/mega-events/session-handlers.ts +2 -1
  58. package/extensions/mega-pipeline/compact.ts +3 -2
  59. package/extensions/mega-runtime/state.ts +7 -7
  60. package/extensions/openclaw-mega-compact.ts +2 -2
  61. package/package.json +2 -2
  62. package/src/dedup/raptor/multilevel.test.ts +278 -0
  63. package/src/dedup/raptor/multilevel.ts +246 -0
  64. package/src/dedup/raptor/promote.test.ts +5 -5
  65. package/src/dedup/raptor/retrieval.ts +1 -1
  66. package/src/dedup/sprint12.test.ts +7 -7
  67. package/src/dedup-engine.test.ts +30 -30
  68. package/src/e2e.test.ts +38 -38
  69. package/src/engine.test.ts +6 -6
  70. package/src/engine.ts +3 -3
  71. package/src/importance.test.ts +538 -0
  72. package/src/importance.ts +312 -0
  73. package/src/ratio.bench.test.ts +18 -18
  74. package/src/recall.test.ts +101 -29
  75. package/src/recall.ts +9 -9
  76. package/src/sprint14.test.ts +2 -2
  77. package/src/store/migrate.test.ts +5 -5
  78. package/src/store/sprint10.test.ts +5 -5
  79. package/src/store/sqlite/global-index.ts +18 -290
  80. package/src/store/sqlite/global-sessions.ts +291 -0
  81. package/src/vector-read.ts +237 -0
  82. package/src/vector-search.ts +231 -0
  83. package/src/vectorStore.test.ts +32 -32
  84. package/src/vectorStore.ts +29 -356
@@ -12,6 +12,7 @@ import { decompressSmart } from "../src/store/compression.js";
12
12
  import { loadMetrics, fpRate, p95, defaultMetricsPath } from "../src/monitoring.js";
13
13
  import { C, recentUserQuery } from "./mega-runtime.js";
14
14
  import { runCompact, doRecall, doRecallAsync } from "./mega-pipeline.js";
15
+ import { vectorStats, vectorRepoStats, vectorDataInvariant } from "../src/vectorStore.js";
15
16
  /** Resolve a checkpoint by id (or "recent"/"last") from this session's store. */
16
17
  export function findCheckpoint(runtime, sid, ref) {
17
18
  const all = listCheckpoints(sid, runtime.currentStateDir);
@@ -88,9 +89,9 @@ export function registerCommands(pi, runtime, config) {
88
89
  const pct = usage?.percent != null ? `${usage.percent}%` : "n/a";
89
90
  const tokens = usage?.tokens != null ? `${usage.tokens} tok` : "n/a";
90
91
  const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
91
- const st = runtime.store.stats(sid);
92
- const repo = runtime.store.repoStats();
93
- const di = runtime.store.dataInvariant();
92
+ const st = vectorStats(runtime.store, sid);
93
+ const repo = vectorRepoStats(runtime.store);
94
+ const di = vectorDataInvariant(runtime.store);
94
95
  const fmtB = (b) => b >= 1_048_576 ? `${(b / 1_048_576).toFixed(1)} MiB` :
95
96
  b >= 1024 ? `${(b / 1024).toFixed(1)} KiB` : `${b} B`;
96
97
  // Real cost: tokens saved × the captured model's input rate (USD/token),
@@ -6,6 +6,7 @@ import { isMegaCache } from "../../src/game/scoring.js";
6
6
  import { resolveRepoRoot } from "../mega-config.js";
7
7
  import { classifyError } from "./error-classifier.js";
8
8
  import { safeSendUserMessage } from "./send-safe.js";
9
+ import { vectorStats } from "../../src/vectorStore.js";
9
10
  /** Register agent/turn tracking event handlers. */
10
11
  export function registerAgentHandlers(pi, runtime, config) {
11
12
  // ---- Agent tracking for real-time widget + status-line updates ---------
@@ -215,7 +216,7 @@ export function registerAgentHandlers(pi, runtime, config) {
215
216
  try {
216
217
  if (runtime.getCachedGameState().game_mode_on) {
217
218
  const repo = resolveRepoRoot(ctx.cwd) ?? runtime.currentStateDir;
218
- const st = runtime.store.stats(runtime.rt.sessionId);
219
+ const st = vectorStats(runtime.store, runtime.rt.sessionId);
219
220
  const cachePct = st.dedupHitRate * 100;
220
221
  const modelId = runtime.currentModel?.modelId ?? "unknown";
221
222
  recordScore(runtime.currentStateDir, {
@@ -79,6 +79,32 @@ export function registerCompactHandlers(pi, runtime, config) {
79
79
  });
80
80
  if (!config.auto)
81
81
  return {}; // let pi run its own native compaction
82
+ // S38.5: COMPACT-DEDUP RACE GUARD — pi's _runAutoCompaction fires
83
+ // session_before_compact from within the agent loop (after agent_end,
84
+ // _handlePostAgentRun → _checkCompaction → _runAutoCompaction). If a
85
+ // compaction just completed (within the cooldown window), pi's
86
+ // prepareCompaction may still see the last entry as a compaction but
87
+ // the extension's session_compact handler hasn't been called yet, OR
88
+ // the setTimeout deferred ctx.compact() from the agent_end handler is
89
+ // about to race. Skip the durable trim and let pi run its own compact
90
+ // (or no-op if the branch is already compacted). Matches the cooldown
91
+ // in agent-handlers.ts so both call sites are consistent.
92
+ const cooldownMs = config.raceGuardStrict ? 30_000 : 10_000;
93
+ const sinceCompact = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
94
+ if (sinceCompact < cooldownMs) {
95
+ runtime.logger.info("before-compact-skip-recent", {
96
+ sessionId: runtime.rt.sessionId,
97
+ reason: event.reason,
98
+ sinceCompactMs: sinceCompact,
99
+ cooldownMs,
100
+ });
101
+ runtime.diagBeforeCompactSupplied++;
102
+ const fb = fallbackCompaction(event);
103
+ if (fb) {
104
+ return { compaction: fb.compaction };
105
+ }
106
+ return {};
107
+ }
82
108
  try {
83
109
  const result = driveNativeCompaction(event, runtime, config);
84
110
  if (result && result.compaction.summary?.trim()) {
@@ -3,6 +3,7 @@ import { autoMaintain } from "../../src/store/sqlite.js";
3
3
  import { recentUserQuery, WIDGET_KEY, } from "../mega-runtime.js";
4
4
  import { doRecall, doRecallAsync, } from "../mega-pipeline.js";
5
5
  import { recallMemoriesAndInline } from "../../src/recall.js";
6
+ import { vectorStats } from "../../src/vectorStore.js";
6
7
  /** Register session lifecycle event handlers. */
7
8
  export function registerSessionHandlers(pi, runtime, config) {
8
9
  // Capture model/provider whenever it changes (drives real cost estimation).
@@ -26,7 +27,7 @@ export function registerSessionHandlers(pi, runtime, config) {
26
27
  if (config.autoInline) {
27
28
  const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
28
29
  const query = recentUserQuery(ctx);
29
- if (query && runtime.store.stats(sid).checkpointCount > 0) {
30
+ if (query && vectorStats(runtime.store, sid).checkpointCount > 0) {
30
31
  // S17: use the async variant on resume so cross-repo HNSW recall can
31
32
  // augment when this repo's store is thin. session_start is an async-safe
32
33
  // point (unlike the mid-turn context handler, which stays sync).
@@ -19,6 +19,7 @@ import { runRaptor } from "../../src/dedup/raptor/index.js";
19
19
  import { loadDedupConfig } from "../../src/config/dedup.js";
20
20
  import { upsertEmbedding as indexUpsertEmbedding } from "../../src/store/vectorIndex.js";
21
21
  import { runMemoryReview } from "./memory-review.js";
22
+ import { vectorList } from "../../src/vectorStore.js";
22
23
  /** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
23
24
  export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
24
25
  runtime.bindRepo(ctx.cwd);
@@ -171,7 +172,7 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
171
172
  if (config.raptorEnabled && !result.deduped) {
172
173
  try {
173
174
  const dd = loadDedupConfig();
174
- const all = runtime.store.list(sid);
175
+ const all = vectorList(runtime.store, sid);
175
176
  const leaves = all.map((cp) => ({
176
177
  id: cp.checkpointId,
177
178
  messages: [],
@@ -204,7 +205,7 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
204
205
  // Non-fatal: a WASM init failure degrades to the sync scan silently.
205
206
  if (!result.deduped) {
206
207
  try {
207
- const all = runtime.store.list(sid);
208
+ const all = vectorList(runtime.store, sid);
208
209
  const latest = all.find((cp) => cp.checkpointId === result.checkpointId);
209
210
  if (latest?.embedding) {
210
211
  void indexUpsertEmbedding(runtime.currentStateDir, sid, latest.checkpointId, latest.embedding).catch(() => {
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { join } from "node:path";
13
13
  import { appendFileSync, mkdirSync } from "node:fs";
14
- import { VectorStore } from "../../src/vectorStore.js";
14
+ import { VectorStore, vectorStats, vectorRepoStats, vectorDataInvariant } from "../../src/vectorStore.js";
15
15
  import { toEngineMessages } from "../../src/adapt.js";
16
16
  import { normalizeSessionId } from "../../src/store.js";
17
17
  import { Logger } from "../../src/log.js";
@@ -306,8 +306,8 @@ export class MegaRuntime {
306
306
  // never break the per-repo compaction path. Runs only on repo-switch
307
307
  // (this branch), so it's infrequent — not per-context-event.
308
308
  try {
309
- const repo = this.store.repoStats();
310
- const di = this.store.dataInvariant();
309
+ const repo = vectorRepoStats(this.store);
310
+ const di = vectorDataInvariant(this.store);
311
311
  const root = key !== dir ? key : (resolveRepoRoot(cwd ?? dir) ?? dir);
312
312
  upsertRepoRegistry({
313
313
  repoRoot: root,
@@ -346,9 +346,9 @@ export class MegaRuntime {
346
346
  return;
347
347
  }
348
348
  const perfT0 = performance.now();
349
- const st = this.store.stats(this.rt.sessionId);
350
- const repo = this.store.repoStats();
351
- const di = this.store.dataInvariant();
349
+ const st = vectorStats(this.store, this.rt.sessionId);
350
+ const repo = vectorRepoStats(this.store);
351
+ const di = vectorDataInvariant(this.store);
352
352
  // Live + store-wide cache-hit / compaction counters for the dashboard.
353
353
  const ds = getDedupStats(this.currentStateDir);
354
354
  const cacheHitsTotal = ds.deduped + getRecallInjected(this.currentStateDir);
@@ -492,7 +492,7 @@ export class MegaRuntime {
492
492
  total: getCompactCount(this.currentStateDir),
493
493
  },
494
494
  timeSaved: {
495
- compact: { sessionSec: sec(this.rt.tokensSaved), totalSec: sec(this.store.repoStats().tokensSaved) },
495
+ compact: { sessionSec: sec(this.rt.tokensSaved), totalSec: sec(vectorRepoStats(this.store).tokensSaved) },
496
496
  cacheHit: { sessionSec: sec(this.rt.cacheHitTokens), totalSec: sec(cacheHitsTotalTokens) },
497
497
  },
498
498
  model,
@@ -0,0 +1,172 @@
1
+ /**
2
+ * multilevel.ts — Multi-level RAPTOR retrieval engine (S42A).
3
+ *
4
+ * Upgrades the RAPTOR recall path from flat (leaf-only) to multi-level
5
+ * retrieval across the entire hierarchical tree. Searches ALL levels with
6
+ * configurable level weights, supports leaf expansion for cluster hits,
7
+ * and deduplicates overlapping results.
8
+ *
9
+ * PREVENT-PI-004: pure in-process math (cosine, BFS, extractive). No network.
10
+ * PREVENT-PI-001: produces SearchHit[] that feed into recallAndInline() —
11
+ * affects which checkpoints are recalled, not how messages are dropped.
12
+ */
13
+ import { cosineSimilarity } from "../../embedder.js";
14
+ import { mmrRerank } from "../mmr.js";
15
+ import { leafDescendants } from "./retrieval.js";
16
+ const DEFAULT_LEVEL_WEIGHTS = [1.0, 0.9, 0.8, 0.7, 0.5];
17
+ // ── S42A-2: Level-weighted scoring ─────────────────────────────────────────
18
+ /**
19
+ * Score all RAPTOR tree nodes by cosine similarity to the query, then apply
20
+ * level-specific weights. Returns hits sorted by weighted score descending.
21
+ *
22
+ * Level weights: leaves (level 0) get weight 1.0, level 1 gets 0.9, etc.
23
+ * This ensures detailed leaves score highest while still surfacing higher-level
24
+ * summaries when they're highly relevant.
25
+ */
26
+ export function scoreTreeLevels(query, tree, opts) {
27
+ const { embedder } = opts;
28
+ const weights = opts.levelWeights ?? DEFAULT_LEVEL_WEIGHTS;
29
+ const qv = embedder.embed(query);
30
+ const hits = [];
31
+ // 1. Score all internal (summary) nodes.
32
+ for (const node of tree.nodes.values()) {
33
+ const rawScore = cosineSimilarity(qv, node.embedding);
34
+ const levelWeight = weights[Math.min(node.level, weights.length - 1)];
35
+ hits.push({
36
+ nodeId: node.id,
37
+ level: node.level,
38
+ score: rawScore * levelWeight,
39
+ rawScore,
40
+ isLeaf: false,
41
+ leafIds: node.children,
42
+ summary: node.summary,
43
+ embedding: node.embedding,
44
+ });
45
+ }
46
+ // 2. Score leaf nodes. Leaf ids are not in tree.nodes — they are children
47
+ // referenced by internal nodes. Each leaf's embedding is the level-0
48
+ // parent node that wraps it (same approach as stagedExpansion:95–102).
49
+ const seenLeaves = new Set();
50
+ for (const node of tree.nodes.values()) {
51
+ for (const leafId of node.children) {
52
+ if (seenLeaves.has(leafId) || tree.nodes.has(leafId))
53
+ continue;
54
+ seenLeaves.add(leafId);
55
+ const rawScore = cosineSimilarity(qv, node.embedding);
56
+ const leafWeight = weights[0];
57
+ hits.push({
58
+ nodeId: leafId,
59
+ level: 0,
60
+ score: rawScore * leafWeight,
61
+ rawScore,
62
+ isLeaf: true,
63
+ leafIds: [leafId],
64
+ summary: "", // leaves have no summary — they are raw checkpoint ids
65
+ embedding: node.embedding,
66
+ });
67
+ }
68
+ }
69
+ hits.sort((a, b) => b.score - a.score);
70
+ return hits;
71
+ }
72
+ // ── S42A-3: Leaf expansion ─────────────────────────────────────────────────
73
+ /**
74
+ * Given a set of cluster-level hits, expand each one to include its leaf
75
+ * descendants. Deduplicates: if a leaf is already present as a direct hit,
76
+ * it is not duplicated. Returns the merged set (original hits + expanded leaves).
77
+ */
78
+ export function expandLeafDescendants(hits, tree, maxPerCluster, _embedder, queryVector, levelWeights) {
79
+ const weights = levelWeights ?? DEFAULT_LEVEL_WEIGHTS;
80
+ const existingIds = new Set(hits.map((h) => h.nodeId));
81
+ const expanded = [];
82
+ for (const hit of hits) {
83
+ if (hit.isLeaf) {
84
+ expanded.push(hit);
85
+ continue;
86
+ }
87
+ // Get all leaf descendants for this cluster node.
88
+ const node = tree.nodes.get(hit.nodeId);
89
+ if (!node) {
90
+ expanded.push(hit);
91
+ continue;
92
+ }
93
+ const rawLeafIds = leafDescendants(node, tree);
94
+ // Sort by cosine similarity to query, cap at maxPerCluster.
95
+ const leafHits = rawLeafIds
96
+ .map((lid) => {
97
+ // Leaf embedding = its nearest internal parent's embedding.
98
+ const parent = [...tree.nodes.values()].find((n) => n.children.includes(lid));
99
+ const sim = parent
100
+ ? cosineSimilarity(queryVector, parent.embedding)
101
+ : 0;
102
+ return { lid, sim, parent };
103
+ })
104
+ .sort((a, b) => b.sim - a.sim)
105
+ .slice(0, maxPerCluster)
106
+ .filter((l) => !existingIds.has(l.lid))
107
+ .map((l) => {
108
+ existingIds.add(l.lid);
109
+ const rawScore = l.sim;
110
+ return {
111
+ nodeId: l.lid,
112
+ level: 0,
113
+ score: rawScore * weights[0],
114
+ rawScore,
115
+ isLeaf: true,
116
+ leafIds: [l.lid],
117
+ summary: "",
118
+ embedding: l.parent?.embedding ?? hit.embedding,
119
+ };
120
+ });
121
+ expanded.push(hit, ...leafHits);
122
+ }
123
+ return expanded;
124
+ }
125
+ // ── S42A-4: Result dedup ───────────────────────────────────────────────────
126
+ /**
127
+ * Deduplicate hits: if both a cluster node and its leaf children appear in
128
+ * results, remove the cluster hit (leaves provide more specific context).
129
+ * If no leaves are in the set, keep the cluster hit (it provides the abstract view).
130
+ */
131
+ export function deduplicateMultilevelHits(hits) {
132
+ const leafIds = new Set(hits.filter((h) => h.isLeaf).map((h) => h.nodeId));
133
+ return hits.filter((h) => {
134
+ if (h.isLeaf)
135
+ return true;
136
+ // Cluster hit: keep only if none of its leaf children are present.
137
+ return !h.leafIds.some((lid) => leafIds.has(lid));
138
+ });
139
+ }
140
+ // ── S42A-5: Top-level pipeline ─────────────────────────────────────────────
141
+ /**
142
+ * Full multi-level retrieval pipeline: score → expand → dedup → MMR → top-K.
143
+ * Drop-in replacement for `stagedExpansion()` in the RAPTOR recall path.
144
+ */
145
+ export function multilevelRetrieval(query, tree, opts) {
146
+ if (!tree.rootId)
147
+ return [];
148
+ const { embedder } = opts;
149
+ const weights = opts.levelWeights ?? DEFAULT_LEVEL_WEIGHTS;
150
+ const leafExp = opts.leafExpansion !== false; // default true
151
+ const maxLeafExp = opts.maxLeafExpansion ?? 10;
152
+ const k = opts.k ?? 5;
153
+ const lambda = opts.mmrLambda ?? 0.5;
154
+ const qv = embedder.embed(query);
155
+ // 1. Score all nodes with level weights.
156
+ const scored = scoreTreeLevels(query, tree, { embedder, levelWeights: weights });
157
+ // 2. Top-N candidates for MMR diversity window.
158
+ const topN = scored.slice(0, k * 3);
159
+ // 3. Leaf expansion (optional).
160
+ const expanded = leafExp
161
+ ? expandLeafDescendants(topN, tree, maxLeafExp, embedder, qv, weights)
162
+ : topN;
163
+ // 4. Dedup: remove cluster hits when leaf children are present.
164
+ const deduped = deduplicateMultilevelHits(expanded);
165
+ // 5. MMR rerank to k.
166
+ const mmrItems = deduped.map((h) => ({
167
+ item: h,
168
+ vector: h.embedding,
169
+ relevance: h.score,
170
+ }));
171
+ return mmrRerank(mmrItems, k, lambda);
172
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * multilevel.test.ts — hermetic unit tests for S42A multi-level RAPTOR retrieval.
3
+ *
4
+ * Tests the scoreTreeLevels → expandLeafDescendants → deduplicateMultilevelHits
5
+ * → multilevelRetrieval pipeline. No network, no live store — uses TrigramEmbedder
6
+ * and synthetic RaptorTrees built from makeLeaves().
7
+ */
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { TrigramEmbedder } from "../../embedder.js";
11
+ import { buildRaptorTree } from "./tree.js";
12
+ import { scoreTreeLevels, expandLeafDescendants, deduplicateMultilevelHits, multilevelRetrieval, } from "./multilevel.js";
13
+ function msg(text) {
14
+ return { role: "user", text };
15
+ }
16
+ /** Build N distinct leaves with deterministic content. */
17
+ function makeLeaves(n, embedder = new TrigramEmbedder()) {
18
+ const leaves = [];
19
+ for (let i = 0; i < n; i++) {
20
+ const text = `topic ${i % 7}: the module ${i} validated the session token and refreshed the cache for region ${i}`;
21
+ leaves.push({
22
+ id: `leaf_${i}`,
23
+ messages: [msg(text)],
24
+ sourceText: text,
25
+ embedding: embedder.embed(text),
26
+ });
27
+ }
28
+ return leaves;
29
+ }
30
+ // ── test: scoreTreeLevels returns nodes at all levels ────────────────────────
31
+ test("scoreTreeLevels returns results at multiple tree levels", () => {
32
+ const embedder = new TrigramEmbedder();
33
+ const leaves = makeLeaves(50);
34
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 4 });
35
+ const hits = scoreTreeLevels("the auth module validates the session token", tree, {
36
+ embedder,
37
+ });
38
+ assert.ok(hits.length > 0, "should return hits");
39
+ // Should include both leaf (level 0) and cluster (level ≥ 1) hits.
40
+ const levels = new Set(hits.map((h) => h.level));
41
+ assert.ok(levels.has(0), "should have leaf-level hits");
42
+ if (tree.levels > 1) {
43
+ const hasCluster = [...levels].some((l) => l >= 1);
44
+ assert.ok(hasCluster, "should have cluster-level hits for multi-level tree");
45
+ }
46
+ // All hits should have valid scores.
47
+ for (const h of hits) {
48
+ assert.ok(h.score >= 0 && h.score <= 1, `score ${h.score} out of range`);
49
+ assert.ok(h.rawScore >= 0 && h.rawScore <= 1, `rawScore ${h.rawScore} out of range`);
50
+ assert.ok(h.score <= h.rawScore, "weighted score <= raw score (level weights ≤ 1)");
51
+ }
52
+ // Hits should be sorted by score descending.
53
+ for (let i = 1; i < hits.length; i++) {
54
+ assert.ok(hits[i - 1].score >= hits[i].score, `hits not sorted: hit[${i - 1}].score=${hits[i - 1].score} < hit[${i}].score=${hits[i].score}`);
55
+ }
56
+ });
57
+ // ── test: level weights affect scoring ───────────────────────────────────────
58
+ test("level weights shift scores: higher weight for level → higher weighted score", () => {
59
+ const embedder = new TrigramEmbedder();
60
+ const leaves = makeLeaves(50);
61
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 4 });
62
+ const query = "the auth module validates the session token";
63
+ // Uniform weights: all levels scored equally.
64
+ const uniform = scoreTreeLevels(query, tree, {
65
+ embedder,
66
+ levelWeights: [1.0, 1.0, 1.0, 1.0, 1.0],
67
+ });
68
+ // Penalized weights: higher levels penalized.
69
+ const penalized = scoreTreeLevels(query, tree, {
70
+ embedder,
71
+ levelWeights: [1.0, 0.1, 0.1, 0.1, 0.1],
72
+ });
73
+ // With penalized weights, cluster-level hits should score lower.
74
+ const clusterUniform = uniform.filter((h) => h.level >= 1);
75
+ const clusterPenalized = penalized.filter((h) => h.level >= 1);
76
+ if (clusterUniform.length > 0 && clusterPenalized.length > 0) {
77
+ const avgUniform = clusterUniform.reduce((s, h) => s + h.score, 0) / clusterUniform.length;
78
+ const avgPenalized = clusterPenalized.reduce((s, h) => s + h.score, 0) / clusterPenalized.length;
79
+ assert.ok(avgPenalized < avgUniform, `penalized cluster avg (${avgPenalized.toFixed(3)}) should be < uniform (${avgUniform.toFixed(3)})`);
80
+ }
81
+ });
82
+ // ── test: expandLeafDescendants adds leaf hits ──────────────────────────────
83
+ test("expandLeafDescendants adds leaf descendants for cluster hits", () => {
84
+ const embedder = new TrigramEmbedder();
85
+ const leaves = makeLeaves(50);
86
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 4 });
87
+ const query = "the auth module validates the session token";
88
+ const scored = scoreTreeLevels(query, tree, { embedder });
89
+ const qv = embedder.embed(query);
90
+ // Take only cluster hits (level ≥ 1).
91
+ const clusterHits = scored.filter((h) => !h.isLeaf).slice(0, 3);
92
+ assert.ok(clusterHits.length > 0, "should have cluster hits");
93
+ const expanded = expandLeafDescendants(clusterHits, tree, 5, // maxPerCluster
94
+ embedder, qv);
95
+ // Expanded set should include leaf hits.
96
+ const leafHits = expanded.filter((h) => h.isLeaf);
97
+ assert.ok(leafHits.length > 0, "should have expanded leaf hits");
98
+ assert.ok(expanded.length > clusterHits.length, `expanded (${expanded.length}) should be > cluster hits (${clusterHits.length})`);
99
+ // No duplicate ids.
100
+ const ids = new Set(expanded.map((h) => h.nodeId));
101
+ assert.equal(ids.size, expanded.length, "no duplicate node ids");
102
+ });
103
+ // ── test: deduplicateMultilevelHits removes cluster when leaves present ─────
104
+ test("deduplicateMultilevelHits removes cluster hits when leaf children are present", () => {
105
+ const embedder = new TrigramEmbedder();
106
+ const leaves = makeLeaves(50);
107
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 4 });
108
+ const query = "the auth module validates the session token";
109
+ const scored = scoreTreeLevels(query, tree, { embedder });
110
+ // Take a cluster hit and its leaf children.
111
+ const cluster = scored.find((h) => !h.isLeaf);
112
+ assert.ok(cluster, "should have a cluster hit");
113
+ const leafChildren = cluster.leafIds.slice(0, 2).map((lid) => ({
114
+ nodeId: lid,
115
+ level: 0,
116
+ score: 0.5,
117
+ rawScore: 0.5,
118
+ isLeaf: true,
119
+ leafIds: [lid],
120
+ summary: "",
121
+ embedding: cluster.embedding,
122
+ }));
123
+ const mixed = [cluster, ...leafChildren];
124
+ const deduped = deduplicateMultilevelHits(mixed);
125
+ // Cluster should be removed because its leaf children are present.
126
+ assert.ok(!deduped.find((h) => h.nodeId === cluster.nodeId), "cluster hit should be removed when leaf children are present");
127
+ assert.equal(deduped.length, leafChildren.length, "only leaf hits remain");
128
+ });
129
+ test("deduplicateMultilevelHits keeps cluster hits when no leaf children present", () => {
130
+ const clusterHit = {
131
+ nodeId: "cluster_1",
132
+ level: 1,
133
+ score: 0.8,
134
+ rawScore: 0.8,
135
+ isLeaf: false,
136
+ leafIds: ["leaf_1", "leaf_2"],
137
+ summary: "summarized content",
138
+ embedding: [1, 0, 0],
139
+ };
140
+ const deduped = deduplicateMultilevelHits([clusterHit]);
141
+ assert.equal(deduped.length, 1, "cluster should be kept when no leaf children present");
142
+ assert.equal(deduped[0].nodeId, "cluster_1");
143
+ });
144
+ // ── test: multilevelRetrieval returns cluster + leaf mix ────────────────────
145
+ test("multilevelRetrieval returns a mix of cluster and leaf hits", () => {
146
+ const embedder = new TrigramEmbedder();
147
+ const leaves = makeLeaves(50);
148
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 4 });
149
+ const hits = multilevelRetrieval("the auth module validates the session token", tree, {
150
+ embedder,
151
+ k: 5,
152
+ leafExpansion: true,
153
+ maxLeafExpansion: 3,
154
+ });
155
+ assert.ok(hits.length > 0, "should return hits");
156
+ assert.ok(hits.length <= 5, "should respect k=5");
157
+ // Should include leaf hits.
158
+ const leafHits = hits.filter((h) => h.isLeaf);
159
+ assert.ok(leafHits.length > 0, "should include leaf hits");
160
+ // All hits should have valid properties.
161
+ for (const h of hits) {
162
+ assert.ok(h.nodeId, "hit should have nodeId");
163
+ assert.ok(typeof h.level === "number", "hit should have numeric level");
164
+ assert.ok(h.score >= 0, "hit score should be non-negative");
165
+ assert.ok(Array.isArray(h.leafIds), "hit should have leafIds array");
166
+ }
167
+ });
168
+ test("multilevelRetrieval respects k parameter", () => {
169
+ const embedder = new TrigramEmbedder();
170
+ const leaves = makeLeaves(100);
171
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 8 });
172
+ for (const k of [1, 3, 5, 10]) {
173
+ const hits = multilevelRetrieval("the auth module validates the session token", tree, {
174
+ embedder,
175
+ k,
176
+ });
177
+ assert.ok(hits.length <= k, `k=${k}: got ${hits.length} hits, should be ≤ ${k}`);
178
+ }
179
+ });
180
+ test("multilevelRetrieval returns empty for empty tree", () => {
181
+ const embedder = new TrigramEmbedder();
182
+ const emptyTree = { nodes: new Map(), rootId: null, levels: 0, timedOut: false };
183
+ const hits = multilevelRetrieval("any query", emptyTree, { embedder });
184
+ assert.deepEqual(hits, []);
185
+ });
186
+ test("multilevelRetrieval with leafExpansion=false skips leaf expansion", () => {
187
+ const embedder = new TrigramEmbedder();
188
+ const leaves = makeLeaves(50);
189
+ const tree = buildRaptorTree(leaves, { embedder, clustersPerLevel: 4 });
190
+ const hits = multilevelRetrieval("the auth module validates the session token", tree, {
191
+ embedder,
192
+ k: 5,
193
+ leafExpansion: false,
194
+ });
195
+ assert.ok(hits.length > 0, "should still return hits");
196
+ // With leaf expansion off and tree having multiple levels, we may get
197
+ // cluster hits that are NOT expanded. The mix depends on tree structure.
198
+ // Just verify we got valid results.
199
+ for (const h of hits) {
200
+ assert.ok(h.nodeId, "hit should have nodeId");
201
+ assert.ok(h.score >= 0, "hit score should be non-negative");
202
+ }
203
+ });
@@ -11,7 +11,7 @@ import assert from "node:assert/strict";
11
11
  import { mkdtempSync, rmSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
- import { VectorStore } from "../../vectorStore.js";
14
+ import { VectorStore, vectorList, vectorSearch } from "../../vectorStore.js";
15
15
  import { runRaptor } from "./index.js";
16
16
  import { compactSession } from "../../engine.js";
17
17
  import { Logger } from "../../log.js";
@@ -35,10 +35,10 @@ test("Fix D: vectorStore.search serves a persisted RAPTOR tree (broader recall)"
35
35
  }
36
36
  // No tree yet → flat search only, returns hits, no RAPTOR coverage.
37
37
  assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree initially");
38
- const flat = s.search(SESS, "alpha wire bootstrap", 3);
38
+ const flat = vectorSearch(s, SESS, "alpha wire bootstrap", 3);
39
39
  assert.ok(flat.length > 0, "flat search returns hits");
40
40
  // Build + persist a RAPTOR tree for the session (mirrors runCompact refresh).
41
- const all = s.list(SESS);
41
+ const all = vectorList(s, SESS);
42
42
  const leaves = all.map((cp) => ({
43
43
  id: cp.checkpointId,
44
44
  messages: [],
@@ -49,7 +49,7 @@ test("Fix D: vectorStore.search serves a persisted RAPTOR tree (broader recall)"
49
49
  assert.ok(tree && listRaptorNodes(SESS, stateDir).length > 0, "tree persisted");
50
50
  // With the tree live + RAPTOR_ENABLED, search still returns hits and now
51
51
  // exercises the RAPTOR-served path without regression.
52
- const withTree = s.search(SESS, "alpha wire bootstrap", 3);
52
+ const withTree = vectorSearch(s, SESS, "alpha wire bootstrap", 3);
53
53
  assert.ok(withTree.length > 0, "search returns hits with RAPTOR promoted");
54
54
  // Every returned hit is a real checkpoint in the session.
55
55
  for (const h of withTree) {
@@ -60,7 +60,7 @@ test("Fix D: search still works for a session with <2 leaves (no tree)", () => {
60
60
  const stateDir = join(baseTmp, `run-${counter++}`);
61
61
  const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
62
62
  compactSession({ sessionId: SESS, messages: [msg("only one topic here"), msg("ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
63
- const r = s.search(SESS, "only one topic", 3);
63
+ const r = vectorSearch(s, SESS, "only one topic", 3);
64
64
  assert.ok(r.length > 0, "single-checkpoint search still works (no tree)");
65
65
  assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree built for <2 leaves");
66
66
  });
@@ -18,7 +18,7 @@ function isLeafId(id, tree) {
18
18
  return !tree.nodes.has(id);
19
19
  }
20
20
  /** All leaf (raw) ids reachable beneath a node via BFS. */
21
- function leafDescendants(node, tree) {
21
+ export function leafDescendants(node, tree) {
22
22
  const out = [];
23
23
  const queue = [node];
24
24
  while (queue.length) {
@@ -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, L2_ENABLED } from "../vectorStore.js";
6
+ import { VectorStore, L2_ENABLED, vectorSemDedup, vectorList, vectorSearch } from "../vectorStore.js";
7
7
  import { mmrRerank } from "./mmr.js";
8
8
  import { topK } from "./topk.js";
9
9
  import { cosineSimilarity, defaultEmbedder } from "../embedder.js";
@@ -67,7 +67,7 @@ test("L2_ENABLED defaults true; search still returns hits", () => {
67
67
  assert.equal(L2_ENABLED, true);
68
68
  const s = store();
69
69
  s.add({ sessionId: "sess_l2", summary: "investigated the parser", regionText: "investigated src/parser.ts and added a tokenizer", timestamp: 1 });
70
- const hits = s.search("sess_l2", "src/parser.ts tokenizer", 3);
70
+ const hits = vectorSearch(s, "sess_l2", "src/parser.ts tokenizer", 3);
71
71
  assert.ok(hits.length >= 1);
72
72
  });
73
73
  test("L2_ENABLED=false skips semantic tier but L0/L1 still work", () => {
@@ -106,14 +106,14 @@ test("semDedup marks redundant near-identical rows 'removed' and search excludes
106
106
  { id: "chkpt_001", text: "the cache stores parsed ast nodes for fast lookup", tok: 100 },
107
107
  { id: "chkpt_002", text: "the cache stores parsed ast nodes for fast lookup and reuse", tok: 900 },
108
108
  ]);
109
- const removed = s.semDedup("sess_sd", 0.85);
109
+ const removed = vectorSemDedup(s, "sess_sd", 0.85);
110
110
  assert.equal(removed, 1);
111
- const st = s.list("sess_sd");
111
+ const st = vectorList(s, "sess_sd");
112
112
  const dropped = st.find((c) => c.dedupStatus === "removed");
113
113
  assert.ok(dropped);
114
114
  assert.equal(dropped.checkpointId, "chkpt_001"); // lower tokenEstimate removed
115
115
  // Search excludes the removed row (only one active remains).
116
- const hits = s.search("sess_sd", "cache parsed ast nodes", 5);
116
+ const hits = vectorSearch(s, "sess_sd", "cache parsed ast nodes", 5);
117
117
  assert.equal(hits.length, 1);
118
118
  });
119
119
  test("semDedup is idempotent (re-run removes nothing new)", () => {
@@ -123,8 +123,8 @@ test("semDedup is idempotent (re-run removes nothing new)", () => {
123
123
  { id: "chkpt_001", text: "identical region text for the dedup job now", tok: 100 },
124
124
  { id: "chkpt_002", text: "identical region text for the dedup job right now", tok: 200 },
125
125
  ]);
126
- const first = s.semDedup("sess_sd2", 0.85);
127
- const second = s.semDedup("sess_sd2", 0.85);
126
+ const first = vectorSemDedup(s, "sess_sd2", 0.85);
127
+ const second = vectorSemDedup(s, "sess_sd2", 0.85);
128
128
  assert.equal(first, 1);
129
129
  assert.equal(second, 0);
130
130
  });