pi-mega-compact 0.21.4 → 0.21.6
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/README.md +4 -0
- package/dist/extensions/mega-config.js +41 -24
- package/dist/extensions/mega-events/agent-handlers/turnEndHandler/lengthStop.js +7 -0
- package/dist/extensions/mega-events/context-handler/gateCheck.js +17 -0
- package/dist/extensions/mega-events/context-handler.js +17 -0
- package/dist/extensions/mega-runtime/helpers.js +1 -0
- package/dist/extensions/mega-runtime/pressure-getters.js +12 -10
- package/dist/extensions/mega-runtime/reset-runtime.js +3 -1
- package/dist/extensions/mega-runtime/runtime-instrumentation.js +6 -1
- package/dist/src/compact-summary.js +99 -0
- package/dist/src/compact.js +15 -99
- package/extensions/mega-config-types.ts +8 -0
- package/extensions/mega-config.ts +51 -27
- package/extensions/mega-events/agent-handlers/turnEndHandler/lengthStop.ts +7 -0
- package/extensions/mega-events/context-handler/gateCheck.ts +18 -0
- package/extensions/mega-events/context-handler.ts +20 -0
- package/extensions/mega-runtime/helpers.ts +5 -0
- package/extensions/mega-runtime/pressure-getters.ts +12 -12
- package/extensions/mega-runtime/reset-runtime.ts +3 -1
- package/extensions/mega-runtime/runtime-instrumentation.ts +6 -1
- package/package.json +1 -1
- package/src/compact-summary.ts +120 -0
- package/src/compact.ts +19 -109
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# pi-mega-compact
|
|
2
2
|
|
|
3
|
+
> **⚠️ LTS — patches only (2026-08-13).** This extension is maintained for bug fixes only. New feature development has moved to **[radcode](https://github.com/TheArchitectit/radcode)**, a Rust pi.dev replacement that has ported mega-compact's compaction/recall/dedup/RAPTOR stack. See [`docs/LTS.md`](docs/LTS.md) and [`docs/SUCCESSION.md`](docs/SUCCESSION.md).
|
|
4
|
+
>
|
|
5
|
+
Pending public release
|
|
6
|
+
>
|
|
3
7
|
A local-first context compressor for the [pi coding agent](https://github.com/earendil-works/pi). Keeps long sessions running without overflowing the context window. Local by default — no cloud, no API calls, no telemetry. Bring your own localhost embedder (Ollama, ONNX, TEI) for better semantic matches, or opt in to a remote endpoint if you need to.
|
|
4
8
|
|
|
5
9
|
## Features
|
|
@@ -10,31 +10,41 @@ import { STATE_DIR_DEFAULT } from "../src/config.js";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: read-only `git rev-parse` to scope the store per-repo
|
|
12
12
|
/**
|
|
13
|
-
* Named compaction tiers
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Named compaction tiers — each tier sets the compaction fire point as a
|
|
14
|
+
* FRACTION of the model's context window (NOT a static token amount): the live
|
|
15
|
+
* + durable trim fire at `tier × window`, so they always fire BELOW pi's native
|
|
16
|
+
* auto-compaction (~80% of window) for any model size (200k or 1M). Pick by how
|
|
17
|
+
* aggressively you want the session trimmed. Explicit
|
|
18
|
+
* MEGACOMPACT_THRESHOLD_TOKENS always wins (`custom`, tierPct null — absolute,
|
|
19
|
+
* never percent-scaled). This is the SINGLE source of truth for tier fractions;
|
|
20
|
+
* `keyof typeof COMPACT_TIERS` is the `CompactTier` type and
|
|
21
|
+
* `raw in COMPACT_TIERS` validates a named preset name.
|
|
16
22
|
*/
|
|
17
23
|
export const COMPACT_TIERS = {
|
|
18
|
-
low: 50_000,
|
|
19
|
-
medium: 100_000,
|
|
20
|
-
high: 200_000,
|
|
21
|
-
ultra: 1_000_000,
|
|
22
|
-
mega: 10_000_000,
|
|
23
|
-
};
|
|
24
|
-
/**
|
|
25
|
-
* Compaction thresholds as a FRACTION of the model's context window (NOT a
|
|
26
|
-
* static token amount). The live + durable trim fire at tier% of the window,
|
|
27
|
-
* so they always fire BELOW pi's native auto-compaction (~80% of window) for
|
|
28
|
-
* any model size (200k or 1M). `custom` (explicit MEGACOMPACT_THRESHOLD_TOKENS)
|
|
29
|
-
* is NOT scaled by this map — it stays an absolute token count.
|
|
30
|
-
*/
|
|
31
|
-
export const TIER_PCT = {
|
|
32
24
|
low: 0.5,
|
|
33
25
|
medium: 0.6,
|
|
34
26
|
high: 0.7,
|
|
35
27
|
ultra: 0.7,
|
|
36
28
|
mega: 0.75,
|
|
37
29
|
};
|
|
30
|
+
/**
|
|
31
|
+
* Boot-fallback context window (env-overridable). Used ONLY as a display/seed
|
|
32
|
+
* placeholder before the first real context event supplies the provider's
|
|
33
|
+
* window — the live fire point (`effectiveThresholdImpl`) DEFERS (returns
|
|
34
|
+
* `+Infinity`) when the window is unknown, so this value is never a guessed
|
|
35
|
+
* gate. Default 200k keeps the display seed stable; set
|
|
36
|
+
* `MEGACOMPACT_DEFAULT_CONTEXT_WINDOW=<tokens>` to repoint it (e.g. for a
|
|
37
|
+
* 32k-model fleet the seed should reflect that).
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_CONTEXT_WINDOW = envFlag("MEGACOMPACT_DEFAULT_CONTEXT_WINDOW", 200_000);
|
|
40
|
+
/**
|
|
41
|
+
* Display seed for the progress-bar "saved tokens goal" denominator. The live
|
|
42
|
+
* value grows dynamically (`run.ts`: `savedGoal = ceil(tokensSaved × 1.25)` once
|
|
43
|
+
* exceeded), so this is only the initial target before the first compaction.
|
|
44
|
+
* Derived from the low-tier fire point at the default window (no bare magic):
|
|
45
|
+
* `COMPACT_TIERS.low × DEFAULT_CONTEXT_WINDOW`. Display-only.
|
|
46
|
+
*/
|
|
47
|
+
export const DEFAULT_SAVED_GOAL = Math.round(COMPACT_TIERS.low * DEFAULT_CONTEXT_WINDOW);
|
|
38
48
|
function envFlag(name, fallback) {
|
|
39
49
|
const v = process.env[name];
|
|
40
50
|
if (v == null || v === "")
|
|
@@ -52,8 +62,8 @@ function envBool(name, fallback) {
|
|
|
52
62
|
* Resolve the effective token threshold from TIER (or explicit) env vars.
|
|
53
63
|
*
|
|
54
64
|
* For a named tier the returned `thresholdTokens` is a BOOT FALLBACK
|
|
55
|
-
* (`round(tierPct *
|
|
56
|
-
* runtime. The true fire point is computed per-window at runtime via
|
|
65
|
+
* (`round(tierPct * DEFAULT_CONTEXT_WINDOW)`) — sane before any context event
|
|
66
|
+
* reaches the runtime. The true fire point is computed per-window at runtime via
|
|
57
67
|
* `effectiveThresholdTokens(...)`. `custom` (explicit MEGACOMPACT_THRESHOLD_TOKENS)
|
|
58
68
|
* keeps `tierPct: null` and an ABSOLUTE `thresholdTokens` (never percent-scaled).
|
|
59
69
|
*/
|
|
@@ -66,7 +76,7 @@ function resolveThreshold() {
|
|
|
66
76
|
}
|
|
67
77
|
const raw = (process.env.MEGACOMPACT_TIER ?? "low").toLowerCase();
|
|
68
78
|
const tier = (raw in COMPACT_TIERS ? raw : "low");
|
|
69
|
-
let tierPct =
|
|
79
|
+
let tierPct = COMPACT_TIERS[tier];
|
|
70
80
|
// 3WF-2 threshold invariant: under the umbrella, when no named tier is set
|
|
71
81
|
// the fire point is the configurable % of the ACTUAL model window (default
|
|
72
82
|
// 0.80 — "20% free remaining"). Tiered (named preset) keeps its preset pct;
|
|
@@ -80,8 +90,9 @@ function resolveThreshold() {
|
|
|
80
90
|
// Boot fallback: sane gate before the first context event provides a window.
|
|
81
91
|
// (NO hardcoded window in the firing path — effectiveThresholdImpl defers
|
|
82
92
|
// when window unknown; this remains only a display placeholder + custom
|
|
83
|
-
// companion under the umbrella.)
|
|
84
|
-
|
|
93
|
+
// companion under the umbrella.) Env-overridable via
|
|
94
|
+
// MEGACOMPACT_DEFAULT_CONTEXT_WINDOW (see DEFAULT_CONTEXT_WINDOW).
|
|
95
|
+
const thresholdTokens = Math.round(tierPct * DEFAULT_CONTEXT_WINDOW);
|
|
85
96
|
return { tier, tierPct, thresholdTokens };
|
|
86
97
|
}
|
|
87
98
|
/** Clamp `n` into [lo, hi]; non-finite → fallback. */
|
|
@@ -115,8 +126,11 @@ export function effectiveThresholdTokens(opts) {
|
|
|
115
126
|
* Resolve the optional manual arming-floor override (MEGACOMPACT_FAST_GATE_PCT).
|
|
116
127
|
* Kept for backward-compat: when unset, the default arming floor equals the
|
|
117
128
|
* tier's percent threshold (tierPct*100) so the dashboard stays consistent;
|
|
118
|
-
* `custom` (tierPct null
|
|
129
|
+
* `custom` (tierPct null — an explicit absolute opt-out of percent scaling by
|
|
130
|
+
* design) has no tier fraction to derive from, so it falls back to the named
|
|
131
|
+
* DEFAULT_FAST_GATE_PCT_CUSTOM. Env-overridable like every other default here.
|
|
119
132
|
*/
|
|
133
|
+
export const DEFAULT_FAST_GATE_PCT_CUSTOM = 70;
|
|
120
134
|
function resolveFastGatePct(tierPct) {
|
|
121
135
|
const raw = process.env.MEGACOMPACT_FAST_GATE_PCT;
|
|
122
136
|
if (raw != null && raw !== "") {
|
|
@@ -124,7 +138,7 @@ function resolveFastGatePct(tierPct) {
|
|
|
124
138
|
if (Number.isFinite(n))
|
|
125
139
|
return n;
|
|
126
140
|
}
|
|
127
|
-
return tierPct != null ? Math.round(tierPct * 100) :
|
|
141
|
+
return tierPct != null ? Math.round(tierPct * 100) : DEFAULT_FAST_GATE_PCT_CUSTOM;
|
|
128
142
|
}
|
|
129
143
|
/**
|
|
130
144
|
* Pressure helpers for adaptive compression live in src/config.ts (pi-agnostic)
|
|
@@ -193,6 +207,9 @@ export function loadConfig() {
|
|
|
193
207
|
memoryAutoReview: envBool("MEGACOMPACT_MEMORY_AUTO_REVIEW", true),
|
|
194
208
|
memoryReviewInterval: envFlag("MEGACOMPACT_MEMORY_REVIEW_INTERVAL", 10),
|
|
195
209
|
recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
|
|
210
|
+
// Phase H: output-error catch — trip compaction on a truncated model output
|
|
211
|
+
// (S28 stopReason==='length'). Default ON; OFF byte-identical pre-H.
|
|
212
|
+
outputErrorCompact: envBool("MEGACOMPACT_OUTPUT_ERROR_COMPACT", true),
|
|
196
213
|
windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
|
|
197
214
|
recallTailInject: envBool("MEGACOMPACT_RECALL_TAIL_INJECT", true),
|
|
198
215
|
// 3WF-1: TriggerGuard — guarantee a staged recall block on every context
|
|
@@ -7,6 +7,13 @@ export function lengthStop(event, runtime, config) {
|
|
|
7
7
|
event.message.role === "assistant" &&
|
|
8
8
|
event.message.stopReason === "length") {
|
|
9
9
|
runtime.rt.lengthStopPending = true;
|
|
10
|
+
// Phase H: output-error catch — ALSO arm the one-shot force-compact flag so
|
|
11
|
+
// the next compaction gate trips immediately, freeing input headroom for
|
|
12
|
+
// the model's next response. Gated by config.outputErrorCompact (default
|
|
13
|
+
// ON; OFF = byte-identical pre-H — only the S28 auto-continue nudge fires).
|
|
14
|
+
if (config.outputErrorCompact) {
|
|
15
|
+
runtime.rt.forceCompactNextGate = true;
|
|
16
|
+
}
|
|
10
17
|
runtime.dashboard.event("length_stop", { turnIndex: event.turnIndex });
|
|
11
18
|
}
|
|
12
19
|
}
|
|
@@ -50,6 +50,23 @@ export function evaluateGate(runtime, config, opts) {
|
|
|
50
50
|
: DEFAULT_FIRE_POINT_PCT,
|
|
51
51
|
stateDir: runtime.currentStateDir,
|
|
52
52
|
});
|
|
53
|
+
// Phase H: output-error catch. A truncated model output (S28
|
|
54
|
+
// stopReason==='length' — "Response was truncated before completion") trips
|
|
55
|
+
// a one-shot force-compact so the next compaction runs IMMEDIATELY, freeing
|
|
56
|
+
// input headroom for the model's next response. This closes the
|
|
57
|
+
// small-context-model deadlock: the model truncates MID-OUTPUT below the
|
|
58
|
+
// 80% INPUT threshold (so the gate never fires) → "compact never" → every
|
|
59
|
+
// subsequent response also truncates. lengthStop.ts sets this flag
|
|
60
|
+
// alongside its auto-continue nudge; gated by config.outputErrorCompact
|
|
61
|
+
// (default ON; OFF = byte-identical pre-H). One-shot: cleared on consumption.
|
|
62
|
+
// thrashGuardBlocks is consulted separately by the handler; in the reported
|
|
63
|
+
// "compact never" case compactCount===0 so the guard is never armed and will
|
|
64
|
+
// not block this trip.
|
|
65
|
+
if (config.outputErrorCompact && runtime.rt?.forceCompactNextGate) {
|
|
66
|
+
runtime.rt.forceCompactNextGate = false;
|
|
67
|
+
runtime.diagCtxOutputErrorTrip++;
|
|
68
|
+
return { kind: "proceed", perModelThreshold };
|
|
69
|
+
}
|
|
53
70
|
// S29 FAST GATE: `custom` (absolute MEGACOMPACT_THRESHOLD_TOKENS,
|
|
54
71
|
// tierPct null) is an explicit opt-out of percent scaling — it keeps the
|
|
55
72
|
// token gate. When pct is unavailable (window unknown / a model that
|
|
@@ -99,6 +99,23 @@ export function registerContextHandler(pi, runtime, config) {
|
|
|
99
99
|
// S27 DB-mirror + VC1B ledger append. Runs BEFORE the fast-gate so every
|
|
100
100
|
// message is captured, even if we don't compact this turn. Non-fatal.
|
|
101
101
|
appendMirrorAndLedger(runtime, config, messages);
|
|
102
|
+
// Phase H.1: output-error catch noop guard. When forceCompactNextGate is
|
|
103
|
+
// armed (Phase H: model truncated its output → lengthStop.ts set the flag
|
|
104
|
+
// at turn_end), check piCompactWouldNoop BEFORE evaluateGate consumes the
|
|
105
|
+
// flag. If pi would noop (session too small to summarize — the transcript
|
|
106
|
+
// is under pi's keepRecentTokens floor), clear the flag + return the tail
|
|
107
|
+
// result silently instead of forcing a proceed that will throw "Nothing
|
|
108
|
+
// to compact (session too small)" to the user. The session is genuinely
|
|
109
|
+
// too small to compact — skipping is correct (the model retries on the
|
|
110
|
+
// next turn regardless). Gated by config.outputErrorCompact (default ON;
|
|
111
|
+
// OFF = byte-identical pre-H.1).
|
|
112
|
+
if (config.outputErrorCompact &&
|
|
113
|
+
runtime.rt.forceCompactNextGate &&
|
|
114
|
+
piCompactWouldNoop(ctx)) {
|
|
115
|
+
runtime.rt.forceCompactNextGate = false;
|
|
116
|
+
runtime.diagCtxOutputErrorTrip++;
|
|
117
|
+
return tailResult() ?? undefined;
|
|
118
|
+
}
|
|
102
119
|
// S29 FAST GATE: drive the auto-trigger off the context percent (see
|
|
103
120
|
// gateCheck.ts). Returns a tailed view when the gate does not pass.
|
|
104
121
|
const gate = evaluateGate(runtime, config, { pct, currentTokens, tailResult });
|
|
@@ -51,16 +51,18 @@ export function pressureImpl(self) {
|
|
|
51
51
|
* always below pi's native auto-compaction (~80% of window).
|
|
52
52
|
*/
|
|
53
53
|
export function effectiveThresholdImpl(self) {
|
|
54
|
-
// 3WF-2 threshold invariant
|
|
55
|
-
// UNKNOWN window (lastCtxWindow <= 0) DEFERS —
|
|
56
|
-
// substitute a guessed window
|
|
57
|
-
// `tokens >= threshold`
|
|
58
|
-
// agent_end durable trigger, live-trim
|
|
59
|
-
// until the provider reports a real
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
// 3WF-2 threshold invariant (LTS-correctness fix, Phase C): a tiered config
|
|
55
|
+
// (tierPct != null) with an UNKNOWN window (lastCtxWindow <= 0) DEFERS —
|
|
56
|
+
// auto-compaction must never substitute a guessed window, under ANY umbrella
|
|
57
|
+
// state. Returning +Infinity keeps every downstream `tokens >= threshold`
|
|
58
|
+
// comparison false (gateCheck token path, agent_end durable trigger, live-trim
|
|
59
|
+
// re-compact), so no compaction fires until the provider reports a real
|
|
60
|
+
// window. This closes the small-context-model deadlock: umbrella-OFF
|
|
61
|
+
// previously fell through to the legacy 200k helper (round(tierPct × 200k)),
|
|
62
|
+
// which is unreachable on a 32k window → the model truncates before the gate
|
|
63
|
+
// ever fires → "compact never". custom (tierPct null) always falls through
|
|
64
|
+
// to the explicit absolute.
|
|
65
|
+
if (self.config.tierPct != null && self.lastCtxWindow <= 0) {
|
|
64
66
|
return Number.POSITIVE_INFINITY;
|
|
65
67
|
}
|
|
66
68
|
return effectiveThresholdTokens({
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* runtime-helpers.ts.
|
|
10
10
|
*/
|
|
11
11
|
import { normalizeSessionId } from "../../src/store.js";
|
|
12
|
+
import { DEFAULT_SAVED_GOAL } from "../mega-config.js";
|
|
12
13
|
// --------------------------------------------------------------- resetRuntime
|
|
13
14
|
export function resetRuntimeImpl(self, sessionId) {
|
|
14
15
|
// Only call normalizeSessionId when a real sessionId string is passed.
|
|
@@ -37,6 +38,7 @@ export function resetRuntimeImpl(self, sessionId) {
|
|
|
37
38
|
recallInjections: 0,
|
|
38
39
|
cacheHitTokens: 0,
|
|
39
40
|
lengthStopPending: false,
|
|
41
|
+
forceCompactNextGate: false,
|
|
40
42
|
errorRetryCount: 0,
|
|
41
43
|
errorRetryUntil: 0,
|
|
42
44
|
consecutiveErrors: 0,
|
|
@@ -60,7 +62,7 @@ export function resetRuntimeImpl(self, sessionId) {
|
|
|
60
62
|
self.tierTrace = undefined;
|
|
61
63
|
self.ticker.length = 0;
|
|
62
64
|
self.pulsing = false;
|
|
63
|
-
self.savedGoal =
|
|
65
|
+
self.savedGoal = DEFAULT_SAVED_GOAL;
|
|
64
66
|
self.lastWhy = undefined;
|
|
65
67
|
// S31 audit P2: symmetry with bindRepo — a reset can coincide with a context
|
|
66
68
|
// that re-binds the repo, so drop the memo too. Cheap; the next
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_SAVED_GOAL } from "../mega-config.js";
|
|
1
2
|
/**
|
|
2
3
|
* DIAG counters for the "team run doesn't relieve context" investigation.
|
|
3
4
|
* Plain integers, incremented at the three compaction decision points. They
|
|
@@ -21,6 +22,7 @@ export class RuntimeInstrumentation {
|
|
|
21
22
|
diagCtxRunSkipped = 0; // runCompact() returned skipped
|
|
22
23
|
diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
|
|
23
24
|
diagCtxThrown = 0; // live-trim try threw (caught)
|
|
25
|
+
diagCtxOutputErrorTrip = 0; // Phase H: output-error catch tripped a forced compaction
|
|
24
26
|
// Context health instrumentation (v0.12): rolling ring buffers for
|
|
25
27
|
// drift detection + cache poison Layer 1 hash baseline.
|
|
26
28
|
recentTurnEmbeddings = [];
|
|
@@ -110,7 +112,10 @@ export class RuntimeInstrumentation {
|
|
|
110
112
|
memoriesTouchedThisCompaction = 0;
|
|
111
113
|
// Rolling "saved" goal for the progress bar — grows as we save more, so the
|
|
112
114
|
// bar always has a meaningful denominator (never sits at 100% forever).
|
|
113
|
-
|
|
115
|
+
// Seeded from the %-derived DEFAULT_SAVED_GOAL (low-tier fire point at the
|
|
116
|
+
// default context window); resetRuntime re-seeds from the live
|
|
117
|
+
// effectiveThreshold when the window is known. Display-only.
|
|
118
|
+
savedGoal = DEFAULT_SAVED_GOAL;
|
|
114
119
|
// Last explain-why line (dedup reason / anchor-kept / superseded), surfaced
|
|
115
120
|
// while fresh.
|
|
116
121
|
lastWhy = undefined;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
function truncate(s, max) {
|
|
2
|
+
return s.length <= max ? s : `${s.slice(0, max)}…`;
|
|
3
|
+
}
|
|
4
|
+
function summarizeBlock(m) {
|
|
5
|
+
if (m.role === "tool")
|
|
6
|
+
return `tool_result ${m.toolName ?? "?"}: ${truncate(m.output ?? m.text, 160)}`;
|
|
7
|
+
if (m.toolName)
|
|
8
|
+
return `tool_use ${m.toolName}(${truncate(m.input ?? "", 160)})`;
|
|
9
|
+
return truncate(m.text, 160);
|
|
10
|
+
}
|
|
11
|
+
function stripTag(block, tag) {
|
|
12
|
+
const start = `<${tag}>`;
|
|
13
|
+
const end = `</${tag}>`;
|
|
14
|
+
const s = block.indexOf(start);
|
|
15
|
+
const e = block.indexOf(end);
|
|
16
|
+
if (s === -1 || e === -1)
|
|
17
|
+
return block;
|
|
18
|
+
return block.slice(0, s) + block.slice(e + end.length);
|
|
19
|
+
}
|
|
20
|
+
function extractTag(block, tag) {
|
|
21
|
+
const s = block.indexOf(`<${tag}>`);
|
|
22
|
+
const e = block.indexOf(`</${tag}>`);
|
|
23
|
+
if (s === -1 || e === -1)
|
|
24
|
+
return undefined;
|
|
25
|
+
return block.slice(s + `<${tag}>`.length, e);
|
|
26
|
+
}
|
|
27
|
+
/** Normalize a raw summary into user-facing "Summary: ..." text. */
|
|
28
|
+
export function formatCompactSummary(summary) {
|
|
29
|
+
const withoutAnalysis = stripTag(summary, "analysis");
|
|
30
|
+
let formatted = withoutAnalysis;
|
|
31
|
+
const content = extractTag(withoutAnalysis, "summary");
|
|
32
|
+
if (content !== undefined) {
|
|
33
|
+
formatted = withoutAnalysis.replace(`<summary>${content}</summary>`, `Summary:\n${content.trim()}`);
|
|
34
|
+
}
|
|
35
|
+
return formatted.replace(/\n{3,}/g, "\n\n").trim();
|
|
36
|
+
}
|
|
37
|
+
/** Extract the prior "highlights" + "timeline" sections from an existing summary. */
|
|
38
|
+
export function extractSummaryHighlights(summary) {
|
|
39
|
+
const lines = formatCompactSummary(summary).split("\n");
|
|
40
|
+
const out = [];
|
|
41
|
+
let inTimeline = false;
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
const t = line.trimEnd();
|
|
44
|
+
if (!t || t === "Summary:" || t === "Conversation summary:")
|
|
45
|
+
continue;
|
|
46
|
+
if (t === "- Key timeline:") {
|
|
47
|
+
inTimeline = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (inTimeline)
|
|
51
|
+
continue;
|
|
52
|
+
out.push(t);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
export function extractSummaryTimeline(summary) {
|
|
57
|
+
const lines = formatCompactSummary(summary).split("\n");
|
|
58
|
+
const out = [];
|
|
59
|
+
let inTimeline = false;
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const t = line.trimEnd();
|
|
62
|
+
if (t === "- Key timeline:") {
|
|
63
|
+
inTimeline = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!inTimeline)
|
|
67
|
+
continue;
|
|
68
|
+
if (!t)
|
|
69
|
+
break;
|
|
70
|
+
out.push(t);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/** Merge an existing compact summary with a new one (accumulate, don't overwrite). */
|
|
75
|
+
export function mergeCompactSummaries(existing, newSummary) {
|
|
76
|
+
if (!existing)
|
|
77
|
+
return newSummary;
|
|
78
|
+
const prevHighlights = extractSummaryHighlights(existing);
|
|
79
|
+
const newHighlights = extractSummaryHighlights(formatCompactSummary(newSummary));
|
|
80
|
+
const newTimeline = extractSummaryTimeline(formatCompactSummary(newSummary));
|
|
81
|
+
const lines = ["<summary>", "Conversation summary:"];
|
|
82
|
+
if (prevHighlights.length) {
|
|
83
|
+
lines.push("- Previously compacted context:");
|
|
84
|
+
prevHighlights.forEach((l) => lines.push(` ${l}`));
|
|
85
|
+
}
|
|
86
|
+
if (newHighlights.length) {
|
|
87
|
+
lines.push("- Newly compacted context:");
|
|
88
|
+
newHighlights.forEach((l) => lines.push(` ${l}`));
|
|
89
|
+
}
|
|
90
|
+
if (newTimeline.length) {
|
|
91
|
+
lines.push("- Key timeline:");
|
|
92
|
+
newTimeline.forEach((l) => lines.push(` ${l}`));
|
|
93
|
+
}
|
|
94
|
+
lines.push("</summary>");
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
// Private helpers re-exported for the shell's summarizeMessages (which stays
|
|
98
|
+
// in compact.ts because it depends on the inference helpers there).
|
|
99
|
+
export { truncate, summarizeBlock };
|
package/dist/src/compact.js
CHANGED
|
@@ -7,14 +7,19 @@
|
|
|
7
7
|
* Pure, pi-agnostic, deterministic, no LLM required.
|
|
8
8
|
*/
|
|
9
9
|
import { estimateSessionTokens } from "./tokens.js";
|
|
10
|
+
// Summary tag/format/merge helpers live in the compact-summary sibling (delegate-
|
|
11
|
+
// shell split, Phase D follow-up) so this file stays under the 300-line soft
|
|
12
|
+
// limit. truncate + summarizeBlock are re-imported here because summarizeMessages
|
|
13
|
+
// (kept below) depends on them alongside the inference helpers that stay here.
|
|
14
|
+
import { truncate, summarizeBlock, formatCompactSummary, } from "./compact-summary.js";
|
|
15
|
+
// Re-export the public summary API so external consumers importing from
|
|
16
|
+
// `../compact.js` are unchanged by the split.
|
|
17
|
+
export { formatCompactSummary, mergeCompactSummaries } from "./compact-summary.js";
|
|
10
18
|
const INTERESTING_EXT = new Set(["rs", "ts", "tsx", "js", "json", "md"]);
|
|
11
19
|
const PENDING_WORDS = ["todo", "next", "pending", "follow up", "remaining"];
|
|
12
20
|
const COMPACT_PREAMBLE = "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n";
|
|
13
21
|
const RECENT_NOTE = "Recent messages are preserved verbatim.";
|
|
14
22
|
const DIRECT_RESUME = "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
|
|
15
|
-
function truncate(s, max) {
|
|
16
|
-
return s.length <= max ? s : `${s.slice(0, max)}…`;
|
|
17
|
-
}
|
|
18
23
|
function firstText(m) {
|
|
19
24
|
// PREVENT crash: pi can hand us a message with text: undefined (pure
|
|
20
25
|
// tool-call/tool-result). Guard the trim so the legacy summarizeMessages
|
|
@@ -99,40 +104,6 @@ export function collectRecentUserRequests(messages, limit) {
|
|
|
99
104
|
.map((t) => truncate(t, 160));
|
|
100
105
|
return reqs.slice(-limit);
|
|
101
106
|
}
|
|
102
|
-
/** Summarize a block to a one-line description. */
|
|
103
|
-
function summarizeBlock(m) {
|
|
104
|
-
if (m.role === "tool")
|
|
105
|
-
return `tool_result ${m.toolName ?? "?"}: ${truncate(m.output ?? m.text, 160)}`;
|
|
106
|
-
if (m.toolName)
|
|
107
|
-
return `tool_use ${m.toolName}(${truncate(m.input ?? "", 160)})`;
|
|
108
|
-
return truncate(m.text, 160);
|
|
109
|
-
}
|
|
110
|
-
function stripTag(block, tag) {
|
|
111
|
-
const start = `<${tag}>`;
|
|
112
|
-
const end = `</${tag}>`;
|
|
113
|
-
const s = block.indexOf(start);
|
|
114
|
-
const e = block.indexOf(end);
|
|
115
|
-
if (s === -1 || e === -1)
|
|
116
|
-
return block;
|
|
117
|
-
return block.slice(0, s) + block.slice(e + end.length);
|
|
118
|
-
}
|
|
119
|
-
function extractTag(block, tag) {
|
|
120
|
-
const s = block.indexOf(`<${tag}>`);
|
|
121
|
-
const e = block.indexOf(`</${tag}>`);
|
|
122
|
-
if (s === -1 || e === -1)
|
|
123
|
-
return undefined;
|
|
124
|
-
return block.slice(s + `<${tag}>`.length, e);
|
|
125
|
-
}
|
|
126
|
-
/** Normalize a raw summary into user-facing "Summary: ..." text. */
|
|
127
|
-
export function formatCompactSummary(summary) {
|
|
128
|
-
const withoutAnalysis = stripTag(summary, "analysis");
|
|
129
|
-
let formatted = withoutAnalysis;
|
|
130
|
-
const content = extractTag(withoutAnalysis, "summary");
|
|
131
|
-
if (content !== undefined) {
|
|
132
|
-
formatted = withoutAnalysis.replace(`<summary>${content}</summary>`, `Summary:\n${content.trim()}`);
|
|
133
|
-
}
|
|
134
|
-
return formatted.replace(/\n{3,}/g, "\n\n").trim();
|
|
135
|
-
}
|
|
136
107
|
/**
|
|
137
108
|
* Build a <summary> block from a slice of messages (the COLLAPSE output).
|
|
138
109
|
* Mirrors claw-code summarize_messages.
|
|
@@ -175,66 +146,6 @@ export function summarizeMessages(messages) {
|
|
|
175
146
|
lines.push("</summary>");
|
|
176
147
|
return lines.join("\n");
|
|
177
148
|
}
|
|
178
|
-
/** Extract the prior "highlights" + "timeline" sections from an existing summary. */
|
|
179
|
-
function extractSummaryHighlights(summary) {
|
|
180
|
-
const lines = formatCompactSummary(summary).split("\n");
|
|
181
|
-
const out = [];
|
|
182
|
-
let inTimeline = false;
|
|
183
|
-
for (const line of lines) {
|
|
184
|
-
const t = line.trimEnd();
|
|
185
|
-
if (!t || t === "Summary:" || t === "Conversation summary:")
|
|
186
|
-
continue;
|
|
187
|
-
if (t === "- Key timeline:") {
|
|
188
|
-
inTimeline = true;
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
191
|
-
if (inTimeline)
|
|
192
|
-
continue;
|
|
193
|
-
out.push(t);
|
|
194
|
-
}
|
|
195
|
-
return out;
|
|
196
|
-
}
|
|
197
|
-
function extractSummaryTimeline(summary) {
|
|
198
|
-
const lines = formatCompactSummary(summary).split("\n");
|
|
199
|
-
const out = [];
|
|
200
|
-
let inTimeline = false;
|
|
201
|
-
for (const line of lines) {
|
|
202
|
-
const t = line.trimEnd();
|
|
203
|
-
if (t === "- Key timeline:") {
|
|
204
|
-
inTimeline = true;
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
if (!inTimeline)
|
|
208
|
-
continue;
|
|
209
|
-
if (!t)
|
|
210
|
-
break;
|
|
211
|
-
out.push(t);
|
|
212
|
-
}
|
|
213
|
-
return out;
|
|
214
|
-
}
|
|
215
|
-
/** Merge an existing compact summary with a new one (accumulate, don't overwrite). */
|
|
216
|
-
export function mergeCompactSummaries(existing, newSummary) {
|
|
217
|
-
if (!existing)
|
|
218
|
-
return newSummary;
|
|
219
|
-
const prevHighlights = extractSummaryHighlights(existing);
|
|
220
|
-
const newHighlights = extractSummaryHighlights(formatCompactSummary(newSummary));
|
|
221
|
-
const newTimeline = extractSummaryTimeline(formatCompactSummary(newSummary));
|
|
222
|
-
const lines = ["<summary>", "Conversation summary:"];
|
|
223
|
-
if (prevHighlights.length) {
|
|
224
|
-
lines.push("- Previously compacted context:");
|
|
225
|
-
prevHighlights.forEach((l) => lines.push(` ${l}`));
|
|
226
|
-
}
|
|
227
|
-
if (newHighlights.length) {
|
|
228
|
-
lines.push("- Newly compacted context:");
|
|
229
|
-
newHighlights.forEach((l) => lines.push(` ${l}`));
|
|
230
|
-
}
|
|
231
|
-
if (newTimeline.length) {
|
|
232
|
-
lines.push("- Key timeline:");
|
|
233
|
-
newTimeline.forEach((l) => lines.push(` ${l}`));
|
|
234
|
-
}
|
|
235
|
-
lines.push("</summary>");
|
|
236
|
-
return lines.join("\n");
|
|
237
|
-
}
|
|
238
149
|
/** True when the compactable portion exceeds the budget. */
|
|
239
150
|
export function shouldCompact(messages, maxEstimatedTokens, preserveRecent) {
|
|
240
151
|
if (messages.length <= preserveRecent)
|
|
@@ -242,8 +153,13 @@ export function shouldCompact(messages, maxEstimatedTokens, preserveRecent) {
|
|
|
242
153
|
const compactable = messages.slice(0, messages.length - preserveRecent);
|
|
243
154
|
return estimateSessionTokens(compactable) >= maxEstimatedTokens;
|
|
244
155
|
}
|
|
245
|
-
/** Local reimplementation of memory-mcp auto_compact_check.
|
|
246
|
-
|
|
156
|
+
/** Local reimplementation of memory-mcp auto_compact_check.
|
|
157
|
+
*
|
|
158
|
+
* `threshold` is REQUIRED (no default) — every caller (gateCheck.ts) passes the
|
|
159
|
+
* resolved `gateThreshold` (effectiveThresholdImpl: `tierPct × window`, or the
|
|
160
|
+
* custom absolute). A bare default here would silently re-introduce a hardcoded
|
|
161
|
+
* magic-number gate that bypasses the percent-based fire point. */
|
|
162
|
+
export function autoCompactCheck(currentTokens, threshold) {
|
|
247
163
|
return {
|
|
248
164
|
shouldCompact: currentTokens >= threshold,
|
|
249
165
|
currentTokens,
|
|
@@ -133,6 +133,14 @@ export interface MegaConfig {
|
|
|
133
133
|
* adding checkpoints once the block would exceed this — bounds read-path
|
|
134
134
|
* token cost so it can never net-inflate the window. */
|
|
135
135
|
recallMaxTokens: number;
|
|
136
|
+
/** Phase H: output-error catch. When a model response is truncated mid-OUTPUT
|
|
137
|
+
* (S28 stopReason==='length' — "Response was truncated before completion"),
|
|
138
|
+
* trip a one-shot forced compaction on the next gate so the model's NEXT
|
|
139
|
+
* response has freed input headroom. This closes the small-context-model
|
|
140
|
+
* deadlock where the model truncates BELOW the 80% INPUT threshold (so the
|
|
141
|
+
* gate never fires → "compact never" → every subsequent response truncates
|
|
142
|
+
* too). Default ON; OFF (=0/`=false`) = byte-identical pre-H. */
|
|
143
|
+
outputErrorCompact: boolean;
|
|
136
144
|
/** Inline-dedupe recalled checkpoints against the live window (Fix C): drop
|
|
137
145
|
* a hit whose summary is ≥ dedupSim similar to a live message — "dedupe on
|
|
138
146
|
* inline/read" so we never re-inject context already resident. */
|
|
@@ -13,33 +13,49 @@ import { execSync } from "node:child_process"; // guardrails-allow PREVENT-PI-00
|
|
|
13
13
|
import type { MegaConfig } from "./mega-config-types.js";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Named compaction tiers
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Named compaction tiers — each tier sets the compaction fire point as a
|
|
17
|
+
* FRACTION of the model's context window (NOT a static token amount): the live
|
|
18
|
+
* + durable trim fire at `tier × window`, so they always fire BELOW pi's native
|
|
19
|
+
* auto-compaction (~80% of window) for any model size (200k or 1M). Pick by how
|
|
20
|
+
* aggressively you want the session trimmed. Explicit
|
|
21
|
+
* MEGACOMPACT_THRESHOLD_TOKENS always wins (`custom`, tierPct null — absolute,
|
|
22
|
+
* never percent-scaled). This is the SINGLE source of truth for tier fractions;
|
|
23
|
+
* `keyof typeof COMPACT_TIERS` is the `CompactTier` type and
|
|
24
|
+
* `raw in COMPACT_TIERS` validates a named preset name.
|
|
19
25
|
*/
|
|
20
26
|
export const COMPACT_TIERS = {
|
|
21
|
-
low: 50_000,
|
|
22
|
-
medium: 100_000,
|
|
23
|
-
high: 200_000,
|
|
24
|
-
ultra: 1_000_000,
|
|
25
|
-
mega: 10_000_000,
|
|
26
|
-
} as const;
|
|
27
|
-
export type CompactTier = keyof typeof COMPACT_TIERS;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Compaction thresholds as a FRACTION of the model's context window (NOT a
|
|
31
|
-
* static token amount). The live + durable trim fire at tier% of the window,
|
|
32
|
-
* so they always fire BELOW pi's native auto-compaction (~80% of window) for
|
|
33
|
-
* any model size (200k or 1M). `custom` (explicit MEGACOMPACT_THRESHOLD_TOKENS)
|
|
34
|
-
* is NOT scaled by this map — it stays an absolute token count.
|
|
35
|
-
*/
|
|
36
|
-
export const TIER_PCT: Record<CompactTier, number> = {
|
|
37
27
|
low: 0.5,
|
|
38
28
|
medium: 0.6,
|
|
39
29
|
high: 0.7,
|
|
40
30
|
ultra: 0.7,
|
|
41
31
|
mega: 0.75,
|
|
42
|
-
};
|
|
32
|
+
} as const;
|
|
33
|
+
export type CompactTier = keyof typeof COMPACT_TIERS;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Boot-fallback context window (env-overridable). Used ONLY as a display/seed
|
|
37
|
+
* placeholder before the first real context event supplies the provider's
|
|
38
|
+
* window — the live fire point (`effectiveThresholdImpl`) DEFERS (returns
|
|
39
|
+
* `+Infinity`) when the window is unknown, so this value is never a guessed
|
|
40
|
+
* gate. Default 200k keeps the display seed stable; set
|
|
41
|
+
* `MEGACOMPACT_DEFAULT_CONTEXT_WINDOW=<tokens>` to repoint it (e.g. for a
|
|
42
|
+
* 32k-model fleet the seed should reflect that).
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_CONTEXT_WINDOW = envFlag(
|
|
45
|
+
"MEGACOMPACT_DEFAULT_CONTEXT_WINDOW",
|
|
46
|
+
200_000,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Display seed for the progress-bar "saved tokens goal" denominator. The live
|
|
51
|
+
* value grows dynamically (`run.ts`: `savedGoal = ceil(tokensSaved × 1.25)` once
|
|
52
|
+
* exceeded), so this is only the initial target before the first compaction.
|
|
53
|
+
* Derived from the low-tier fire point at the default window (no bare magic):
|
|
54
|
+
* `COMPACT_TIERS.low × DEFAULT_CONTEXT_WINDOW`. Display-only.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_SAVED_GOAL = Math.round(
|
|
57
|
+
COMPACT_TIERS.low * DEFAULT_CONTEXT_WINDOW,
|
|
58
|
+
);
|
|
43
59
|
|
|
44
60
|
// MegaConfig type moved to mega-config-types.ts (delegate-shell split, PC-A)
|
|
45
61
|
// so this runtime config barrel stays under the 400-line soft limit.
|
|
@@ -60,8 +76,8 @@ function envBool(name: string, fallback: boolean): boolean {
|
|
|
60
76
|
* Resolve the effective token threshold from TIER (or explicit) env vars.
|
|
61
77
|
*
|
|
62
78
|
* For a named tier the returned `thresholdTokens` is a BOOT FALLBACK
|
|
63
|
-
* (`round(tierPct *
|
|
64
|
-
* runtime. The true fire point is computed per-window at runtime via
|
|
79
|
+
* (`round(tierPct * DEFAULT_CONTEXT_WINDOW)`) — sane before any context event
|
|
80
|
+
* reaches the runtime. The true fire point is computed per-window at runtime via
|
|
65
81
|
* `effectiveThresholdTokens(...)`. `custom` (explicit MEGACOMPACT_THRESHOLD_TOKENS)
|
|
66
82
|
* keeps `tierPct: null` and an ABSOLUTE `thresholdTokens` (never percent-scaled).
|
|
67
83
|
*/
|
|
@@ -78,7 +94,7 @@ function resolveThreshold(): {
|
|
|
78
94
|
}
|
|
79
95
|
const raw = (process.env.MEGACOMPACT_TIER ?? "low").toLowerCase();
|
|
80
96
|
const tier = (raw in COMPACT_TIERS ? raw : "low") as CompactTier;
|
|
81
|
-
let tierPct =
|
|
97
|
+
let tierPct: number = COMPACT_TIERS[tier];
|
|
82
98
|
// 3WF-2 threshold invariant: under the umbrella, when no named tier is set
|
|
83
99
|
// the fire point is the configurable % of the ACTUAL model window (default
|
|
84
100
|
// 0.80 — "20% free remaining"). Tiered (named preset) keeps its preset pct;
|
|
@@ -92,8 +108,9 @@ function resolveThreshold(): {
|
|
|
92
108
|
// Boot fallback: sane gate before the first context event provides a window.
|
|
93
109
|
// (NO hardcoded window in the firing path — effectiveThresholdImpl defers
|
|
94
110
|
// when window unknown; this remains only a display placeholder + custom
|
|
95
|
-
// companion under the umbrella.)
|
|
96
|
-
|
|
111
|
+
// companion under the umbrella.) Env-overridable via
|
|
112
|
+
// MEGACOMPACT_DEFAULT_CONTEXT_WINDOW (see DEFAULT_CONTEXT_WINDOW).
|
|
113
|
+
const thresholdTokens = Math.round(tierPct * DEFAULT_CONTEXT_WINDOW);
|
|
97
114
|
return { tier, tierPct, thresholdTokens };
|
|
98
115
|
}
|
|
99
116
|
|
|
@@ -133,15 +150,19 @@ export function effectiveThresholdTokens(opts: {
|
|
|
133
150
|
* Resolve the optional manual arming-floor override (MEGACOMPACT_FAST_GATE_PCT).
|
|
134
151
|
* Kept for backward-compat: when unset, the default arming floor equals the
|
|
135
152
|
* tier's percent threshold (tierPct*100) so the dashboard stays consistent;
|
|
136
|
-
* `custom` (tierPct null
|
|
153
|
+
* `custom` (tierPct null — an explicit absolute opt-out of percent scaling by
|
|
154
|
+
* design) has no tier fraction to derive from, so it falls back to the named
|
|
155
|
+
* DEFAULT_FAST_GATE_PCT_CUSTOM. Env-overridable like every other default here.
|
|
137
156
|
*/
|
|
157
|
+
export const DEFAULT_FAST_GATE_PCT_CUSTOM = 70;
|
|
158
|
+
|
|
138
159
|
function resolveFastGatePct(tierPct: number | null): number {
|
|
139
160
|
const raw = process.env.MEGACOMPACT_FAST_GATE_PCT;
|
|
140
161
|
if (raw != null && raw !== "") {
|
|
141
162
|
const n = Number(raw);
|
|
142
163
|
if (Number.isFinite(n)) return n;
|
|
143
164
|
}
|
|
144
|
-
return tierPct != null ? Math.round(tierPct * 100) :
|
|
165
|
+
return tierPct != null ? Math.round(tierPct * 100) : DEFAULT_FAST_GATE_PCT_CUSTOM;
|
|
145
166
|
}
|
|
146
167
|
|
|
147
168
|
/**
|
|
@@ -229,6 +250,9 @@ export function loadConfig(): MegaConfig {
|
|
|
229
250
|
memoryAutoReview: envBool("MEGACOMPACT_MEMORY_AUTO_REVIEW", true),
|
|
230
251
|
memoryReviewInterval: envFlag("MEGACOMPACT_MEMORY_REVIEW_INTERVAL", 10),
|
|
231
252
|
recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
|
|
253
|
+
// Phase H: output-error catch — trip compaction on a truncated model output
|
|
254
|
+
// (S28 stopReason==='length'). Default ON; OFF byte-identical pre-H.
|
|
255
|
+
outputErrorCompact: envBool("MEGACOMPACT_OUTPUT_ERROR_COMPACT", true),
|
|
232
256
|
windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
|
|
233
257
|
recallTailInject: envBool("MEGACOMPACT_RECALL_TAIL_INJECT", true),
|
|
234
258
|
// 3WF-1: TriggerGuard — guarantee a staged recall block on every context
|
|
@@ -24,6 +24,13 @@ export function lengthStop(
|
|
|
24
24
|
event.message.stopReason === "length"
|
|
25
25
|
) {
|
|
26
26
|
runtime.rt.lengthStopPending = true;
|
|
27
|
+
// Phase H: output-error catch — ALSO arm the one-shot force-compact flag so
|
|
28
|
+
// the next compaction gate trips immediately, freeing input headroom for
|
|
29
|
+
// the model's next response. Gated by config.outputErrorCompact (default
|
|
30
|
+
// ON; OFF = byte-identical pre-H — only the S28 auto-continue nudge fires).
|
|
31
|
+
if (config.outputErrorCompact) {
|
|
32
|
+
runtime.rt.forceCompactNextGate = true;
|
|
33
|
+
}
|
|
27
34
|
runtime.dashboard.event("length_stop", { turnIndex: event.turnIndex });
|
|
28
35
|
}
|
|
29
36
|
}
|
|
@@ -95,6 +95,24 @@ export function evaluateGate(
|
|
|
95
95
|
stateDir: runtime.currentStateDir,
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
// Phase H: output-error catch. A truncated model output (S28
|
|
99
|
+
// stopReason==='length' — "Response was truncated before completion") trips
|
|
100
|
+
// a one-shot force-compact so the next compaction runs IMMEDIATELY, freeing
|
|
101
|
+
// input headroom for the model's next response. This closes the
|
|
102
|
+
// small-context-model deadlock: the model truncates MID-OUTPUT below the
|
|
103
|
+
// 80% INPUT threshold (so the gate never fires) → "compact never" → every
|
|
104
|
+
// subsequent response also truncates. lengthStop.ts sets this flag
|
|
105
|
+
// alongside its auto-continue nudge; gated by config.outputErrorCompact
|
|
106
|
+
// (default ON; OFF = byte-identical pre-H). One-shot: cleared on consumption.
|
|
107
|
+
// thrashGuardBlocks is consulted separately by the handler; in the reported
|
|
108
|
+
// "compact never" case compactCount===0 so the guard is never armed and will
|
|
109
|
+
// not block this trip.
|
|
110
|
+
if (config.outputErrorCompact && runtime.rt?.forceCompactNextGate) {
|
|
111
|
+
runtime.rt.forceCompactNextGate = false;
|
|
112
|
+
runtime.diagCtxOutputErrorTrip++;
|
|
113
|
+
return { kind: "proceed", perModelThreshold };
|
|
114
|
+
}
|
|
115
|
+
|
|
98
116
|
// S29 FAST GATE: `custom` (absolute MEGACOMPACT_THRESHOLD_TOKENS,
|
|
99
117
|
// tierPct null) is an explicit opt-out of percent scaling — it keeps the
|
|
100
118
|
// token gate. When pct is unavailable (window unknown / a model that
|
|
@@ -130,6 +130,26 @@ export function registerContextHandler(
|
|
|
130
130
|
// message is captured, even if we don't compact this turn. Non-fatal.
|
|
131
131
|
appendMirrorAndLedger(runtime, config, messages);
|
|
132
132
|
|
|
133
|
+
// Phase H.1: output-error catch noop guard. When forceCompactNextGate is
|
|
134
|
+
// armed (Phase H: model truncated its output → lengthStop.ts set the flag
|
|
135
|
+
// at turn_end), check piCompactWouldNoop BEFORE evaluateGate consumes the
|
|
136
|
+
// flag. If pi would noop (session too small to summarize — the transcript
|
|
137
|
+
// is under pi's keepRecentTokens floor), clear the flag + return the tail
|
|
138
|
+
// result silently instead of forcing a proceed that will throw "Nothing
|
|
139
|
+
// to compact (session too small)" to the user. The session is genuinely
|
|
140
|
+
// too small to compact — skipping is correct (the model retries on the
|
|
141
|
+
// next turn regardless). Gated by config.outputErrorCompact (default ON;
|
|
142
|
+
// OFF = byte-identical pre-H.1).
|
|
143
|
+
if (
|
|
144
|
+
config.outputErrorCompact &&
|
|
145
|
+
runtime.rt.forceCompactNextGate &&
|
|
146
|
+
piCompactWouldNoop(ctx)
|
|
147
|
+
) {
|
|
148
|
+
runtime.rt.forceCompactNextGate = false;
|
|
149
|
+
runtime.diagCtxOutputErrorTrip++;
|
|
150
|
+
return tailResult() ?? undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
133
153
|
// S29 FAST GATE: drive the auto-trigger off the context percent (see
|
|
134
154
|
// gateCheck.ts). Returns a tailed view when the gate does not pass.
|
|
135
155
|
const gate = evaluateGate(runtime, config, { pct, currentTokens, tailResult });
|
|
@@ -51,6 +51,10 @@ export interface SessionRuntime {
|
|
|
51
51
|
recallInjections: number; // recall blocks injected this session-instance
|
|
52
52
|
cacheHitTokens: number; // tokens saved via cache hits (dedup + recall) this session
|
|
53
53
|
lengthStopPending: boolean; // S28: set on turn_end when stopReason==='length'
|
|
54
|
+
/** Phase H: one-shot flag set alongside lengthStopPending (S28) so the next
|
|
55
|
+
* compaction gate trips immediately regardless of input-pressure. Cleared on
|
|
56
|
+
* consumption by evaluateGate (gateCheck.ts). See config.outputErrorCompact. */
|
|
57
|
+
forceCompactNextGate: boolean;
|
|
54
58
|
errorRetryCount: number; // S38: consecutive error turns, reset on success/turn_start
|
|
55
59
|
errorRetryUntil: number; // S38: wall-clock ms before which the next nudge is suppressed (R1: now gating)
|
|
56
60
|
// S38.6: circuit-breaker state — consecutive error turns across the session.
|
|
@@ -141,6 +145,7 @@ export function createSessionRuntime(): SessionRuntime {
|
|
|
141
145
|
recallInjections: 0,
|
|
142
146
|
cacheHitTokens: 0,
|
|
143
147
|
lengthStopPending: false,
|
|
148
|
+
forceCompactNextGate: false,
|
|
144
149
|
errorRetryCount: 0,
|
|
145
150
|
errorRetryUntil: 0,
|
|
146
151
|
consecutiveErrors: 0,
|
|
@@ -81,18 +81,18 @@ export function pressureImpl(self: PressureContext): number {
|
|
|
81
81
|
* always below pi's native auto-compaction (~80% of window).
|
|
82
82
|
*/
|
|
83
83
|
export function effectiveThresholdImpl(self: PressureContext): number {
|
|
84
|
-
// 3WF-2 threshold invariant
|
|
85
|
-
// UNKNOWN window (lastCtxWindow <= 0) DEFERS —
|
|
86
|
-
// substitute a guessed window
|
|
87
|
-
// `tokens >= threshold`
|
|
88
|
-
// agent_end durable trigger, live-trim
|
|
89
|
-
// until the provider reports a real
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
) {
|
|
84
|
+
// 3WF-2 threshold invariant (LTS-correctness fix, Phase C): a tiered config
|
|
85
|
+
// (tierPct != null) with an UNKNOWN window (lastCtxWindow <= 0) DEFERS —
|
|
86
|
+
// auto-compaction must never substitute a guessed window, under ANY umbrella
|
|
87
|
+
// state. Returning +Infinity keeps every downstream `tokens >= threshold`
|
|
88
|
+
// comparison false (gateCheck token path, agent_end durable trigger, live-trim
|
|
89
|
+
// re-compact), so no compaction fires until the provider reports a real
|
|
90
|
+
// window. This closes the small-context-model deadlock: umbrella-OFF
|
|
91
|
+
// previously fell through to the legacy 200k helper (round(tierPct × 200k)),
|
|
92
|
+
// which is unreachable on a 32k window → the model truncates before the gate
|
|
93
|
+
// ever fires → "compact never". custom (tierPct null) always falls through
|
|
94
|
+
// to the explicit absolute.
|
|
95
|
+
if (self.config.tierPct != null && self.lastCtxWindow <= 0) {
|
|
96
96
|
return Number.POSITIVE_INFINITY;
|
|
97
97
|
}
|
|
98
98
|
return effectiveThresholdTokens({
|
|
@@ -13,6 +13,7 @@ import { normalizeSessionId } from "../../src/store.js";
|
|
|
13
13
|
import type { TickerEntry } from "./widget.js";
|
|
14
14
|
import type { GameState } from "../../src/store/sqlite.js";
|
|
15
15
|
import type { SessionRuntime } from "./helpers.js";
|
|
16
|
+
import { DEFAULT_SAVED_GOAL } from "../mega-config.js";
|
|
16
17
|
|
|
17
18
|
// ---------------------------------------------------------------------- types
|
|
18
19
|
|
|
@@ -67,6 +68,7 @@ export function resetRuntimeImpl(
|
|
|
67
68
|
recallInjections: 0,
|
|
68
69
|
cacheHitTokens: 0,
|
|
69
70
|
lengthStopPending: false,
|
|
71
|
+
forceCompactNextGate: false,
|
|
70
72
|
errorRetryCount: 0,
|
|
71
73
|
errorRetryUntil: 0,
|
|
72
74
|
consecutiveErrors: 0,
|
|
@@ -90,7 +92,7 @@ export function resetRuntimeImpl(
|
|
|
90
92
|
self.tierTrace = undefined;
|
|
91
93
|
self.ticker.length = 0;
|
|
92
94
|
self.pulsing = false;
|
|
93
|
-
self.savedGoal =
|
|
95
|
+
self.savedGoal = DEFAULT_SAVED_GOAL;
|
|
94
96
|
self.lastWhy = undefined;
|
|
95
97
|
// S31 audit P2: symmetry with bindRepo — a reset can coincide with a context
|
|
96
98
|
// that re-binds the repo, so drop the memo too. Cheap; the next
|
|
@@ -13,6 +13,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
13
13
|
import type { ModelSnapshot, GameState } from "../../src/store/sqlite.js";
|
|
14
14
|
import type { TickerEntry, WidgetData } from "./widget.js";
|
|
15
15
|
import type { FSWatcher } from "node:fs";
|
|
16
|
+
import { DEFAULT_SAVED_GOAL } from "../mega-config.js";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* DIAG counters for the "team run doesn't relieve context" investigation.
|
|
@@ -37,6 +38,7 @@ export class RuntimeInstrumentation {
|
|
|
37
38
|
diagCtxRunSkipped = 0; // runCompact() returned skipped
|
|
38
39
|
diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
|
|
39
40
|
diagCtxThrown = 0; // live-trim try threw (caught)
|
|
41
|
+
diagCtxOutputErrorTrip = 0; // Phase H: output-error catch tripped a forced compaction
|
|
40
42
|
|
|
41
43
|
// Context health instrumentation (v0.12): rolling ring buffers for
|
|
42
44
|
// drift detection + cache poison Layer 1 hash baseline.
|
|
@@ -137,7 +139,10 @@ export class RuntimeInstrumentation {
|
|
|
137
139
|
memoriesTouchedThisCompaction = 0;
|
|
138
140
|
// Rolling "saved" goal for the progress bar — grows as we save more, so the
|
|
139
141
|
// bar always has a meaningful denominator (never sits at 100% forever).
|
|
140
|
-
|
|
142
|
+
// Seeded from the %-derived DEFAULT_SAVED_GOAL (low-tier fire point at the
|
|
143
|
+
// default context window); resetRuntime re-seeds from the live
|
|
144
|
+
// effectiveThreshold when the window is known. Display-only.
|
|
145
|
+
savedGoal = DEFAULT_SAVED_GOAL;
|
|
141
146
|
// Last explain-why line (dedup reason / anchor-kept / superseded), surfaced
|
|
142
147
|
// while fresh.
|
|
143
148
|
lastWhy: string | undefined = undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.6",
|
|
4
4
|
"description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact-summary.ts — summary tag helpers, formatting, and merging (Layer 2).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from compact.ts (delegate-shell split, Phase D follow-up) so
|
|
5
|
+
* compact.ts stays under the 300-line soft limit without squeezing. These
|
|
6
|
+
* functions form a cohesive unit: they all operate on the `<summary>…</summary>`
|
|
7
|
+
* tag block produced by the COLLAPSE output and consumed by the continuation
|
|
8
|
+
* message builder. Kept pi-agnostic (only `EngineMessage` type imported).
|
|
9
|
+
*/
|
|
10
|
+
import type { EngineMessage } from "./types.js";
|
|
11
|
+
|
|
12
|
+
function truncate(s: string, max: number): string {
|
|
13
|
+
return s.length <= max ? s : `${s.slice(0, max)}…`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function summarizeBlock(m: EngineMessage): string {
|
|
17
|
+
if (m.role === "tool")
|
|
18
|
+
return `tool_result ${m.toolName ?? "?"}: ${truncate(m.output ?? m.text, 160)}`;
|
|
19
|
+
if (m.toolName)
|
|
20
|
+
return `tool_use ${m.toolName}(${truncate(m.input ?? "", 160)})`;
|
|
21
|
+
return truncate(m.text, 160);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stripTag(block: string, tag: string): string {
|
|
25
|
+
const start = `<${tag}>`;
|
|
26
|
+
const end = `</${tag}>`;
|
|
27
|
+
const s = block.indexOf(start);
|
|
28
|
+
const e = block.indexOf(end);
|
|
29
|
+
if (s === -1 || e === -1) return block;
|
|
30
|
+
return block.slice(0, s) + block.slice(e + end.length);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function extractTag(block: string, tag: string): string | undefined {
|
|
34
|
+
const s = block.indexOf(`<${tag}>`);
|
|
35
|
+
const e = block.indexOf(`</${tag}>`);
|
|
36
|
+
if (s === -1 || e === -1) return undefined;
|
|
37
|
+
return block.slice(s + `<${tag}>`.length, e);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Normalize a raw summary into user-facing "Summary: ..." text. */
|
|
41
|
+
export function formatCompactSummary(summary: string): string {
|
|
42
|
+
const withoutAnalysis = stripTag(summary, "analysis");
|
|
43
|
+
let formatted = withoutAnalysis;
|
|
44
|
+
const content = extractTag(withoutAnalysis, "summary");
|
|
45
|
+
if (content !== undefined) {
|
|
46
|
+
formatted = withoutAnalysis.replace(
|
|
47
|
+
`<summary>${content}</summary>`,
|
|
48
|
+
`Summary:\n${content.trim()}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return formatted.replace(/\n{3,}/g, "\n\n").trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Extract the prior "highlights" + "timeline" sections from an existing summary. */
|
|
55
|
+
export function extractSummaryHighlights(summary: string): string[] {
|
|
56
|
+
const lines = formatCompactSummary(summary).split("\n");
|
|
57
|
+
const out: string[] = [];
|
|
58
|
+
let inTimeline = false;
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
const t = line.trimEnd();
|
|
61
|
+
if (!t || t === "Summary:" || t === "Conversation summary:") continue;
|
|
62
|
+
if (t === "- Key timeline:") {
|
|
63
|
+
inTimeline = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (inTimeline) continue;
|
|
67
|
+
out.push(t);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function extractSummaryTimeline(summary: string): string[] {
|
|
73
|
+
const lines = formatCompactSummary(summary).split("\n");
|
|
74
|
+
const out: string[] = [];
|
|
75
|
+
let inTimeline = false;
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
const t = line.trimEnd();
|
|
78
|
+
if (t === "- Key timeline:") {
|
|
79
|
+
inTimeline = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!inTimeline) continue;
|
|
83
|
+
if (!t) break;
|
|
84
|
+
out.push(t);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Merge an existing compact summary with a new one (accumulate, don't overwrite). */
|
|
90
|
+
export function mergeCompactSummaries(
|
|
91
|
+
existing: string | undefined,
|
|
92
|
+
newSummary: string,
|
|
93
|
+
): string {
|
|
94
|
+
if (!existing) return newSummary;
|
|
95
|
+
const prevHighlights = extractSummaryHighlights(existing);
|
|
96
|
+
const newHighlights = extractSummaryHighlights(
|
|
97
|
+
formatCompactSummary(newSummary),
|
|
98
|
+
);
|
|
99
|
+
const newTimeline = extractSummaryTimeline(formatCompactSummary(newSummary));
|
|
100
|
+
|
|
101
|
+
const lines = ["<summary>", "Conversation summary:"];
|
|
102
|
+
if (prevHighlights.length) {
|
|
103
|
+
lines.push("- Previously compacted context:");
|
|
104
|
+
prevHighlights.forEach((l) => lines.push(` ${l}`));
|
|
105
|
+
}
|
|
106
|
+
if (newHighlights.length) {
|
|
107
|
+
lines.push("- Newly compacted context:");
|
|
108
|
+
newHighlights.forEach((l) => lines.push(` ${l}`));
|
|
109
|
+
}
|
|
110
|
+
if (newTimeline.length) {
|
|
111
|
+
lines.push("- Key timeline:");
|
|
112
|
+
newTimeline.forEach((l) => lines.push(` ${l}`));
|
|
113
|
+
}
|
|
114
|
+
lines.push("</summary>");
|
|
115
|
+
return lines.join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Private helpers re-exported for the shell's summarizeMessages (which stays
|
|
119
|
+
// in compact.ts because it depends on the inference helpers there).
|
|
120
|
+
export { truncate, summarizeBlock };
|
package/src/compact.ts
CHANGED
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
|
|
10
10
|
import type { EngineMessage } from "./types.js";
|
|
11
11
|
import { estimateSessionTokens } from "./tokens.js";
|
|
12
|
+
// Summary tag/format/merge helpers live in the compact-summary sibling (delegate-
|
|
13
|
+
// shell split, Phase D follow-up) so this file stays under the 300-line soft
|
|
14
|
+
// limit. truncate + summarizeBlock are re-imported here because summarizeMessages
|
|
15
|
+
// (kept below) depends on them alongside the inference helpers that stay here.
|
|
16
|
+
import {
|
|
17
|
+
truncate,
|
|
18
|
+
summarizeBlock,
|
|
19
|
+
formatCompactSummary,
|
|
20
|
+
} from "./compact-summary.js";
|
|
21
|
+
// Re-export the public summary API so external consumers importing from
|
|
22
|
+
// `../compact.js` are unchanged by the split.
|
|
23
|
+
export { formatCompactSummary, mergeCompactSummaries } from "./compact-summary.js";
|
|
12
24
|
|
|
13
25
|
const INTERESTING_EXT = new Set(["rs", "ts", "tsx", "js", "json", "md"]);
|
|
14
26
|
const PENDING_WORDS = ["todo", "next", "pending", "follow up", "remaining"];
|
|
@@ -19,10 +31,6 @@ const RECENT_NOTE = "Recent messages are preserved verbatim.";
|
|
|
19
31
|
const DIRECT_RESUME =
|
|
20
32
|
"Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
|
|
21
33
|
|
|
22
|
-
function truncate(s: string, max: number): string {
|
|
23
|
-
return s.length <= max ? s : `${s.slice(0, max)}…`;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
34
|
function firstText(m: EngineMessage): string | undefined {
|
|
27
35
|
// PREVENT crash: pi can hand us a message with text: undefined (pure
|
|
28
36
|
// tool-call/tool-result). Guard the trim so the legacy summarizeMessages
|
|
@@ -113,45 +121,6 @@ export function collectRecentUserRequests(
|
|
|
113
121
|
return reqs.slice(-limit);
|
|
114
122
|
}
|
|
115
123
|
|
|
116
|
-
/** Summarize a block to a one-line description. */
|
|
117
|
-
function summarizeBlock(m: EngineMessage): string {
|
|
118
|
-
if (m.role === "tool")
|
|
119
|
-
return `tool_result ${m.toolName ?? "?"}: ${truncate(m.output ?? m.text, 160)}`;
|
|
120
|
-
if (m.toolName)
|
|
121
|
-
return `tool_use ${m.toolName}(${truncate(m.input ?? "", 160)})`;
|
|
122
|
-
return truncate(m.text, 160);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function stripTag(block: string, tag: string): string {
|
|
126
|
-
const start = `<${tag}>`;
|
|
127
|
-
const end = `</${tag}>`;
|
|
128
|
-
const s = block.indexOf(start);
|
|
129
|
-
const e = block.indexOf(end);
|
|
130
|
-
if (s === -1 || e === -1) return block;
|
|
131
|
-
return block.slice(0, s) + block.slice(e + end.length);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function extractTag(block: string, tag: string): string | undefined {
|
|
135
|
-
const s = block.indexOf(`<${tag}>`);
|
|
136
|
-
const e = block.indexOf(`</${tag}>`);
|
|
137
|
-
if (s === -1 || e === -1) return undefined;
|
|
138
|
-
return block.slice(s + `<${tag}>`.length, e);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Normalize a raw summary into user-facing "Summary: ..." text. */
|
|
142
|
-
export function formatCompactSummary(summary: string): string {
|
|
143
|
-
const withoutAnalysis = stripTag(summary, "analysis");
|
|
144
|
-
let formatted = withoutAnalysis;
|
|
145
|
-
const content = extractTag(withoutAnalysis, "summary");
|
|
146
|
-
if (content !== undefined) {
|
|
147
|
-
formatted = withoutAnalysis.replace(
|
|
148
|
-
`<summary>${content}</summary>`,
|
|
149
|
-
`Summary:\n${content.trim()}`,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
return formatted.replace(/\n{3,}/g, "\n\n").trim();
|
|
153
|
-
}
|
|
154
|
-
|
|
155
124
|
/**
|
|
156
125
|
* Build a <summary> block from a slice of messages (the COLLAPSE output).
|
|
157
126
|
* Mirrors claw-code summarize_messages.
|
|
@@ -200,70 +169,6 @@ export function summarizeMessages(messages: EngineMessage[]): string {
|
|
|
200
169
|
return lines.join("\n");
|
|
201
170
|
}
|
|
202
171
|
|
|
203
|
-
/** Extract the prior "highlights" + "timeline" sections from an existing summary. */
|
|
204
|
-
function extractSummaryHighlights(summary: string): string[] {
|
|
205
|
-
const lines = formatCompactSummary(summary).split("\n");
|
|
206
|
-
const out: string[] = [];
|
|
207
|
-
let inTimeline = false;
|
|
208
|
-
for (const line of lines) {
|
|
209
|
-
const t = line.trimEnd();
|
|
210
|
-
if (!t || t === "Summary:" || t === "Conversation summary:") continue;
|
|
211
|
-
if (t === "- Key timeline:") {
|
|
212
|
-
inTimeline = true;
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
if (inTimeline) continue;
|
|
216
|
-
out.push(t);
|
|
217
|
-
}
|
|
218
|
-
return out;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function extractSummaryTimeline(summary: string): string[] {
|
|
222
|
-
const lines = formatCompactSummary(summary).split("\n");
|
|
223
|
-
const out: string[] = [];
|
|
224
|
-
let inTimeline = false;
|
|
225
|
-
for (const line of lines) {
|
|
226
|
-
const t = line.trimEnd();
|
|
227
|
-
if (t === "- Key timeline:") {
|
|
228
|
-
inTimeline = true;
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
if (!inTimeline) continue;
|
|
232
|
-
if (!t) break;
|
|
233
|
-
out.push(t);
|
|
234
|
-
}
|
|
235
|
-
return out;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Merge an existing compact summary with a new one (accumulate, don't overwrite). */
|
|
239
|
-
export function mergeCompactSummaries(
|
|
240
|
-
existing: string | undefined,
|
|
241
|
-
newSummary: string,
|
|
242
|
-
): string {
|
|
243
|
-
if (!existing) return newSummary;
|
|
244
|
-
const prevHighlights = extractSummaryHighlights(existing);
|
|
245
|
-
const newHighlights = extractSummaryHighlights(
|
|
246
|
-
formatCompactSummary(newSummary),
|
|
247
|
-
);
|
|
248
|
-
const newTimeline = extractSummaryTimeline(formatCompactSummary(newSummary));
|
|
249
|
-
|
|
250
|
-
const lines = ["<summary>", "Conversation summary:"];
|
|
251
|
-
if (prevHighlights.length) {
|
|
252
|
-
lines.push("- Previously compacted context:");
|
|
253
|
-
prevHighlights.forEach((l) => lines.push(` ${l}`));
|
|
254
|
-
}
|
|
255
|
-
if (newHighlights.length) {
|
|
256
|
-
lines.push("- Newly compacted context:");
|
|
257
|
-
newHighlights.forEach((l) => lines.push(` ${l}`));
|
|
258
|
-
}
|
|
259
|
-
if (newTimeline.length) {
|
|
260
|
-
lines.push("- Key timeline:");
|
|
261
|
-
newTimeline.forEach((l) => lines.push(` ${l}`));
|
|
262
|
-
}
|
|
263
|
-
lines.push("</summary>");
|
|
264
|
-
return lines.join("\n");
|
|
265
|
-
}
|
|
266
|
-
|
|
267
172
|
/** True when the compactable portion exceeds the budget. */
|
|
268
173
|
export function shouldCompact(
|
|
269
174
|
messages: EngineMessage[],
|
|
@@ -275,10 +180,15 @@ export function shouldCompact(
|
|
|
275
180
|
return estimateSessionTokens(compactable) >= maxEstimatedTokens;
|
|
276
181
|
}
|
|
277
182
|
|
|
278
|
-
/** Local reimplementation of memory-mcp auto_compact_check.
|
|
183
|
+
/** Local reimplementation of memory-mcp auto_compact_check.
|
|
184
|
+
*
|
|
185
|
+
* `threshold` is REQUIRED (no default) — every caller (gateCheck.ts) passes the
|
|
186
|
+
* resolved `gateThreshold` (effectiveThresholdImpl: `tierPct × window`, or the
|
|
187
|
+
* custom absolute). A bare default here would silently re-introduce a hardcoded
|
|
188
|
+
* magic-number gate that bypasses the percent-based fire point. */
|
|
279
189
|
export function autoCompactCheck(
|
|
280
190
|
currentTokens: number,
|
|
281
|
-
threshold
|
|
191
|
+
threshold: number,
|
|
282
192
|
): {
|
|
283
193
|
shouldCompact: boolean;
|
|
284
194
|
currentTokens: number;
|