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
@@ -11,10 +11,10 @@ import type { ExtensionAPI, ExtensionContext, ContextEvent, SessionBeforeCompact
11
11
  import { normalizeSessionId } from "../src/store.js";
12
12
  import { autoCompactCheck } from "../src/compact.js";
13
13
  import { estimateSessionTokens } from "../src/tokens.js";
14
- import { dropCompactedRange } from "../src/adapt.js";
15
14
  import { MegaRuntime, recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
16
15
  import { runCompact, doRecall } from "./mega-pipeline.js";
17
- import type { MegaConfig } from "./mega-config.js";
16
+ import { driveNativeCompaction } from "./mega-compact-driver.js";
17
+ import { pressureFromPct, type MegaConfig } from "./mega-config.js";
18
18
 
19
19
  /** Register all pi lifecycle event handlers. */
20
20
  export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, config: MegaConfig): void {
@@ -119,7 +119,15 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
119
119
  runtime.snapshot(ctx);
120
120
  });
121
121
 
122
- // ---- Auto-trigger: fast-gate confirm Trident+persist drop --------
122
+ // ---- Auto-trigger: own the decision, pi owns the durable write ----------
123
+ // OUR auto-trigger (over threshold + debounce): persist our Trident checkpoint,
124
+ // then start pi's compaction flow via ctx.compact(). That fires
125
+ // `session_before_compact`, where OUR handler returns our summary +
126
+ // firstKeptEntryId, and pi durably writes the trim to disk (appendCompaction).
127
+ // Result: auto-compact AND a durable trim — resume reloads the trimmed window,
128
+ // no full-reload + additive recall inflation (Fix B kills the token-growth bug).
129
+ // We do NOT drop messages here (that would be ephemeral; the read-only session
130
+ // manager can't trim disk, so the trim has to come through pi).
123
131
  pi.on("context", async (event: ContextEvent, ctx: ExtensionContext) => {
124
132
  if (!config.auto) return;
125
133
  const usage = ctx.getContextUsage();
@@ -133,15 +141,11 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
133
141
 
134
142
  const messages = event.messages;
135
143
  const view = runtime.engineView(messages);
136
- // Prefer the runtime's real token estimate; fall back to our heuristic
137
- // (and to a percent-of-window proxy when tokens is unknown).
138
144
  const currentTokens =
139
145
  usage?.tokens ?? estimateSessionTokens(view) ??
140
146
  Math.round((pct / 100) * (usage?.contextWindow ?? 0));
141
147
 
142
148
  // FAST GATE: token-based (tier threshold), not percentage-based.
143
- // A 20% gate on a 2M window = 400k, which is way above the 50k low-tier
144
- // threshold. Gate on the actual token count instead.
145
149
  if (currentTokens < config.thresholdTokens) return;
146
150
 
147
151
  const check = autoCompactCheck(currentTokens, config.thresholdTokens); // SERVER-STYLE CONFIRM (local)
@@ -152,29 +156,44 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
152
156
  if (now < runtime.debounceUntil) return;
153
157
  runtime.debounceUntil = now + 2000;
154
158
 
155
- const ran = runCompact(pi, runtime, config, ctx, messages);
159
+ // Adaptive compression (Fix E): scale compression strength + keepFrom depth
160
+ // with how close we are to the model context limit.
161
+ const pressure = pressureFromPct(pct);
162
+ const ran = runCompact(pi, runtime, config, ctx, messages, { compressionPressure: pressure });
156
163
  if (ran.skipped) return;
157
164
 
158
- // DROP the compacted range from the outgoing context, honoring the anchor
159
- // floor + tool-pair boundary guards (PREVENT-PI-001/002).
160
- const kept = dropCompactedRange(messages, ran.keepFrom!, config.anchorUserMessages);
161
- if (kept.length < messages.length) {
162
- return { messages: kept };
163
- }
165
+ // Start pi's compaction flow so our session_before_compact handler can
166
+ // supply the durable trim (pi writes it to disk). We never use pi's summary.
167
+ ctx.compact({ customInstructions: undefined });
164
168
  });
165
169
 
