pi-mega-compact 0.4.20 → 0.4.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/extensions/conflict-scan.js +201 -0
  2. package/dist/extensions/dashboard-server.js +3 -3
  3. package/dist/extensions/mega-compact-driver.js +79 -0
  4. package/dist/extensions/mega-compact.js +2 -0
  5. package/dist/extensions/mega-compact.test.js +54 -18
  6. package/dist/extensions/mega-config.js +10 -0
  7. package/dist/extensions/mega-conflict-cmds.js +121 -0
  8. package/dist/extensions/mega-events.js +45 -23
  9. package/dist/extensions/mega-pipeline.js +80 -8
  10. package/dist/extensions/mega-runtime.js +14 -20
  11. package/dist/src/config/dedup.js +4 -1
  12. package/dist/src/config.js +21 -0
  13. package/dist/src/dedup/raptor/index.js +28 -6
  14. package/dist/src/dedup/raptor/promote.test.js +69 -0
  15. package/dist/src/engine.js +1 -0
  16. package/dist/src/recall.js +30 -4
  17. package/dist/src/recall.test.js +28 -0
  18. package/dist/src/store/backfill.js +5 -6
  19. package/dist/src/store/compression.js +47 -7
  20. package/dist/src/store/compression.test.js +48 -0
  21. package/dist/src/store/sqlite.js +123 -41
  22. package/dist/src/store.test.js +19 -0
  23. package/dist/src/vectorStore.js +56 -1
  24. package/extensions/DASHBOARD.md +3 -3
  25. package/extensions/conflict-scan.ts +209 -0
  26. package/extensions/dashboard-server.ts +4 -4
  27. package/extensions/mega-compact-driver.ts +105 -0
  28. package/extensions/mega-compact.test.ts +65 -18
  29. package/extensions/mega-compact.ts +2 -0
  30. package/extensions/mega-config.ts +25 -0
  31. package/extensions/mega-conflict-cmds.ts +129 -0
  32. package/extensions/mega-events.ts +43 -24
  33. package/extensions/mega-pipeline.ts +86 -9
  34. package/extensions/mega-runtime.ts +14 -18
  35. package/package.json +6 -7
  36. package/src/config/dedup.ts +4 -1
  37. package/src/config.ts +26 -0
  38. package/src/dedup/raptor/index.ts +42 -7
  39. package/src/dedup/raptor/promote.test.ts +82 -0
  40. package/src/engine.ts +5 -0
  41. package/src/recall.test.ts +44 -0
  42. package/src/recall.ts +43 -4
  43. package/src/store/backfill.ts +10 -11
  44. package/src/store/compression.test.ts +58 -0
  45. package/src/store/compression.ts +48 -7
  46. package/src/store/sqlite.ts +156 -49
  47. package/src/store.test.ts +22 -0
  48. package/src/vectorStore.ts +63 -1
  49. package/dist/extensions/openclaw-mega-compact.js +0 -291
  50. package/dist/src/minilm.js +0 -92
  51. package/dist/src/wordpiece.js +0 -129
@@ -9,9 +9,10 @@
9
9
  import { normalizeSessionId } from "../src/store.js";
10
10
  import { autoCompactCheck } from "../src/compact.js";
11
11
  import { estimateSessionTokens } from "../src/tokens.js";
