pi-mega-compact 0.8.24 → 0.8.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +26 -0
  2. package/dist/extensions/mega-compact-s38.test.js +263 -14
  3. package/dist/extensions/mega-compact.js +15 -0
  4. package/dist/extensions/mega-config.js +3 -0
  5. package/dist/extensions/mega-events/agent-handlers.js +211 -26
  6. package/dist/extensions/mega-events/context-handler.js +45 -7
  7. package/dist/extensions/mega-events/error-classifier.js +125 -18
  8. package/dist/extensions/mega-pipeline/compact.js +24 -13
  9. package/dist/extensions/mega-pipeline/recall.js +31 -2
  10. package/dist/extensions/mega-runtime/dashboard-snapshot.js +4 -0
  11. package/dist/extensions/mega-runtime/runtime-snapshot.js +4 -0
  12. package/dist/extensions/mega-runtime/runtime.js +58 -5
  13. package/dist/src/boundary.js +79 -43
  14. package/dist/src/boundary.test.js +119 -2
  15. package/dist/src/canary.js +10 -0
  16. package/dist/src/config/dedup.js +14 -0
  17. package/dist/src/config.js +3 -1
  18. package/dist/src/dedup/raptor/buildHistory.js +164 -0
  19. package/dist/src/dedup/raptor/buildHistory.test.js +292 -0
  20. package/dist/src/dedup/raptor/index.js +38 -0
  21. package/dist/src/dedup/raptor/multilevel-serve.test.js +229 -0
  22. package/dist/src/dedup/raptor/multilevel.js +17 -5
  23. package/dist/src/dedup/raptor/multilevel.test.js +36 -1
  24. package/dist/src/dedup/raptor/raptor.test.js +43 -0
  25. package/dist/src/dedup/raptor/retrieval.js +14 -2
  26. package/dist/src/dedup/raptor/retrieval.test.js +95 -0
  27. package/dist/src/dedup/raptor/serve-gate.test.js +298 -0
  28. package/dist/src/dedup/raptor/summarizer.js +1 -0
  29. package/dist/src/dedup/raptor/tree.js +16 -2
  30. package/dist/src/engine.js +18 -2
  31. package/dist/src/httpEmbedder.js +96 -6
  32. package/dist/src/httpEmbedder.test.js +277 -0
  33. package/dist/src/mechanical-fix.test.js +65 -0
  34. package/dist/src/raptor-inject-summaries.test.js +162 -0
  35. package/dist/src/recall.js +153 -24
  36. package/dist/src/recall.test.js +179 -4
  37. package/dist/src/store/sqlite/dedup-mirror.js +32 -15
  38. package/dist/src/store/sqlite/maintenance.js +2 -2
  39. package/dist/src/store/sqlite/mechanical-fix.test.js +146 -0
  40. package/dist/src/store/sqlite/memories.js +5 -5
  41. package/dist/src/store/sqlite/meta.js +1 -1
  42. package/dist/src/store/sqlite/raptor.js +56 -17
  43. package/dist/src/store/sqlite/raptor.test.js +106 -0
  44. package/dist/src/store/sqlite/schema.js +90 -1
  45. package/dist/src/store/sqlite/session-state.js +9 -3
  46. package/dist/src/store/sqlite/stats.js +9 -5
  47. package/dist/src/store/sqlite/turns.js +179 -0
  48. package/dist/src/store/sqlite/turns.test.js +183 -0
  49. package/dist/src/store/sqlite/utils.js +15 -4
  50. package/dist/src/store/sqlite.js +1 -0
  51. package/dist/src/store.js +2 -2
  52. package/dist/src/vector-search-cache.test.js +157 -0
  53. package/dist/src/vector-search.js +107 -15
  54. package/dist/src/vectorStore.js +36 -8
  55. package/extensions/mega-compact-s38.test.ts +259 -14
  56. package/extensions/mega-compact.ts +15 -0
  57. package/extensions/mega-config.ts +18 -0
  58. package/extensions/mega-dashboard.ts +10 -1
  59. package/extensions/mega-events/agent-handlers.ts +211 -26
  60. package/extensions/mega-events/context-handler.ts +43 -7
  61. package/extensions/mega-events/error-classifier.ts +125 -17
  62. package/extensions/mega-pipeline/compact.ts +28 -16
  63. package/extensions/mega-pipeline/recall.ts +34 -2
  64. package/extensions/mega-runtime/dashboard-snapshot.ts +8 -0
  65. package/extensions/mega-runtime/helpers.ts +25 -1
  66. package/extensions/mega-runtime/runtime-snapshot.ts +4 -0
  67. package/extensions/mega-runtime/runtime.ts +69 -23
  68. package/package.json +1 -1
  69. package/src/boundary.test.ts +128 -2
  70. package/src/boundary.ts +75 -39
  71. package/src/canary.ts +10 -0
  72. package/src/config/dedup.ts +25 -0
  73. package/src/config.ts +3 -1
  74. package/src/dedup/raptor/buildHistory.test.ts +353 -0
  75. package/src/dedup/raptor/buildHistory.ts +259 -0
  76. package/src/dedup/raptor/index.ts +38 -0
  77. package/src/dedup/raptor/multilevel-serve.test.ts +273 -0
  78. package/src/dedup/raptor/multilevel.test.ts +47 -0
  79. package/src/dedup/raptor/multilevel.ts +18 -8
  80. package/src/dedup/raptor/raptor.test.ts +59 -0
  81. package/src/dedup/raptor/retrieval.test.ts +118 -0
  82. package/src/dedup/raptor/retrieval.ts +14 -2
  83. package/src/dedup/raptor/serve-gate.test.ts +348 -0
  84. package/src/dedup/raptor/summarizer.ts +1 -0
  85. package/src/dedup/raptor/tree.ts +17 -2
  86. package/src/engine.ts +32 -3
  87. package/src/httpEmbedder.test.ts +286 -0
  88. package/src/httpEmbedder.ts +98 -8
  89. package/src/mechanical-fix.test.ts +70 -0
  90. package/src/raptor-inject-summaries.test.ts +228 -0
  91. package/src/recall.test.ts +220 -4
  92. package/src/recall.ts +462 -265
  93. package/src/store/sqlite/dedup-mirror.ts +35 -18
  94. package/src/store/sqlite/maintenance.ts +2 -2
  95. package/src/store/sqlite/mechanical-fix.test.ts +162 -0
  96. package/src/store/sqlite/memories.ts +5 -5
  97. package/src/store/sqlite/meta.ts +1 -1
  98. package/src/store/sqlite/raptor.test.ts +139 -0
  99. package/src/store/sqlite/raptor.ts +135 -81
  100. package/src/store/sqlite/schema.ts +90 -1
  101. package/src/store/sqlite/session-state.ts +9 -3
  102. package/src/store/sqlite/stats.ts +10 -8
  103. package/src/store/sqlite/turns.test.ts +218 -0
  104. package/src/store/sqlite/turns.ts +302 -0
  105. package/src/store/sqlite/utils.ts +14 -4
  106. package/src/store/sqlite.ts +1 -0
  107. package/src/store.ts +9 -2
  108. package/src/vector-search-cache.test.ts +190 -0
  109. package/src/vector-search.ts +273 -156
  110. package/src/vectorStore.ts +443 -382
  111. package/extensions/mega-runtime/reset-runtime.ts +0 -80
