pi-mega-compact 0.4.26 → 0.4.28

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.
@@ -77,6 +77,9 @@ function harness(opts = {}) {
77
77
  const sessionManager = {
78
78
  getSessionId: () => "sess_ext_001",
79
79
  getEntries: () => session.map(toEntry),
80
+ // Faithful mock: getBranch() returns the current branch's entries, which
81
+ // piCompactWouldNoop() reads to predict whether ctx.compact() would no-op.
82
+ getBranch: () => session.map(toEntry),
80
83
  };
81
84
  function makeCtx(over = {}) {
82
85
  return {
@@ -163,19 +166,46 @@ function harness(opts = {}) {
163
166
  test("auto-trigger: past threshold persists a chkpt and starts a durable trim", async () => {
164
167
  const h = harness();
165
168
  const messages = h.session;
169
+ // The mock session is tiny (~100 tokens). piCompactWouldNoop() would skip
170
+ // ctx.compact() for a transcript under pi's keepRecentTokens budget — so
171
+ // lower the floor to 0 to simulate a transcript large enough that pi WOULD
172
+ // compact (the positive path this test exercises).
173
+ process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0";
174
+ try {
175
+ const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
176
+ const res = await h.fire("context", { type: "context", messages }, ctx);
177
+ // L1->L4 ran: a checkpoint was persisted to the SQLite store + a marker entry written.
178
+ const { listCheckpoints } = await import("../src/store/sqlite.js");
179
+ assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
180
+ assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel appended");
181
+ // The context handler no longer drops messages itself (that was ephemeral —
182
+ // the read-path token-growth bug). It triggers pi's compaction flow, which
183
+ // calls our session_before_compact handler to supply the DURABLE trim.
184
+ assert.equal(res, undefined, "context handler returns nothing (no local drop)");
185
+ assert.equal(h.compactCalls.length, 1, "ctx.compact() called to start durable trim");
186
+ // The durable trim was supplied (summary + firstKeptEntryId from pi's prep).
187
+ assert.ok(h.compactCalls[0] !== undefined, "compaction flow executed");
188
+ }
189
+ finally {
190
+ delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
191
+ }
192
+ });
193
+ test("auto-trigger: skips ctx.compact() when pi would no-op (session too small)", async () => {
194
+ const h = harness();
195
+ const messages = h.session;
196
+ // Default floor (20000): the tiny mock transcript is below pi's
197
+ // keepRecentTokens budget, so piCompactWouldNoop() must skip ctx.compact()
198
+ // rather than surface pi's "Nothing to compact (session too small)" throw.
199
+ delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
166
200
  const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
167
201
  const res = await h.fire("context", { type: "context", messages }, ctx);
168
- // L1->L4 ran: a checkpoint was persisted to the SQLite store + a marker entry written.
169
- const { listCheckpoints } = await import("../src/store/sqlite.js");
170
- assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
171
- assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel appended");
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
202
  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");
203
+ assert.equal(h.compactCalls.length, 0, "ctx.compact() NOT called pi would no-op");
204
+ // Our recall checkpoint still persisted (Path A) the durable trim is the
205
+ // only thing skipped; recall is independent of it.
206
+ const { listCheckpoints } = await import("../src/store/sqlite.js");
207
+ assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "recall checkpoint still persisted");
208
+ assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel still appended");
179
209
  });
180
210
  test("session_before_compact supplies our durable trim (not pi's summary)", async () => {
181
211
  const h = harness();
@@ -10,7 +10,7 @@ import { normalizeSessionId } from "../src/store.js";
10
10
  import { autoCompactCheck } from "../src/compact.js";
11
11
  import { estimateSessionTokens } from "../src/tokens.js";
12
12
  import { recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
13
- import { runCompact, doRecall } from "./mega-pipeline.js";
13
+ import { runCompact, doRecall, piCompactWouldNoop } from "./mega-pipeline.js";
14
14
  import { driveNativeCompaction } from "./mega-compact-driver.js";
15
15
  import { pressureFromPct } from "./mega-config.js";
16
16
  /** Register all pi lifecycle event handlers. */
@@ -151,6 +151,14 @@ export function registerEventHandlers(pi, runtime, config) {
151
151
  const ran = runCompact(pi, runtime, config, ctx, messages, { compressionPressure: pressure });
152
152
  if (ran.skipped)
153
153
  return;
154
+ // Skip pi's durable-trim flow when pi would no-op ("Nothing to compact /
155
+ // Already compacted"). pi throws that error *before* session_before_compact
156
+ // fires and renders it via a compaction_end event we can't mute (onError is
157
+ // too late), so the only fix is to not call ctx.compact() on a no-op. Our
158
+ // recall checkpoint above is already persisted; the durable trim is
159
+ // unnecessary for a transcript pi would keep in full anyway.
160
+ if (piCompactWouldNoop(ctx))
161
+ return;
154
162
  // Start pi's compaction flow so our session_before_compact handler can
155
163
  // supply the durable trim (pi writes it to disk). We never use pi's summary.
156
164
  ctx.compact({ customInstructions: undefined });
@@ -10,6 +10,7 @@ import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
10
10
  import { compactSession } from "../src/engine.js";
11
11
  import { recallAndInline } from "../src/recall.js";
12
12
  import { normalizeSessionId } from "../src/store.js";
13
+ import { estimateBlockTokens } from "../src/tokens.js";
13
14
  import { touchSession, logDaily } from "../src/store/sqlite.js";
14
15
  import { C, MARKER_TYPE, } from "./mega-runtime.js";
15
16
  import { resolveRepoRoot, preserveRecentForPressure } from "./mega-config.js";
@@ -27,8 +28,19 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
27
28
  // compact more of the session, down to the preserveRecentMin floor.
28
29
  const preserve = preserveRecentForPressure(opts.compressionPressure ?? 0, config.preserveRecent, config.preserveRecentMin);
29
30
  const keepFrom = opts.keepFrom ?? Math.max(0, view.length - preserve);
30
- if (keepFrom <= 0)
31
- return { skipped: true };
31
+ // For very small sessions (fewer messages than preserveRecent), allow
32
+ // compacting everything except the last message — the user explicitly
33
+ // requested compaction, so don't refuse it just because the session is short.
34
+ if (keepFrom <= 0) {
35
+ if (view.length <= 1)
36
+ return { skipped: true };
37
+ // Use the fallback: compact everything except the last message
38
+ const fallbackKeepFrom = view.length - 1;
39
+ return doCompact(view, fallbackKeepFrom, opts, sid, config, pi, ctx, runtime);
40
+ }
41
+ return doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime);
42
+ }
43
+ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
32
44
  runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
33
45
  const result = compactSession({
34
46
  sessionId: sid,
@@ -170,6 +182,100 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
170
182
  runtime.snapshot(ctx);
171
183
  return { skipped: false, result, keepFrom, saved };
172
184
  }
185
+ /**
186
+ * Predict whether pi's `ctx.compact()` would throw a no-op error — "Already
187
+ * compacted" or "Nothing to compact (session too small)" — so the auto-trigger
188
+ * can SKIP the call instead of surfacing a hard, user-facing error.
189
+ *
190
+ * Why we can't intercept or suppress it: pi's public `compact()` computes
191
+ * `prepareCompaction()` and throws *before* it emits `session_before_compact`,
192
+ * so our handler there never runs on the no-op path. And `ctx.compact()`'s
193
+ * `onError` callback fires only AFTER pi has already emitted a `compaction_end`
194
+ * event carrying the error message (which the interactive UI renders) — so
195
+ * `onError` cannot mute it either. The only robust fix is to not call
196
+ * `ctx.compact()` when pi would no-op. (pi's own `_runAutoCompaction` path is
197
+ * silent on this same condition; the public path we're forced through is the
198
+ * one that throws.)
199
+ *
200
+ * Skipping is correct, not a compromise: by the time this runs, `runCompact()`
201
+ * has already persisted the recall checkpoint (Path A). The durable on-disk
202
+ * trim is only useful when pi can actually summarize a region; a transcript
203
+ * under pi's `keepRecentTokens` budget is small enough that reloading it on
204
+ * resume isn't a token-growth problem, so the durable trim is unnecessary
205
+ * there anyway.
206
+ *
207
+ * Mirrors pi's `prepareCompaction()` return-undefined conditions (compaction.js):
208
+ * (1) last entry is a compaction → "Already compacted"
209
+ * (2) <2 cut-point messages since the last compaction → nothing to summarize
210
+ * (a cut point = any non-toolResult message — user/assistant/bash/custom/
211
+ * branchSummary/compactionSummary — matching pi's isCutPointMessage)
212
+ * (3) transcript tokens since the last compaction < keepRecentTokens → pi
213
+ * keeps everything → nothing to summarize
214
+ * `keepRecentTokens` isn't readable from the extension API, so (3) uses the pi
215
+ * default (20000) as a conservative floor; raise it via
216
+ * `MEGACOMPACT_DURABLE_TRIM_FLOOR` if you raise pi's `compact.keepRecentTokens`.
217
+ *
218
+ * Best-effort: on any read error returns true (skip) — skipping a durable trim
219
+ * is always safe; calling `ctx.compact()` on a no-op throws to the user.
220
+ */
221
+ export function piCompactWouldNoop(ctx) {
222
+ try {
223
+ const branch = ctx.sessionManager.getBranch();
224
+ if (branch.length === 0)
225
+ return true;
226
+ // (1) already compacted — pi throws "Already compacted"
227
+ if (branch[branch.length - 1].type === "compaction")
228
+ return true;
229
+ // boundaryStart = index just after the most recent compaction entry (or 0)
230
+ let boundaryStart = 0;
231
+ for (let i = branch.length - 1; i >= 0; i--) {
232
+ if (branch[i].type === "compaction") {
233
+ boundaryStart = i + 1;
234
+ break;
235
+ }
236
+ }
237
+ let cutPoints = 0;
238
+ let tokens = 0;
239
+ for (let i = boundaryStart; i < branch.length; i++) {
240
+ const e = branch[i];
241
+ if (e.type === "compaction")
242
+ continue;
243
+ let isCut = false;
244
+ for (const m of sessionEntryToContextMessages(e)) {
245
+ // pi's isCutPointMessage: every role except toolResult
246
+ if (m.role !== "toolResult")
247
+ isCut = true;
248
+ const c = m.content;
249
+ const text = typeof c === "string" ? c
250
+ : Array.isArray(c)
251
+ ? c.map((b) => b?.text ?? "").join(" ")
252
+ : "";
253
+ if (text)
254
+ tokens += estimateBlockTokens(text);
255
+ }
256
+ if (isCut)
257
+ cutPoints++;
258
+ }
259
+ // (2) need >=2 cut points so the kept cut isn't the first message
260
+ if (cutPoints < 2)
261
+ return true;
262
+ // (3) transcript under pi's keepRecentTokens budget → pi keeps everything
263
+ if (tokens < durableTrimFloorTokens())
264
+ return true;
265
+ return false;
266
+ }
267
+ catch {
268
+ return true; // safe: skip the durable trim rather than risk a user-facing throw
269
+ }
270
+ }
271
+ /** pi's default keepRecentTokens (compaction settings). Override with
272
+ * MEGACOMPACT_DURABLE_TRIM_FLOOR if you raise pi's compact.keepRecentTokens. */
273
+ function durableTrimFloorTokens() {
274
+ const raw = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
275
+ if (raw !== undefined && Number.isFinite(Number(raw)))
276
+ return Number(raw);
277
+ return 20_000;
278
+ }
173
279
  /**
174
280
  * Unified recall (Layer 5). The ONE path that injects. Returns the recall
175
281
  * result; callers decide whether to stage it for before_agent_start (resume)
@@ -83,6 +83,9 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
83
83
  const sessionManager = {
84
84
  getSessionId: () => "sess_ext_001",
85
85
  getEntries: () => session.map(toEntry),
86
+ // Faithful mock: getBranch() returns the current branch's entries, which
87
+ // piCompactWouldNoop() reads to predict whether ctx.compact() would no-op.
88
+ getBranch: () => session.map(toEntry),
86
89
  };
87
90
 
88
91
  function makeCtx(over: Partial<any> = {}) {
@@ -177,19 +180,46 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
177
180
  test("auto-trigger: past threshold persists a chkpt and starts a durable trim", async () => {
178
181
  const h = harness();
179
182
  const messages = h.session;
183
+ // The mock session is tiny (~100 tokens). piCompactWouldNoop() would skip
184
+ // ctx.compact() for a transcript under pi's keepRecentTokens budget — so
185
+ // lower the floor to 0 to simulate a transcript large enough that pi WOULD
186
+ // compact (the positive path this test exercises).
187
+ process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR = "0";
188
+ try {
189
+ const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
190
+ const res = await h.fire("context", { type: "context", messages }, ctx);
191
+ // L1->L4 ran: a checkpoint was persisted to the SQLite store + a marker entry written.
192
+ const { listCheckpoints } = await import("../src/store/sqlite.js");
193
+ assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
194
+ assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel appended");
195
+ // The context handler no longer drops messages itself (that was ephemeral —
196
+ // the read-path token-growth bug). It triggers pi's compaction flow, which
197
+ // calls our session_before_compact handler to supply the DURABLE trim.
198
+ assert.equal(res, undefined, "context handler returns nothing (no local drop)");
199
+ assert.equal(h.compactCalls.length, 1, "ctx.compact() called to start durable trim");
200
+ // The durable trim was supplied (summary + firstKeptEntryId from pi's prep).
201
+ assert.ok(h.compactCalls[0] !== undefined, "compaction flow executed");
202
+ } finally {
203
+ delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
204
+ }
205
+ });
206
+
207
+ test("auto-trigger: skips ctx.compact() when pi would no-op (session too small)", async () => {
208
+ const h = harness();
209
+ const messages = h.session;
210
+ // Default floor (20000): the tiny mock transcript is below pi's
211
+ // keepRecentTokens budget, so piCompactWouldNoop() must skip ctx.compact()
212
+ // rather than surface pi's "Nothing to compact (session too small)" throw.
213
+ delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
180
214
  const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
181
215
  const res = await h.fire("context", { type: "context", messages }, ctx);
182
- // L1->L4 ran: a checkpoint was persisted to the SQLite store + a marker entry written.
183
- const { listCheckpoints } = await import("../src/store/sqlite.js");
184
- assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
185
- assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel appended");
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
216
  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");
217
+ assert.equal(h.compactCalls.length, 0, "ctx.compact() NOT called pi would no-op");
218
+ // Our recall checkpoint still persisted (Path A) the durable trim is the
219
+ // only thing skipped; recall is independent of it.
220
+ const { listCheckpoints } = await import("../src/store/sqlite.js");
221
+ assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "recall checkpoint still persisted");
222
+ assert.equal(h.appended.some((a) => a.t === "mega-compact-marker"), true, "marker sentinel still appended");
193
223
  });
194
224
 
195
225
  test("session_before_compact supplies our durable trim (not pi's summary)", async () => {
@@ -12,7 +12,7 @@ import { normalizeSessionId } from "../src/store.js";
12
12
  import { autoCompactCheck } from "../src/compact.js";
13
13
  import { estimateSessionTokens } from "../src/tokens.js";
14
14
  import { MegaRuntime, recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
15
- import { runCompact, doRecall } from "./mega-pipeline.js";
15
+ import { runCompact, doRecall, piCompactWouldNoop } from "./mega-pipeline.js";
16
16
  import { driveNativeCompaction } from "./mega-compact-driver.js";
17
17
  import { pressureFromPct, type MegaConfig } from "./mega-config.js";
18
18
 
@@ -162,6 +162,14 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
162
162
  const ran = runCompact(pi, runtime, config, ctx, messages, { compressionPressure: pressure });
163
163
  if (ran.skipped) return;
164
164
 
165
+ // Skip pi's durable-trim flow when pi would no-op ("Nothing to compact /
166
+ // Already compacted"). pi throws that error *before* session_before_compact
167
+ // fires and renders it via a compaction_end event we can't mute (onError is
168
+ // too late), so the only fix is to not call ctx.compact() on a no-op. Our
169
+ // recall checkpoint above is already persisted; the durable trim is
170
+ // unnecessary for a transcript pi would keep in full anyway.
171
+ if (piCompactWouldNoop(ctx)) return;
172
+
165
173
  // Start pi's compaction flow so our session_before_compact handler can
166
174
  // supply the durable trim (pi writes it to disk). We never use pi's summary.
167
175
  ctx.compact({ customInstructions: undefined });
@@ -11,8 +11,10 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
11
11
  import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
12
12
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
13
13
  import { compactSession } from "../src/engine.js";
14
+ import type { EngineMessage } from "../src/types.js";
14
15
  import { recallAndInline } from "../src/recall.js";
15
16
  import { normalizeSessionId } from "../src/store.js";
17
+ import { estimateBlockTokens } from "../src/tokens.js";
16
18
  import { touchSession, logDaily } from "../src/store/sqlite.js";
17
19
  import {
18
20
  MegaRuntime,
@@ -51,8 +53,29 @@ export function runCompact(
51
53
  config.preserveRecentMin,
52
54
  );
53
55
  const keepFrom = opts.keepFrom ?? Math.max(0, view.length - preserve);
54
- if (keepFrom <= 0) return { skipped: true };
56
+ // For very small sessions (fewer messages than preserveRecent), allow
57
+ // compacting everything except the last message — the user explicitly
58
+ // requested compaction, so don't refuse it just because the session is short.
59
+ if (keepFrom <= 0) {
60
+ if (view.length <= 1) return { skipped: true };
61
+ // Use the fallback: compact everything except the last message
62
+ const fallbackKeepFrom = view.length - 1;
63
+ return doCompact(view, fallbackKeepFrom, opts, sid, config, pi, ctx, runtime);
64
+ }
65
+
66
+ return doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime);
67
+ }
55
68
 
69
+ function doCompact(
70
+ view: EngineMessage[],
71
+ keepFrom: number,
72
+ opts: { keepFrom?: number; summary?: string; compressionPressure?: number },
73
+ sid: string,
74
+ config: MegaConfig,
75
+ pi: ExtensionAPI,
76
+ ctx: ExtensionContext,
77
+ runtime: MegaRuntime,
78
+ ): RunCompactResult {
56
79
  runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
57
80
  const result = compactSession(
58
81
  {
@@ -212,6 +235,90 @@ export function runCompact(
212
235
  return { skipped: false, result, keepFrom, saved };
213
236
  }
214
237
 
238
+ /**
239
+ * Predict whether pi's `ctx.compact()` would throw a no-op error — "Already
240
+ * compacted" or "Nothing to compact (session too small)" — so the auto-trigger
241
+ * can SKIP the call instead of surfacing a hard, user-facing error.
242
+ *
243
+ * Why we can't intercept or suppress it: pi's public `compact()` computes
244
+ * `prepareCompaction()` and throws *before* it emits `session_before_compact`,
245
+ * so our handler there never runs on the no-op path. And `ctx.compact()`'s
246
+ * `onError` callback fires only AFTER pi has already emitted a `compaction_end`
247
+ * event carrying the error message (which the interactive UI renders) — so
248
+ * `onError` cannot mute it either. The only robust fix is to not call
249
+ * `ctx.compact()` when pi would no-op. (pi's own `_runAutoCompaction` path is
250
+ * silent on this same condition; the public path we're forced through is the
251
+ * one that throws.)
252
+ *
253
+ * Skipping is correct, not a compromise: by the time this runs, `runCompact()`
254
+ * has already persisted the recall checkpoint (Path A). The durable on-disk
255
+ * trim is only useful when pi can actually summarize a region; a transcript
256
+ * under pi's `keepRecentTokens` budget is small enough that reloading it on
257
+ * resume isn't a token-growth problem, so the durable trim is unnecessary
258
+ * there anyway.
259
+ *
260
+ * Mirrors pi's `prepareCompaction()` return-undefined conditions (compaction.js):
261
+ * (1) last entry is a compaction → "Already compacted"
262
+ * (2) <2 cut-point messages since the last compaction → nothing to summarize
263
+ * (a cut point = any non-toolResult message — user/assistant/bash/custom/
264
+ * branchSummary/compactionSummary — matching pi's isCutPointMessage)
265
+ * (3) transcript tokens since the last compaction < keepRecentTokens → pi
266
+ * keeps everything → nothing to summarize
267
+ * `keepRecentTokens` isn't readable from the extension API, so (3) uses the pi
268
+ * default (20000) as a conservative floor; raise it via
269
+ * `MEGACOMPACT_DURABLE_TRIM_FLOOR` if you raise pi's `compact.keepRecentTokens`.
270
+ *
271
+ * Best-effort: on any read error returns true (skip) — skipping a durable trim
272
+ * is always safe; calling `ctx.compact()` on a no-op throws to the user.
273
+ */
274
+ export function piCompactWouldNoop(ctx: ExtensionContext): boolean {
275
+ try {
276
+ const branch = ctx.sessionManager.getBranch();
277
+ if (branch.length === 0) return true;
278
+ // (1) already compacted — pi throws "Already compacted"
279
+ if (branch[branch.length - 1].type === "compaction") return true;
280
+ // boundaryStart = index just after the most recent compaction entry (or 0)
281
+ let boundaryStart = 0;
282
+ for (let i = branch.length - 1; i >= 0; i--) {
283
+ if (branch[i].type === "compaction") { boundaryStart = i + 1; break; }
284
+ }
285
+ let cutPoints = 0;
286
+ let tokens = 0;
287
+ for (let i = boundaryStart; i < branch.length; i++) {
288
+ const e = branch[i];
289
+ if (e.type === "compaction") continue;
290
+ let isCut = false;
291
+ for (const m of sessionEntryToContextMessages(e)) {
292
+ // pi's isCutPointMessage: every role except toolResult
293
+ if ((m as { role?: string }).role !== "toolResult") isCut = true;
294
+ const c = (m as { content?: unknown }).content;
295
+ const text =
296
+ typeof c === "string" ? c
297
+ : Array.isArray(c)
298
+ ? (c as { text?: string }[]).map((b) => b?.text ?? "").join(" ")
299
+ : "";
300
+ if (text) tokens += estimateBlockTokens(text);
301
+ }
302
+ if (isCut) cutPoints++;
303
+ }
304
+ // (2) need >=2 cut points so the kept cut isn't the first message
305
+ if (cutPoints < 2) return true;
306
+ // (3) transcript under pi's keepRecentTokens budget → pi keeps everything
307
+ if (tokens < durableTrimFloorTokens()) return true;
308
+ return false;
309
+ } catch {
310
+ return true; // safe: skip the durable trim rather than risk a user-facing throw
311
+ }
312
+ }
313
+
314
+ /** pi's default keepRecentTokens (compaction settings). Override with
315
+ * MEGACOMPACT_DURABLE_TRIM_FLOOR if you raise pi's compact.keepRecentTokens. */
316
+ function durableTrimFloorTokens(): number {
317
+ const raw = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
318
+ if (raw !== undefined && Number.isFinite(Number(raw))) return Number(raw);
319
+ return 20_000;
320
+ }
321
+
215
322
  /**
216
323
  * Unified recall (Layer 5). The ONE path that injects. Returns the recall
217
324
  * result; callers decide whether to stage it for before_agent_start (resume)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
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",
@@ -42,7 +42,7 @@
42
42
  "scripts": {
43
43
  "build": "tsc -p tsconfig.json",
44
44
  "lint": "tsc --noEmit && node scripts/guardrails-scan.mjs",
45
- "test": "npm run build && node --test \"dist/src/**/*.test.js\" \"dist/extensions/**/*.test.js\"",
45
+ "test": "npm run build && node --test --test-timeout=180000 \"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
48
  "prepublishOnly": "npm run build",