pi-mega-compact 0.11.9 → 0.11.11

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.
package/README.md CHANGED
@@ -6,15 +6,19 @@ A local context compressor for the [pi coding agent](https://github.com/earendil
6
6
 
7
7
  - **Auto-compaction** — the store watches context pressure and compacts quietly in the background. You'll notice when a long session just stays long while the token gauge rests comfortably far from the ceiling.
8
8
  - **Two-layer compaction** — every LLM call sees a live trim of the context window, and every trim is checkpointed to SQLite so a crash or a `/clear` never loses the work.
9
- - **Semantic dedup, three layers deep** — exact hash MinHash/LSH cosine over trigram embeddings. You rarely notice it; that's the point.
10
- - **RAPTOR memory hierarchy** — decisions you made an hour ago don't scroll off; they get packed up as hierarchical checkpoints and re-inlined the moment your next session asks for them. Multi-level retrieval (leaves + summary clusters) is on by default and tunes itself off the build history.
9
+ - **Semantic dedup, three layers deep** — exact hash (L0) -> MinHash/LSH (L1) -> cosine over trigram embeddings (L2). The dedup audit log records per-tier decisions with similarity scores for tuning.
10
+ - **RAPTOR memory hierarchy** — decisions you made an hour ago don't scroll off; they get packed up as hierarchical checkpoints and re-inlined the moment your next session asks for them. Multi-level retrieval (leaves + summary clusters) is on by default. Since v0.11.10, RAPTOR tree updates are incremental (no full rebuild) — enabled by default.
11
11
  - **Per-turn tracking + rewind.** Every turn, checkpoint, and recall hit lands as a row in an isolated `turns.db` — `turns`, `turn_recall`, `conversation_forks`. The dashboard **Turns tab** shows turn-by-turn memory: context pressure, the compact epoch that superseded each turn, and the exact checkpoints recalled into it. A **fork** action branches a conversation at any turn (carrying its recall set); a **rewind** action queues an intent the host consumes at the next `before_agent_start`. The `TurnStore` is contract-first (capability-gated reader/writer/admin views) so the same spine backs the dashboard, the TUI, or an API gateway.
12
- - **Cross-repo recall** — doors I close in one repo don't reopen when I move to another. A decision stored while hacking repo A is a recall hit the next time I'm in repo B.
13
- - **Durable memory** — every ten turns the store auto-reviews and safe-keeps decisions, facts, and preferences as first-class RAG memories, so long-running projects remember what mattered.
12
+ - **Cross-repo recall** — doors you close in one repo don't reopen when you move to another. A decision stored while hacking repo A is a recall hit the next time you're in repo B.
13
+ - **Durable memory** — on a cadence the store auto-reviews and safe-keeps decisions, facts, and preferences as first-class RAG memories, so long-running projects remember what mattered.
14
+ - **Prompt-cache optimization** — message separation + cache striping (default OFF, opt-in). Targets 82-90% cache hit rate by structuring context around provider cache boundaries. Enables with `MEGACOMPACT_MESSAGE_SEPARATION` and `MEGACOMPACT_CACHE_STRIPING`.
15
+ - **RAG suite** — query reformulation (TF-IDF + RRF), tiered routing (L0 cache -> L1 FTS5 -> L2 PGlite), and recall-quality metrics (CRAG). All default OFF, opt-in via flags `MEGACOMPACT_QUERY_REFORMULATION`, `MEGACOMPACT_TIERED_ROUTER`, `MEGACOMPACT_RECALL_METRICS`.
16
+ - **Stacked memory graph** — the dashboard shows memory composition over time from 3 content sources (turns, durable memories, wiki) with a 9-gate validation system and a graph-health indicator. Per-model provider cache breakdown in the Cache tab.
17
+ - **Debug bundle** — Maintenance tab in the dashboard has a **Gather Debug Logs** button that collects events, config, and store state into a shareable archive for bug reports.
14
18
  - **Fully local** — node:sqlite + trigram embeddings by default. Bring your own localhost embedder (ONNX, Ollama, TEI) for better semantic matches. Zero calls off your machine except the optional, localhost-only dashboard.
15
19
  - **Team-run aware** — fine-grained durable trim fires at agent settle during sub-agent runs, so long multi-agent work doesn't just collapse at the end.
16
- - **Multi-pi dashboard** — one dashboard tab per active pi process with the context stack, per-repo stats, and a live SSE feed across all of them. The React SPA now has lazy-loaded tabs: Overview, Repos, Events, Config, Metrics, Cache, Game, Achievements, Sessions, Topics (wiki), and Turns (per-turn memory + recall + rewind).
17
- - **Auto-categorizing wiki.** Every ten compactions, the store clusters your real memory embeddings (k-means) and labels each cluster with its most discriminative terms (TF-IDF) — no LLM, no Ollama, fully local. The dashboard **Wiki tab** browses topics, searches by label or term, and drills down into the member memories of each cluster.
20
+ - **Multi-pi dashboard** — one dashboard tab per active pi process with the context stack, per-repo stats, and a live SSE feed across all of them. The React SPA has lazy-loaded tabs: Overview, Repos, Events, Config, Metrics, Cache, Game, Achievements, Sessions, Topics (wiki), Turns (per-turn memory + recall + rewind), and Maintenance (debug bundle).
21
+ - **Auto-categorizing wiki.** Every 3 compactions (seeds from turns before that), the store clusters your real memory embeddings (k-means) and labels each cluster with its most discriminative terms (TF-IDF) — no LLM, no Ollama, fully local. The dashboard **Wiki tab** browses topics, searches by label or term, and drills down into the member memories of each cluster.
18
22
 
19
23
  ## Install
20
24
 
@@ -22,7 +26,7 @@ A local context compressor for the [pi coding agent](https://github.com/earendil
22
26
  pi install npm:pi-mega-compact
23
27
  ```
24
28
 
25
- That's it. `pi update --extensions` pulls updates going forward.
29
+ That's it. **`pi update --extensions`** pulls updates going forward. npm is the only distribution path — never use `.tgz` tarballs or symlinks for shipping.
26
30
 
27
31
  <details>
28
32
  <summary>From source (development)</summary>
@@ -48,6 +52,7 @@ Key commands:
48
52
  - `/mega-recall [query]` — semantic search the store, `--cross-repo` for all repos
49
53
  - `/mega-memory save|list|search|forget` — manage durable memories
50
54
  - `/mega-dashboard` — start the localhost dashboard
55
+ - `/mega-setup` — embedding wizard: detects Ollama/llama.cpp, suggests upgrades when recall quality is low
51
56
 
52
57
  Full command reference: [`docs/COMMANDS.md`](docs/COMMANDS.md)
53
58
 
@@ -62,7 +67,15 @@ Set env vars before starting pi. Defaults are in `src/config/dedup.ts`.
62
67
  | `MEGACOMPACT_DEDUP_SIM` | `0.90` | Cosine threshold for near-dup collapse |
63
68
  | `MEGACOMPACT_CROSSREPO_ENABLED` | `true` | Cross-repo recall on resume |
64
69
  | `MEGACOMPACT_EMBEDDING_URL` | _(unset)_ | BYO localhost embedder endpoint |
65
- | `MEGACOMPACT_TUI_WIDGET` | `true` | Render the above-editor panel. Set to `0` to suppress it — useful when pi runs inside an editor terminal (e.g. Neovim `:terminal`) where you drive scrollback yourself and the panel's repaints fight it. Compaction is unaffected. |
70
+ | `MEGACOMPACT_TUI_WIDGET` | `true` | Render the above-editor panel |
71
+ | `MEGACOMPACT_MESSAGE_SEPARATION` | `false` | Opt-in: separate messages at provider cache boundaries |
72
+ | `MEGACOMPACT_CACHE_STRIPING` | `false` | Opt-in: stripe cache stripes across conversation |
73
+ | `MEGACOMPACT_QUERY_REFORMULATION` | `false` | Opt-in: TF-IDF + RRF query reformulation |
74
+ | `MEGACOMPACT_TIERED_ROUTER` | `false` | Opt-in: L0->L1->L2 recall routing |
75
+ | `MEGACOMPACT_RECALL_METRICS` | `false` | Opt-in: CRAG recall quality metrics |
76
+ | `MEGACOMPACT_MEMORY_GRAPH_SEED_TURNS` | `true` | Seed memory graph from turns (default ON) |
77
+ | `MEGACOMPACT_WIKI_SEED_FROM_TURNS` | `true` | Seed wiki from turns (default ON) |
78
+ | `MEGACOMPACT_RAPTOR_INCREMENTAL` | `true` | Incremental RAPTOR updates (default ON) |
66
79
 
67
80
  Full config reference: [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md)
68
81
 
@@ -70,11 +83,11 @@ Full config reference: [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md)
70
83
 
71
84
  ```
72
85
  extensions/ Pi entry points (mega-compact, mega-trim, dashboard)
73
- src/engine.ts Trident pipeline (supersede collapse cluster)
86
+ src/engine.ts Trident pipeline (supersede -> collapse -> cluster)
74
87
  src/vectorStore.ts Local vector DB (add/search/dedupe)
75
88
  src/compact.ts Summarize / merge / auto-compact
76
89
  src/memory.ts Durable memories + auto-review
77
- src/store/sqlite.ts node:sqlite store (Node 22.13)
90
+ src/store/sqlite.ts node:sqlite store (Node >=22.13)
78
91
  src/store/vectorIndex.ts PGlite/HNSW cross-repo index
79
92
  ```
80
93
 
@@ -84,12 +97,22 @@ Detailed architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
84
97
 
85
98
  ```bash
86
99
  npm run build # TypeScript compile
87
- npm test # Build + 769 tests
100
+ npm test # Build + 1112 tests
88
101
  npm run lint # Type check + guardrails scan
89
102
  ```
90
103
 
91
104
  Testing guide: [`TESTER_GUIDE.md`](TESTER_GUIDE.md)
92
105
 
106
+ ## Tester requests
107
+
108
+ These areas benefit from real-world usage data. The automated test suite covers correctness, but tuning requires diverse sessions.
109
+
110
+ - **Cosine threshold validation** — the 0.90 dedup threshold may need adjustment per content type (try 0.93 for code, 0.87 for prose). Run with `MEGACOMPACT_DEDUP_SIM` set to different values and report false positives via the debug bundle (Maintenance -> Gather Debug Logs). The dedup audit log (events.log) records `similarityScore` + `matchedId` per decision.
111
+ - **Cross-repo recall quality** — enable cross-repo recall and report the relevance-vs-noise ratio from real multi-repo sessions. The Turns tab shows recall hits per turn.
112
+ - **Dedup layer audit** — is MinHash/LSH (L1) catching enough over exact-hash (L0) + cosine (L2) to justify the complexity? If L1 catches <5% additional in your sessions, we may simplify to 2-layer. The dedup audit log has per-tier decisions.
113
+ - **Compaction death-spiral** — v0.11.9 fixed an "Already compacted" loop that made sessions unrecoverable (critical-over escape hatch forces a trim + durable compact at >=90% context). If you see this recur, report it immediately with the debug bundle.
114
+ - **Share debug bundles** — when reporting any issue, first gather from Maintenance -> Gather Debug Logs and include the archive.
115
+
93
116
  ## Troubleshooting
94
117
 
95
118
  ### npm lifecycle scripts disabled
@@ -107,7 +130,7 @@ npm install --install-strategy=linked
107
130
 
108
131
  ### node:sqlite not found / experimental flag required
109
132
 
110
- pi-mega-compact requires Node 22.13 for the built-in `node:sqlite` module. If you're on an older version or your Node build doesn't include it by default:
133
+ pi-mega-compact requires Node >=22.13 for the built-in `node:sqlite` module. If you're on an older version or your Node build doesn't include it by default:
111
134
 
112
135
  ```bash
113
136
  export NODE_OPTIONS="--experimental-sqlite"
@@ -120,7 +143,7 @@ Add the export to your shell profile (`.bashrc`, `.zshrc`, etc.) to make it perm
120
143
 
121
144
  BSD 3-Clause
122
145
 
123
- ## Support
146
+ ## Support
124
147
 
125
148
  If this project helped you, consider buying me a coffee:
126
149
 
@@ -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.11",
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;