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.
- package/dist/extensions/mega-compact.test.js +40 -10
- package/dist/extensions/mega-events.js +9 -1
- package/dist/extensions/mega-pipeline.js +108 -2
- package/extensions/mega-compact.test.ts +40 -10
- package/extensions/mega-events.ts +9 -1
- package/extensions/mega-pipeline.ts +108 -1
- package/package.json +2 -2
|
@@ -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,
|
|
177
|
-
//
|
|
178
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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,
|
|
191
|
-
//
|
|
192
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|