pi-mega-compact 0.4.21 → 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 (43) hide show
  1. package/dist/extensions/dashboard-server.js +3 -3
  2. package/dist/extensions/mega-compact-driver.js +79 -0
  3. package/dist/extensions/mega-compact.test.js +54 -18
  4. package/dist/extensions/mega-config.js +10 -0
  5. package/dist/extensions/mega-events.js +45 -23
  6. package/dist/extensions/mega-pipeline.js +77 -3
  7. package/dist/src/config/dedup.js +4 -1
  8. package/dist/src/config.js +21 -0
  9. package/dist/src/dedup/raptor/index.js +28 -6
  10. package/dist/src/dedup/raptor/promote.test.js +69 -0
  11. package/dist/src/engine.js +1 -0
  12. package/dist/src/recall.js +30 -4
  13. package/dist/src/recall.test.js +28 -0
  14. package/dist/src/store/backfill.js +5 -6
  15. package/dist/src/store/compression.js +47 -7
  16. package/dist/src/store/compression.test.js +48 -0
  17. package/dist/src/store/sqlite.js +64 -41
  18. package/dist/src/store.test.js +19 -0
  19. package/dist/src/vectorStore.js +56 -1
  20. package/extensions/DASHBOARD.md +3 -3
  21. package/extensions/dashboard-server.ts +4 -4
  22. package/extensions/mega-compact-driver.ts +105 -0
  23. package/extensions/mega-compact.test.ts +65 -18
  24. package/extensions/mega-config.ts +25 -0
  25. package/extensions/mega-events.ts +43 -24
  26. package/extensions/mega-pipeline.ts +83 -4
  27. package/package.json +6 -7
  28. package/src/config/dedup.ts +4 -1
  29. package/src/config.ts +26 -0
  30. package/src/dedup/raptor/index.ts +42 -7
  31. package/src/dedup/raptor/promote.test.ts +82 -0
  32. package/src/engine.ts +5 -0
  33. package/src/recall.test.ts +44 -0
  34. package/src/recall.ts +43 -4
  35. package/src/store/backfill.ts +10 -11
  36. package/src/store/compression.test.ts +58 -0
  37. package/src/store/compression.ts +48 -7
  38. package/src/store/sqlite.ts +72 -49
  39. package/src/store.test.ts +22 -0
  40. package/src/vectorStore.ts +63 -1
  41. package/dist/extensions/openclaw-mega-compact.js +0 -291
  42. package/dist/src/minilm.js +0 -92
  43. package/dist/src/wordpiece.js +0 -129
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync }
15
15
  import { homedir } from "node:os";
16
16
  import { join, dirname } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
- import Database from "better-sqlite3";
18
+ import { DatabaseSync } from "node:sqlite";
19
19
  // --- Multi-repo index (Phase 5b) ------------------------------------------------
20
20
  // The extension writes a machine-wide repo registry into a single SQLite DB
21
21
  // (<indexDir>/index.sqlite) as the concurrency-safe write path; the dashboard