12
- import { dropCompactedRange } from "../src/adapt.js";
13
12
  import { recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
14
13
  import { runCompact, doRecall } from "./mega-pipeline.js";
14
+ import { driveNativeCompaction } from "./mega-compact-driver.js";
15
+ import { pressureFromPct } from "./mega-config.js";
15
16
  /** Register all pi lifecycle event handlers. */
16
17
  export function registerEventHandlers(pi, runtime, config) {
17
18
  // ---- Session lifecycle (state reset points) -------------------------------
@@ -108,7 +109,15 @@ export function registerEventHandlers(pi, runtime, config) {
108
109
  runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
109
110
  runtime.snapshot(ctx);
110
111
  });
111
- // ---- Auto-trigger: fast-gate confirm Trident+persist drop --------
112
+ // ---- Auto-trigger: own the decision, pi owns the durable write ----------
113
+ // OUR auto-trigger (over threshold + debounce): persist our Trident checkpoint,
114
+ // then start pi's compaction flow via ctx.compact(). That fires
115
+ // `session_before_compact`, where OUR handler returns our summary +
116
+ // firstKeptEntryId, and pi durably writes the trim to disk (appendCompaction).
117
+ // Result: auto-compact AND a durable trim — resume reloads the trimmed window,
118
+ // no full-reload + additive recall inflation (Fix B kills the token-growth bug).
119
+ // We do NOT drop messages here (that would be ephemeral; the read-only session
120
+ // manager can't trim disk, so the trim has to come through pi).
112
121
  pi.on("context", async (event, ctx) => {
113
122
  if (!config.auto)
114
123
  return;
@@ -123,13 +132,9 @@ export function registerEventHandlers(pi, runtime, config) {
123
132
  return;
124
133
  const messages = event.messages;
125
134
  const view = runtime.engineView(messages);
126
- // Prefer the runtime's real token estimate; fall back to our heuristic
127
- // (and to a percent-of-window proxy when tokens is unknown).
128
135
  const currentTokens = usage?.tokens ?? estimateSessionTokens(view) ??
129
136
  Math.round((pct / 100) * (usage?.contextWindow ?? 0));
130
137
  // FAST GATE: token-based (tier threshold), not percentage-based.
131
- // A 20% gate on a 2M window = 400k, which is way above the 50k low-tier
132
- // threshold. Gate on the actual token count instead.
133
138
  if (currentTokens < config.thresholdTokens)
134
139
  return;
135
140
  const check = autoCompactCheck(currentTokens, config.thresholdTokens); // SERVER-STYLE CONFIRM (local)
@@ -140,28 +145,45 @@ export function registerEventHandlers(pi, runtime, config) {
140
145
  if (now < runtime.debounceUntil)
141
146
  return;
142
147
  runtime.debounceUntil = now + 2000;
143
- const ran = runCompact(pi, runtime, config, ctx, messages);
148
+ // Adaptive compression (Fix E): scale compression strength + keepFrom depth
149
+ // with how close we are to the model context limit.
150
+ const pressure = pressureFromPct(pct);
151
+ const ran = runCompact(pi, runtime, config, ctx, messages, { compressionPressure: pressure });
144
152
  if (ran.skipped)
145
153
  return;
146
- // DROP the compacted range from the outgoing context, honoring the anchor
147
- // floor + tool-pair boundary guards (PREVENT-PI-001/002).
148
- const kept = dropCompactedRange(messages, ran.keepFrom, config.anchorUserMessages);
149
- if (kept.length < messages.length) {
150
- return { messages: kept };
151
- }
154
+ // Start pi's compaction flow so our session_before_compact handler can
155
+ // supply the durable trim (pi writes it to disk). We never use pi's summary.
156
+ ctx.compact({ customInstructions: undefined });
152
157
  });
153
- // ---- Cancel native compaction once we've persisted our own -------------
154
- pi.on("session_before_compact", async (_event, ctx) => {
158
+ // ---- Supply a DURABLE trim to pi's native compaction (Fix B) ----------
159
+ // We run the Trident pipeline to produce a compressed summary, then return
160
+ // it as a CompactionResult. pi writes the summary into a compactionSummary
161
+ // entry AND truncates the on-disk transcript from firstKeptEntryId. This is
162
+ // the durable fix for "tokens grow on read": the trim survives resume, so
163
+ // there is no full-reload + additive recall inflation.
164
+ pi.on("session_before_compact", async (event, ctx) => {
155
165
  runtime.resetRuntime(ctx.sessionManager.getSessionId());
156
- if (runtime.rt.persistedThisSession) {
157
- // We already persisted a checkpoint for this session (via the context
158
- // hook drop) — cancel pi's own compaction to avoid double-compacting.
159
- // Our context-hook drop already trimmed the window.
160
- return { cancel: true };
166
+ if (!config.auto)
167
+ return {}; // let pi run its own native compaction
168
+ try {
169
+ const result = driveNativeCompaction(event, runtime, config);
170
+ if (result) {
171
+ runtime.logger.info("native-compact", {
172
+ sessionId: runtime.rt.sessionId,
173
+ firstKeptEntryId: result.compaction.firstKeptEntryId,
174
+ tokensBefore: result.compaction.tokensBefore,
175
+ summaryTokens: result.compaction.estimatedTokensAfter,
176
+ });
177
+ return { compaction: result.compaction };
178
+ }
179
+ }
180
+ catch (err) {
181
+ runtime.logger.error("native-compact-failed", {
182
+ sessionId: runtime.rt.sessionId,
183
+ error: String(err instanceof Error ? err.message : err),
184
+ });
161
185
  }
162
- // We haven't persisted yet this session: let pi run its native compaction.
163
- // (Our auto-trigger only fires again past the threshold, and will then
164
- // capture a checkpoint next time around.)
186
+ // Fall back to pi's own native compaction if we can't supply one.
165
187
  return {};
166
188
  });
167
189
  }
@@ -6,12 +6,15 @@
6
6
  * the shared MegaRuntime (token accounting, ticker, status, events) and are
7
7
  * driven by the event + command handlers in mega-events.ts / mega-commands.ts.
8
8
  */
9
+ import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
9
10
  import { compactSession } from "../src/engine.js";
10
11
  import { recallAndInline } from "../src/recall.js";
11
12
  import { normalizeSessionId } from "../src/store.js";
12
13
  import { touchSession, logDaily } from "../src/store/sqlite.js";
13
14
  import { C, MARKER_TYPE, } from "./mega-runtime.js";
14
- import { resolveRepoRoot } from "./mega-config.js";
15
+ import { resolveRepoRoot, preserveRecentForPressure } from "./mega-config.js";
16
+ import { runRaptor } from "../src/dedup/raptor/index.js";
17
+ import { loadDedupConfig } from "../src/config/dedup.js";
15
18
  /** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
16
19
  export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
17
20
  runtime.bindRepo(ctx.cwd);
@@ -19,7 +22,10 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
19
22
  runtime.resetRuntime(sid);
20
23
  runtime.rt.sessionId = sid;
21
24
  const view = runtime.engineView(messages);
22
- const keepFrom = opts.keepFrom ?? Math.max(0, view.length - config.preserveRecent);
25
+ // keepFrom deepens with context pressure (Fix E): under high pressure we
26
+ // compact more of the session, down to the preserveRecentMin floor.
27
+ const preserve = preserveRecentForPressure(opts.compressionPressure ?? 0, config.preserveRecent, config.preserveRecentMin);
28
+ const keepFrom = opts.keepFrom ?? Math.max(0, view.length - preserve);
23
29
  if (keepFrom <= 0)
24
30
  return { skipped: true };
25
31
  runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
@@ -30,6 +36,7 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
30
36
  summary: opts.summary,
31
37
  timestamp: Date.now(),
32
38
  onTier: runtime.makeTierCallback(ctx),
39
+ compressionPressure: opts.compressionPressure,
33
40
  }, runtime.store);
34
41
  runtime.pulsing = false;
35
42
  if (result.skipped)
@@ -56,15 +63,13 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
56
63
  // denominator (we don't want it pinned at 100% once we pass an old target).
57
64
  if (runtime.rt.tokensSaved > runtime.savedGoal)
58
65
  runtime.savedGoal = Math.ceil((runtime.rt.tokensSaved * 1.25) / 10_000) * 10_000;
59
- // Live toolbar "now processing" line: what file/region just got compacted or
60
- // deduped. Reset to the last-seen action after a few seconds (see snapshot).
66
+ // Live toolbar activity: what file/region just got compacted or deduped.
67
+ // Rendered via the rotating ticker line (see snapshot); the ring buffer is
68
+ // cycled one-per-repaint so the single line scrolls through recent files.
61
69
  const files = result.filesModified ?? [];
62
70
  const fileLabel = files.length
63
71
  ? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ")
64
72
  : result.regionHash.slice(0, 8);
65
- runtime.currentActivity = result.deduped
66
- ? `♻ deduped ${fileLabel}`
67
- : `🗜 compacted ${result.checkpointId} · ${fileLabel}`;
68
73
  runtime.lastActivityAt = Date.now();
69
74
  // Explain-why line: surfaced while fresh. Pulls the dedup reason (which for
70
75
  // L2 includes the cosine sim) so the user sees WHY a region was kept/dropped.
@@ -98,6 +103,34 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
98
103
  tokenEstimate: result.tokenEstimate,
99
104
  deduped: result.deduped,
100
105
  });
106
+ // Fix D: refresh the RAPTOR tree for this session so live recall (search) can
107
+ // serve high-level summaries. Best-effort + non-fatal: never block compaction.
108
+ // Budget-guarded (RAPTOR_BUDGET_MS) so it can't hang a large session.
109
+ if (config.raptorEnabled && !result.deduped) {
110
+ try {
111
+ const dd = loadDedupConfig();
112
+ const all = runtime.store.list(sid);
113
+ const leaves = all.map((cp) => ({
114
+ id: cp.checkpointId,
115
+ messages: [],
116
+ sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
117
+ embedding: cp.embedding,
118
+ }));
119
+ if (leaves.length >= 2) {
120
+ runRaptor(leaves, {
121
+ stateDir: runtime.currentStateDir,
122
+ sessionId: sid,
123
+ budgetMs: dd.RAPTOR_BUDGET_MS,
124
+ clustersPerLevel: dd.RAPTOR_CLUSTERS_PER_LEVEL,
125
+ consistencyThreshold: dd.RAPTOR_CONSISTENCY,
126
+ logger: runtime.logger,
127
+ });
128
+ }
129
+ }
130
+ catch {
131
+ /* non-fatal: tree refresh never blocks a compaction */
132
+ }
133
+ }
101
134
  runtime.setStatus(ctx, runtime.rt.persistedThisSession
102
135
  ? `mega-compact: ${result.checkpointId} · ${saved} tok saved`
103
136
  : `mega-compact: ready`);
@@ -126,7 +159,21 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
126
159
  export function doRecall(runtime, config, ctx, query, source) {
127
160
  runtime.bindRepo(ctx.cwd);
128
161
  const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
129
- const result = recallAndInline({ sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true }, runtime.store);
162
+ // Live window text for inline dedupe (Fix C): drop recalled checkpoints that
163
+ // are already resident in the session, so recall never re-injects context the
164
+ // model can already see. Best-effort — an empty window just skips dedupe.
165
+ const liveWindow = config.windowDedupe ? extractLiveWindow(ctx) : undefined;
166
+ const result = recallAndInline({
167
+ sessionId: sid,
168
+ query,
169
+ limit: config.autoInlineK,
170
+ source,
171
+ skipInjected: true,
172
+ recallMaxTokens: config.recallMaxTokens,
173
+ windowDedupe: config.windowDedupe,
174
+ liveWindow,
175
+ dedupSim: config.dedupSim,
176
+ }, runtime.store);
130
177
  runtime.dashboard.event("recall", { source, query: query.slice(0, 120), injected: result.toInject.length, empty: result.empty });
131
178
  if (!result.empty && result.toInject.length > 0) {
132
179
  const top = result.toInject[0];
@@ -138,3 +185,28 @@ export function doRecall(runtime, config, ctx, query, source) {
138
185
  }
139
186
  return result;
140
187
  }
188
+ /**
189
+ * Extract the live-window message texts from the session manager (Fix C),
190
+ * for inline-dedupe of recalled checkpoints. Best-effort: returns [] on any
191
+ * error so recall falls back to unbounded (still correct, just no dedupe).
192
+ * Mirrors recentUserQuery's use of sessionEntryToContextMessages.
193
+ */
194
+ function extractLiveWindow(ctx) {
195
+ try {
196
+ const entries = ctx.sessionManager.getEntries();
197
+ const texts = [];
198
+ for (const e of entries) {
199
+ for (const m of sessionEntryToContextMessages(e)) {
200
+ const c = m.content;
201
+ if (typeof c === "string")
202
+ texts.push(c);
203
+ else if (Array.isArray(c))
204
+ texts.push(c.map((b) => b.text).join(" "));
205
+ }
206
+ }
207
+ return texts;
208
+ }
209
+ catch {
210
+ return [];
211
+ }
212
+ }
@@ -68,10 +68,7 @@ export class MegaRuntime {
68
68
  // on model_select + session_start; persisted to SQL so cost + the dashboard
69
69
  // can read it without a live ctx.
70
70
  currentModel;
71
- // Live "what it's doing right now" line for the toolbar. Set on each
72
- // compaction; shown in teal while recent, then kept as the last-seen action so
73
- // the widget is never blank. Cleared on session reset.
74
- currentActivity;
71
+ // Live "what it's doing right now" timestamp, used for the fresh-window.
75
72
  lastActivityAt = 0;
76
73
  // Live per-tier dedup trace (Phase 1): e.g. "L0 ✓ → L1 ✓ → L2 0.91 → stored".
77
74
  // Built from the store's sync onTier callback during a compaction so the user
@@ -243,29 +240,27 @@ export class MegaRuntime {
243
240
  const bar = "▓".repeat(filled) + "░".repeat(10 - filled);
244
241
  lines.push(` ${C.green}saved ${fmt(this.rt.tokensSaved)} ${bar}${C.reset} ${pct}% of ${fmt(goal)}`);
245
242
  }
246
- // Live "now processing" line teal while fresh (≤4s), then the last-seen
247
- // action keeps the widget lively. Cleared on session reset.
243
+ // Live "now processing" line + why + recent deduped/compacted events,
244
+ // collapsed to ONE rotating line (fresh only). The ticker ring buffer
245
+ // (≤5 most-recent events) is cycled one-per-repaint so the line scrolls
246
+ // through recent files in real time while activity fires. We rotate on a
247
+ // 250ms step (same cadence as the pulse), using an event counter as the
248
+ // deterministic phase so consecutive repaints advance the visible entry.
248
249
  const fresh = Date.now() - this.lastActivityAt < 4000;
249
250
  if (this.tierTrace && fresh) {
250
251
  lines.push(` ${pulse}${this.tierTrace}`);
251
252
  }
252
- else if (this.currentActivity) {
253
- lines.push(` ${fresh ? C.teal : C.dim}${this.currentActivity}${C.reset}`);
253
+ else if (this.ticker.length > 0) {
254
+ const step = Math.floor(Date.now() / 250);
255
+ const idx = this.ticker.length - 1 - (step % this.ticker.length);
256
+ const head = this.ticker[idx].text;
257
+ const why = this.lastWhy ? ` ${C.gray}· ${this.lastWhy}${C.reset}` : "";
258
+ const more = this.ticker.length > 1 ? ` ${C.dim}(+${this.ticker.length - 1} more)${C.reset}` : "";
259
+ lines.push(` ${fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`);
254
260
  }
255
261
  else if (this.pulsing) {
256
262
  lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
257
263
  }
258
- // Phase 3 — explain-why line (fresh only).
259
- if (this.lastWhy && fresh)
260
- lines.push(` ${C.gray}${this.lastWhy}${C.reset}`);
261
- // Phase 3 — recall/activity ticker (most-recent first), fresh only.
262
- if (fresh) {
263
- for (let i = this.ticker.length - 1; i >= 0; i--) {
264
- if (lines.length >= 9)
265
- break; // leave room for the hint line (MAX 10)
266
- lines.push(` ${i === this.ticker.length - 1 ? "" : C.dim}${this.ticker[i].text}${C.reset}`);
267
- }
268
- }
269
264
  // Plain-language hint so first-time users understand the widget. Always
270
265
  // last, dimmed. "/mega-help explains these terms."
271
266
  if (lines.length < 10) {
@@ -295,7 +290,6 @@ export class MegaRuntime {
295
290
  this.statusKey = undefined;
296
291
  this.activeAgents = 0;
297
292
  this.currentTurn = 0;
298
- this.currentActivity = undefined;
299
293
  this.lastActivityAt = 0;
300
294
  this.tierTrace = undefined;
301
295
  this.ticker.length = 0;
@@ -33,7 +33,10 @@ export function loadDedupConfig() {
33
33
  L0_ENABLED: envBool("MEGACOMPACT_L0_ENABLED", true),
34
34
  L1_ENABLED: envBool("MEGACOMPACT_L1_ENABLED", true),
35
35
  L2_ENABLED: envBool("MEGACOMPACT_L2_ENABLED", true),
36
- RAPTOR_ENABLED: envBool("MEGACOMPACT_RAPTOR_ENABLED", false), // shadow by default
36
+ // Fix D: RAPTOR promoted to live recall. Default ON; canary.ts sequences it
37
+ // last (L0→L1→L2→RAPTOR) and auto-disables on p95 breach, so promotion is
38
+ // safe. `RAPTOR_SHADOW_MODE=false` still gates serving during transition.
39
+ RAPTOR_ENABLED: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
37
40
  MARK_ONLY_L0: envBool("MEGACOMPACT_MARK_ONLY_L0", false),
38
41
  MARK_ONLY_L1: envBool("MEGACOMPACT_MARK_ONLY_L1", false),
39
42
  MARK_ONLY_L2: envBool("MEGACOMPACT_MARK_ONLY_L2", false),
@@ -10,3 +10,24 @@ import { homedir } from "node:os";
10
10
  export const STATE_DIR_DEFAULT = join(homedir(), ".pi", "agent", "extensions", "pi-mega-compact");
11
11
  /** Pi custom message / entry type used as the dedup sentinel. */
12
12
  export const MARKER_TYPE = "mega-compact-marker";
13
+ /**
14
+ * Derive context-window pressure (0–1) from a usage percentage. Used to scale
15
+ * compression strength + keepFrom depth (Fix E): low pct = room to spare,
16
+ * high pct = near the limit. Deterministic; clamps to [0,1].
17
+ */
18
+ export function pressureFromPct(pct) {
19
+ if (pct == null || Number.isNaN(pct))
20
+ return 0;
21
+ return pct < 0 ? 0 : pct > 100 ? 1 : pct / 100;
22
+ }
23
+ /**
24
+ * Map pressure → how many recent messages to preserve verbatim. Under low
25
+ * pressure we keep `preserveRecent`; under high pressure we compact deeper,
26
+ * down to `preserveRecentMin`. Never splits a tool pair / anchor floor — the
27
+ * boundary guard (computeDropRange) enforces that downstream.
28
+ */
29
+ export function preserveRecentForPressure(pressure, preserveRecent, preserveRecentMin) {
30
+ const p = pressure < 0 ? 0 : pressure > 1 ? 1 : pressure;
31
+ const v = Math.round(preserveRecent - (preserveRecent - preserveRecentMin) * p);
32
+ return Math.max(preserveRecentMin, Math.min(preserveRecent, v));
33
+ }
@@ -66,11 +66,20 @@ export function runRaptor(leaves, opts) {
66
66
  */
67
67
  export function recallRaptor(query, sessionId, opts) {
68
68
  const embedder = opts.embedder ?? defaultEmbedder();
69
- const nodes = listRaptorNodes(sessionId, opts.stateDir);
70
- if (nodes.length === 0)
69
+ const tree = rehydrateRaptorTree(sessionId, opts.stateDir);
70
+ if (!tree)
71
71
  return [];
72
- // Rehydrate a minimal in-memory tree (parent links reconstructed from children).
73
- const byId = new Map(nodes.map((n) => [n.id, n]));
72
+ return stagedExpansion(query, tree, { embedder, k: opts.k, topM: opts.topM });
73
+ }
74
+ /**
75
+ * Rehydrate a persisted RAPTOR tree from raptor_nodes (Fix D): rebuild the
76
+ * in-memory RaptorTree + parent links so vectorStore.search can serve it live.
77
+ * Returns null when no tree exists (caller falls back to the flat path).
78
+ */
79
+ export function rehydrateRaptorTree(sessionId, stateDir) {
80
+ const nodes = listRaptorNodes(sessionId, stateDir);
81
+ if (nodes.length === 0)
82
+ return null;
74
83
  const tree = {
75
84
  nodes: new Map(nodes.map((n) => [
76
85
  n.id,
@@ -89,6 +98,19 @@ export function recallRaptor(query, sessionId, opts) {
89
98
  levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
90
99
  timedOut: false,
91
100
  };
92
- void byId;
93
- return stagedExpansion(query, tree, { embedder, k: opts.k, topM: opts.topM });
101
+ return tree;
102
+ }
103
+ /**
104
+ * Return the RAPTOR root summary for a session, if a tree has been built.
105
+ * Used by the durable-trim driver (Fix B/D) to supply pi a session-level
106
+ * compressed summary instead of one slice's extractive summary. Returns
107
+ * undefined when no tree exists yet (caller falls back to the slice summary).
108
+ */
109
+ export function recallRaptorRootSummary(sessionId, stateDir) {
110
+ const nodes = listRaptorNodes(sessionId, stateDir);
111
+ if (nodes.length === 0)
112
+ return undefined;
113
+ // Highest-level node = the root (covers all leaves).
114
+ const root = nodes.reduce((best, n) => (!best || n.level > best.level ? n : best), null);
115
+ return root?.summary || undefined;
94
116
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * promote.test.ts — Fix D: RAPTOR tree served by vectorStore.search.
3
+ *
4
+ * Asserts that, when a RAPTOR tree has been built + persisted for a session,
5
+ * VectorStore.search returns the tree's staged-expansion hits (broader, O(log n)
6
+ * coverage) merged with the flat hits — so the dormant tree becomes the live
7
+ * recall surface. No network: default extractive summarizer + trigram embedder.
8
+ */
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { VectorStore } from "../../vectorStore.js";
15
+ import { runRaptor } from "./index.js";
16
+ import { compactSession } from "../../engine.js";
17
+ import { Logger } from "../../log.js";
18
+ import { loadDedupConfig } from "../../config/dedup.js";
19
+ import { listRaptorNodes } from "../../store/sqlite.js";
20
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-promote-"));
21
+ let counter = 0;
22
+ function raptorConfig() {
23
+ return { ...loadDedupConfig(), RAPTOR_ENABLED: true };
24
+ }
25
+ function msg(text, toolName) {
26
+ return toolName ? { role: "assistant", text, toolName, input: text, output: text } : { role: "user", text };
27
+ }
28
+ const SESS = "sess_promote";
29
+ test("Fix D: vectorStore.search serves a persisted RAPTOR tree (broader recall)", () => {
30
+ const stateDir = join(baseTmp, `run-${counter++}`);
31
+ const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
32
+ // Persist several distinct checkpoints.
33
+ for (let i = 1; i <= 5; i++) {
34
+ compactSession({ sessionId: SESS, messages: [msg(`topic alpha wire ${i} and bootstrap sequence`), msg(`ok ${i}`, "Edit")], keepFrom: 2, timestamp: i }, s);
35
+ }
36
+ // No tree yet → flat search only, returns hits, no RAPTOR coverage.
37
+ assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree initially");
38
+ const flat = s.search(SESS, "alpha wire bootstrap", 3);
39
+ assert.ok(flat.length > 0, "flat search returns hits");
40
+ // Build + persist a RAPTOR tree for the session (mirrors runCompact refresh).
41
+ const all = s.list(SESS);
42
+ const leaves = all.map((cp) => ({
43
+ id: cp.checkpointId,
44
+ messages: [],
45
+ sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
46
+ embedding: cp.embedding,
47
+ }));
48
+ const tree = runRaptor(leaves, { stateDir, sessionId: SESS, logger: new Logger() });
49
+ assert.ok(tree && listRaptorNodes(SESS, stateDir).length > 0, "tree persisted");
50
+ // With the tree live + RAPTOR_ENABLED, search still returns hits and now
51
+ // exercises the RAPTOR-served path without regression.
52
+ const withTree = s.search(SESS, "alpha wire bootstrap", 3);
53
+ assert.ok(withTree.length > 0, "search returns hits with RAPTOR promoted");
54
+ // Every returned hit is a real checkpoint in the session.
55
+ for (const h of withTree) {
56
+ assert.ok(all.some((cp) => cp.checkpointId === h.checkpoint.checkpointId), "hit is a real checkpoint");
57
+ }
58
+ });
59
+ test("Fix D: search still works for a session with <2 leaves (no tree)", () => {
60
+ const stateDir = join(baseTmp, `run-${counter++}`);
61
+ const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
62
+ compactSession({ sessionId: SESS, messages: [msg("only one topic here"), msg("ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
63
+ const r = s.search(SESS, "only one topic", 3);
64
+ assert.ok(r.length > 0, "single-checkpoint search still works (no tree)");
65
+ assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree built for <2 leaves");
66
+ });
67
+ test("cleanup", () => {
68
+ rmSync(baseTmp, { recursive: true, force: true });
69
+ });
@@ -104,6 +104,7 @@ export function compactSession(input, store = getDefaultStore()) {
104
104
  originalTokenEstimate,
105
105
  timestamp: input.timestamp ?? 0,
106
106
  onTier: input.onTier,
107
+ compressionPressure: input.compressionPressure,
107
108
  });
108
109
  return {
109
110
  skipped: false,
@@ -14,6 +14,8 @@
14
14
  * extension decides where it lands.
15
15
  */
16
16
  import { recall as searchRecall } from "./engine.js";
17
+ import { estimateBlockTokens } from "./tokens.js";
18
+ import { defaultEmbedder, cosineSimilarity } from "./embedder.js";
17
19
  /** Wrap a recall block so the model reads it as restored compacted context. */
18
20
  export function formatRecallBlock(hits) {
19
21
  if (hits.length === 0)
@@ -38,18 +40,42 @@ export function formatRecallBlock(hits) {
38
40
  export function recallAndInline(opts, store) {
39
41
  const limit = opts.limit ?? 3;
40
42
  const skip = opts.skipInjected ?? true;
43
+ const maxTokens = opts.recallMaxTokens ?? 0; // 0 = unbounded (legacy behavior)
44
+ const doWindowDedupe = opts.windowDedupe ?? false;
45
+ const dedupSim = opts.dedupSim ?? 0.9;
41
46
  const { hits } = searchRecall({ sessionId: opts.sessionId, query: opts.query, limit, skipInjected: false }, store);
42
- // Shared dedup: drop checkpoints already injected this session, then mark the
43
- // survivors so repeated triggers are free. (Cosine near-dup collapse already
44
- // happened inside store.search.)
47
+ // Precompute live-window embeddings once for inline dedupe (Fix C). Trigram
48
+ // embedder is local + cheap; never a network call (PREVENT-PI-004).
49
+ let liveEmbeddings = [];
50
+ if (doWindowDedupe && opts.liveWindow && opts.liveWindow.length > 0) {
51
+ const embedder = defaultEmbedder();
52
+ liveEmbeddings = opts.liveWindow.map((m) => embedder.embed(m));
53
+ }
54
+ // Shared dedup + bounded/inline block assembly. We build the block
55
+ // incrementally so the token cap can stop mid-stream (Fix C).
45
56
  const toInject = [];
57
+ const parts = [];
58
+ let blockTokens = 0;
46
59
  for (const h of hits) {
47
60
  if (skip && store.wasInjected(opts.sessionId, h.checkpoint.checkpointId))
48
61
  continue;
62
+ // Inline dedupe: skip a hit already resident in the live window (Fix C).
63
+ if (doWindowDedupe && liveEmbeddings.length > 0) {
64
+ const hitVec = defaultEmbedder().embed(h.checkpoint.summary);
65
+ if (liveEmbeddings.some((v) => cosineSimilarity(v, hitVec) >= dedupSim))
66
+ continue;
67
+ }
68
+ const part = formatRecallBlock([h]);
69
+ const partTokens = estimateBlockTokens(part);
70
+ // Token cap: never push a chunk that would overrun the ceiling.
71
+ if (maxTokens > 0 && blockTokens + partTokens > maxTokens)
72
+ break;
73
+ parts.push(part);
49
74
  toInject.push(h);
75
+ blockTokens += partTokens;
50
76
  store.markInjected(opts.sessionId, h.checkpoint.checkpointId);
51
77
  }
52
- const block = formatRecallBlock(toInject);
78
+ const block = parts.join("\n");
53
79
  const report = toInject.map((h) => ` • ${h.checkpoint.checkpointId} (${h.checkpoint.summary.slice(0, 60).replace(/\n/g, " ")}…)`);
54
80
  return {
55
81
  toInject,
@@ -45,6 +45,34 @@ test("recallAndInline empty when store has nothing for query", () => {
45
45
  assert.equal(r.empty, true);
46
46
  assert.equal(r.block, "");
47
47
  });
48
+ test("Fix C: recallMaxTokens caps the injected block", () => {
49
+ const s = store();
50
+ // Three distinct checkpoints so we can observe the cap bite mid-stream.
51
+ compactSession({ sessionId: SESS, messages: [msg("user", "alpha module wiring and bootstrap sequence"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
52
+ compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
53
+ compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
54
+ // A ceiling of 100 tokens fits the first checkpoint (~82) but stops before the
55
+ // second (~163 cumulative) — proving the cap bites mid-stream.
56
+ const r = recallAndInline({ sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", recallMaxTokens: 100, skipInjected: false }, s);
57
+ assert.ok(r.toInject.length >= 1, "at least one injected under the cap");
58
+ assert.ok(r.toInject.length < 3, "cap prevented all three from injecting");
59
+ assert.ok(r.block.length > 0, "block non-empty");
60
+ });
61
+ test("Fix C: inline dedupe drops a hit already resident in the live window", () => {
62
+ const s = store();
63
+ const resident = "alpha module wiring and bootstrap sequence";
64
+ compactSession({ sessionId: SESS, messages: [msg("user", resident), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
65
+ compactSession({ sessionId: SESS, messages: [msg("user", "omega module telemetry and tracing spans"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
66
+ // Baseline: with dedupe OFF, both checkpoints are candidates.
67
+ const rNoDedup = recallAndInline({ sessionId: SESS, query: "module wiring telemetry", limit: 5, source: "command", skipInjected: false }, s);
68
+ // The live window contains the exact summary of the first checkpoint — as it
69
+ // would be if a prior recall already injected it. Inline dedupe must drop it
70
+ // (strictly fewer injected than the no-dedupe baseline).
71
+ const residentSummary = rNoDedup.toInject[0].checkpoint.summary;
72
+ const rDedup = recallAndInline({ sessionId: SESS, query: "module wiring telemetry", limit: 5, source: "command", skipInjected: false, windowDedupe: true, liveWindow: [residentSummary], dedupSim: 0.9 }, s);
73
+ assert.ok(rDedup.toInject.length <= rNoDedup.toInject.length, "dedupe never adds hits");
74
+ assert.ok(rDedup.toInject.length < rNoDedup.toInject.length, "inline dedupe dropped a resident hit");
75
+ });
48
76
  test("cleanup", () => {
49
77
  rmSync(baseTmp, { recursive: true, force: true });
50
78
  });
@@ -19,7 +19,7 @@ import { openStore } from "./sqlite.js";
19
19
  import { computeContentDigest } from "../dedup/digest.js";
20
20
  import { minhashSignature, SIGNATURE_VERSION, NUM_HASHES } from "../dedup/l1-minhash.js";
21
21
  import { lshBands } from "../dedup/l1-lsh.js";
22
- import { upsertMinhashSignature, insertLshBuckets, listCheckpoints, saveRaptorTree } from "./sqlite.js";
22
+ import { upsertMinhashSignature, insertLshBuckets, listCheckpoints, saveRaptorTree, withTx } from "./sqlite.js";
23
23
  import { buildRaptorTree } from "../dedup/raptor/tree.js";
24
24
  import { defaultEmbedder } from "../embedder.js";
25
25
  import { getStateDir } from "../store.js";
@@ -64,7 +64,7 @@ export function backfillContentHashes(stateDir = getStateDir()) {
64
64
  let processed = 0;
65
65
  let lastSid = start.lastSid;
66
66
  let lastId = start.lastId;
67
- const tx = db.transaction((rows) => {
67
+ function applyRows(rows) {
68
68
  const lookup = db.prepare("SELECT id FROM context_chunks WHERE session_id = ? AND content_hash = ? AND content_hash2 = ? AND id != ? LIMIT 1");
69
69
  const update = db.prepare(`UPDATE context_chunks
70
70
  SET content_hash=?, content_hash2=?, content_hash_version=?, normalized_text=?,
@@ -87,9 +87,9 @@ export function backfillContentHashes(stateDir = getStateDir()) {
87
87
  lastId = row.id;
88
88
  processed++;
89
89
  }
90
- });
90
+ }
91
91
  if (pending.length > 0) {
92
- tx(pending);
92
+ withTx(db, () => applyRows(pending));
93
93
  db.prepare("INSERT INTO backfill_progress(name, last_session_id, last_id, updated, duplicates_resolved) VALUES('content_hashes',?,?,?,?) ON CONFLICT(name) DO UPDATE SET last_session_id=excluded.last_session_id, last_id=excluded.last_id, updated=excluded.updated, duplicates_resolved=excluded.duplicates_resolved").run(lastSid, lastId, updated, duplicatesResolved);
94
94
  }
95
95
  if (THROTTLE_MS > 0) {
@@ -135,7 +135,7 @@ export function backfillPhase(phase, sessionId, stateDir, opts = {}) {
135
135
  let cursor = lastId ?? undefined;
136
136
  for (let i = Math.max(0, startIndex); i < all.length; i += batchSize) {
137
137
  const batch = all.slice(i, i + batchSize);
138
- const tx = db.transaction(() => {
138
+ withTx(db, () => {
139
139
  for (const cp of batch) {
140
140
  const sig = minhashSignature(cp.normalizedText ?? cp.summary ?? "");
141
141
  if (sig.length === NUM_HASHES) {
@@ -148,7 +148,6 @@ export function backfillPhase(phase, sessionId, stateDir, opts = {}) {
148
148
  processed++;
149
149
  }
150
150
  });
151
- tx();
152
151
  savePhaseCursor(db, phase, cursor ?? null, processed);
153
152
  batches++;
154
153
  if (THROTTLE_MS > 0) {