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