@@ -42,8 +42,8 @@ function readIndex() {
42
42
  let db;
43
43
  try {
44
44
  // Read-only + immutable WAL so a concurrent writer's WAL never blocks us.
45
- db = new Database(indexPath, { readonly: true, fileMustExist: true });
46
- db.pragma("journal_mode = WAL");
45
+ db = new DatabaseSync(indexPath, { readOnly: true });
46
+ db.exec("PRAGMA journal_mode = WAL");
47
47
  const rows = db
48
48
  .prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC")
49
49
  .all();
@@ -0,0 +1,79 @@
1
+ /**
2
+ * mega-compact-driver.ts — the durable-trim driver (Fix B).
3
+ *
4
+ * The read-path token-growth bug: the old design cancelled pi's native
5
+ * compaction (`{ cancel: true }`) and did its own ephemeral `context`-hook
6
+ * drop. That drop only affected the outgoing request — the on-disk transcript
7
+ * was never trimmed (the session manager is read-only for extensions). So on
8
+ * resume pi reloaded the FULL transcript and we ADDED a recall block on top →
9
+ * more tokens than before compaction.
10
+ *
11
+ * The fix: on `session_before_compact` we RUN the Trident pipeline to produce a
12
+ * genuinely compressed summary, then RETURN it as a `CompactionResult`. pi
13
+ * durably writes our summary into a `compactionSummary` entry AND truncates the
14
+ * transcript from `firstKeptEntryId`. After that, resume reloads the already-
15
+ * trimmed transcript (summary baked in) — no additive re-injection, no token
16
+ * growth.
17
+ *
18
+ * We reuse pi's `preparation.firstKeptEntryId` (pi already computed the cut
19
+ * honoring the anchor-floor + tool-pair guards — PREVENT-PI-002) rather than
20
+ * recomputing it, so we cannot hand pi a boundary that splits a tool pair.
21
+ */
22
+ import { compactSession } from "../src/engine.js";
23
+ import { toEngineMessages } from "../src/adapt.js";
24
+ import { estimateBlockTokens, estimateSessionTokens } from "../src/tokens.js";
25
+ import { recallRaptorRootSummary } from "../src/dedup/raptor/index.js";
26
+ /**
27
+ * Build our durable compaction result from pi's pre-computed preparation.
28
+ *
29
+ * Returns undefined when there is nothing to summarize (pi will then run its
30
+ * own native compaction, or skip). Never throws for "empty" — best-effort.
31
+ */
32
+ export function driveNativeCompaction(event, runtime, config) {
33
+ const prep = event.preparation;
34
+ if (!prep)
35
+ return undefined;
36
+ const sid = runtime.rt.sessionId;
37
+ const messagesToSummarize = prep.messagesToSummarize ?? [];
38
+ if (messagesToSummarize.length === 0)
39
+ return undefined;
40
+ const engineView = toEngineMessages(messagesToSummarize);
41
+ // We don't drop anything here — pi keeps from prep.firstKeptEntryId. We only
42
+ // summarize the region pi is about to discard.
43
+ const keepFrom = engineView.length;
44
+ const result = compactSession({
45
+ sessionId: sid,
46
+ messages: engineView,
47
+ keepFrom,
48
+ timestamp: Date.now(),
49
+ useExtractiveSummary: true,
50
+ }, runtime.store);
51
+ if (result.skipped)
52
+ return undefined;
53
+ // Prefer the RAPTOR root summary when the tree is built + enabled (Fix D):
54
+ // it is a session-level compressed summary, broader than one slice's. Fall
55
+ // back to the extractive topicSummary of this slice.
56
+ let summary = result.summary;
57
+ if (config.raptorEnabled) {
58
+ const root = recallRaptorRootSummary(sid, runtime.currentStateDir);
59
+ if (root)
60
+ summary = root;
61
+ }
62
+ const tokensBefore = prep.tokensBefore ?? estimateSessionTokens(engineView);
63
+ const summaryTokens = estimateBlockTokens(summary);
64
+ // pi keeps the tail from firstKeptEntryId; our summary replaces the discarded
65
+ // region. Honest saved = discarded-region tokens − our summary tokens.
66
+ const savedTokens = Math.max(0, tokensBefore - summaryTokens);
67
+ runtime.rt.lastCompactedFrom = keepFrom;
68
+ runtime.rt.lastCompactedTokens = tokensBefore;
69
+ runtime.rt.tokensSaved += savedTokens;
70
+ runtime.rt.persistedThisSession = true;
71
+ return {
72
+ compaction: {
73
+ summary,
74
+ firstKeptEntryId: prep.firstKeptEntryId,
75
+ tokensBefore,
76
+ estimatedTokensAfter: summaryTokens,
77
+ },
78
+ };
79
+ }
@@ -42,6 +42,7 @@ function harness(opts = {}) {
42
42
  let statusKey;
43
43
  let statusText;
44
44
  const notifies = [];
45
+ const compactCalls = [];
45
46
  // Minimal AgentMessage factory for the session we project into the extension.
46
47
  function msg(role, text, toolName) {
47
48
  if (role === "assistant" && toolName) {
@@ -100,7 +101,27 @@ function harness(opts = {}) {
100
101
  hasPendingMessages: () => false,
101
102
  shutdown: () => { },
102
103
  getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }),
103
- compact: () => { },
104
+ // Faithful mock: ctx.compact() starts pi's flow, which fires the
105
+ // session_before_compact handler (where WE supply the durable trim).
106
+ compact: (opts) => {
107
+ compactCalls.push(opts);
108
+ if (handlers["session_before_compact"]) {
109
+ return handlers["session_before_compact"]({
110
+ type: "session_before_compact",
111
+ reason: "threshold",
112
+ willRetry: false,
113
+ signal: undefined,
114
+ // pi computed the cut honoring anchor floor + tool-pair (PREVENT-PI-002);
115
+ // our handler reuses it as firstKeptEntryId.
116
+ preparation: {
117
+ firstKeptEntryId: "e2",
118
+ messagesToSummarize: session.slice(0, 2),
119
+ tokensBefore: 500,
120
+ },
121
+ }, makeCtx());
122
+ }
123
+ return undefined;
124
+ },
104
125
  getSystemPrompt: () => "system base",
105
126
  ...over,
106
127
  };
@@ -133,13 +154,13 @@ function harness(opts = {}) {
133
154
  const mod = require("./mega-compact.js");
134
155
  mod.default(pi);
135
156
  return {
136
- stateDir, handlers, commands, appended, get status() { return { statusKey, statusText }; }, notifies,
157
+ stateDir, handlers, commands, appended, get status() { return { statusKey, statusText }; }, notifies, compactCalls,
137
158
  fire: (ev, event, ctx) => handlers[ev](event, ctx),
138
159
  ctx: makeCtx,
139
160
  session,
140
161
  };
141
162
  }
142
- test("auto-trigger: past threshold persists a chkpt and drops context", async () => {
163
+ test("auto-trigger: past threshold persists a chkpt and starts a durable trim", async () => {
143
164
  const h = harness();
144
165
  const messages = h.session;
145
166
  const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
@@ -148,25 +169,40 @@ test("auto-trigger: past threshold persists a chkpt and drops context", async ()
148
169
  const { listCheckpoints } = await import("../src/store/sqlite.js");
149
170
  assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
150
171
  assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel appended");
151
- // Context dropped (the compacted range was trimmed).
152
- assert.ok(res && Array.isArray(res.messages), "context handler returns filtered messages");
153
- assert.ok(res.messages.length < messages.length, "outgoing context shrank");
172
+ // The context handler no longer drops messages itself (that was ephemeral —
173
+ // the read-path token-growth bug). It triggers pi's compaction flow, which
174
+ // calls our session_before_compact handler to supply the DURABLE trim.
175
+ assert.equal(res, undefined, "context handler returns nothing (no local drop)");
176
+ assert.equal(h.compactCalls.length, 1, "ctx.compact() called to start durable trim");
177
+ // The durable trim was supplied (summary + firstKeptEntryId from pi's prep).
178
+ assert.ok(h.compactCalls[0] !== undefined, "compaction flow executed");
154
179
  });
155
- test("session_before_compact cancels once we've persisted", async () => {
180
+ test("session_before_compact supplies our durable trim (not pi's summary)", async () => {
156
181
  const h = harness();
157
- const ctx = h.ctx();
158
- // First fire the auto-trigger so a checkpoint is persisted this session.
159
- await h.fire("context", { type: "context", messages: h.session }, h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) }));
160
- // Now pi tries to compact natively — we must cancel (no double-compact).
161
- const res = await h.fire("session_before_compact", { type: "session_before_compact", reason: "overflow", willRetry: true, preparation: {}, signal: undefined }, ctx);
162
- assert.deepEqual(res, { cancel: true });
182
+ // pi fires session_before_compact with its own computed preparation.
183
+ const res = await h.fire("session_before_compact", {
184
+ type: "session_before_compact",
185
+ reason: "overflow",
186
+ willRetry: true,
187
+ preparation: { firstKeptEntryId: "e2", messagesToSummarize: h.session.slice(0, 2), tokensBefore: 500 },
188
+ signal: undefined,
189
+ }, h.ctx());
190
+ assert.ok(res && res.compaction, "returns a compaction result");
191
+ assert.equal(res.compaction.firstKeptEntryId, "e2", "reuses pi's cut boundary (PREVENT-PI-002 safe)");
192
+ assert.ok(typeof res.compaction.summary === "string" && res.compaction.summary.length > 0, "our summary supplied");
193
+ assert.ok(res.compaction.tokensBefore >= 0, "tokensBefore reported");
163
194
  });
164
- test("session_before_compact does NOT cancel when nothing persisted", async () => {
195
+ test("session_before_compact falls back to pi when nothing to summarize", async () => {
165
196
  const h = harness();
166
- const ctx = h.ctx();
167
- // Do NOT fire context first; this session has no checkpoint.
168
- const res = await h.fire("session_before_compact", { type: "session_before_compact", reason: "threshold", willRetry: false, preparation: {}, signal: undefined }, ctx);
169
- assert.deepEqual(res, {});
197
+ // Empty preparation → no messages to summarize → return {} so pi compacts natively.
198
+ const res = await h.fire("session_before_compact", {
199
+ type: "session_before_compact",
200
+ reason: "threshold",
201
+ willRetry: false,
202
+ preparation: { firstKeptEntryId: "e0", messagesToSummarize: [], tokensBefore: 0 },
203
+ signal: undefined,
204
+ }, h.ctx());
205
+ assert.deepEqual(res, {}, "no compaction supplied → pi runs its own");
170
206
  });
171
207
  test("resume auto-inline stages recall into the system prompt", async () => {
172
208
  const h = harness();
@@ -46,6 +46,12 @@ function resolveThreshold() {
46
46
  const tier = (raw in COMPACT_TIERS ? raw : "low");
47
47
  return { tier, thresholdTokens: COMPACT_TIERS[tier] };
48
48
  }
49
+ /**
50
+ * Pressure helpers for adaptive compression (Fix E) live in src/config.ts
51
+ * (pi-agnostic) so unit tests can import them without the pi runtime. Re-export
52
+ * here so the extension has one import surface.
53
+ */
54
+ export { pressureFromPct, preserveRecentForPressure } from "../src/config.js";
49
55
  /** Build the resolved config from env + defaults. */
50
56
  export function loadConfig() {
51
57
  const { tier, thresholdTokens } = resolveThreshold();
@@ -58,10 +64,14 @@ export function loadConfig() {
58
64
  thresholdTokens,
59
65
  anchorUserMessages: envFlag("MEGACOMPACT_ANCHOR_USER_MESSAGES", 3),
60
66
  preserveRecent: envFlag("MEGACOMPACT_PRESERVE_RECENT", 4),
67
+ preserveRecentMin: envFlag("MEGACOMPACT_PRESERVE_RECENT_MIN", 2),
61
68
  auto: envBool("MEGACOMPACT_AUTO", true),
62
69
  autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
63
70
  autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
64
71
  dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
72
+ raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
73
+ recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
74
+ windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
65
75
  debug: envBool("MEGACOMPACT_DEBUG", false),
66
76
  };
67
77
  }
@@ -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)
@@ -96,6 +103,34 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
96
103
  tokenEstimate: result.tokenEstimate,
97
104
  deduped: result.deduped,
98
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
+ }
99
134
  runtime.setStatus(ctx, runtime.rt.persistedThisSession
100
135
  ? `mega-compact: ${result.checkpointId} · ${saved} tok saved`
101
136
  : `mega-compact: ready`);
@@ -124,7 +159,21 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
124
159
  export function doRecall(runtime, config, ctx, query, source) {
125
160
  runtime.bindRepo(ctx.cwd);
126
161
  const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
127
- 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);
128
177
  runtime.dashboard.event("recall", { source, query: query.slice(0, 120), injected: result.toInject.length, empty: result.empty });
129
178
  if (!result.empty && result.toInject.length > 0) {
130
179
  const top = result.toInject[0];
@@ -136,3 +185,28 @@ export function doRecall(runtime, config, ctx, query, source) {
136
185
  }
137
186
  return result;
138
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
+ }
@@ -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,