166
- // ---- Cancel native compaction once we've persisted our own -------------
167
- pi.on("session_before_compact", async (_event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
170
+ // ---- Supply a DURABLE trim to pi's native compaction (Fix B) ----------
171
+ // We run the Trident pipeline to produce a compressed summary, then return
172
+ // it as a CompactionResult. pi writes the summary into a compactionSummary
173
+ // entry AND truncates the on-disk transcript from firstKeptEntryId. This is
174
+ // the durable fix for "tokens grow on read": the trim survives resume, so
175
+ // there is no full-reload + additive recall inflation.
176
+ pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
168
177
  runtime.resetRuntime(ctx.sessionManager.getSessionId());
169
- if (runtime.rt.persistedThisSession) {
170
- // We already persisted a checkpoint for this session (via the context
171
- // hook drop) cancel pi's own compaction to avoid double-compacting.
172
- // Our context-hook drop already trimmed the window.
173
- return { cancel: true };
178
+ if (!config.auto) return {}; // let pi run its own native compaction
179
+ try {
180
+ const result = driveNativeCompaction(event, runtime, config);
181
+ if (result) {
182
+ runtime.logger.info("native-compact", {
183
+ sessionId: runtime.rt.sessionId,
184
+ firstKeptEntryId: result.compaction.firstKeptEntryId,
185
+ tokensBefore: result.compaction.tokensBefore,
186
+ summaryTokens: result.compaction.estimatedTokensAfter,
187
+ });
188
+ return { compaction: result.compaction };
189
+ }
190
+ } catch (err) {
191
+ runtime.logger.error("native-compact-failed", {
192
+ sessionId: runtime.rt.sessionId,
193
+ error: String(err instanceof Error ? err.message : err),
194
+ });
174
195
  }
175
- // We haven't persisted yet this session: let pi run its native compaction.
176
- // (Our auto-trigger only fires again past the threshold, and will then
177
- // capture a checkpoint next time around.)
196
+ // Fall back to pi's own native compaction if we can't supply one.
178
197
  return {};
179
198
  });
180
199
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
11
12
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
12
13
  import { compactSession } from "../src/engine.js";
13
14
  import { recallAndInline } from "../src/recall.js";
@@ -18,7 +19,9 @@ import {
18
19
  C,
19
20
  MARKER_TYPE,
20
21
  } from "./mega-runtime.js";
21
- import { resolveRepoRoot, type MegaConfig } from "./mega-config.js";
22
+ import { resolveRepoRoot, preserveRecentForPressure, type MegaConfig } from "./mega-config.js";
23
+ import { runRaptor } from "../src/dedup/raptor/index.js";
24
+ import { loadDedupConfig } from "../src/config/dedup.js";
22
25
 
23
26
  export type RunCompactResult =
24
27
  | { skipped: true }
@@ -31,7 +34,7 @@ export function runCompact(
31
34
  config: MegaConfig,
32
35
  ctx: ExtensionContext,
33
36
  messages: AgentMessage[],
34
- opts: { keepFrom?: number; summary?: string } = {},
37
+ opts: { keepFrom?: number; summary?: string; compressionPressure?: number } = {},
35
38
  ): RunCompactResult {
36
39
  runtime.bindRepo(ctx.cwd);
37
40
  const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
@@ -39,7 +42,14 @@ export function runCompact(
39
42
  runtime.rt.sessionId = sid;
40
43
 
41
44
  const view = runtime.engineView(messages);
42
- const keepFrom = opts.keepFrom ?? Math.max(0, view.length - config.preserveRecent);
45
+ // keepFrom deepens with context pressure (Fix E): under high pressure we
46
+ // compact more of the session, down to the preserveRecentMin floor.
47
+ const preserve = preserveRecentForPressure(
48
+ opts.compressionPressure ?? 0,
49
+ config.preserveRecent,
50
+ config.preserveRecentMin,
51
+ );
52
+ const keepFrom = opts.keepFrom ?? Math.max(0, view.length - preserve);
43
53
  if (keepFrom <= 0) return { skipped: true };
44
54
 
45
55
  runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
@@ -51,6 +61,7 @@ export function runCompact(
51
61
  summary: opts.summary,
52
62
  timestamp: Date.now(),
53
63
  onTier: runtime.makeTierCallback(ctx),
64
+ compressionPressure: opts.compressionPressure,
54
65
  },
55
66
  runtime.store,
56
67
  );
@@ -78,15 +89,13 @@ export function runCompact(
78
89
  // denominator (we don't want it pinned at 100% once we pass an old target).
79
90
  if (runtime.rt.tokensSaved > runtime.savedGoal) runtime.savedGoal = Math.ceil((runtime.rt.tokensSaved * 1.25) / 10_000) * 10_000;
80
91
 
81
- // Live toolbar "now processing" line: what file/region just got compacted or
82
- // deduped. Reset to the last-seen action after a few seconds (see snapshot).
92
+ // Live toolbar activity: what file/region just got compacted or deduped.
93
+ // Rendered via the rotating ticker line (see snapshot); the ring buffer is
94
+ // cycled one-per-repaint so the single line scrolls through recent files.
83
95
  const files = result.filesModified ?? [];
84
96
  const fileLabel = files.length
85
97
  ? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ")
86
98
  : result.regionHash.slice(0, 8);
87
- runtime.currentActivity = result.deduped
88
- ? `♻ deduped ${fileLabel}`
89
- : `🗜 compacted ${result.checkpointId} · ${fileLabel}`;
90
99
  runtime.lastActivityAt = Date.now();
91
100
  // Explain-why line: surfaced while fresh. Pulls the dedup reason (which for
92
101
  // L2 includes the cosine sim) so the user sees WHY a region was kept/dropped.
@@ -124,6 +133,37 @@ export function runCompact(
124
133
  deduped: result.deduped,
125
134
  });
126
135
 
136
+ // Fix D: refresh the RAPTOR tree for this session so live recall (search) can
137
+ // serve high-level summaries. Best-effort + non-fatal: never block compaction.
138
+ // Budget-guarded (RAPTOR_BUDGET_MS) so it can't hang a large session.
139
+ if (config.raptorEnabled && !result.deduped) {
140
+ try {
141
+ const dd = loadDedupConfig();
142
+ const all = runtime.store.list(sid);
143
+ const leaves = all.map((cp) => ({
144
+ id: cp.checkpointId,
145
+ messages: [],
146
+ sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
147
+ embedding: cp.embedding,
148
+ }));
149
+ if (leaves.length >= 2) {
150
+ runRaptor(
151
+ leaves,
152
+ {
153
+ stateDir: runtime.currentStateDir,
154
+ sessionId: sid,
155
+ budgetMs: dd.RAPTOR_BUDGET_MS,
156
+ clustersPerLevel: dd.RAPTOR_CLUSTERS_PER_LEVEL,
157
+ consistencyThreshold: dd.RAPTOR_CONSISTENCY,
158
+ logger: runtime.logger,
159
+ },
160
+ );
161
+ }
162
+ } catch {
163
+ /* non-fatal: tree refresh never blocks a compaction */
164
+ }
165
+ }
166
+
127
167
  runtime.setStatus(
128
168
  ctx,
129
169
  runtime.rt.persistedThisSession
@@ -162,8 +202,22 @@ export function doRecall(
162
202
  ) {
163
203
  runtime.bindRepo(ctx.cwd);
164
204
  const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
205
+ // Live window text for inline dedupe (Fix C): drop recalled checkpoints that
206
+ // are already resident in the session, so recall never re-injects context the
207
+ // model can already see. Best-effort — an empty window just skips dedupe.
208
+ const liveWindow = config.windowDedupe ? extractLiveWindow(ctx) : undefined;
165
209
  const result = recallAndInline(
166
- { sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true },
210
+ {
211
+ sessionId: sid,
212
+ query,
213
+ limit: config.autoInlineK,
214
+ source,
215
+ skipInjected: true,
216
+ recallMaxTokens: config.recallMaxTokens,
217
+ windowDedupe: config.windowDedupe,
218
+ liveWindow,
219
+ dedupSim: config.dedupSim,
220
+ },
167
221
  runtime.store,
168
222
  );
169
223
  runtime.dashboard.event("recall", { source, query: query.slice(0, 120), injected: result.toInject.length, empty: result.empty });
@@ -177,3 +231,26 @@ export function doRecall(
177
231
  }
178
232
  return result;
179
233
  }
234
+
235
+ /**
236
+ * Extract the live-window message texts from the session manager (Fix C),
237
+ * for inline-dedupe of recalled checkpoints. Best-effort: returns [] on any
238
+ * error so recall falls back to unbounded (still correct, just no dedupe).
239
+ * Mirrors recentUserQuery's use of sessionEntryToContextMessages.
240
+ */
241
+ function extractLiveWindow(ctx: ExtensionContext): string[] {
242
+ try {
243
+ const entries = ctx.sessionManager.getEntries();
244
+ const texts: string[] = [];
245
+ for (const e of entries) {
246
+ for (const m of sessionEntryToContextMessages(e)) {
247
+ const c = (m as { content?: unknown }).content;
248
+ if (typeof c === "string") texts.push(c);
249
+ else if (Array.isArray(c)) texts.push(c.map((b: any) => b.text).join(" "));
250
+ }
251
+ }
252
+ return texts;
253
+ } catch {
254
+ return [];
255
+ }
256
+ }
@@ -90,10 +90,7 @@ export class MegaRuntime {
90
90
  // on model_select + session_start; persisted to SQL so cost + the dashboard
91
91
  // can read it without a live ctx.
92
92
  currentModel: ModelSnapshot | undefined;
93
- // Live "what it's doing right now" line for the toolbar. Set on each
94
- // compaction; shown in teal while recent, then kept as the last-seen action so
95
- // the widget is never blank. Cleared on session reset.
96
- currentActivity: string | undefined;
93
+ // Live "what it's doing right now" timestamp, used for the fresh-window.
97
94
  lastActivityAt = 0;
98
95
  // Live per-tier dedup trace (Phase 1): e.g. "L0 ✓ → L1 ✓ → L2 0.91 → stored".
99
96
  // Built from the store's sync onTier callback during a compaction so the user
@@ -269,25 +266,25 @@ export class MegaRuntime {
269
266
  const bar = "▓".repeat(filled) + "░".repeat(10 - filled);
270
267
  lines.push(` ${C.green}saved ${fmt(this.rt.tokensSaved)} ${bar}${C.reset} ${pct}% of ${fmt(goal)}`);
271
268
  }
272
- // Live "now processing" line teal while fresh (≤4s), then the last-seen
273
- // action keeps the widget lively. Cleared on session reset.
269
+ // Live "now processing" line + why + recent deduped/compacted events,
270
+ // collapsed to ONE rotating line (fresh only). The ticker ring buffer
271
+ // (≤5 most-recent events) is cycled one-per-repaint so the line scrolls
272
+ // through recent files in real time while activity fires. We rotate on a
273
+ // 250ms step (same cadence as the pulse), using an event counter as the
274
+ // deterministic phase so consecutive repaints advance the visible entry.
274
275
  const fresh = Date.now() - this.lastActivityAt < 4000;
275
276
  if (this.tierTrace && fresh) {
276
277
  lines.push(` ${pulse}${this.tierTrace}`);
277
- } else if (this.currentActivity) {
278
- lines.push(` ${fresh ? C.teal : C.dim}${this.currentActivity}${C.reset}`);
278
+ } else if (this.ticker.length > 0) {
279
+ const step = Math.floor(Date.now() / 250);
280
+ const idx = this.ticker.length - 1 - (step % this.ticker.length);
281
+ const head = this.ticker[idx].text;
282
+ const why = this.lastWhy ? ` ${C.gray}· ${this.lastWhy}${C.reset}` : "";
283
+ const more = this.ticker.length > 1 ? ` ${C.dim}(+${this.ticker.length - 1} more)${C.reset}` : "";
284
+ lines.push(` ${fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`);
279
285
  } else if (this.pulsing) {
280
286
  lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
281
287
  }
282
- // Phase 3 — explain-why line (fresh only).
283
- if (this.lastWhy && fresh) lines.push(` ${C.gray}${this.lastWhy}${C.reset}`);
284
- // Phase 3 — recall/activity ticker (most-recent first), fresh only.
285
- if (fresh) {
286
- for (let i = this.ticker.length - 1; i >= 0; i--) {
287
- if (lines.length >= 9) break; // leave room for the hint line (MAX 10)
288
- lines.push(` ${i === this.ticker.length - 1 ? "" : C.dim}${this.ticker[i].text}${C.reset}`);
289
- }
290
- }
291
288
  // Plain-language hint so first-time users understand the widget. Always
292
289
  // last, dimmed. "/mega-help explains these terms."
293
290
  if (lines.length < 10) {
@@ -318,7 +315,6 @@ export class MegaRuntime {
318
315
  this.statusKey = undefined;
319
316
  this.activeAgents = 0;
320
317
  this.currentTurn = 0;
321
- this.currentActivity = undefined;
322
318
  this.lastActivityAt = 0;
323
319
  this.tierTrace = undefined;
324
320
  this.ticker.length = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.4.20",
3
+ "version": "0.4.23",
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-2-Clause",
@@ -20,7 +20,7 @@
20
20
  "trident"
21
21
  ],
22
22
  "engines": {
23
- "node": ">=18"
23
+ "node": ">=22.13"
24
24
  },
25
25
  "files": [
26
26
  "dist",
@@ -45,19 +45,18 @@
45
45
  "test": "npm run build && node --test \"dist/src/**/*.test.js\" \"dist/extensions/**/*.test.js\"",
46
46
  "guardrails": "python3 scripts/regression_check.py --all || node scripts/guardrails-scan.mjs",
47
47
  "precommit": "bash .claude/hooks/pre-commit.sh",
48
- "prepublishOnly": "npm run build"
48
+ "prepublishOnly": "npm run build",
49
+ "postinstall": "npm rebuild @mongodb-js/zstd || true"
49
50
  },
50
51
  "peerDependencies": {
51
52
  "@earendil-works/pi-coding-agent": "*",
52
53
  "openclaw": ">=0.1.0"
53
54
  },
54
55
  "devDependencies": {
55
- "@types/better-sqlite3": "^7.6.13",
56
- "@types/node": "^20.0.0",
56
+ "@types/node": "^22.20.1",
57
57
  "typescript": "^5.4.0"
58
58
  },
59
59
  "dependencies": {
60
- "@mongodb-js/zstd": "^7.0.0",
61
- "better-sqlite3": "^12.11.1"
60
+ "@mongodb-js/zstd": "^7.0.0"
62
61
  }
63
62
  }
@@ -66,7 +66,10 @@ export function loadDedupConfig(): DedupConfigShape {
66
66
  L0_ENABLED: envBool("MEGACOMPACT_L0_ENABLED", true),
67
67
  L1_ENABLED: envBool("MEGACOMPACT_L1_ENABLED", true),
68
68
  L2_ENABLED: envBool("MEGACOMPACT_L2_ENABLED", true),
69
- RAPTOR_ENABLED: envBool("MEGACOMPACT_RAPTOR_ENABLED", false), // shadow by default
69
+ // Fix D: RAPTOR promoted to live recall. Default ON; canary.ts sequences it
70
+ // last (L0→L1→L2→RAPTOR) and auto-disables on p95 breach, so promotion is
71
+ // safe. `RAPTOR_SHADOW_MODE=false` still gates serving during transition.
72
+ RAPTOR_ENABLED: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
70
73
  MARK_ONLY_L0: envBool("MEGACOMPACT_MARK_ONLY_L0", false),
71
74
  MARK_ONLY_L1: envBool("MEGACOMPACT_MARK_ONLY_L1", false),
72
75
  MARK_ONLY_L2: envBool("MEGACOMPACT_MARK_ONLY_L2", false),
package/src/config.ts CHANGED
@@ -13,3 +13,29 @@ export const STATE_DIR_DEFAULT = join(homedir(), ".pi", "agent", "extensions", "
13
13
 
14
14
  /** Pi custom message / entry type used as the dedup sentinel. */
15
15
  export const MARKER_TYPE = "mega-compact-marker";
16
+
17
+ /**
18
+ * Derive context-window pressure (0–1) from a usage percentage. Used to scale
19
+ * compression strength + keepFrom depth (Fix E): low pct = room to spare,
20
+ * high pct = near the limit. Deterministic; clamps to [0,1].
21
+ */
22
+ export function pressureFromPct(pct: number | null | undefined): number {
23
+ if (pct == null || Number.isNaN(pct)) return 0;
24
+ return pct < 0 ? 0 : pct > 100 ? 1 : pct / 100;
25
+ }
26
+
27
+ /**
28
+ * Map pressure → how many recent messages to preserve verbatim. Under low
29
+ * pressure we keep `preserveRecent`; under high pressure we compact deeper,
30
+ * down to `preserveRecentMin`. Never splits a tool pair / anchor floor — the
31
+ * boundary guard (computeDropRange) enforces that downstream.
32
+ */
33
+ export function preserveRecentForPressure(
34
+ pressure: number,
35
+ preserveRecent: number,
36
+ preserveRecentMin: number,
37
+ ): number {
38
+ const p = pressure < 0 ? 0 : pressure > 1 ? 1 : pressure;
39
+ const v = Math.round(preserveRecent - (preserveRecent - preserveRecentMin) * p);
40
+ return Math.max(preserveRecentMin, Math.min(preserveRecent, v));
41
+ }
@@ -89,10 +89,22 @@ export function recallRaptor(
89
89
  opts: { embedder?: Embedder; stateDir: string; k?: number; topM?: number },
90
90
  ): string[] {
91
91
  const embedder = opts.embedder ?? defaultEmbedder();
92
- const nodes = listRaptorNodes(sessionId, opts.stateDir);
93
- if (nodes.length === 0) return [];
94
- // Rehydrate a minimal in-memory tree (parent links reconstructed from children).
95
- const byId = new Map(nodes.map((n) => [n.id, n]));
92
+ const tree = rehydrateRaptorTree(sessionId, opts.stateDir);
93
+ if (!tree) return [];
94
+ return stagedExpansion(query, tree, { embedder, k: opts.k, topM: opts.topM });
95
+ }
96
+
97
+ /**
98
+ * Rehydrate a persisted RAPTOR tree from raptor_nodes (Fix D): rebuild the
99
+ * in-memory RaptorTree + parent links so vectorStore.search can serve it live.
100
+ * Returns null when no tree exists (caller falls back to the flat path).
101
+ */
102
+ export function rehydrateRaptorTree(
103
+ sessionId: string,
104
+ stateDir: string,
105
+ ): RaptorTree | null {
106
+ const nodes = listRaptorNodes(sessionId, stateDir);
107
+ if (nodes.length === 0) return null;
96
108
  const tree: RaptorTree = {
97
109
  nodes: new Map(
98
110
  nodes.map((n) => [
@@ -109,10 +121,33 @@ export function recallRaptor(
109
121
  },
110
122
  ]),
111
123
  ),
112
- rootId: nodes.reduce<typeof nodes[number] | null>((best, n) => (!best || n.level > (best?.level ?? -1) ? n : best), null)?.id ?? null,
124
+ rootId:
125
+ nodes.reduce<typeof nodes[number] | null>(
126
+ (best, n) => (!best || n.level > (best?.level ?? -1) ? n : best),
127
+ null,
128
+ )?.id ?? null,
113
129
  levels: Math.max(1, ...nodes.map((n) => n.level + 1)),
114
130
  timedOut: false,
115
131
  };
116
- void byId;
117
- return stagedExpansion(query, tree, { embedder, k: opts.k, topM: opts.topM });
132
+ return tree;
133
+ }
134
+
135
+ /**
136
+ * Return the RAPTOR root summary for a session, if a tree has been built.
137
+ * Used by the durable-trim driver (Fix B/D) to supply pi a session-level
138
+ * compressed summary instead of one slice's extractive summary. Returns
139
+ * undefined when no tree exists yet (caller falls back to the slice summary).
140
+ */
141
+ export function recallRaptorRootSummary(
142
+ sessionId: string,
143
+ stateDir: string,
144
+ ): string | undefined {
145
+ const nodes = listRaptorNodes(sessionId, stateDir);
146
+ if (nodes.length === 0) return undefined;
147
+ // Highest-level node = the root (covers all leaves).
148
+ const root = nodes.reduce<(typeof nodes)[number] | null>(
149
+ (best, n) => (!best || n.level > best.level ? n : best),
150
+ null,
151
+ );
152
+ return root?.summary || undefined;
118
153
  }
@@ -0,0 +1,82 @@
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
+
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { VectorStore } from "../../vectorStore.js";
16
+ import { runRaptor } from "./index.js";
17
+ import { compactSession } from "../../engine.js";
18
+ import { Logger } from "../../log.js";
19
+ import { loadDedupConfig } from "../../config/dedup.js";
20
+ import { listRaptorNodes } from "../../store/sqlite.js";
21
+ import type { EngineMessage } from "../../types.js";
22
+
23
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-promote-"));
24
+ let counter = 0;
25
+ function raptorConfig() {
26
+ return { ...loadDedupConfig(), RAPTOR_ENABLED: true };
27
+ }
28
+ function msg(text: string, toolName?: string): EngineMessage {
29
+ return toolName ? { role: "assistant", text, toolName, input: text, output: text } : { role: "user", text };
30
+ }
31
+ const SESS = "sess_promote";
32
+
33
+ test("Fix D: vectorStore.search serves a persisted RAPTOR tree (broader recall)", () => {
34
+ const stateDir = join(baseTmp, `run-${counter++}`);
35
+ const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
36
+
37
+ // Persist several distinct checkpoints.
38
+ for (let i = 1; i <= 5; i++) {
39
+ compactSession(
40
+ { sessionId: SESS, messages: [msg(`topic alpha wire ${i} and bootstrap sequence`), msg(`ok ${i}`, "Edit")], keepFrom: 2, timestamp: i },
41
+ s,
42
+ );
43
+ }
44
+
45
+ // No tree yet → flat search only, returns hits, no RAPTOR coverage.
46
+ assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree initially");
47
+ const flat = s.search(SESS, "alpha wire bootstrap", 3);
48
+ assert.ok(flat.length > 0, "flat search returns hits");
49
+
50
+ // Build + persist a RAPTOR tree for the session (mirrors runCompact refresh).
51
+ const all = s.list(SESS);
52
+ const leaves = all.map((cp) => ({
53
+ id: cp.checkpointId,
54
+ messages: [],
55
+ sourceText: cp.normalizedText ?? cp.summary ?? cp.regionHash,
56
+ embedding: cp.embedding,
57
+ }));
58
+ const tree = runRaptor(leaves, { stateDir, sessionId: SESS, logger: new Logger() });
59
+ assert.ok(tree && listRaptorNodes(SESS, stateDir).length > 0, "tree persisted");
60
+
61
+ // With the tree live + RAPTOR_ENABLED, search still returns hits and now
62
+ // exercises the RAPTOR-served path without regression.
63
+ const withTree = s.search(SESS, "alpha wire bootstrap", 3);
64
+ assert.ok(withTree.length > 0, "search returns hits with RAPTOR promoted");
65
+ // Every returned hit is a real checkpoint in the session.
66
+ for (const h of withTree) {
67
+ assert.ok(all.some((cp) => cp.checkpointId === h.checkpoint.checkpointId), "hit is a real checkpoint");
68
+ }
69
+ });
70
+
71
+ test("Fix D: search still works for a session with <2 leaves (no tree)", () => {
72
+ const stateDir = join(baseTmp, `run-${counter++}`);
73
+ const s = new VectorStore({ dedupSim: 0.9, stateDir, config: raptorConfig() });
74
+ compactSession({ sessionId: SESS, messages: [msg("only one topic here"), msg("ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
75
+ const r = s.search(SESS, "only one topic", 3);
76
+ assert.ok(r.length > 0, "single-checkpoint search still works (no tree)");
77
+ assert.equal(listRaptorNodes(SESS, stateDir).length, 0, "no tree built for <2 leaves");
78
+ });
79
+
80
+ test("cleanup", () => {
81
+ rmSync(baseTmp, { recursive: true, force: true });
82
+ });
package/src/engine.ts CHANGED
@@ -38,6 +38,10 @@ export interface CompactInput {
38
38
  timestamp?: number;
39
39
  /** When true (default), use extractive summary instead of raw concatenation. */
40
40
  useExtractiveSummary?: boolean;
41
+ /** Context-window pressure (0–1): how close the session is to the model
42
+ * limit. Drives adaptive compression strength in the stored checkpoint
43
+ * (Fix E). 0/undefined = room to spare; 1 = at the limit. */
44
+ compressionPressure?: number;
41
45
  /** Sync progress callback fired by the store as each dedup tier is evaluated
42
46
  * (L0→L1→L2→new). Lets the UI render a live "L0 ✓ → L1 ✓ → L2 0.91 → stored"
43
47
  * progress line during compaction. Never awaited; must be side-effect-free-ish
@@ -174,6 +178,7 @@ export function compactSession(input: CompactInput, store: VectorStore = getDefa
174
178
  originalTokenEstimate,
175
179
  timestamp: input.timestamp ?? 0,
176
180
  onTier: input.onTier,
181
+ compressionPressure: input.compressionPressure,
177
182
  });
178
183
 
179
184
  return {
@@ -54,6 +54,50 @@ test("recallAndInline empty when store has nothing for query", () => {
54
54
  assert.equal(r.block, "");
55
55
  });
56
56
 
57
+ test("Fix C: recallMaxTokens caps the injected block", () => {
58
+ const s = store();
59
+ // Three distinct checkpoints so we can observe the cap bite mid-stream.
60
+ compactSession({ sessionId: SESS, messages: [msg("user", "alpha module wiring and bootstrap sequence"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
61
+ compactSession({ sessionId: SESS, messages: [msg("user", "beta module config and env resolution"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
62
+ compactSession({ sessionId: SESS, messages: [msg("user", "gamma module shutdown and cleanup hooks"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 3 }, s);
63
+
64
+ // A ceiling of 100 tokens fits the first checkpoint (~82) but stops before the
65
+ // second (~163 cumulative) — proving the cap bites mid-stream.
66
+ const r = recallAndInline(
67
+ { sessionId: SESS, query: "module wiring config shutdown", limit: 5, source: "command", recallMaxTokens: 100, skipInjected: false },
68
+ s as any,
69
+ );
70
+ assert.ok(r.toInject.length >= 1, "at least one injected under the cap");
71
+ assert.ok(r.toInject.length < 3, "cap prevented all three from injecting");
72
+ assert.ok(r.block.length > 0, "block non-empty");
73
+ });
74
+
75
+ test("Fix C: inline dedupe drops a hit already resident in the live window", () => {
76
+ const s = store();
77
+ const resident = "alpha module wiring and bootstrap sequence";
78
+ compactSession({ sessionId: SESS, messages: [msg("user", resident), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 1 }, s);
79
+ compactSession({ sessionId: SESS, messages: [msg("user", "omega module telemetry and tracing spans"), msg("assistant", "ok", "Edit")], keepFrom: 2, timestamp: 2 }, s);
80
+
81
+ // Baseline: with dedupe OFF, both checkpoints are candidates.
82
+ const rNoDedup = recallAndInline(
83
+ { sessionId: SESS, query: "module wiring telemetry", limit: 5, source: "command", skipInjected: false },
84
+ s as any,
85
+ );
86
+ // The live window contains the exact summary of the first checkpoint — as it
87
+ // would be if a prior recall already injected it. Inline dedupe must drop it
88
+ // (strictly fewer injected than the no-dedupe baseline).
89
+ const residentSummary = rNoDedup.toInject[0].checkpoint.summary;
90
+ const rDedup = recallAndInline(
91
+ { sessionId: SESS, query: "module wiring telemetry", limit: 5, source: "command", skipInjected: false, windowDedupe: true, liveWindow: [residentSummary], dedupSim: 0.9 },
92
+ s as any,
93
+ );
94
+ assert.ok(rDedup.toInject.length <= rNoDedup.toInject.length, "dedupe never adds hits");
95
+ assert.ok(
96
+ rDedup.toInject.length < rNoDedup.toInject.length,
97
+ "inline dedupe dropped a resident hit",
98
+ );
99
+ });
100
+
57
101
  test("cleanup", () => {
58
102
  rmSync(baseTmp, { recursive: true, force: true });
59
103
  });