pi-mega-compact 0.4.21 → 0.4.24

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 (47) hide show
  1. package/dist/extensions/dashboard-server.js +60 -9
  2. package/dist/extensions/dashboard-server.test.js +77 -0
  3. package/dist/extensions/mega-compact-driver.js +79 -0
  4. package/dist/extensions/mega-compact.test.js +54 -18
  5. package/dist/extensions/mega-config.js +10 -0
  6. package/dist/extensions/mega-dashboard-cmds.js +32 -2
  7. package/dist/extensions/mega-events.js +45 -23
  8. package/dist/extensions/mega-pipeline.js +77 -3
  9. package/dist/src/config/dedup.js +4 -1
  10. package/dist/src/config.js +21 -0
  11. package/dist/src/dedup/raptor/index.js +28 -6
  12. package/dist/src/dedup/raptor/promote.test.js +69 -0
  13. package/dist/src/engine.js +1 -0
  14. package/dist/src/recall.js +30 -4
  15. package/dist/src/recall.test.js +28 -0
  16. package/dist/src/store/backfill.js +5 -6
  17. package/dist/src/store/compression.js +47 -7
  18. package/dist/src/store/compression.test.js +48 -0
  19. package/dist/src/store/sqlite.js +64 -41
  20. package/dist/src/store.test.js +19 -0
  21. package/dist/src/vectorStore.js +56 -1
  22. package/extensions/DASHBOARD.md +3 -3
  23. package/extensions/dashboard-server.test.ts +77 -0
  24. package/extensions/dashboard-server.ts +57 -11
  25. package/extensions/mega-compact-driver.ts +105 -0
  26. package/extensions/mega-compact.test.ts +65 -18
  27. package/extensions/mega-config.ts +25 -0
  28. package/extensions/mega-dashboard-cmds.ts +23 -2
  29. package/extensions/mega-events.ts +43 -24
  30. package/extensions/mega-pipeline.ts +83 -4
  31. package/package.json +6 -7
  32. package/src/config/dedup.ts +4 -1
  33. package/src/config.ts +26 -0
  34. package/src/dedup/raptor/index.ts +42 -7
  35. package/src/dedup/raptor/promote.test.ts +82 -0
  36. package/src/engine.ts +5 -0
  37. package/src/recall.test.ts +44 -0
  38. package/src/recall.ts +43 -4
  39. package/src/store/backfill.ts +10 -11
  40. package/src/store/compression.test.ts +58 -0
  41. package/src/store/compression.ts +48 -7
  42. package/src/store/sqlite.ts +72 -49
  43. package/src/store.test.ts +22 -0
  44. package/src/vectorStore.ts +63 -1
  45. package/dist/extensions/openclaw-mega-compact.js +0 -291
  46. package/dist/src/minilm.js +0 -92
  47. package/dist/src/wordpiece.js +0 -129
@@ -45,6 +45,7 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
45
45
  let statusKey: string | undefined;
46
46
  let statusText: string | undefined;
47
47
  const notifies: string[] = [];
48
+ const compactCalls: any[] = [];
48
49
 
49
50
  // Minimal AgentMessage factory for the session we project into the extension.
50
51
  function msg(role: string, text: string, toolName?: string): AgentMessage {
@@ -107,7 +108,30 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
107
108
  hasPendingMessages: () => false,
108
109
  shutdown: () => {},
109
110
  getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }),
110
- compact: () => {},
111
+ // Faithful mock: ctx.compact() starts pi's flow, which fires the
112
+ // session_before_compact handler (where WE supply the durable trim).
113
+ compact: (opts?: any) => {
114
+ compactCalls.push(opts);
115
+ if (handlers["session_before_compact"]) {
116
+ return handlers["session_before_compact"](
117
+ {
118
+ type: "session_before_compact",
119
+ reason: "threshold",
120
+ willRetry: false,
121
+ signal: undefined,
122
+ // pi computed the cut honoring anchor floor + tool-pair (PREVENT-PI-002);
123
+ // our handler reuses it as firstKeptEntryId.
124
+ preparation: {
125
+ firstKeptEntryId: "e2",
126
+ messagesToSummarize: session.slice(0, 2),
127
+ tokensBefore: 500,
128
+ },
129
+ } as any,
130
+ makeCtx(),
131
+ );
132
+ }
133
+ return undefined;
134
+ },
111
135
  getSystemPrompt: () => "system base",
112
136
  ...over,
113
137
  } as any;
@@ -143,14 +167,14 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
143
167
  mod.default(pi);
144
168
 
145
169
  return {
146
- stateDir, handlers, commands, appended, get status() { return { statusKey, statusText }; }, notifies,
170
+ stateDir, handlers, commands, appended, get status() { return { statusKey, statusText }; }, notifies, compactCalls,
147
171
  fire: (ev: string, event: any, ctx: any) => handlers[ev](event, ctx),
148
172
  ctx: makeCtx,
149
173
  session,
150
174
  };
151
175
  }
152
176
 
