pi-mega-compact 0.21.4 → 0.21.5

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 CHANGED
@@ -1,5 +1,7 @@
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
+
3
5
  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
6
 
5
7
  ## 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. A tier sets the token threshold at which the
14
- * auto-trigger persists a checkpoint; pick by how aggressively you want the
15
- * session trimmed. Explicit MEGACOMPACT_THRESHOLD_TOKENS always wins.
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 * 200_000)`) — sane before any context event reaches the
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 = TIER_PCT[tier];
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
- const thresholdTokens = Math.round(tierPct * 200_000);
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) falls back to the legacy 70% default.
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) : 70;
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
@@ -67,6 +67,7 @@ export function createSessionRuntime() {
67
67
  recallInjections: 0,
68
68
  cacheHitTokens: 0,
69
69
  lengthStopPending: false,
70
+ forceCompactNextGate: false,
70
71
  errorRetryCount: 0,
71
72
  errorRetryUntil: 0,
72
73
  consecutiveErrors: 0,
@@ -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: under the umbrella, a tiered config with an
55
- // UNKNOWN window (lastCtxWindow <= 0) DEFERS — auto-compaction must never
56
- // substitute a guessed window. Returning +Infinity keeps every downstream
57
- // `tokens >= threshold` comparison false (gateCheck token path,
58
- // agent_end durable trigger, live-trim re-compact), so no compaction fires
59
- // until the provider reports a real window. custom (tierPct null) and
60
- // umbrella-OFF fall through to the legacy helper (byte-identical).
61
- if (self.config.threeWayFailback &&
62
- self.config.tierPct != null &&
63
- self.lastCtxWindow <= 0) {
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 = 50_000;
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
- savedGoal = 50_000;
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 };
@@ -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
- export function autoCompactCheck(currentTokens, threshold = 50000) {
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. A tier sets the token threshold at which the
17
- * auto-trigger persists a checkpoint; pick by how aggressively you want the
18
- * session trimmed. Explicit MEGACOMPACT_THRESHOLD_TOKENS always wins.
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 * 200_000)`) — sane before any context event reaches the
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 = TIER_PCT[tier];
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
- const thresholdTokens = Math.round(tierPct * 200_000);
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) falls back to the legacy 70% default.
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) : 70;
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
@@ -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: under the umbrella, a tiered config with an
85
- // UNKNOWN window (lastCtxWindow <= 0) DEFERS — auto-compaction must never
86
- // substitute a guessed window. Returning +Infinity keeps every downstream
87
- // `tokens >= threshold` comparison false (gateCheck token path,
88
- // agent_end durable trigger, live-trim re-compact), so no compaction fires
89
- // until the provider reports a real window. custom (tierPct null) and
90
- // umbrella-OFF fall through to the legacy helper (byte-identical).
91
- if (
92
- self.config.threeWayFailback &&
93
- self.config.tierPct != null &&
94
- self.lastCtxWindow <= 0
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 = 50_000;
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
- savedGoal = 50_000;
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.4",
3
+ "version": "0.21.5",
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 = 50000,
191
+ threshold: number,
282
192
  ): {
283
193
  shouldCompact: boolean;
284
194
  currentTokens: number;