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.
@@ -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
+ }