pi-mega-compact 0.20.85 → 0.20.86
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/dashboard-server/routes-rag-settings-helpers.js +1 -0
- package/dist/extensions/mega-config.js +6 -0
- package/dist/extensions/mega-events/context-handler/gateCheck.js +27 -0
- package/dist/extensions/mega-events/context-handler/thrashGuard.js +186 -0
- package/dist/extensions/mega-events/context-handler.js +33 -1
- package/dist/extensions/mega-pipeline/compact/noop.js +104 -0
- package/dist/extensions/mega-pipeline/compact/run.js +268 -0
- package/dist/extensions/mega-pipeline/compact/vote.js +72 -0
- package/dist/extensions/mega-pipeline/compact.js +12 -343
- package/dist/src/failback/compact.js +109 -0
- package/dist/src/store/sqlite/meta.js +32 -0
- package/extensions/dashboard-server/routes-rag-settings-helpers.ts +8 -0
- package/extensions/mega-config-types.ts +8 -0
- package/extensions/mega-config.ts +6 -0
- package/extensions/mega-events/context-handler/gateCheck.ts +30 -0
- package/extensions/mega-events/context-handler/thrashGuard.ts +228 -0
- package/extensions/mega-events/context-handler.ts +36 -1
- package/extensions/mega-pipeline/compact/noop.ts +96 -0
- package/extensions/mega-pipeline/compact/run.ts +322 -0
- package/extensions/mega-pipeline/compact/vote.ts +85 -0
- package/extensions/mega-pipeline/compact.ts +12 -385
- package/package.json +1 -1
- package/src/failback/compact.ts +122 -0
- package/src/failback/types.ts +44 -0
- package/src/store/sqlite/meta.ts +36 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-handler/thrashGuard.ts — 3WF-2 ReductionValidator + ThrashGuard.
|
|
3
|
+
*
|
|
4
|
+
* Production bug fixed here: compaction fired 496× freeing 0.0% of the live
|
|
5
|
+
* window. Root cause — correctness was judged by the STORED `saved` metric
|
|
6
|
+
* (a cumulative SQLite total that the dedup made look healthy every fire)
|
|
7
|
+
* while the LIVE context window (`currentTokens`) never shrank. This module
|
|
8
|
+
* judges correctness by the LIVE-WINDOW delta across consecutive `context`
|
|
9
|
+
* events, and after an ineffective compaction persists a meta-backed refusal
|
|
10
|
+
* so the guard will not re-fire until the window has grown meaningfully again.
|
|
11
|
+
*
|
|
12
|
+
* Everything is gated on the umbrella `config.threeWayFailback`
|
|
13
|
+
* (MEGACOMPACT_THREE_WAY_FAILBACK, default ON). Flag OFF ⇒ every entry point
|
|
14
|
+
* is an immediate no-op, so gateCheck + compactSession behave byte-identically
|
|
15
|
+
* to v0.20.83.
|
|
16
|
+
*
|
|
17
|
+
* Non-fatal EVERYWHERE: every store read/write is best-effort, swallowed on
|
|
18
|
+
* failure. Structured JSON logging only (runtime.logger.info with ts + event).
|
|
19
|
+
* No console.*, no network, no mocks.
|
|
20
|
+
*/
|
|
21
|
+
import type { MegaRuntime } from "../../mega-runtime.js";
|
|
22
|
+
import type { MegaConfig } from "../../mega-config.js";
|
|
23
|
+
import { getMetaNumber, setMetaNumber } from "../../../src/store/sqlite.js";
|
|
24
|
+
import type { ReductionVerdict } from "../../../src/failback/types.js";
|
|
25
|
+
|
|
26
|
+
/** Meta key holding the live-window baseline (tokens) at the ineffective fire. */
|
|
27
|
+
export const THRASH_BASELINE_KEY = "thrasguard.baseline_tokens";
|
|
28
|
+
/** Meta key holding the live-window token count below which re-firing is blocked. */
|
|
29
|
+
export const THRASH_BLOCKED_KEY = "thrasguard.blocked_until";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* "Meaningful reduction" floor as a FRACTION of `liveBefore`. A compaction is
|
|
33
|
+
* only credited with freeing space when the live window shrank by at least this
|
|
34
|
+
* fraction of its pre-compaction size.
|
|
35
|
+
*
|
|
36
|
+
* Rationale (invented constant, calibrated + configurable-by-design): the model
|
|
37
|
+
* re-reports token counts on every context event with noise on the order of a
|
|
38
|
+
* percent or two, so a sub-1% wobble is not a real reduction — crediting it
|
|
39
|
+
* would suppress the guard on a genuine no-op fire (the exact bug we are
|
|
40
|
+
* fixing). 2% is a defensible "real shrink" threshold: it is well above typical
|
|
41
|
+
* re-estimation noise but low enough that a compaction that freed even a few
|
|
42
|
+
* percent of the window is not punished. A reduction of ≤0 tokens is
|
|
43
|
+
* unconditionally ineffective regardless of this floor.
|
|
44
|
+
*/
|
|
45
|
+
const MEANINGFUL_REDUCTION_PCT = 0.02;
|
|
46
|
+
|
|
47
|
+
/** Pure reduction verdict for a live-window bracketing pair. */
|
|
48
|
+
export const ReductionValidator = {
|
|
49
|
+
/**
|
|
50
|
+
* Judge whether the LIVE window actually shrank between two consecutive
|
|
51
|
+
* context events bracketing a compaction.
|
|
52
|
+
* - a reduction of ≤0 tokens ⇒ definitively ineffective.
|
|
53
|
+
* - otherwise effective only when the reduction is ≥ the small positive
|
|
54
|
+
* floor (MEANINGFUL_REDUCTION_PCT of liveBefore), so estimation noise on
|
|
55
|
+
* the model's re-reported token count is not mistaken for a real shrink.
|
|
56
|
+
*/
|
|
57
|
+
validateReduction(liveBefore: number, liveAfter: number): ReductionVerdict {
|
|
58
|
+
const reduction = liveBefore - liveAfter;
|
|
59
|
+
const floor = Math.max(1, Math.round(MEANINGFUL_REDUCTION_PCT * liveBefore));
|
|
60
|
+
const effective = Number.isFinite(reduction) && reduction > 0 && reduction >= floor;
|
|
61
|
+
return { effective, liveBefore, liveAfter };
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Arm the ThrashGuard after an ineffective compaction. Persists:
|
|
67
|
+
* - `thrasguard.baseline_tokens` = the live currentTokens at this (post-fire)
|
|
68
|
+
* context event, so re-arm is measured from the window that failed to shrink.
|
|
69
|
+
* - `thrasguard.blocked_until` = baseline + N, where N = `rearmPct ×
|
|
70
|
+
* effectiveThreshold`. Re-firing is refused until the live window grows past
|
|
71
|
+
* `blocked_until`.
|
|
72
|
+
*
|
|
73
|
+
* If `effectiveThreshold` is non-finite (+Infinity — the 3WF-2 invariant when
|
|
74
|
+
* the model window is unknown), N cannot be computed; we MUST NOT persist
|
|
75
|
+
* Infinity/NaN into meta (getMetaNumber would read it back as 0). Skip arming
|
|
76
|
+
* + log instead; the next over-threshold event simply re-fires (pre-sprint
|
|
77
|
+
* behavior) rather than corrupting the guard.
|
|
78
|
+
*/
|
|
79
|
+
export function armThrashGuard(
|
|
80
|
+
currentTokens: number,
|
|
81
|
+
rearmPct: number,
|
|
82
|
+
effectiveThreshold: number,
|
|
83
|
+
stateDir: string,
|
|
84
|
+
logger?: { info(event: string, fields?: Record<string, unknown>): void },
|
|
85
|
+
): void {
|
|
86
|
+
if (!Number.isFinite(currentTokens) || currentTokens <= 0) return;
|
|
87
|
+
if (!Number.isFinite(rearmPct) || rearmPct <= 0) return;
|
|
88
|
+
if (!Number.isFinite(effectiveThreshold)) {
|
|
89
|
+
logger?.info("thrasguard_skip_arm", {
|
|
90
|
+
reason: "nonfinite_effective_threshold",
|
|
91
|
+
currentTokens,
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const n = Math.round(rearmPct * effectiveThreshold);
|
|
97
|
+
setMetaNumber(THRASH_BASELINE_KEY, Math.round(currentTokens), stateDir);
|
|
98
|
+
setMetaNumber(THRASH_BLOCKED_KEY, Math.round(currentTokens + n), stateDir);
|
|
99
|
+
logger?.info("thrasguard_armed", {
|
|
100
|
+
baselineTokens: Math.round(currentTokens),
|
|
101
|
+
blockedUntilTokens: Math.round(currentTokens + n),
|
|
102
|
+
rearmTokens: n,
|
|
103
|
+
});
|
|
104
|
+
} catch {
|
|
105
|
+
/* non-fatal: best-effort meta write */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Consult the ThrashGuard for the current live-window token count. Returns true
|
|
111
|
+
* when compaction must be refused (the window is still below the armed
|
|
112
|
+
* `blocked_until`). A `blocked_until` of 0/absent ⇒ never blocked. When the
|
|
113
|
+
* live tokens have grown past `blocked_until`, the guard no longer blocks
|
|
114
|
+
* (caller re-fires normally). Pure read, best-effort — on any failure returns
|
|
115
|
+
* false (do not refuse compaction on a store error).
|
|
116
|
+
*/
|
|
117
|
+
export function isThrashBlocked(currentTokens: number, stateDir: string): boolean {
|
|
118
|
+
try {
|
|
119
|
+
const blockedUntil = getMetaNumber(THRASH_BLOCKED_KEY, stateDir);
|
|
120
|
+
if (blockedUntil <= 0) return false;
|
|
121
|
+
return Number.isFinite(currentTokens) && currentTokens < blockedUntil;
|
|
122
|
+
} catch {
|
|
123
|
+
return false; // non-fatal: never refuse compaction on a read error
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Per-runtime one-shot session state for the live-window delta correlation.
|
|
129
|
+
* Like triggerGuard.ts, keyed by runtime in a WeakMap so it dies with the
|
|
130
|
+
* runtime and a test can pass a thin stub. Holds the live token count observed
|
|
131
|
+
* at the event that FIRED a compaction; consumed on the following context event
|
|
132
|
+
* to judge whether the window actually shrank.
|
|
133
|
+
*/
|
|
134
|
+
const sessionBefore = new WeakMap<MegaRuntime, { liveBefore: number }>();
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The live-token count of the event that most recently ARMED the guard, per
|
|
138
|
+
* runtime. The arming happens early in a context event (the live-delta consume
|
|
139
|
+
* point), but the guard consult runs LATER IN THAT SAME EVENT — and since
|
|
140
|
+
* `blocked_until = currentTokens + N`, a naive consult would always find
|
|
141
|
+
* `currentTokens < blocked_until` and swallow the very event that armed it.
|
|
142
|
+
* That is an off-by-one-event error: the guard's contract is to refuse
|
|
143
|
+
* SUBSEQUENT re-fires, not to cancel the compaction that revealed the problem.
|
|
144
|
+
* Recording the arming event's token count lets the consult skip exactly that
|
|
145
|
+
* one event. Cleared once the window grows past it.
|
|
146
|
+
*/
|
|
147
|
+
const armedOnEvent = new WeakMap<MegaRuntime, number>();
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Runtime-aware ThrashGuard consult: true when a NEW compaction must be refused.
|
|
151
|
+
*
|
|
152
|
+
* Reads the persisted `thrasguard.blocked_until` (see `isThrashBlocked`) but
|
|
153
|
+
* EXEMPTS the single event that armed the guard — otherwise, because arming sets
|
|
154
|
+
* `blocked_until = currentTokens + N` earlier in the very same context event, the
|
|
155
|
+
* consult would always fire and cancel the compaction that exposed the thrash.
|
|
156
|
+
* The guard exists to refuse SUBSEQUENT re-fires. Once the live window grows past
|
|
157
|
+
* the armed count the exemption is dropped, and normal blocking resumes until the
|
|
158
|
+
* window clears `blocked_until`.
|
|
159
|
+
*
|
|
160
|
+
* Best-effort: any failure returns false (never refuse on a store fault).
|
|
161
|
+
*/
|
|
162
|
+
export function isThrashBlockedFor(
|
|
163
|
+
runtime: MegaRuntime,
|
|
164
|
+
currentTokens: number,
|
|
165
|
+
stateDir: string,
|
|
166
|
+
): boolean {
|
|
167
|
+
try {
|
|
168
|
+
if (!isThrashBlocked(currentTokens, stateDir)) return false;
|
|
169
|
+
const armedAt = armedOnEvent.get(runtime);
|
|
170
|
+
if (armedAt !== undefined && currentTokens === armedAt) {
|
|
171
|
+
// EXACTLY the event that armed the guard (same live-token reading): let it
|
|
172
|
+
// through once, then block normally from the next event onward. An exact
|
|
173
|
+
// match (not <=) is required so a genuinely lower or different live reading
|
|
174
|
+
// on a later event is still blocked.
|
|
175
|
+
armedOnEvent.delete(runtime);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Record that a compaction fired at `liveBefore` tokens (call on the firing event). */
|
|
185
|
+
export function markCompactionFired(runtime: MegaRuntime, liveBefore: number): void {
|
|
186
|
+
try {
|
|
187
|
+
if (Number.isFinite(liveBefore) && liveBefore > 0) {
|
|
188
|
+
sessionBefore.set(runtime, { liveBefore });
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
/* non-fatal */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Consume a pending live-window delta on a subsequent context event. If a
|
|
197
|
+
* compaction fired on a prior event, compare THIS event's live tokens against
|
|
198
|
+
* that pre-fire baseline; an ineffective reduction arms the guard. The pending
|
|
199
|
+
* marker is consumed exactly once (cleared before any re-arm). No-op when no
|
|
200
|
+
* compaction is pending, when the umbrella flag is OFF, or on any error.
|
|
201
|
+
*/
|
|
202
|
+
export function evaluatePendingReduction(
|
|
203
|
+
runtime: MegaRuntime,
|
|
204
|
+
currentTokens: number,
|
|
205
|
+
config: MegaConfig,
|
|
206
|
+
): void {
|
|
207
|
+
if (!config.threeWayFailback) return;
|
|
208
|
+
const pending = sessionBefore.get(runtime);
|
|
209
|
+
if (pending == null) return;
|
|
210
|
+
try {
|
|
211
|
+
sessionBefore.delete(runtime); // consume once, regardless of verdict
|
|
212
|
+
const verdict = ReductionValidator.validateReduction(pending.liveBefore, currentTokens);
|
|
213
|
+
if (!verdict.effective) {
|
|
214
|
+
// Remember which event armed us so the consult later in THIS SAME event
|
|
215
|
+
// does not swallow it (see armedOnEvent).
|
|
216
|
+
armedOnEvent.set(runtime, currentTokens);
|
|
217
|
+
armThrashGuard(
|
|
218
|
+
currentTokens,
|
|
219
|
+
config.thrashRearmPct,
|
|
220
|
+
runtime.effectiveThreshold,
|
|
221
|
+
runtime.currentStateDir,
|
|
222
|
+
runtime.logger,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
/* non-fatal */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -26,7 +26,11 @@ import { buildTailResult } from "./context-handler/tailResult.js";
|
|
|
26
26
|
import { runTriggerGuard } from "./context-handler/triggerGuard.js";
|
|
27
27
|
import { persistEpochAndMaintain } from "./context-handler/afterCompact.js";
|
|
28
28
|
import { appendMirrorAndLedger } from "./context-handler/dbMirrorAppend.js";
|
|
29
|
-
import { evaluateGate } from "./context-handler/gateCheck.js";
|
|
29
|
+
import { evaluateGate, thrashGuardBlocks } from "./context-handler/gateCheck.js";
|
|
30
|
+
import {
|
|
31
|
+
markCompactionFired,
|
|
32
|
+
evaluatePendingReduction,
|
|
33
|
+
} from "./context-handler/thrashGuard.js";
|
|
30
34
|
import { invokePipeline } from "./context-handler/pipelineRun.js";
|
|
31
35
|
import { buildLiveTrimView } from "./context-handler/liveTrim.js";
|
|
32
36
|
|
|
@@ -88,6 +92,14 @@ export function registerContextHandler(
|
|
|
88
92
|
: null) ??
|
|
89
93
|
Math.round(((pct ?? 0) / 100) * (usage?.contextWindow ?? 0));
|
|
90
94
|
runtime.lastCtxTokens = currentTokens ?? null;
|
|
95
|
+
// 3WF-2: consume a pending live-window delta from a prior compaction. If a
|
|
96
|
+
// compaction fired on the previous context event and the live window did
|
|
97
|
+
// not shrink, this arms the ThrashGuard (meta). No-op when none pending.
|
|
98
|
+
try {
|
|
99
|
+
evaluatePendingReduction(runtime, currentTokens ?? 0, config);
|
|
100
|
+
} catch {
|
|
101
|
+
/* non-fatal */
|
|
102
|
+
}
|
|
91
103
|
runtime.lastCtxPercent = pct ?? null;
|
|
92
104
|
runtime.lastCtxWindow = usage?.contextWindow ?? 0;
|
|
93
105
|
runtime.snapshot(ctx);
|
|
@@ -147,6 +159,18 @@ export function registerContextHandler(
|
|
|
147
159
|
// else: context grew enough → fall through to re-compact (cache is stale)
|
|
148
160
|
}
|
|
149
161
|
|
|
162
|
+
// 3WF-2 ThrashGuard: refuse a NEW compaction while armed (an ineffective
|
|
163
|
+
// prior compaction left the live window unshrunk). Sits AFTER the replay
|
|
164
|
+
// block — replay is free and must stay exempt — and BEFORE debounce +
|
|
165
|
+
// invokePipeline (the real fire point), so it covers the percent + token
|
|
166
|
+
// gate paths alike. Umbrella OFF ⇒ never blocks (byte-identical). Returns
|
|
167
|
+
// the tailed view so a staged recall block still rides along.
|
|
168
|
+
if (thrashGuardBlocks(runtime, config, currentTokens)) {
|
|
169
|
+
runtime.diagCtxFastGate++;
|
|
170
|
+
runtime.snapshot(ctx);
|
|
171
|
+
return tailResult() ?? undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
150
174
|
// Debounce so we don't fire on every context event past threshold.
|
|
151
175
|
// (Replay already returned above — only fresh compacts reach this point.)
|
|
152
176
|
const now = Date.now();
|
|
@@ -171,6 +195,17 @@ export function registerContextHandler(
|
|
|
171
195
|
// topic seed + fire-and-forget dedup. Best-effort + non-fatal.
|
|
172
196
|
await persistEpochAndMaintain(runtime, config, pipeline.ran);
|
|
173
197
|
|
|
198
|
+
// 3WF-2: record the live-window baseline at the moment a compaction actually
|
|
199
|
+
// fired, so the NEXT context event can judge whether the window shrank. We
|
|
200
|
+
// use the LIVE currentTokens here (not ran.saved — that is the false
|
|
201
|
+
// stored-checkpoint metric the thrash bug used), matching the spec's
|
|
202
|
+
// "value observed just BEFORE that compaction fired" seam.
|
|
203
|
+
try {
|
|
204
|
+
markCompactionFired(runtime, currentTokens ?? 0);
|
|
205
|
+
} catch {
|
|
206
|
+
/* non-fatal */
|
|
207
|
+
}
|
|
208
|
+
|
|
174
209
|
// LEGACY path (rollback): v0.4.28 ctx.compact() + the no-op gate. The
|
|
175
210
|
// manual compact path aborts the in-flight turn — only used behind the flag.
|
|
176
211
|
// Read live from env (in addition to the load-time config) so the flag can be
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* noop.ts — pi durable-compact no-op prediction (moved from compact.ts as part
|
|
3
|
+
* of the delegate-shell split; the shell re-exports the public API unchanged).
|
|
4
|
+
*
|
|
5
|
+
* See piCompactWouldNoop's JSDoc for the full rationale (predicting pi's
|
|
6
|
+
* `ctx.compact()` no-op throw before it surfaces a user-facing error).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { estimateBlockTokens } from "../../../src/tokens.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Predict whether pi's `ctx.compact()` would throw a no-op error — "Already
|
|
15
|
+
* compacted" or "Nothing to compact (session too small)" — so the auto-trigger
|
|
16
|
+
* can SKIP the call instead of surfacing a hard, user-facing error.
|
|
17
|
+
*
|
|
18
|
+
* Why we can't intercept or suppress it: pi's public `compact()` computes
|
|
19
|
+
* `prepareCompaction()` and throws *before* it emits `session_before_compact`,
|
|
20
|
+
* so our handler there never runs on the no-op path. And `ctx.compact()`'s
|
|
21
|
+
* `onError` callback fires only AFTER pi has already emitted a `compaction_end`
|
|
22
|
+
* event carrying the error message (which the interactive UI renders) — so
|
|
23
|
+
* `onError` cannot mute it either. The only robust fix is to not call
|
|
24
|
+
* `ctx.compact()` when pi would no-op. (pi's own `_runAutoCompaction` path is
|
|
25
|
+
* silent on this same condition; the public path we're forced through is the
|
|
26
|
+
* one that throws.)
|
|
27
|
+
*
|
|
28
|
+
* Skipping is correct, not a compromise: by the time this runs, `runCompact()`
|
|
29
|
+
* has already persisted the recall checkpoint (Path A). The durable on-disk
|
|
30
|
+
* trim is only useful when pi can actually summarize a region; a transcript
|
|
31
|
+
* under pi's `keepRecentTokens` budget is small enough that reloading it on
|
|
32
|
+
* resume isn't a token-growth problem, so the durable trim is unnecessary
|
|
33
|
+
* there anyway.
|
|
34
|
+
*
|
|
35
|
+
* Mirrors pi's `prepareCompaction()` return-undefined conditions (compaction.js):
|
|
36
|
+
* (1) last entry is a compaction → "Already compacted"
|
|
37
|
+
* (2) <2 cut-point messages since the last compaction → nothing to summarize
|
|
38
|
+
* (a cut point = any non-toolResult message — user/assistant/bash/custom/
|
|
39
|
+
* branchSummary/compactionSummary — matching pi's isCutPointMessage)
|
|
40
|
+
* (3) transcript tokens since the last compaction < keepRecentTokens → pi
|
|
41
|
+
* keeps everything → nothing to summarize
|
|
42
|
+
* `keepRecentTokens` isn't readable from the extension API, so (3) uses the pi
|
|
43
|
+
* default (20000) as a conservative floor; raise it via
|
|
44
|
+
* `MEGACOMPACT_DURABLE_TRIM_FLOOR` if you raise pi's `compact.keepRecentTokens`.
|
|
45
|
+
*
|
|
46
|
+
* Best-effort: on any read error returns true (skip) — skipping a durable trim
|
|
47
|
+
* is always safe; calling `ctx.compact()` on a no-op throws to the user.
|
|
48
|
+
*/
|
|
49
|
+
export function piCompactWouldNoop(ctx: ExtensionContext): boolean {
|
|
50
|
+
try {
|
|
51
|
+
const branch = ctx.sessionManager.getBranch();
|
|
52
|
+
if (branch.length === 0) return true;
|
|
53
|
+
// (1) already compacted — pi throws "Already compacted"
|
|
54
|
+
if (branch[branch.length - 1].type === "compaction") return true;
|
|
55
|
+
// boundaryStart = index just after the most recent compaction entry (or 0)
|
|
56
|
+
let boundaryStart = 0;
|
|
57
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
58
|
+
if (branch[i].type === "compaction") { boundaryStart = i + 1; break; }
|
|
59
|
+
}
|
|
60
|
+
void boundaryStart;
|
|
61
|
+
let cutPoints = 0;
|
|
62
|
+
let tokens = 0;
|
|
63
|
+
for (let i = boundaryStart; i < branch.length; i++) {
|
|
64
|
+
const e = branch[i];
|
|
65
|
+
if (e.type === "compaction") continue;
|
|
66
|
+
let isCut = false;
|
|
67
|
+
for (const m of sessionEntryToContextMessages(e)) {
|
|
68
|
+
// pi's isCutPointMessage: every role except toolResult
|
|
69
|
+
if ((m as { role?: string }).role !== "toolResult") isCut = true;
|
|
70
|
+
const c = (m as { content?: unknown }).content;
|
|
71
|
+
const text =
|
|
72
|
+
typeof c === "string" ? c
|
|
73
|
+
: Array.isArray(c)
|
|
74
|
+
? (c as { text?: string }[]).map((b) => b?.text ?? "").join(" ")
|
|
75
|
+
: "";
|
|
76
|
+
if (text) tokens += estimateBlockTokens(text);
|
|
77
|
+
}
|
|
78
|
+
if (isCut) cutPoints++;
|
|
79
|
+
}
|
|
80
|
+
// (2) need >=2 cut points so the kept cut isn't the first message
|
|
81
|
+
if (cutPoints < 2) return true;
|
|
82
|
+
// (3) transcript under pi's keepRecentTokens budget → pi keeps everything
|
|
83
|
+
if (tokens < durableTrimFloorTokens()) return true;
|
|
84
|
+
return false;
|
|
85
|
+
} catch {
|
|
86
|
+
return true; // safe: skip the durable trim rather than risk a user-facing throw
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** pi's default keepRecentTokens (compaction settings). Override with
|
|
91
|
+
* MEGACOMPACT_DURABLE_TRIM_FLOOR if you raise pi's compact.keepRecentTokens. */
|
|
92
|
+
function durableTrimFloorTokens(): number {
|
|
93
|
+
const raw = process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
|
|
94
|
+
if (raw !== undefined && Number.isFinite(Number(raw))) return Number(raw);
|
|
95
|
+
return 20_000;
|
|
96
|
+
}
|