pi-mega-compact 0.11.9 → 0.11.10

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.
@@ -240,7 +240,7 @@ export function registerContextHandler(pi, runtime, config) {
240
240
  if (runtime.trimCache &&
241
241
  runtime.trimCache.checkpointId === runtime.rt.lastCheckpointId &&
242
242
  runtime.trimCache.cut <= messages.length) {
243
- const recent = messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002
243
+ const recent = messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002: cached `cut` was sanitized by computeLiveTrimCut (src/boundary.ts); replayed verbatim, transcript only grows within an epoch.
244
244
  runtime.diagLiveTrimFires++;
245
245
  runtime.diagLiveTrimReplays++;
246
246
  runtime.snapshot(ctx);
@@ -64,6 +64,7 @@ export function loadDedupConfig() {
64
64
  RAPTOR_MULTILEVEL_ENABLED: envBool("MEGACOMPACT_RAPTOR_MULTILEVEL", true),
65
65
  RAPTOR_LEVEL_WEIGHTS: envNumArray("MEGACOMPACT_RAPTOR_LEVEL_WEIGHTS", [1.0, 0.9, 0.8, 0.7, 0.5]),
66
66
  RAPTOR_LEAF_EXPANSION: envBool("MEGACOMPACT_RAPTOR_LEAF_EXPANSION", true),
67
+ RAPTOR_INCREMENTAL: envBool("MEGACOMPACT_RAPTOR_INCREMENTAL", true),
67
68
  RAPTOR_MAX_LEAF_EXPANSION: envNum("MEGACOMPACT_RAPTOR_MAX_LEAF_EXP", 10),
68
69
  RAPTOR_FRESHNESS_HOURS: envNum("MEGACOMPACT_RAPTOR_FRESHNESS_HOURS", 4),
69
70
  RAPTOR_INJECT_SUMMARIES: envBool("MEGACOMPACT_RAPTOR_INJECT_SUMMARIES", true),
@@ -0,0 +1,276 @@
1
+ /**
2
+ * incremental.ts — incremental RAPTOR tree update (Sprint 26, #7).
3
+ *
4
+ * Instead of rebuilding the full tree (~2s per compaction), insert only the new
5
+ * checkpoints since the last build and recompute only the affected cluster
6
+ * assignments up the tree. Falls back to a full rebuild when the tree is
7
+ * missing, corrupted, or >50% of nodes are new.
8
+ *
9
+ * Non-fatal: every failure logs and returns null (caller falls back to full
10
+ * rebuild). PREVENT-PI-004: zero network; all operations are local SQLite + CPU.
11
+ */
12
+ import { cosineSimilarity } from "../../embedder.js";
13
+ import { defaultEmbedder as getDefaultEmbedder } from "../../embedder.js";
14
+ import { buildRaptorTree } from "./tree.js";
15
+ import { meanVector } from "./kmeans.js";
16
+ import { summarizeCluster } from "./summarizer.js";
17
+ import { saveRaptorTree, listRaptorNodes, } from "../../store/sqlite.js";
18
+ /** When >FRESH_THRESHOLD of existing leaves are new, a full rebuild is cheaper. */
19
+ const FRESH_THRESHOLD = 0.5;
20
+ /**
21
+ * Incrementally update a persisted RAPTOR tree with new leaves.
22
+ *
23
+ * 1. Reads the existing tree from the store.
24
+ * 2. Filters `newLeaves` to those NOT already covered by the tree.
25
+ * 3. If no new leaves → early-return (existing tree).
26
+ * 4. If no existing tree or >50% new → full rebuild.
27
+ * 5. For each new leaf, finds the best-matching Level-0 cluster node,
28
+ * reassigns (inserts into its children), updates that node's summary
29
+ * and centroid embedding.
30
+ * 6. Propagates changes up through parent levels to the root.
31
+ * 7. Persists the updated tree (full delete + reinsert in a tx).
32
+ *
33
+ * Never throws. Returns the updated tree, or null on failure/fallback.
34
+ */
35
+ export function incrementRaptorTree(existingLeaves, newLeaves, opts) {
36
+ const logger = opts.logger;
37
+ const builtAt = opts.builtAt ?? Date.now();
38
+ try {
39
+ // 1. Read existing tree.
40
+ const storedNodes = listRaptorNodes(opts.sessionId, opts.stateDir);
41
+ if (storedNodes.length === 0) {
42
+ logger?.info("raptor_incremental_no_tree", { sessionId: opts.sessionId });
43
+ return null; // no existing tree — caller falls back to full buildRaptorTree
44
+ }
45
+ // 2. Find new leaf ids not already in the tree.
46
+ const coveredLeafIds = new Set();
47
+ for (const n of storedNodes) {
48
+ for (const cid of n.children)
49
+ coveredLeafIds.add(cid);
50
+ }
51
+ const reallyNewLeaves = newLeaves.filter((l) => !coveredLeafIds.has(l.id));
52
+ const totalCovered = coveredLeafIds.size;
53
+ if (reallyNewLeaves.length === 0) {
54
+ logger?.info("raptor_incremental_no_new", { sessionId: opts.sessionId });
55
+ return rehydrateFromStored(storedNodes);
56
+ }
57
+ // 4. Fallback guard: >50% new → full rebuild.
58
+ if (totalCovered > 0 &&
59
+ reallyNewLeaves.length / totalCovered > FRESH_THRESHOLD) {
60
+ logger?.info("raptor_incremental_fallback_ratio", {
61
+ sessionId: opts.sessionId,
62
+ coveredNodes: totalCovered,
63
+ newLeaves: reallyNewLeaves.length,
64
+ threshold: FRESH_THRESHOLD,
65
+ });
66
+ return fullRebuild(existingLeaves, reallyNewLeaves, opts, logger);
67
+ }
68
+ // 5. Build the in-memory tree from stored nodes.
69
+ const tree = rehydrateFromStored(storedNodes);
70
+ if (!tree || tree.nodes.size === 0) {
71
+ logger?.info("raptor_incremental_rehydrate_failed", {
72
+ sessionId: opts.sessionId,
73
+ });
74
+ return fullRebuild(existingLeaves, reallyNewLeaves, opts, logger);
75
+ }
76
+ // Group stored nodes by level.
77
+ const levelGroups = new Map();
78
+ for (const n of storedNodes) {
79
+ const g = levelGroups.get(n.level);
80
+ if (g)
81
+ g.push(n);
82
+ else
83
+ levelGroups.set(n.level, [n]);
84
+ }
85
+ // Level-0 covers leaf ids directly.
86
+ const level0Nodes = levelGroups.get(0) ??
87
+ storedNodes.filter((n) => {
88
+ // A node is level 0 if its level is the minimum.
89
+ const minLevel = Math.min(...storedNodes.map((x) => x.level));
90
+ return n.level === minLevel;
91
+ });
92
+ if (level0Nodes.length === 0) {
93
+ // Single-root tree with no hierarchy: cannot increment, fall back.
94
+ return fullRebuild(existingLeaves, reallyNewLeaves, opts, logger);
95
+ }
96
+ // For each new leaf, assign to the nearest Level-0 cluster node.
97
+ const leafEmbeddings = new Map();
98
+ for (const l of reallyNewLeaves)
99
+ leafEmbeddings.set(l.id, l.embedding);
100
+ // Track which Level-0 nodes changed (by their stored id).
101
+ const affectedNodeIds = new Set();
102
+ for (const leaf of reallyNewLeaves) {
103
+ const emb = leafEmbeddings.get(leaf.id);
104
+ if (!emb || emb.length === 0)
105
+ continue;
106
+ // Find best matching Level-0 node.
107
+ let bestNode = null;
108
+ let bestSim = -Infinity;
109
+ for (const cn of level0Nodes) {
110
+ const sim = cosineSimilarity(emb, cn.embedding);
111
+ if (sim > bestSim) {
112
+ bestSim = sim;
113
+ bestNode = cn;
114
+ }
115
+ }
116
+ if (!bestNode)
117
+ continue;
118
+ // Add the leaf id to this node's children.
119
+ const updated = tree.nodes.get(bestNode.id);
120
+ if (updated) {
121
+ if (!updated.children.includes(leaf.id)) {
122
+ updated.children.push(leaf.id);
123
+ }
124
+ affectedNodeIds.add(bestNode.id);
125
+ }
126
+ }
127
+ if (affectedNodeIds.size === 0) {
128
+ // No matches found — fall back to full rebuild.
129
+ logger?.info("raptor_incremental_no_matches", {
130
+ sessionId: opts.sessionId,
131
+ });
132
+ return fullRebuild(existingLeaves, reallyNewLeaves, opts, logger);
133
+ }
134
+ // 6. Recompute embeddings and summaries for affected nodes, bottom-up.
135
+ // Build a map: leafId → leaf info for summarization.
136
+ const leafMap = new Map();
137
+ for (const l of existingLeaves)
138
+ leafMap.set(l.id, l);
139
+ for (const l of reallyNewLeaves)
140
+ leafMap.set(l.id, l);
141
+ // Recompute affected Level-0 nodes.
142
+ for (const nid of affectedNodeIds) {
143
+ const node = tree.nodes.get(nid);
144
+ if (!node)
145
+ continue;
146
+ const coveredLeaves = node.children
147
+ .map((cid) => leafMap.get(cid))
148
+ .filter((l) => !!l);
149
+ if (coveredLeaves.length > 0) {
150
+ node.embedding = meanVector(coveredLeaves.map((l) => l.embedding));
151
+ // Summarize: flatten messages from all covered leaves.
152
+ const messages = coveredLeaves.flatMap((l) => l.messages);
153
+ if (messages.length > 0) {
154
+ const cs = summarizeCluster(messages);
155
+ node.summary = cs.summary;
156
+ node.tokenEstimate = cs.tokenEstimate;
157
+ }
158
+ }
159
+ }
160
+ // Propagate up: for each higher level, recompute nodes whose leaf set
161
+ // includes any newly inserted leaf. Since `children` stores flattened leaf
162
+ // ids (not node ids), we check intersection with the new leaf id set.
163
+ const newLeafIds = new Set(reallyNewLeaves.map((l) => l.id));
164
+ const sortedLevels = [...levelGroups.keys()].sort((a, b) => a - b);
165
+ for (const level of sortedLevels.slice(1)) {
166
+ const levelNodes = levelGroups.get(level) ?? [];
167
+ for (const stored of levelNodes) {
168
+ const node = tree.nodes.get(stored.id);
169
+ if (!node)
170
+ continue;
171
+ if (node.children.some((cid) => newLeafIds.has(cid))) {
172
+ // Recompute embedding as centroid of covered leaves.
173
+ const covered = node.children
174
+ .map((cid) => leafMap.get(cid))
175
+ .filter((l) => !!l);
176
+ if (covered.length > 0) {
177
+ node.embedding = meanVector(covered.map((l) => l.embedding));
178
+ const messages = covered.flatMap((l) => l.messages);
179
+ if (messages.length > 0) {
180
+ const cs = summarizeCluster(messages);
181
+ node.summary = cs.summary;
182
+ node.tokenEstimate = cs.tokenEstimate;
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+ // 7. Persist the full updated tree (delete + reinsert in a tx).
189
+ tree.builtAt = builtAt;
190
+ saveRaptorTree(opts.sessionId, tree, builtAt, opts.stateDir);
191
+ logger?.info("raptor_incremental_success", {
192
+ sessionId: opts.sessionId,
193
+ nodeCount: tree.nodes.size,
194
+ newLeaves: reallyNewLeaves.length,
195
+ affectedNodes: affectedNodeIds.size,
196
+ });
197
+ return tree;
198
+ }
199
+ catch (e) {
200
+ logger?.error("raptor_incremental_failed", {
201
+ sessionId: opts.sessionId,
202
+ error: String(e instanceof Error ? e.message : e),
203
+ });
204
+ return null;
205
+ }
206
+ }
207
+ // ---------------------------------------------------------------------------
208
+ // Helpers
209
+ // ---------------------------------------------------------------------------
210
+ /**
211
+ * Full rebuild fallback: merges existingLeaves + newLeaves, builds from scratch.
212
+ */
213
+ function fullRebuild(existingLeaves, newLeaves, opts, logger) {
214
+ logger?.info("raptor_incremental_full_rebuild", {
215
+ sessionId: opts.sessionId,
216
+ });
217
+ try {
218
+ // Merge: deduplicate by leaf id.
219
+ const seen = new Set();
220
+ const allLeaves = [];
221
+ for (const l of existingLeaves) {
222
+ if (!seen.has(l.id)) {
223
+ seen.add(l.id);
224
+ allLeaves.push(l);
225
+ }
226
+ }
227
+ for (const l of newLeaves) {
228
+ if (!seen.has(l.id)) {
229
+ seen.add(l.id);
230
+ allLeaves.push(l);
231
+ }
232
+ }
233
+ const tree = buildRaptorTree(allLeaves, {
234
+ embedder: opts.embedder ?? getDefaultEmbedder(),
235
+ budgetMs: opts.budgetMs,
236
+ clustersPerLevel: opts.clustersPerLevel,
237
+ consistencyThreshold: opts.consistencyThreshold,
238
+ });
239
+ const builtAt = opts.builtAt ?? Date.now();
240
+ saveRaptorTree(opts.sessionId, tree, builtAt, opts.stateDir);
241
+ return tree;
242
+ }
243
+ catch (e2) {
244
+ logger?.error("raptor_incremental_full_rebuild_failed", {
245
+ sessionId: opts.sessionId,
246
+ error: String(e2 instanceof Error ? e2.message : e2),
247
+ });
248
+ return null;
249
+ }
250
+ }
251
+ /** Rebuild the in-memory RaptorTree from stored nodes. */
252
+ function rehydrateFromStored(nodes) {
253
+ if (nodes.length === 0)
254
+ return null;
255
+ const root = nodes.reduce((best, n) => (!best || n.level > (best?.level ?? -1) ? n : best), null);
256
+ const tree = {
257
+ nodes: new Map(nodes.map((n) => [
258
+ n.id,
259
+ {
260
+ id: n.id,
261
+ level: n.level,
262
+ parentId: n.parentId,
263
+ children: n.children,
264
+ summary: n.summary,
265
+ embedding: n.embedding,
266
+ qualityMarker: n.qualityMarker,
267
+ tokenEstimate: n.tokenEstimate,
268
+ },
269
+ ])),
270
+ rootId: root?.id ?? null,
271
+ levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
272
+ timedOut: root != null && root.level >= 99,
273
+ builtAt: nodes.reduce((max, n) => Math.max(max, n.builtAt), 0),
274
+ };
275
+ return tree;
276
+ }
@@ -11,9 +11,11 @@
11
11
  */
12
12
  import { defaultEmbedder } from "../../embedder.js";
13
13
  import { buildRaptorTree } from "./tree.js";
14
+ import { incrementRaptorTree } from "./incremental.js";
14
15
  import { stagedExpansion } from "./retrieval.js";
15
16
  import { saveRaptorTree, listRaptorNodes } from "../../store/sqlite.js";
16
17
  import { insertBuildHistory, computeCoherenceScore } from "./buildHistory.js";
18
+ import { loadDedupConfig } from "../../config/dedup.js";
17
19
  /** Shadow mode is on by default; set RAPTOR_SHADOW_MODE=false to serve live. */
18
20
  export function isShadowMode() {
19
21
  return process.env.RAPTOR_SHADOW_MODE !== "false";
@@ -30,15 +32,37 @@ export function runRaptor(leaves, opts) {
30
32
  const embedder = opts.embedder ?? defaultEmbedder();
31
33
  const logger = opts.logger;
32
34
  const startedAt = opts.builtAt ?? Date.now();
35
+ const builtAt = startedAt;
33
36
  try {
34
- const tree = buildRaptorTree(leaves, {
35
- embedder,
36
- budgetMs: opts.budgetMs,
37
- clustersPerLevel: opts.clustersPerLevel,
38
- consistencyThreshold: opts.consistencyThreshold,
39
- });
40
- const builtAt = opts.builtAt ?? Date.now();
41
- saveRaptorTree(opts.sessionId, tree, builtAt, opts.stateDir);
37
+ // Try incremental update first when the flag is ON (Sprint 26, #7).
38
+ // The incremental path reads the existing tree, diffs by leaf id, and
39
+ // inserts only new leaves into the nearest clusters. Falls back to a full
40
+ // rebuild when the tree is missing, corrupted, or >50% of nodes are new.
41
+ const cfg = loadDedupConfig();
42
+ let tree = null;
43
+ if (cfg.RAPTOR_INCREMENTAL) {
44
+ tree = incrementRaptorTree(leaves, leaves, {
45
+ embedder,
46
+ stateDir: opts.stateDir,
47
+ sessionId: opts.sessionId,
48
+ budgetMs: opts.budgetMs,
49
+ clustersPerLevel: opts.clustersPerLevel,
50
+ consistencyThreshold: opts.consistencyThreshold,
51
+ logger,
52
+ builtAt,
53
+ });
54
+ }
55
+ // Fall back to full rebuild when incremental returned null (not attempted,
56
+ // tree missing, ratio exceeded, or internal error).
57
+ if (!tree) {
58
+ tree = buildRaptorTree(leaves, {
59
+ embedder,
60
+ budgetMs: opts.budgetMs,
61
+ clustersPerLevel: opts.clustersPerLevel,
62
+ consistencyThreshold: opts.consistencyThreshold,
63
+ });
64
+ saveRaptorTree(opts.sessionId, tree, builtAt, opts.stateDir);
65
+ }
42
66
  logger?.info("raptor_build", {
43
67
  sessionId: opts.sessionId,
44
68
  nodes: tree.nodes.size,
@@ -17,7 +17,7 @@ function ollamaEndpoint() {
17
17
  const model = process.env.MEGACOMPACT_RAPTOR_MODEL;
18
18
  if (!model)
19
19
  return null;
20
- const base = process.env.MEGACOMPACT_RAPTOR_URL ?? "http://127.0.0.1:11434";
20
+ const base = process.env.MEGACOMPACT_RAPTOR_URL ?? "http://127.0.0.1:11434"; // guardrails-allow PREVENT-PI-004: loopback-only Ollama endpoint for local RAPTOR summarization
21
21
  // Guard: only loopback is permitted (remote Ollama would violate PREVENT-PI-004).
22
22
  if (!/^https?:\/\/(localhost|127\.0\.0\.1)([:/]|$)/.test(base)) {
23
23
  throw new Error(`MEGACOMPACT_RAPTOR_URL must be localhost/127.0.0.1 (got ${base}). ` +
@@ -26,7 +26,7 @@
26
26
  */
27
27
  import { l2Normalize } from "./embedder.js";
28
28
  import { spawnSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: localhost-only user-spawned embedding server (BYO backend, never remote)
29
- import { isIP } from "node:net";
29
+ import { isIP } from "node:net"; // guardrails-allow PREVENT-PI-004: localhost-only loopback address validation in BYO embedding server
30
30
  // Inline worker script: resolves a hostname via dns.lookup in a child process
31
31
  // (dns.lookup is callback-async; the child has its own event loop). Used to
32
32
  // verify that a hostname in the embedding URL resolves to loopback ONLY.
@@ -293,7 +293,7 @@ export function registerContextHandler(
293
293
  runtime.trimCache.checkpointId === runtime.rt.lastCheckpointId &&
294
294
  runtime.trimCache.cut <= messages.length
295
295
  ) {
296
- const recent = messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002
296
+ const recent = messages.slice(runtime.trimCache.cut); // guardrails-allow PREVENT-PI-002: cached `cut` was sanitized by computeLiveTrimCut (src/boundary.ts); replayed verbatim, transcript only grows within an epoch.
297
297
  runtime.diagLiveTrimFires++;
298
298
  runtime.diagLiveTrimReplays++;
299
299
  runtime.snapshot(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.11.9",
3
+ "version": "0.11.10",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",
@@ -66,6 +66,7 @@ export interface DedupConfigShape {
66
66
  RAPTOR_MULTILEVEL_ENABLED: boolean;
67
67
  RAPTOR_LEVEL_WEIGHTS: number[]; // per-level weights, index 0 = leaves (uncalibrated)
68
68
  RAPTOR_LEAF_EXPANSION: boolean;
69
+ RAPTOR_INCREMENTAL: boolean;
69
70
  RAPTOR_MAX_LEAF_EXPANSION: number; // uncalibrated
70
71
  RAPTOR_FRESHNESS_HOURS: number; // S42D: skip rebuild when tree is fresh (uncalibrated)
71
72
  /** S25 Phase-2: inject top-level RAPTOR summary nodes (root + level-1
@@ -109,6 +110,7 @@ export function loadDedupConfig(): DedupConfigShape {
109
110
  RAPTOR_MULTILEVEL_ENABLED: envBool("MEGACOMPACT_RAPTOR_MULTILEVEL", true),
110
111
  RAPTOR_LEVEL_WEIGHTS: envNumArray("MEGACOMPACT_RAPTOR_LEVEL_WEIGHTS", [1.0, 0.9, 0.8, 0.7, 0.5]),
111
112
  RAPTOR_LEAF_EXPANSION: envBool("MEGACOMPACT_RAPTOR_LEAF_EXPANSION", true),
113
+ RAPTOR_INCREMENTAL: envBool("MEGACOMPACT_RAPTOR_INCREMENTAL", true),
112
114
  RAPTOR_MAX_LEAF_EXPANSION: envNum("MEGACOMPACT_RAPTOR_MAX_LEAF_EXP", 10),
113
115
  RAPTOR_FRESHNESS_HOURS: envNum("MEGACOMPACT_RAPTOR_FRESHNESS_HOURS", 4),
114
116
  RAPTOR_INJECT_SUMMARIES: envBool("MEGACOMPACT_RAPTOR_INJECT_SUMMARIES", true),
@@ -0,0 +1,339 @@
1
+ /**
2
+ * incremental.ts — incremental RAPTOR tree update (Sprint 26, #7).
3
+ *
4
+ * Instead of rebuilding the full tree (~2s per compaction), insert only the new
5
+ * checkpoints since the last build and recompute only the affected cluster
6
+ * assignments up the tree. Falls back to a full rebuild when the tree is
7
+ * missing, corrupted, or >50% of nodes are new.
8
+ *
9
+ * Non-fatal: every failure logs and returns null (caller falls back to full
10
+ * rebuild). PREVENT-PI-004: zero network; all operations are local SQLite + CPU.
11
+ */
12
+
13
+ import type { Embedder, Vector } from "../../embedder.js";
14
+ import { cosineSimilarity } from "../../embedder.js";
15
+ import { defaultEmbedder as getDefaultEmbedder } from "../../embedder.js";
16
+ import { buildRaptorTree, type Leaf, type RaptorTree } from "./tree.js";
17
+ import type { QualityMarker } from "./guardrails.js";
18
+ import { meanVector } from "./kmeans.js";
19
+ import { summarizeCluster } from "./summarizer.js";
20
+ import type { Logger } from "../../log.js";
21
+ import {
22
+ saveRaptorTree,
23
+ listRaptorNodes,
24
+ type StoredRaptorNode,
25
+ } from "../../store/sqlite.js";
26
+
27
+ /** When >FRESH_THRESHOLD of existing leaves are new, a full rebuild is cheaper. */
28
+ const FRESH_THRESHOLD = 0.5;
29
+
30
+ /**
31
+ * Incrementally update a persisted RAPTOR tree with new leaves.
32
+ *
33
+ * 1. Reads the existing tree from the store.
34
+ * 2. Filters `newLeaves` to those NOT already covered by the tree.
35
+ * 3. If no new leaves → early-return (existing tree).
36
+ * 4. If no existing tree or >50% new → full rebuild.
37
+ * 5. For each new leaf, finds the best-matching Level-0 cluster node,
38
+ * reassigns (inserts into its children), updates that node's summary
39
+ * and centroid embedding.
40
+ * 6. Propagates changes up through parent levels to the root.
41
+ * 7. Persists the updated tree (full delete + reinsert in a tx).
42
+ *
43
+ * Never throws. Returns the updated tree, or null on failure/fallback.
44
+ */
45
+ export function incrementRaptorTree(
46
+ existingLeaves: Leaf[],
47
+ newLeaves: Leaf[],
48
+ opts: {
49
+ embedder?: Embedder;
50
+ stateDir: string;
51
+ sessionId: string;
52
+ budgetMs?: number;
53
+ clustersPerLevel?: number;
54
+ consistencyThreshold?: number;
55
+ logger?: Logger;
56
+ builtAt?: number;
57
+ },
58
+ ): RaptorTree | null {
59
+ const logger = opts.logger;
60
+ const builtAt = opts.builtAt ?? Date.now();
61
+
62
+ try {
63
+ // 1. Read existing tree.
64
+ const storedNodes = listRaptorNodes(opts.sessionId, opts.stateDir);
65
+ if (storedNodes.length === 0) {
66
+ logger?.info("raptor_incremental_no_tree", { sessionId: opts.sessionId });
67
+ return null; // no existing tree — caller falls back to full buildRaptorTree
68
+ }
69
+
70
+ // 2. Find new leaf ids not already in the tree.
71
+ const coveredLeafIds = new Set<string>();
72
+ for (const n of storedNodes) {
73
+ for (const cid of n.children) coveredLeafIds.add(cid);
74
+ }
75
+ const reallyNewLeaves = newLeaves.filter(
76
+ (l) => !coveredLeafIds.has(l.id),
77
+ );
78
+ const totalCovered = coveredLeafIds.size;
79
+
80
+ if (reallyNewLeaves.length === 0) {
81
+ logger?.info("raptor_incremental_no_new", { sessionId: opts.sessionId });
82
+ return rehydrateFromStored(storedNodes);
83
+ }
84
+
85
+ // 4. Fallback guard: >50% new → full rebuild.
86
+ if (
87
+ totalCovered > 0 &&
88
+ reallyNewLeaves.length / totalCovered > FRESH_THRESHOLD
89
+ ) {
90
+ logger?.info("raptor_incremental_fallback_ratio", {
91
+ sessionId: opts.sessionId,
92
+ coveredNodes: totalCovered,
93
+ newLeaves: reallyNewLeaves.length,
94
+ threshold: FRESH_THRESHOLD,
95
+ });
96
+ return fullRebuild(existingLeaves, reallyNewLeaves, opts, logger);
97
+ }
98
+
99
+ // 5. Build the in-memory tree from stored nodes.
100
+ const tree = rehydrateFromStored(storedNodes);
101
+ if (!tree || tree.nodes.size === 0) {
102
+ logger?.info("raptor_incremental_rehydrate_failed", {
103
+ sessionId: opts.sessionId,
104
+ });
105
+ return fullRebuild(existingLeaves, reallyNewLeaves, opts, logger);
106
+ }
107
+
108
+ // Group stored nodes by level.
109
+ const levelGroups = new Map<number, StoredRaptorNode[]>();
110
+ for (const n of storedNodes) {
111
+ const g = levelGroups.get(n.level);
112
+ if (g) g.push(n);
113
+ else levelGroups.set(n.level, [n]);
114
+ }
115
+ // Level-0 covers leaf ids directly.
116
+ const level0Nodes =
117
+ levelGroups.get(0) ??
118
+ storedNodes.filter((n) => {
119
+ // A node is level 0 if its level is the minimum.
120
+ const minLevel = Math.min(...storedNodes.map((x) => x.level));
121
+ return n.level === minLevel;
122
+ });
123
+ if (level0Nodes.length === 0) {
124
+ // Single-root tree with no hierarchy: cannot increment, fall back.
125
+ return fullRebuild(existingLeaves, reallyNewLeaves, opts, logger);
126
+ }
127
+
128
+ // For each new leaf, assign to the nearest Level-0 cluster node.
129
+ const leafEmbeddings = new Map<string, Vector>();
130
+ for (const l of reallyNewLeaves) leafEmbeddings.set(l.id, l.embedding);
131
+
132
+ // Track which Level-0 nodes changed (by their stored id).
133
+ const affectedNodeIds = new Set<string>();
134
+
135
+ for (const leaf of reallyNewLeaves) {
136
+ const emb = leafEmbeddings.get(leaf.id);
137
+ if (!emb || emb.length === 0) continue;
138
+
139
+ // Find best matching Level-0 node.
140
+ let bestNode: StoredRaptorNode | null = null;
141
+ let bestSim = -Infinity;
142
+ for (const cn of level0Nodes) {
143
+ const sim = cosineSimilarity(emb, cn.embedding);
144
+ if (sim > bestSim) {
145
+ bestSim = sim;
146
+ bestNode = cn;
147
+ }
148
+ }
149
+ if (!bestNode) continue;
150
+
151
+ // Add the leaf id to this node's children.
152
+ const updated = tree.nodes.get(bestNode.id);
153
+ if (updated) {
154
+ if (!updated.children.includes(leaf.id)) {
155
+ updated.children.push(leaf.id);
156
+ }
157
+ affectedNodeIds.add(bestNode.id);
158
+ }
159
+ }
160
+
161
+ if (affectedNodeIds.size === 0) {
162
+ // No matches found — fall back to full rebuild.
163
+ logger?.info("raptor_incremental_no_matches", {
164
+ sessionId: opts.sessionId,
165
+ });
166
+ return fullRebuild(existingLeaves, reallyNewLeaves, opts, logger);
167
+ }
168
+
169
+ // 6. Recompute embeddings and summaries for affected nodes, bottom-up.
170
+ // Build a map: leafId → leaf info for summarization.
171
+ const leafMap = new Map<string, Leaf>();
172
+ for (const l of existingLeaves) leafMap.set(l.id, l);
173
+ for (const l of reallyNewLeaves) leafMap.set(l.id, l);
174
+
175
+ // Recompute affected Level-0 nodes.
176
+ for (const nid of affectedNodeIds) {
177
+ const node = tree.nodes.get(nid);
178
+ if (!node) continue;
179
+ const coveredLeaves = node.children
180
+ .map((cid) => leafMap.get(cid))
181
+ .filter((l): l is Leaf => !!l);
182
+ if (coveredLeaves.length > 0) {
183
+ node.embedding = meanVector(
184
+ coveredLeaves.map((l) => l.embedding),
185
+ );
186
+ // Summarize: flatten messages from all covered leaves.
187
+ const messages = coveredLeaves.flatMap((l) => l.messages);
188
+ if (messages.length > 0) {
189
+ const cs = summarizeCluster(messages);
190
+ node.summary = cs.summary;
191
+ node.tokenEstimate = cs.tokenEstimate;
192
+ }
193
+ }
194
+ }
195
+
196
+ // Propagate up: for each higher level, recompute nodes whose leaf set
197
+ // includes any newly inserted leaf. Since `children` stores flattened leaf
198
+ // ids (not node ids), we check intersection with the new leaf id set.
199
+ const newLeafIds = new Set(reallyNewLeaves.map((l) => l.id));
200
+ const sortedLevels = [...levelGroups.keys()].sort((a, b) => a - b);
201
+ for (const level of sortedLevels.slice(1)) {
202
+ const levelNodes = levelGroups.get(level) ?? [];
203
+ for (const stored of levelNodes) {
204
+ const node = tree.nodes.get(stored.id);
205
+ if (!node) continue;
206
+
207
+ if (node.children.some((cid) => newLeafIds.has(cid))) {
208
+ // Recompute embedding as centroid of covered leaves.
209
+ const covered = node.children
210
+ .map((cid) => leafMap.get(cid))
211
+ .filter((l): l is Leaf => !!l);
212
+ if (covered.length > 0) {
213
+ node.embedding = meanVector(
214
+ covered.map((l) => l.embedding),
215
+ );
216
+ const messages = covered.flatMap((l) => l.messages);
217
+ if (messages.length > 0) {
218
+ const cs = summarizeCluster(messages);
219
+ node.summary = cs.summary;
220
+ node.tokenEstimate = cs.tokenEstimate;
221
+ }
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ // 7. Persist the full updated tree (delete + reinsert in a tx).
228
+ tree.builtAt = builtAt;
229
+ saveRaptorTree(opts.sessionId, tree, builtAt, opts.stateDir);
230
+
231
+ logger?.info("raptor_incremental_success", {
232
+ sessionId: opts.sessionId,
233
+ nodeCount: tree.nodes.size,
234
+ newLeaves: reallyNewLeaves.length,
235
+ affectedNodes: affectedNodeIds.size,
236
+ });
237
+
238
+ return tree;
239
+ } catch (e) {
240
+ logger?.error("raptor_incremental_failed", {
241
+ sessionId: opts.sessionId,
242
+ error: String(e instanceof Error ? e.message : e),
243
+ });
244
+ return null;
245
+ }
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Helpers
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /**
253
+ * Full rebuild fallback: merges existingLeaves + newLeaves, builds from scratch.
254
+ */
255
+ function fullRebuild(
256
+ existingLeaves: Leaf[],
257
+ newLeaves: Leaf[],
258
+ opts: {
259
+ embedder?: Embedder;
260
+ budgetMs?: number;
261
+ clustersPerLevel?: number;
262
+ consistencyThreshold?: number;
263
+ stateDir: string;
264
+ sessionId: string;
265
+ logger?: Logger;
266
+ builtAt?: number;
267
+ },
268
+ logger?: Logger,
269
+ ): RaptorTree | null {
270
+ logger?.info("raptor_incremental_full_rebuild", {
271
+ sessionId: opts.sessionId,
272
+ });
273
+ try {
274
+ // Merge: deduplicate by leaf id.
275
+ const seen = new Set<string>();
276
+ const allLeaves: Leaf[] = [];
277
+ for (const l of existingLeaves) {
278
+ if (!seen.has(l.id)) {
279
+ seen.add(l.id);
280
+ allLeaves.push(l);
281
+ }
282
+ }
283
+ for (const l of newLeaves) {
284
+ if (!seen.has(l.id)) {
285
+ seen.add(l.id);
286
+ allLeaves.push(l);
287
+ }
288
+ }
289
+ const tree = buildRaptorTree(allLeaves, {
290
+ embedder: opts.embedder ?? getDefaultEmbedder(),
291
+ budgetMs: opts.budgetMs,
292
+ clustersPerLevel: opts.clustersPerLevel,
293
+ consistencyThreshold: opts.consistencyThreshold,
294
+ });
295
+ const builtAt = opts.builtAt ?? Date.now();
296
+ saveRaptorTree(opts.sessionId, tree, builtAt, opts.stateDir);
297
+ return tree;
298
+ } catch (e2) {
299
+ logger?.error("raptor_incremental_full_rebuild_failed", {
300
+ sessionId: opts.sessionId,
301
+ error: String(e2 instanceof Error ? e2.message : e2),
302
+ });
303
+ return null;
304
+ }
305
+ }
306
+
307
+ /** Rebuild the in-memory RaptorTree from stored nodes. */
308
+ function rehydrateFromStored(
309
+ nodes: StoredRaptorNode[],
310
+ ): RaptorTree | null {
311
+ if (nodes.length === 0) return null;
312
+ const root = nodes.reduce<StoredRaptorNode | null>(
313
+ (best, n) => (!best || n.level > (best?.level ?? -1) ? n : best),
314
+ null,
315
+ );
316
+ const tree: RaptorTree = {
317
+ nodes: new Map(
318
+ nodes.map((n) => [
319
+ n.id,
320
+ {
321
+ id: n.id,
322
+ level: n.level,
323
+ parentId: n.parentId,
324
+ children: n.children,
325
+ summary: n.summary,
326
+ embedding: n.embedding,
327
+ qualityMarker: n.qualityMarker as QualityMarker,
328
+ tokenEstimate: n.tokenEstimate,
329
+ },
330
+ ]),
331
+ ),
332
+ rootId: root?.id ?? null,
333
+ levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
334
+ timedOut: root != null && root.level >= 99,
335
+ builtAt: nodes.reduce((max, n) => Math.max(max, n.builtAt), 0),
336
+ };
337
+ return tree;
338
+ }
339
+
@@ -13,10 +13,12 @@
13
13
  import type { Embedder } from "../../embedder.js";
14
14
  import { defaultEmbedder } from "../../embedder.js";
15
15
  import { buildRaptorTree, type Leaf, type RaptorTree } from "./tree.js";
16
+ import { incrementRaptorTree } from "./incremental.js";
16
17
  import { stagedExpansion } from "./retrieval.js";
17
18
  import { Logger } from "../../log.js";
18
19
  import { saveRaptorTree, listRaptorNodes } from "../../store/sqlite.js";
19
20
  import { insertBuildHistory, computeCoherenceScore } from "./buildHistory.js";
21
+ import { loadDedupConfig } from "../../config/dedup.js";
20
22
 
21
23
  /** Shadow mode is on by default; set RAPTOR_SHADOW_MODE=false to serve live. */
22
24
  export function isShadowMode(): boolean {
@@ -51,15 +53,38 @@ export function runRaptor(
51
53
  const embedder = opts.embedder ?? defaultEmbedder();
52
54
  const logger = opts.logger;
53
55
  const startedAt = opts.builtAt ?? Date.now();
56
+ const builtAt = startedAt;
54
57
  try {
55
- const tree = buildRaptorTree(leaves, {
56
- embedder,
57
- budgetMs: opts.budgetMs,
58
- clustersPerLevel: opts.clustersPerLevel,
59
- consistencyThreshold: opts.consistencyThreshold,
60
- });
61
- const builtAt = opts.builtAt ?? Date.now();
62
- saveRaptorTree(opts.sessionId, tree, builtAt, opts.stateDir);
58
+ // Try incremental update first when the flag is ON (Sprint 26, #7).
59
+ // The incremental path reads the existing tree, diffs by leaf id, and
60
+ // inserts only new leaves into the nearest clusters. Falls back to a full
61
+ // rebuild when the tree is missing, corrupted, or >50% of nodes are new.
62
+ const cfg = loadDedupConfig();
63
+ let tree: RaptorTree | null = null;
64
+ if (cfg.RAPTOR_INCREMENTAL) {
65
+ tree = incrementRaptorTree(leaves, leaves, {
66
+ embedder,
67
+ stateDir: opts.stateDir,
68
+ sessionId: opts.sessionId,
69
+ budgetMs: opts.budgetMs,
70
+ clustersPerLevel: opts.clustersPerLevel,
71
+ consistencyThreshold: opts.consistencyThreshold,
72
+ logger,
73
+ builtAt,
74
+ });
75
+ }
76
+
77
+ // Fall back to full rebuild when incremental returned null (not attempted,
78
+ // tree missing, ratio exceeded, or internal error).
79
+ if (!tree) {
80
+ tree = buildRaptorTree(leaves, {
81
+ embedder,
82
+ budgetMs: opts.budgetMs,
83
+ clustersPerLevel: opts.clustersPerLevel,
84
+ consistencyThreshold: opts.consistencyThreshold,
85
+ });
86
+ saveRaptorTree(opts.sessionId, tree, builtAt, opts.stateDir);
87
+ }
63
88
  logger?.info("raptor_build", {
64
89
  sessionId: opts.sessionId,
65
90
  nodes: tree.nodes.size,
@@ -24,7 +24,7 @@ export interface ClusterSummary {
24
24
  function ollamaEndpoint(): { url: string; model: string } | null {
25
25
  const model = process.env.MEGACOMPACT_RAPTOR_MODEL;
26
26
  if (!model) return null;
27
- const base = process.env.MEGACOMPACT_RAPTOR_URL ?? "http://127.0.0.1:11434";
27
+ const base = process.env.MEGACOMPACT_RAPTOR_URL ?? "http://127.0.0.1:11434"; // guardrails-allow PREVENT-PI-004: loopback-only Ollama endpoint for local RAPTOR summarization
28
28
  // Guard: only loopback is permitted (remote Ollama would violate PREVENT-PI-004).
29
29
  if (!/^https?:\/\/(localhost|127\.0\.0\.1)([:/]|$)/.test(base)) {
30
30
  throw new Error(
@@ -28,7 +28,7 @@
28
28
  import type { Embedder, Vector } from "./embedder.js";
29
29
  import { l2Normalize } from "./embedder.js";
30
30
  import { spawnSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: localhost-only user-spawned embedding server (BYO backend, never remote)
31
- import { isIP } from "node:net";
31
+ import { isIP } from "node:net"; // guardrails-allow PREVENT-PI-004: localhost-only loopback address validation in BYO embedding server
32
32
 
33
33
  export interface HttpEmbedderOptions {
34
34
  url: string;