pi-mega-compact 0.20.84 → 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 +7 -0
- package/dist/extensions/mega-config.js +26 -1
- package/dist/extensions/mega-events/context-handler/gateCheck.js +41 -2
- 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/extensions/mega-runtime/pressure-getters.js +12 -0
- 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 +21 -0
- package/extensions/mega-config-types.ts +8 -0
- package/extensions/mega-config.ts +26 -1
- package/extensions/mega-events/context-handler/gateCheck.ts +48 -2
- 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/extensions/mega-runtime/pressure-getters.ts +14 -0
- 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
|
@@ -126,6 +126,13 @@ export const SETTINGS = [
|
|
|
126
126
|
num("MEGACOMPACT_EMBEDDING_CHARS_PER_TOKEN", "Embedding Chars per Token", "Estimated characters per token used for embedder chunking size", 4, 1, 32),
|
|
127
127
|
],
|
|
128
128
|
},
|
|
129
|
+
{
|
|
130
|
+
name: "Compaction",
|
|
131
|
+
settings: [
|
|
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),
|
|
134
|
+
],
|
|
135
|
+
},
|
|
129
136
|
VECTOR_CORTEX_SETTINGS,
|
|
130
137
|
{
|
|
131
138
|
name: "Cost API",
|
|
@@ -66,11 +66,30 @@ function resolveThreshold() {
|
|
|
66
66
|
}
|
|
67
67
|
const raw = (process.env.MEGACOMPACT_TIER ?? "low").toLowerCase();
|
|
68
68
|
const tier = (raw in COMPACT_TIERS ? raw : "low");
|
|
69
|
-
|
|
69
|
+
let tierPct = TIER_PCT[tier];
|
|
70
|
+
// 3WF-2 threshold invariant: under the umbrella, when no named tier is set
|
|
71
|
+
// the fire point is the configurable % of the ACTUAL model window (default
|
|
72
|
+
// 0.80 — "20% free remaining"). Tiered (named preset) keeps its preset pct;
|
|
73
|
+
// both paths still compute the legacy 200k boot fallback below as a display
|
|
74
|
+
// placeholder + the custom-tier absolute companion. Umbrella OFF stays
|
|
75
|
+
// byte-identical to v0.20.83 (default tier=low 0.5).
|
|
76
|
+
const umbrella = envBool("MEGACOMPACT_THREE_WAY_FAILBACK", true);
|
|
77
|
+
if (umbrella && !(process.env.MEGACOMPACT_TIER && process.env.MEGACOMPACT_TIER !== "")) {
|
|
78
|
+
tierPct = clamp(envFlag("MEGACOMPACT_THRESHOLD_PCT", 0.8), 0.1, 0.95);
|
|
79
|
+
}
|
|
70
80
|
// Boot fallback: sane gate before the first context event provides a window.
|
|
81
|
+
// (NO hardcoded window in the firing path — effectiveThresholdImpl defers
|
|
82
|
+
// when window unknown; this remains only a display placeholder + custom
|
|
83
|
+
// companion under the umbrella.)
|
|
71
84
|
const thresholdTokens = Math.round(tierPct * 200_000);
|
|
72
85
|
return { tier, tierPct, thresholdTokens };
|
|
73
86
|
}
|
|
87
|
+
/** Clamp `n` into [lo, hi]; non-finite → fallback. */
|
|
88
|
+
function clamp(n, lo, hi) {
|
|
89
|
+
if (!Number.isFinite(n))
|
|
90
|
+
return lo;
|
|
91
|
+
return Math.min(hi, Math.max(lo, n));
|
|
92
|
+
}
|
|
74
93
|
/**
|
|
75
94
|
* Pure helper: the real compaction fire point, given the model context window.
|
|
76
95
|
*
|
|
@@ -170,6 +189,12 @@ export function loadConfig() {
|
|
|
170
189
|
// 3WF-1: TriggerGuard — guarantee a staged recall block on every context
|
|
171
190
|
// event even when session_start never fires. Default ON; OFF = byte-identical.
|
|
172
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),
|
|
173
198
|
// PC-A: positive sprint flag, default ON. =0 byte-identical to the
|
|
174
199
|
// pre-change OFF state (single gate lives at the call site in tailResult.ts).
|
|
175
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
|
|
@@ -40,11 +67,23 @@ export function evaluateGate(runtime, config, opts) {
|
|
|
40
67
|
}
|
|
41
68
|
else {
|
|
42
69
|
// custom tier OR tiered-but-pct-unavailable → token gate (S27 fallback).
|
|
43
|
-
|
|
70
|
+
// 3WF-2: under the umbrella, when the window is known AND the config is
|
|
71
|
+
// tiered, honor the per-model Dashboard override exactly like the percent
|
|
72
|
+
// branch (firePointPct % of the actual window) instead of the bare boot
|
|
73
|
+
// fallback. custom / window-unknown / umbrella-OFF keep the bare
|
|
74
|
+
// runtime.effectiveThreshold (window-unknown defers via +Infinity from
|
|
75
|
+
// effectiveThresholdImpl; custom is the explicit absolute).
|
|
76
|
+
let gateThreshold = runtime.effectiveThreshold;
|
|
77
|
+
if (config.threeWayFailback &&
|
|
78
|
+
config.tierPct != null &&
|
|
79
|
+
runtime.lastCtxWindow > 0) {
|
|
80
|
+
gateThreshold = Math.round((perModelThreshold.firePointPct / 100) * runtime.lastCtxWindow);
|
|
81
|
+
}
|
|
82
|
+
if (currentTokens < gateThreshold) {
|
|
44
83
|
runtime.diagCtxFastGate++;
|
|
45
84
|
return { kind: "return", view: tailResult() ?? undefined };
|
|
46
85
|
}
|
|
47
|
-
const check = autoCompactCheck(currentTokens,
|
|
86
|
+
const check = autoCompactCheck(currentTokens, gateThreshold); // SERVER-STYLE CONFIRM (local)
|
|
48
87
|
if (!check.shouldCompact) {
|
|
49
88
|
runtime.diagCtxNoCompact++;
|
|
50
89
|
return { kind: "return", view: tailResult() ?? undefined };
|
|
@@ -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
|
+
}
|