153
- test("auto-trigger: past threshold persists a chkpt and drops context", async () => {
177
+ test("auto-trigger: past threshold persists a chkpt and starts a durable trim", async () => {
154
178
  const h = harness();
155
179
  const messages = h.session;
156
180
  const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
@@ -159,27 +183,50 @@ test("auto-trigger: past threshold persists a chkpt and drops context", async ()
159
183
  const { listCheckpoints } = await import("../src/store/sqlite.js");
160
184
  assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
161
185
  assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel appended");
162
- // Context dropped (the compacted range was trimmed).
163
- assert.ok(res && Array.isArray(res.messages), "context handler returns filtered messages");
164
- assert.ok((res.messages as any[]).length < messages.length, "outgoing context shrank");
186
+ // The context handler no longer drops messages itself (that was ephemeral —
187
+ // the read-path token-growth bug). It triggers pi's compaction flow, which
188
+ // calls our session_before_compact handler to supply the DURABLE trim.
189
+ assert.equal(res, undefined, "context handler returns nothing (no local drop)");
190
+ assert.equal(h.compactCalls.length, 1, "ctx.compact() called to start durable trim");
191
+ // The durable trim was supplied (summary + firstKeptEntryId from pi's prep).
192
+ assert.ok(h.compactCalls[0] !== undefined, "compaction flow executed");
165
193
  });
166
194
 
167
- test("session_before_compact cancels once we've persisted", async () => {
195
+ test("session_before_compact supplies our durable trim (not pi's summary)", async () => {
168
196
  const h = harness();
169
- const ctx = h.ctx();
170
- // First fire the auto-trigger so a checkpoint is persisted this session.
171
- await h.fire("context", { type: "context", messages: h.session }, h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) }));
172
- // Now pi tries to compact natively — we must cancel (no double-compact).
173
- const res = await h.fire("session_before_compact", { type: "session_before_compact", reason: "overflow", willRetry: true, preparation: {}, signal: undefined } as any, ctx);
174
- assert.deepEqual(res, { cancel: true });
197
+ // pi fires session_before_compact with its own computed preparation.
198
+ const res = await h.fire(
199
+ "session_before_compact",
200
+ {
201
+ type: "session_before_compact",
202
+ reason: "overflow",
203
+ willRetry: true,
204
+ preparation: { firstKeptEntryId: "e2", messagesToSummarize: h.session.slice(0, 2), tokensBefore: 500 },
205
+ signal: undefined,
206
+ } as any,
207
+ h.ctx(),
208
+ );
209
+ assert.ok(res && res.compaction, "returns a compaction result");
210
+ assert.equal(res.compaction.firstKeptEntryId, "e2", "reuses pi's cut boundary (PREVENT-PI-002 safe)");
211
+ assert.ok(typeof res.compaction.summary === "string" && res.compaction.summary.length > 0, "our summary supplied");
212
+ assert.ok(res.compaction.tokensBefore >= 0, "tokensBefore reported");
175
213
  });
176
214
 
177
- test("session_before_compact does NOT cancel when nothing persisted", async () => {
215
+ test("session_before_compact falls back to pi when nothing to summarize", async () => {
178
216
  const h = harness();
179
- const ctx = h.ctx();
180
- // Do NOT fire context first; this session has no checkpoint.
181
- const res = await h.fire("session_before_compact", { type: "session_before_compact", reason: "threshold", willRetry: false, preparation: {}, signal: undefined } as any, ctx);
182
- assert.deepEqual(res, {});
217
+ // Empty preparation → no messages to summarize → return {} so pi compacts natively.
218
+ const res = await h.fire(
219
+ "session_before_compact",
220
+ {
221
+ type: "session_before_compact",
222
+ reason: "threshold",
223
+ willRetry: false,
224
+ preparation: { firstKeptEntryId: "e0", messagesToSummarize: [], tokensBefore: 0 },
225
+ signal: undefined,
226
+ } as any,
227
+ h.ctx(),
228
+ );
229
+ assert.deepEqual(res, {}, "no compaction supplied → pi runs its own");
183
230
  });
184
231
 
185
232
  test("resume auto-inline stages recall into the system prompt", async () => {
@@ -34,10 +34,24 @@ export interface MegaConfig {
34
34
  fastGatePct: number;
35
35
  anchorUserMessages: number;
36
36
  preserveRecent: number;
37
+ /** High-pressure floor for preserveRecent — when context is near the limit
38
+ * we compact deeper, but never below this (keeps recent turns for coherence). */
39
+ preserveRecentMin: number;
37
40
  auto: boolean;
38
41
  autoInline: boolean;
39
42
  autoInlineK: number;
40
43
  dedupSim: number;
44
+ /** RAPTOR hierarchical recall enabled (Fix D). Drives both live recall and
45
+ * the durable-trim summary source (root summary). */
46
+ raptorEnabled: boolean;
47
+ /** Token ceiling for the re-injected recall block (Fix C). Recall stops
48
+ * adding checkpoints once the block would exceed this — bounds read-path
49
+ * token cost so it can never net-inflate the window. */
50
+ recallMaxTokens: number;
51
+ /** Inline-dedupe recalled checkpoints against the live window (Fix C): drop
52
+ * a hit whose summary is ≥ dedupSim similar to a live message — "dedupe on
53
+ * inline/read" so we never re-inject context already resident. */
54
+ windowDedupe: boolean;
41
55
  debug: boolean;
42
56
  }
43
57
 
@@ -65,6 +79,13 @@ function resolveThreshold(): { tier: CompactTier | "custom"; thresholdTokens: nu
65
79
  return { tier, thresholdTokens: COMPACT_TIERS[tier] };
66
80
  }
67
81
 
82
+ /**
83
+ * Pressure helpers for adaptive compression (Fix E) live in src/config.ts
84
+ * (pi-agnostic) so unit tests can import them without the pi runtime. Re-export
85
+ * here so the extension has one import surface.
86
+ */
87
+ export { pressureFromPct, preserveRecentForPressure } from "../src/config.js";
88
+
68
89
  /** Build the resolved config from env + defaults. */
69
90
  export function loadConfig(): MegaConfig {
70
91
  const { tier, thresholdTokens } = resolveThreshold();
@@ -77,10 +98,14 @@ export function loadConfig(): MegaConfig {
77
98
  thresholdTokens,
78
99
  anchorUserMessages: envFlag("MEGACOMPACT_ANCHOR_USER_MESSAGES", 3),
79
100
  preserveRecent: envFlag("MEGACOMPACT_PRESERVE_RECENT", 4),
101
+ preserveRecentMin: envFlag("MEGACOMPACT_PRESERVE_RECENT_MIN", 2),
80
102
  auto: envBool("MEGACOMPACT_AUTO", true),
81
103
  autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
82
104
  autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
83
105
  dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
106
+ raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
107
+ recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
108
+ windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
84
109
  debug: envBool("MEGACOMPACT_DEBUG", false),
85
110
  };
86
111
  }
@@ -9,7 +9,7 @@
9
9
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
10
  import { join, dirname, sep } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
- import { existsSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
12
+ import { existsSync, writeFileSync, readFileSync, unlinkSync, openSync, closeSync } from "node:fs";
13
13
  import { spawn } from "node:child_process"; // guardrails-allow PREVENT-PI-004: spawns the optional, user-triggered localhost dashboard server only
14
14
  import { MegaRuntime } from "./mega-runtime.js";
15
15
 
@@ -209,11 +209,32 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
209
209
  return;
210
210
  }
211
211
 
212
+ // Clear any stale marker so a fresh bind never collides with a lingering
213
+ // orphan, and truncate the launch log so the next error report shows only
214
+ // this attempt's output.
215
+ try { unlinkSync(portFile); } catch { /* ignore */ }
216
+ try { writeFileSync(launchLog, ""); } catch { /* ignore */ }
217
+
212
218
  const args = dashboardNeedsStrip ? ["--experimental-strip-types", runnerFile] : [runnerFile];
219
+ // Redirect the child's stderr to the launch log so that a CRASH BEFORE the
220
+ // runner's own __fail handler runs (e.g. an ESM module-load / parse error,
221
+ // or a missing entry) is still captured. With the old `stdio: "ignore"`
222
+ // these failures were completely silent and the "check logs" message
223
+ // pointed at an empty file. We open the fd in the parent and pass it to the
224
+ // child; once spawned we close our copy (the child keeps its own dup).
225
+ let stderrFd: number;
226
+ try {
227
+ stderrFd = openSync(launchLog, "a");
228
+ } catch {
229
+ stderrFd = -1; // fall back to ignored stderr
230
+ }
213
231
  const child = spawn(process.execPath, args, {
214
232
  detached: true,
215
- stdio: "ignore",
233
+ stdio: ["ignore", "ignore", stderrFd >= 0 ? stderrFd : "ignore"],
216
234
  });
235
+ if (stderrFd >= 0) {
236
+ try { closeSync(stderrFd); } catch { /* ignore */ }
237
+ }
217
238
  child.unref();
218
239
 
219
240
  // Poll for a live server (port 9320–9329) instead of relying solely on the
@@ -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
  );
@@ -122,6 +133,37 @@ export function runCompact(
122
133
  deduped: result.deduped,
123
134
  });
124
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
+
125
167
  runtime.setStatus(
126
168
  ctx,
127
169
  runtime.rt.persistedThisSession
@@ -160,8 +202,22 @@ export function doRecall(
160
202
  ) {
161
203
  runtime.bindRepo(ctx.cwd);
162
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;
163
209
  const result = recallAndInline(
164
- { 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
+ },
165
221
  runtime.store,
166
222
  );
167
223
  runtime.dashboard.event("recall", { source, query: query.slice(0, 120), injected: result.toInject.length, empty: result.empty });
@@ -175,3 +231,26 @@ export function doRecall(
175
231
  }
176
232
  return result;
177
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.4.21",
3
+ "version": "0.4.24",
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
  }