@@ -0,0 +1,164 @@
1
+ /**
2
+ * buildHistory.ts — S42D RAPTOR build history + freshness.
3
+ *
4
+ * - Stores one structured row per tree build in `raptor_build_history`.
5
+ * - `computeCoherenceScore`: avg intra-cluster cosine similarity (0–1) for a
6
+ * built tree — a health/diagnostics metric, not a gating signal.
7
+ * - `isRaptorTreeFresh`: lets the build call site skip a rebuild when the last
8
+ * build is recent (within `freshnessHours`) and the checkpoint count has not
9
+ * drifted by more than 20%. Returns false when no history exists (forces a
10
+ * build) — the same default as a missing tree.
11
+ *
12
+ * No network. Pure SQLite + local cosine.
13
+ */
14
+ import { randomUUID } from "node:crypto";
15
+ import { openStore, withTx } from "../../store/sqlite/utils.js";
16
+ import { getStateDir } from "../../store.js";
17
+ import { normalizeSessionId } from "../../store.js";
18
+ import { cosineSimilarity } from "../../embedder.js";
19
+ /** Insert one build-history row. Idempotent on build_id (PK). */
20
+ export function insertBuildHistory(input, stateDir = getStateDir()) {
21
+ const db = openStore(stateDir);
22
+ const buildId = randomUUID();
23
+ const sid = normalizeSessionId(input.sessionId);
24
+ withTx(db, () => {
25
+ db.prepare(`INSERT INTO raptor_build_history
26
+ (build_id, session_id, state_dir, started_at, completed_at,
27
+ node_count, leaf_count, depth, config_json, coherence_score, timed_out)
28
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
29
+ ON CONFLICT(build_id) DO NOTHING`).run(buildId, sid, input.stateDir, input.startedAt, input.completedAt, input.nodeCount, input.leafCount, input.depth, input.configJson, input.coherenceScore, input.timedOut ? 1 : 0);
30
+ });
31
+ return buildId;
32
+ }
33
+ /** The most recent build row for a session (by completed_at), or null. */
34
+ export function getLatestBuild(sessionId, stateDir = getStateDir()) {
35
+ const db = openStore(stateDir);
36
+ const sid = normalizeSessionId(sessionId);
37
+ const row = db
38
+ .prepare(`SELECT build_id, session_id, state_dir, started_at, completed_at,
39
+ node_count, leaf_count, depth, config_json, coherence_score, timed_out
40
+ FROM raptor_build_history
41
+ WHERE session_id = ?
42
+ ORDER BY completed_at DESC
43
+ LIMIT 1`)
44
+ .get(sid);
45
+ if (!row)
46
+ return null;
47
+ return {
48
+ buildId: row.build_id,
49
+ sessionId: row.session_id,
50
+ stateDir: row.state_dir,
51
+ startedAt: row.started_at,
52
+ completedAt: row.completed_at,
53
+ nodeCount: row.node_count,
54
+ leafCount: row.leaf_count,
55
+ depth: row.depth,
56
+ configJson: row.config_json,
57
+ coherenceScore: row.coherence_score,
58
+ timedOut: row.timed_out === 1,
59
+ };
60
+ }
61
+ /** All build rows for a session, newest first. */
62
+ export function listBuildHistory(sessionId, stateDir = getStateDir()) {
63
+ const db = openStore(stateDir);
64
+ const sid = normalizeSessionId(sessionId);
65
+ const rows = db
66
+ .prepare(`SELECT build_id, session_id, state_dir, started_at, completed_at,
67
+ node_count, leaf_count, depth, config_json, coherence_score, timed_out
68
+ FROM raptor_build_history
69
+ WHERE session_id = ?
70
+ ORDER BY completed_at DESC`)
71
+ .all(sid);
72
+ return rows.map((row) => ({
73
+ buildId: row.build_id,
74
+ sessionId: row.session_id,
75
+ stateDir: row.state_dir,
76
+ startedAt: row.started_at,
77
+ completedAt: row.completed_at,
78
+ nodeCount: row.node_count,
79
+ leafCount: row.leaf_count,
80
+ depth: row.depth,
81
+ configJson: row.config_json,
82
+ coherenceScore: row.coherence_score,
83
+ timedOut: row.timed_out === 1,
84
+ }));
85
+ }
86
+ /**
87
+ * Compute the average intra-cluster cosine similarity for a RAPTOR tree.
88
+ * For each internal node, compute the mean pairwise cosine similarity of its
89
+ * children's embeddings. Average across all internal nodes. Returns 0–1
90
+ * (higher = more coherent clusters). A tree with no internal nodes returns 0.
91
+ *
92
+ * Child embeddings: a child that is itself an internal node contributes its own
93
+ * centroid. A child id not in `tree.nodes` is a raw leaf — leaves are not stored
94
+ * on RaptorNodes, so `leafEmbeddings` (id → embedding, from the build-time Leaf[]
95
+ * set) supplies the true leaf embedding; if absent, we fall back to the parent
96
+ * node's centroid. Passing `leafEmbeddings` is strongly recommended — without it
97
+ * every leaf collapses to its parent's centroid and the score inflates to ~1.0.
98
+ */
99
+ export function computeCoherenceScore(tree, leafEmbeddings) {
100
+ let sum = 0;
101
+ let count = 0;
102
+ for (const node of tree.nodes.values()) {
103
+ if (node.embedding.length === 0)
104
+ continue;
105
+ const childEmbeddings = [];
106
+ for (const cid of node.children) {
107
+ const child = tree.nodes.get(cid);
108
+ if (child && child.embedding.length > 0) {
109
+ childEmbeddings.push(child.embedding);
110
+ }
111
+ else {
112
+ // Raw leaf: use its true embedding if known, else parent centroid.
113
+ const leafEmb = leafEmbeddings?.get(cid);
114
+ childEmbeddings.push(leafEmb && leafEmb.length > 0 ? leafEmb : node.embedding);
115
+ }
116
+ }
117
+ if (childEmbeddings.length < 2)
118
+ continue; // need >=2 to measure spread
119
+ let pairSum = 0;
120
+ let pairCount = 0;
121
+ for (let i = 0; i < childEmbeddings.length; i++) {
122
+ for (let j = i + 1; j < childEmbeddings.length; j++) {
123
+ pairSum += cosineSimilarity(childEmbeddings[i], childEmbeddings[j]);
124
+ pairCount++;
125
+ }
126
+ }
127
+ if (pairCount > 0) {
128
+ sum += pairSum / pairCount;
129
+ count++;
130
+ }
131
+ }
132
+ return count > 0 ? sum / count : 0;
133
+ }
134
+ /**
135
+ * Check if the RAPTOR tree is fresh enough to skip a rebuild.
136
+ * Returns true iff:
137
+ * - a build-history row exists, AND
138
+ * - it completed within `freshnessHours`, AND
139
+ * - the checkpoint count has not changed by more than 20% since the build.
140
+ * A `freshnessHours` of 0 always returns false (rebuild every time) — the spec's
141
+ * "0 disables" escape hatch, symmetric to the S38 retry caps.
142
+ */
143
+ export function isRaptorTreeFresh(sessionId, stateDir, freshnessHours, currentCheckpointCount) {
144
+ if (freshnessHours <= 0)
145
+ return false;
146
+ const latest = getLatestBuild(sessionId, stateDir);
147
+ if (!latest)
148
+ return false;
149
+ const ageHours = (Date.now() - latest.completedAt) / 3_600_000;
150
+ if (ageHours > freshnessHours)
151
+ return false;
152
+ if (latest.leafCount <= 0)
153
+ return false;
154
+ const changeRatio = Math.abs(currentCheckpointCount - latest.leafCount) / latest.leafCount;
155
+ return changeRatio <= 0.2;
156
+ }
157
+ /** Clear build history for a session (used by tests + DR). */
158
+ export function clearBuildHistory(sessionId, stateDir = getStateDir()) {
159
+ const db = openStore(stateDir);
160
+ const sid = normalizeSessionId(sessionId);
161
+ withTx(db, () => {
162
+ db.prepare("DELETE FROM raptor_build_history WHERE session_id = ?").run(sid);
163
+ });
164
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * buildHistory.test.ts — S42D RAPTOR build history + freshness tests.
3
+ *
4
+ * No network. Real stores with temp state dirs.
5
+ */
6
+ import { test, beforeEach, afterEach } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { openStore } from "../../store/sqlite/utils.js";
12
+ import { insertBuildHistory, getLatestBuild, listBuildHistory, computeCoherenceScore, isRaptorTreeFresh, clearBuildHistory, } from "./buildHistory.js";
13
+ import { runRaptor } from "./index.js";
14
+ import { VectorStore, vectorList } from "../../vectorStore.js";
15
+ import { compactSession } from "../../engine.js";
16
+ import { loadDedupConfig } from "../../config/dedup.js";
17
+ import { normalizeSessionId } from "../../store.js";
18
+ import { Logger } from "../../log.js";
19
+ let tmpDir;
20
+ let counter = 0;
21
+ beforeEach(() => {
22
+ tmpDir = mkdtempSync(join(tmpdir(), "mc-bh-"));
23
+ });
24
+ afterEach(() => {
25
+ rmSync(tmpDir, { recursive: true, force: true });
26
+ });
27
+ function stateDir() {
28
+ return join(tmpDir, `run-${counter++}`);
29
+ }
30
+ function cfg(overrides) {
31
+ return {
32
+ ...loadDedupConfig(),
33
+ RAPTOR_ENABLED: true,
34
+ L0_ENABLED: false,
35
+ L1_ENABLED: false,
36
+ L2_ENABLED: false,
37
+ ...overrides,
38
+ };
39
+ }
40
+ function msg(text, toolName) {
41
+ return toolName
42
+ ? { role: "assistant", text, toolName, input: text, output: text }
43
+ : { role: "user", text };
44
+ }
45
+ // ─── 1. Build-history rows are created with correct metadata ──────────────
46
+ test("S42D-1: insertBuildHistory + getLatestBuild round-trip", () => {
47
+ const sd = stateDir();
48
+ openStore(sd); // initialize schema
49
+ const ts = Date.now();
50
+ const id = insertBuildHistory({
51
+ sessionId: "sess-foo",
52
+ stateDir: sd,
53
+ startedAt: ts - 500,
54
+ completedAt: ts,
55
+ nodeCount: 6,
56
+ leafCount: 10,
57
+ depth: 3,
58
+ configJson: '{"budgetMs":5000}',
59
+ coherenceScore: 0.82,
60
+ timedOut: false,
61
+ }, sd);
62
+ assert.ok(id, "build id returned");
63
+ const latest = getLatestBuild("sess-foo", sd);
64
+ assert.ok(latest, "latest build found");
65
+ assert.equal(latest.sessionId, normalizeSessionId("sess-foo"));
66
+ assert.equal(latest.nodeCount, 6);
67
+ assert.equal(latest.leafCount, 10);
68
+ assert.equal(latest.depth, 3);
69
+ assert.equal(latest.coherenceScore, 0.82);
70
+ assert.equal(latest.timedOut, false);
71
+ assert.equal(latest.configJson, '{"budgetMs":5000}');
72
+ // getLatest returns the newest by completed_at
73
+ insertBuildHistory({
74
+ sessionId: "sess-foo",
75
+ stateDir: sd,
76
+ startedAt: ts + 1000,
77
+ completedAt: ts + 2000,
78
+ nodeCount: 7,
79
+ leafCount: 11,
80
+ depth: 3,
81
+ configJson: "{}",
82
+ coherenceScore: null,
83
+ timedOut: true,
84
+ }, sd);
85
+ const newest = getLatestBuild("sess-foo", sd);
86
+ assert.equal(newest.nodeCount, 7);
87
+ assert.equal(newest.timedOut, true);
88
+ assert.equal(newest.coherenceScore, null);
89
+ // listBuildHistory returns all, newest first
90
+ const all = listBuildHistory("sess-foo", sd);
91
+ assert.equal(all.length, 2);
92
+ assert.equal(all[0].nodeCount, 7); // newest first
93
+ // No history for an unknown session
94
+ assert.equal(getLatestBuild("sess-none", sd), null);
95
+ assert.deepEqual(listBuildHistory("sess-none", sd), []);
96
+ // clearBuildHistory removes rows
97
+ clearBuildHistory("sess-foo", sd);
98
+ assert.equal(getLatestBuild("sess-foo", sd), null);
99
+ });
100
+ // ─── 2. Coherence score: 1.0 for identical, lower for diverse ───────────────
101
+ test("S42D-2: computeCoherenceScore is 1.0 for identical embeddings, lower for diverse", () => {
102
+ const identical = {
103
+ rootId: "root",
104
+ nodes: new Map([
105
+ ["root", { id: "root", level: 0, parentId: null, children: ["c1", "c2"], summary: "r", embedding: [1, 0], qualityMarker: "low", tokenEstimate: 2 }],
106
+ ["c1", { id: "c1", level: 1, parentId: "root", children: [], summary: "c1", embedding: [1, 0], qualityMarker: "low", tokenEstimate: 1 }],
107
+ ["c2", { id: "c2", level: 1, parentId: "root", children: [], summary: "c2", embedding: [1, 0], qualityMarker: "low", tokenEstimate: 1 }],
108
+ ]),
109
+ levels: 2,
110
+ timedOut: false,
111
+ };
112
+ assert.equal(computeCoherenceScore(identical), 1.0);
113
+ // Diverse: orthogonal children → cosine 0
114
+ const diverse = {
115
+ rootId: "root",
116
+ nodes: new Map([
117
+ ["root", { id: "root", level: 0, parentId: null, children: ["c1", "c2"], summary: "r", embedding: [1, 0], qualityMarker: "low", tokenEstimate: 2 }],
118
+ ["c1", { id: "c1", level: 1, parentId: "root", children: [], summary: "c1", embedding: [1, 0], qualityMarker: "low", tokenEstimate: 1 }],
119
+ ["c2", { id: "c2", level: 1, parentId: "root", children: [], summary: "c2", embedding: [0, 1], qualityMarker: "low", tokenEstimate: 1 }],
120
+ ]),
121
+ levels: 2,
122
+ timedOut: false,
123
+ };
124
+ const diverseScore = computeCoherenceScore(diverse);
125
+ assert.ok(diverseScore < 1.0, `diverse score < 1.0 (got ${diverseScore})`);
126
+ assert.ok(diverseScore >= 0, "score is non-negative");
127
+ // No internal nodes with >=2 children → 0
128
+ const single = {
129
+ rootId: "root",
130
+ nodes: new Map([
131
+ ["root", { id: "root", level: 0, parentId: null, children: ["c1"], summary: "r", embedding: [1, 0], qualityMarker: "low", tokenEstimate: 1 }],
132
+ ["c1", { id: "c1", level: 1, parentId: "root", children: [], summary: "c1", embedding: [1, 0], qualityMarker: "low", tokenEstimate: 1 }],
133
+ ]),
134
+ levels: 2,
135
+ timedOut: false,
136
+ };
137
+ assert.equal(computeCoherenceScore(single), 0);
138
+ });
139
+ // ─── 2b. Coherence with leaf embeddings (the real-tree shape) ──────────────
140
+ test("S42D-2b: computeCoherenceScore uses leafEmbeddings for leaf children (real-tree shape)", () => {
141
+ // Root whose children are raw leaves (the common collapse-path shape).
142
+ // Without leafEmbeddings, leaves fall back to the root centroid → score 1.0
143
+ // (degenerate). With true leaf embeddings, diverse leaves score < 1.0.
144
+ const tree = {
145
+ rootId: "root",
146
+ nodes: new Map([
147
+ ["root", { id: "root", level: 0, parentId: null, children: ["l1", "l2"], summary: "r", embedding: [1, 0], qualityMarker: "low", tokenEstimate: 2 }],
148
+ ]),
149
+ levels: 1,
150
+ timedOut: false,
151
+ };
152
+ // No leaf map → both leaves use root centroid [1,0] → cosine 1.0 (degenerate).
153
+ assert.equal(computeCoherenceScore(tree), 1.0);
154
+ // Leaf map with orthogonal leaves → cosine 0 (truly diverse).
155
+ const leafMap = new Map([
156
+ ["l1", [1, 0]],
157
+ ["l2", [0, 1]],
158
+ ]);
159
+ const score = computeCoherenceScore(tree, leafMap);
160
+ assert.ok(score < 1.0, `diverse leaves score < 1.0 (got ${score})`);
161
+ assert.ok(score >= 0, "score non-negative");
162
+ // Identical leaves via the map → 1.0
163
+ const leafMapIdentical = new Map([
164
+ ["l1", [1, 0]],
165
+ ["l2", [1, 0]],
166
+ ]);
167
+ assert.equal(computeCoherenceScore(tree, leafMapIdentical), 1.0);
168
+ });
169
+ // ─── 3. Freshness check: recent + stable → fresh ────────────────────────────
170
+ test("S42D-3: isRaptorTreeFresh true for recent build + stable count", () => {
171
+ const sd = stateDir();
172
+ openStore(sd);
173
+ insertBuildHistory({
174
+ sessionId: "sess-fresh",
175
+ stateDir: sd,
176
+ startedAt: Date.now() - 1000,
177
+ completedAt: Date.now() - 500, // < 4h old
178
+ nodeCount: 6,
179
+ leafCount: 10,
180
+ depth: 3,
181
+ configJson: "{}",
182
+ coherenceScore: 0.5,
183
+ timedOut: false,
184
+ }, sd);
185
+ // 10 checkpoints, latest had 10 → within 20% → fresh
186
+ assert.equal(isRaptorTreeFresh("sess-fresh", sd, 4, 10), true);
187
+ // 11 checkpoints → 10% change → still fresh
188
+ assert.equal(isRaptorTreeFresh("sess-fresh", sd, 4, 11), true);
189
+ // 9 checkpoints → 10% change → fresh
190
+ assert.equal(isRaptorTreeFresh("sess-fresh", sd, 4, 9), true);
191
+ });
192
+ // ─── 4. Freshness check: stale build → not fresh ────────────────────────────
193
+ test("S42D-4: isRaptorTreeFresh false for stale build (>4h old)", () => {
194
+ const sd = stateDir();
195
+ openStore(sd);
196
+ const oldTs = Date.now() - 5 * 3_600_000; // 5h ago
197
+ insertBuildHistory({
198
+ sessionId: "sess-stale",
199
+ stateDir: sd,
200
+ startedAt: oldTs - 1000,
201
+ completedAt: oldTs,
202
+ nodeCount: 6,
203
+ leafCount: 10,
204
+ depth: 3,
205
+ configJson: "{}",
206
+ coherenceScore: 0.5,
207
+ timedOut: false,
208
+ }, sd);
209
+ assert.equal(isRaptorTreeFresh("sess-stale", sd, 4, 10), false);
210
+ });
211
+ // ─── 5. Freshness check: count drift > 20% → not fresh ──────────────────────
212
+ test("S42D-5: isRaptorTreeFresh false when checkpoint count changed by >20%", () => {
213
+ const sd = stateDir();
214
+ openStore(sd);
215
+ insertBuildHistory({
216
+ sessionId: "sess-drift",
217
+ stateDir: sd,
218
+ startedAt: Date.now() - 1000,
219
+ completedAt: Date.now() - 500,
220
+ nodeCount: 6,
221
+ leafCount: 10,
222
+ depth: 3,
223
+ configJson: "{}",
224
+ coherenceScore: 0.5,
225
+ timedOut: false,
226
+ }, sd);
227
+ // 13 checkpoints vs 10 → 30% increase → not fresh
228
+ assert.equal(isRaptorTreeFresh("sess-drift", sd, 4, 13), false);
229
+ // 7 checkpoints vs 10 → 30% decrease → not fresh
230
+ assert.equal(isRaptorTreeFresh("sess-drift", sd, 4, 7), false);
231
+ });
232
+ // ─── 6. No build history → freshness false (forces build) ───────────────────
233
+ test("S42D-6: isRaptorTreeFresh false when no build history exists", () => {
234
+ const sd = stateDir();
235
+ openStore(sd);
236
+ assert.equal(isRaptorTreeFresh("sess-none", sd, 4, 50), false);
237
+ // freshnessHours=0 always false (disables the gate → always rebuild)
238
+ insertBuildHistory({
239
+ sessionId: "sess-zero",
240
+ stateDir: sd,
241
+ startedAt: Date.now() - 100,
242
+ completedAt: Date.now() - 50,
243
+ nodeCount: 1,
244
+ leafCount: 5,
245
+ depth: 1,
246
+ configJson: "{}",
247
+ coherenceScore: 0.5,
248
+ timedOut: false,
249
+ }, sd);
250
+ assert.equal(isRaptorTreeFresh("sess-zero", sd, 0, 5), false);
251
+ });
252
+ // ─── 7. runRaptor records build history end-to-end ──────────────────────────
253
+ test("S42D-7: runRaptor records a build-history row on real build", () => {
254
+ const sd = stateDir();
255
+ const s = new VectorStore({ dedupSim: 0.9, stateDir: sd, config: cfg() });
256
+ const sid = "bh-e2e";
257
+ // Seed 12 checkpoints and build a tree.
258
+ for (let i = 1; i <= 12; i++) {
259
+ compactSession({
260
+ sessionId: sid,
261
+ messages: [
262
+ msg(`build-history test checkpoint ${i} alpha beta`),
263
+ msg(`ack ${i}`, "Edit"),
264
+ ],
265
+ keepFrom: 2,
266
+ timestamp: i + 1,
267
+ }, s);
268
+ }
269
+ const nsid = normalizeSessionId(sid);
270
+ const all = vectorList(s, nsid);
271
+ const leaves = all.map((cp) => ({
272
+ id: cp.checkpointId,
273
+ messages: [],
274
+ sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
275
+ embedding: cp.embedding,
276
+ }));
277
+ const tree = runRaptor(leaves, {
278
+ stateDir: sd,
279
+ sessionId: nsid,
280
+ logger: new Logger(),
281
+ });
282
+ assert.ok(tree, "tree built");
283
+ const latest = getLatestBuild(nsid, sd);
284
+ assert.ok(latest, "build history recorded by runRaptor");
285
+ assert.equal(latest.leafCount, leaves.length);
286
+ assert.equal(latest.depth, tree.levels);
287
+ assert.equal(latest.timedOut, tree.timedOut);
288
+ assert.equal(latest.nodeCount, tree.nodes.size);
289
+ assert.ok(latest.coherenceScore !== null && latest.coherenceScore >= 0, "coherence score recorded");
290
+ // The freshness gate should now report fresh for the same checkpoint count.
291
+ assert.equal(isRaptorTreeFresh(nsid, sd, 4, all.length), true, "tree is fresh immediately after build");
292
+ });
@@ -13,6 +13,7 @@ import { defaultEmbedder } from "../../embedder.js";
13
13
  import { buildRaptorTree } from "./tree.js";
14
14
  import { stagedExpansion } from "./retrieval.js";
15
15
  import { saveRaptorTree, listRaptorNodes } from "../../store/sqlite.js";
16
+ import { insertBuildHistory, computeCoherenceScore } from "./buildHistory.js";
16
17
  /** Shadow mode is on by default; set RAPTOR_SHADOW_MODE=false to serve live. */
17
18
  export function isShadowMode() {
18
19
  return process.env.RAPTOR_SHADOW_MODE !== "false";
@@ -28,6 +29,7 @@ export function isShadowMode() {
28
29
  export function runRaptor(leaves, opts) {
29
30
  const embedder = opts.embedder ?? defaultEmbedder();
30
31
  const logger = opts.logger;
32
+ const startedAt = opts.builtAt ?? Date.now();
31
33
  try {
32
34
  const tree = buildRaptorTree(leaves, {
33
35
  embedder,
@@ -45,6 +47,42 @@ export function runRaptor(leaves, opts) {
45
47
  timedOut: tree.timedOut,
46
48
  shadow: isShadowMode(),
47
49
  });
50
+ // S42D: record structured build history (coherence score + leaf count) so
51
+ // the freshness check can skip the next rebuild when the tree is recent
52
+ // and the chunk count is stable. Best-effort: a history-write failure must
53
+ // never break the build.
54
+ try {
55
+ // Map leaf id → embedding so computeCoherenceScore can score real
56
+ // intra-cluster spread (without this, leaves collapse to parent centroids
57
+ // and the score inflates to ~1.0 for every tree).
58
+ const leafEmbs = new Map();
59
+ for (const l of leaves)
60
+ if (l.embedding.length > 0)
61
+ leafEmbs.set(l.id, l.embedding);
62
+ const coherence = computeCoherenceScore(tree, leafEmbs);
63
+ insertBuildHistory({
64
+ sessionId: opts.sessionId,
65
+ stateDir: opts.stateDir,
66
+ startedAt,
67
+ completedAt: Date.now(),
68
+ nodeCount: tree.nodes.size,
69
+ leafCount: leaves.length,
70
+ depth: tree.levels,
71
+ configJson: JSON.stringify({
72
+ budgetMs: opts.budgetMs,
73
+ clustersPerLevel: opts.clustersPerLevel,
74
+ consistencyThreshold: opts.consistencyThreshold,
75
+ }),
76
+ coherenceScore: coherence,
77
+ timedOut: tree.timedOut,
78
+ }, opts.stateDir);
79
+ }
80
+ catch (histErr) {
81
+ logger?.warn("raptor_build_history_failed", {
82
+ sessionId: opts.sessionId,
83
+ error: String(histErr instanceof Error ? histErr.message : histErr),
84
+ });
85
+ }
48
86
  if (isShadowMode()) {
49
87
  // Build + log only. Do NOT replace retrieval.
50
88
  logger?.info("raptor_shadow", { sessionId: opts.sessionId, served: false });