dsh-switchman 1.10.3 → 1.11.0

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.
@@ -12,23 +12,29 @@
12
12
  * - agent/status idle hook: the only place fold decisions run (design §4:
13
13
  * fold at the idle boundary, never mid-stream).
14
14
  * - ctx_trim_fold / ctx_trim_status tools: the L1 advisory surface — the
15
- * session model may fold ranges itself and inspect trim state.
16
- * - systemPrompt head section (byte-stable protocol text) + live context
17
- * line (bucketed view tokens / fold count / growth — byte-stable between
18
- * 5k-token steps so the serialized tail stops churning).
15
+ * session model may fold ranges itself and inspect compressible ranges
16
+ * (true sizes only — NEVER a fill percentage or water level; bili lesson
17
+ * #2, docs/ladder-fold.md).
18
+ *
19
+ * MODEL-VISIBILITY INVARIANT: the trim layer registers NO systemPrompt
20
+ * context line. Water levels, view-token estimates, growth numbers, and
21
+ * fold counts are engine internals; the model sees only the byte-stable
22
+ * protocol head and (on demand) the true-size recommendation list. Human
23
+ * diagnostics live in decisions.log.
19
24
  *
20
25
  * Config fields (host/config.js, all volatile): trimEnabled, trimMode
21
26
  * ("off"|"advisory"|"auto"), trimGrowthTokens, trimIncrementTokens,
22
27
  * trimPressurePercent, trimEmergencyPercent, trimWindowTokens,
23
- * trimProtectedTail,
24
- * trimMaxFoldsPerHour, trimSideModel, trimStateDir.
28
+ * trimProtectedTail, trimMaxFoldsPerHour, trimSideModel, trimFoldFallbackModel, trimStateDir,
29
+ * trimLadderEnabled, trimLadderT2Tokens, trimLadderT2MinBlocks,
30
+ * trimLadderT3Tokens, trimLadderT3MinBlocks.
25
31
  */
26
32
 
27
33
  import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
28
34
  import { join } from "node:path";
29
- import { buildView, estimateTokensOf, injectView, ledgerIsStale, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
35
+ import { buildView, estimateTokensOf, injectView, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
30
36
  import { computeFoldableRange, evaluateTrigger, EMERGENCY_COOLDOWN_MS } from "./trigger.js";
31
- import { mechanicalSummary, renderMaterial, summarizeWithModel } from "./summarizer.js";
37
+ import { LADDER_SYSTEM_PROMPT, mechanicalSummary, renderLadderMaterial, renderMaterial, summarizeWithModel } from "./summarizer.js";
32
38
  import { createStore } from "./state.js";
33
39
  import { createMetrics, extractUsage, promptTokensOf, wrapStreamForMetrics } from "./metrics.js";
34
40
  import { forkLinkOf, loadForkLinks, recordForkLink, resolveInheritance, INHERIT_WINDOW_MS } from "./fork.js";
@@ -42,13 +48,16 @@ import { childSessionIdOf } from "../subagent-model.js";
42
48
  * agent/status and assembleContextFor's context.agent are both the object
43
49
  * form). Mirrors the host/context-watch.js:122 precedent. Returns null when
44
50
  * nothing usable is present. */
45
- // ---- cross-layer view registry (meter duality, design §8) ------------------
46
- // Latest outbound-view token estimate per session, refreshed on EVERY
47
- // llm/stream call. host/context-watch.js prefers this over the token meter's
48
- // stored tally while it is fresh, so the watermark tracks what the model
49
- // actually sees after billion-context folding instead of over-triggering
50
- // handovers off a stale stored measure.
51
+ // ---- cross-layer meter registries (meter duality, design §8) ---------------
52
+ // Per session: the latest outbound-view token estimate plus the last sampled
53
+ // provider prompt, refreshed on every ACCEPTED main llm/stream call (the
54
+ // side-dispatch guard keeps auxiliary calls out of both). host/context-watch.js
55
+ // reads the wire-domain bridge over these (recentTrimPromptTokensOf:
56
+ // max(view, wire)) so the watermark meters on the same boundary as the trim
57
+ // pressure tiers; the wire-truth gate decides when neither registry may drive
58
+ // it and the stored tally takes over.
51
59
  const trimViewBySession = new Map(); // sessionId -> { viewTokens, at, inject, lastNoneAt }
60
+ const trimWireBySession = new Map(); // sessionId -> { promptTokens, at } — last sampled provider prompt of an accepted MAIN dispatch (side calls never write; a tiny inherited-id usage sample must not lower the watermark's wire floor)
52
61
  /** Freshness TTL for the in-memory view/injection registry (watermark and
53
62
  * banner display only). NEVER a fold gate: gating folds on "did the surface
54
63
  * channel ship recently" deadlocks resumed sessions (2026-11-05). The fold
@@ -72,33 +81,87 @@ const SIDE_DISPATCH_SKIP_BUDGET = 2;
72
81
  * accepted main dispatch). */
73
82
  const sideDispatchSkipsBySession = new Map();
74
83
 
84
+ /** Shared wire-truth gate (docs/context-trim.md): the view-registry entry for
85
+ * one session when the recent dispatch stream provably carried the view —
86
+ * null when missing, stale, injected "none", or inside the sticky lastNoneAt
87
+ * window (mixed sessions exist — one real session logged 46 none + 14
88
+ * swapped). Observed 2026-11-04: a view that never reaches the wire must not
89
+ * drive the watermark (view ~80k vs a 176k wire while the force tier stayed
90
+ * silent and the auto-handover never fired). Both watermark accessors read
91
+ * through this gate; itself fail-open. */
92
+ function gatedViewEntry(sessionId, maxAgeMs) {
93
+ if (typeof sessionId !== "string" || sessionId === "") return null;
94
+ const entry = trimViewBySession.get(sessionId);
95
+ if (entry === undefined) return null;
96
+ if (Date.now() - entry.at > maxAgeMs) return null;
97
+ if (entry.inject === "none") return null;
98
+ if (Number.isFinite(entry.lastNoneAt) && entry.lastNoneAt > 0 && Date.now() - entry.lastNoneAt < maxAgeMs) return null;
99
+ return entry;
100
+ }
101
+
75
102
  /** Most recent outbound-view token estimate for a session, or null when
76
- * missing, non-positive, older than maxAgeMs — or when the recent dispatch
77
- * stream could not carry the view (injected "none": frozen options AND
78
- * messages, so the model still receives the raw history and the provider
79
- * tally is the only truthful meter). The gate is deliberately sticky: a
80
- * single mutable dispatch right after frozen ones re-arms maybeFold, but
81
- * the WATERMARK keeps falling back to the tally until no "none" occurred
82
- * within the TTL window (mixed sessions exist — one real session logged 46
83
- * none + 14 swapped). Read by the watermark layer (meter duality); itself
84
- * fail-open. */
103
+ * missing, non-positive, or gate-rejected. The bare-view half of the meter
104
+ * duality bridge — kept for diagnostics and as the gate primitive; the
105
+ * watermark layer reads the wire-domain accessor below. */
85
106
  export function recentTrimViewTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
86
107
  try {
87
- if (typeof sessionId !== "string" || sessionId === "") return null;
88
- const entry = trimViewBySession.get(sessionId);
89
- if (entry === undefined) return null;
90
- if (Date.now() - entry.at > maxAgeMs) return null;
91
- // Wire-truth gate: a view that never reaches the wire must not drive
92
- // the watermark (observed 2026-11-04: view ~80k vs a 176k wire while
93
- // the force tier stayed silent and the auto-handover never fired).
94
- if (entry.inject === "none") return null;
95
- if (Number.isFinite(entry.lastNoneAt) && entry.lastNoneAt > 0 && Date.now() - entry.lastNoneAt < maxAgeMs) return null;
96
- return Number.isFinite(entry.viewTokens) && entry.viewTokens > 0 ? entry.viewTokens : null;
108
+ const entry = gatedViewEntry(sessionId, maxAgeMs);
109
+ return entry !== null && Number.isFinite(entry.viewTokens) && entry.viewTokens > 0 ? entry.viewTokens : null;
97
110
  } catch {
98
111
  return null;
99
112
  }
100
113
  }
101
114
 
115
+ /** Meter-duality bridge, wire domain (design §8): the same number the trim
116
+ * pressure tiers evaluate in maybeFold — Math.max(outbound view estimate,
117
+ * the last sampled provider prompt) — or null when the gate rejects or
118
+ * neither component is fresh. The watermark layer reads THIS (not the bare
119
+ * view) so its soft/hard/force tiers and the read budget sit on the same
120
+ * boundary as the fold engine: the view estimate never counts the
121
+ * system/tool sections (a session-shape-dependent mass — live decision-log
122
+ * pairs drift −5k…+6k between view and wire in steady state), so a
123
+ * view-only watermark tightens every protective tier late by exactly that
124
+ * mass, and the wmHardTokens-95k ≙ trim-pressure-90k alignment (c453b91)
125
+ * is silently defeated. The wire half comes from accepted MAIN dispatches
126
+ * only (noteTrimWireTokens below). Same sticky gate as the view bridge;
127
+ * fail-open — null means the caller falls back to the stored tally. */
128
+ export function recentTrimPromptTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
129
+ try {
130
+ const entry = gatedViewEntry(sessionId, maxAgeMs);
131
+ if (entry === null) return null;
132
+ let best = 0;
133
+ if (Number.isFinite(entry.viewTokens) && entry.viewTokens > 0) best = entry.viewTokens;
134
+ const wire = trimWireBySession.get(sessionId);
135
+ if (wire !== undefined && Date.now() - wire.at <= maxAgeMs && Number.isFinite(wire.promptTokens) && wire.promptTokens > best) best = wire.promptTokens;
136
+ return best > 0 ? best : null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /** Record the provider prompt size (uncached + cacheRead + cacheWrite) of one
143
+ * usage sample from an ACCEPTED main dispatch. Side dispatches (fold-span
144
+ * re-sends, title generation, web-search summaries) sample usage on the same
145
+ * session id but must never reach this registry: their tiny prompts would
146
+ * LOWER the watermark's wire floor below the main history's real prompt —
147
+ * exactly the pollution the side-dispatch guard keeps out of the view
148
+ * registry. Fail-open, bounded like the view registry. */
149
+ function noteTrimWireTokens(sessionId, usage) {
150
+ try {
151
+ if (typeof sessionId !== "string" || sessionId === "") return;
152
+ const promptTokens = promptTokensOf({
153
+ lastInputTokens: usage.inputTokens,
154
+ cachedTokens: usage.cachedTokens,
155
+ cacheWriteTokens: usage.cacheWriteTokens,
156
+ });
157
+ if (promptTokens === null) return;
158
+ trimWireBySession.set(sessionId, { promptTokens, at: Date.now() });
159
+ if (trimWireBySession.size > 200) trimWireBySession.delete(trimWireBySession.keys().next().value);
160
+ } catch {
161
+ /* observability must never throw */
162
+ }
163
+ }
164
+
102
165
  /** Injection outcome of the last dispatch for one session ("in-place" |
103
166
  * "swapped" | "none"), or null when unknown or stale. Read by maybeFold:
104
167
  * when the wire cannot carry the view, folding cannot relieve anything and
@@ -124,14 +187,6 @@ export function agentSessionIdOf(agent) {
124
187
  return null;
125
188
  }
126
189
 
127
- /** Display label for the banner: object → name, string → itself, else the
128
- * session id (never prints [object Object]). */
129
- export function agentLabelOf(agent) {
130
- if (typeof agent === "string") return agent;
131
- if (agent !== null && typeof agent === "object" && typeof agent.name === "string" && agent.name !== "") return agent.name;
132
- return agentSessionIdOf(agent);
133
- }
134
-
135
190
  // ---- config field readers (fail-open defaults) -------------------------------
136
191
 
137
192
  function readBool(config, key, fallback) {
@@ -189,12 +244,14 @@ function readRoute(config, key) {
189
244
  // ---- prompt texts -------------------------------------------------------------
190
245
 
191
246
  /** Byte-stable head section: the trim protocol (digit-free, like the
192
- * watermark protocol, so it stays out of KV invariants' way). */
247
+ * watermark protocol, so it stays out of KV invariants' way). States the
248
+ * number-free disclosure policy explicitly — the model never receives
249
+ * water levels or progress numbers from this engine. */
193
250
  export const TRIM_PROTOCOL_TEXT = [
194
- "[SWITCHMAN:TRIM] This session has automatic context trimming.",
251
+ "[SWITCHMAN:TRIM] This session has automatic context trimming. The engine folds consumed ranges itself and never reports sizes, percentages, or progress to you — no action or pacing is expected of you on that front.",
195
252
  "- Old message ranges may be replaced by ONE synthetic [SWITCHMAN:TRIM] summary message; treat those summaries as faithful compressions, and message refs inside them as historical anchors.",
196
253
  "- NEVER contradict or re-derive content a summary already settled; build on it.",
197
- "- When context grows large, you may fold a contiguous range yourself with the ctx_trim_fold tool (give firstId/lastId message refs and a summary following the compression rules); inspect state with ctx_trim_status.",
254
+ "- You may fold a contiguous range yourself with the ctx_trim_fold tool (give firstId/lastId message refs and a summary following the compression rules); ctx_trim_status lists the compressible ranges with their true sizes.",
198
255
  "- Tool-call pairs (an assistant tool call and its results) must always be folded together or not at all.",
199
256
  ].join("\n");
200
257
 
@@ -209,37 +266,6 @@ export function effectiveWindowTokens(resolved, cap) {
209
266
  return Math.min(a, b);
210
267
  }
211
268
 
212
- /** Live context line renderer (pure; exported for tests). Silent in "off"
213
- * mode and whenever nothing has been measured yet. wireRaw marks a session
214
- * whose last dispatch could not carry the fold view (frozen request) — the
215
- * displayed view is then meter-only and the watermark governs off the
216
- * provider tally. */
217
- export function renderTrimContextLine(agent, { enabled, mode, viewTokens, windowTokens, folds, growthTokens, lastModel, wireRaw = false }) {
218
- if (!enabled) return "";
219
- if (mode === "off") return "";
220
- if (agent === null || agent === undefined || agent === "") return "";
221
- const hasView = Number.isFinite(viewTokens) && viewTokens > 0;
222
- const hasFolds = Number.isFinite(folds) && folds > 0;
223
- if (!hasView && !hasFolds) return "";
224
- const kilo = (n) => `${Math.round(n / 1000)}k`;
225
- const parts = [];
226
- if (hasView) {
227
- let usage = "";
228
- if (Number.isFinite(windowTokens) && windowTokens > 0) usage = ` (${Math.round((viewTokens / windowTokens) * 100)}% of window ${kilo(windowTokens)})`;
229
- else usage = " (window unknown — pressure tiers off)";
230
- parts.push(`view ~${kilo(viewTokens)} tokens${usage}`);
231
- if (wireRaw) parts.push("wire=raw (folds not shipping; watermark reads the tally)");
232
- }
233
- if (hasFolds) {
234
- parts.push(`folds ${folds}`);
235
- if (Number.isFinite(growthTokens)) parts.push(`growth ~${kilo(growthTokens)} since last fold`);
236
- }
237
- if (lastModel !== null && typeof lastModel === "object" && typeof lastModel.model === "string" && lastModel.model !== "") parts.push(`model ${lastModel.model}`);
238
- if (parts.length === 0) return "";
239
- const modeNote = mode === "advisory" ? "advisory: fold via ctx_trim_fold when context feels heavy" : "auto folding active";
240
- return `[SWITCHMAN:TRIM] agent ${agent}: ${parts.join(", ")}; ${modeNote}.\n`;
241
- }
242
-
243
269
  // ---- layer ---------------------------------------------------------------------
244
270
 
245
271
  /** Register the context-trim layer. Wired from the plugin root after
@@ -254,6 +280,28 @@ export function applyContextTrim(ctx, config) {
254
280
  }
255
281
  };
256
282
 
283
+ // Reload/teardown marker: HMR disposes and remounts this section on every
284
+ // plugin edit (the profile's node_modules entry is a dev symlink into this
285
+ // checkout). An in-flight fold whose llm stream is cut by the dispose ends
286
+ // as a clean zero-chunk stream — inside the summarizer that is
287
+ // indistinguishable from a genuine empty model reply. Cycles record their
288
+ // start time; a dispose at/after that time reclassifies the outcome as
289
+ // reload-aborted so it neither climbs the failure ladder nor reads as a
290
+ // model failure in decisions.log.
291
+ let disposedAt = 0;
292
+ try {
293
+ // cordis emits no "dispose" event: self-teardown arrives as
294
+ // "internal/plugin" carrying OUR fiber with its uid CLEARED — uid is a
295
+ // positive counter at construction (vendor/cordis fiber.ts:239) and is
296
+ // set to null right before the disposal emit (:274-275; the DISPOSED
297
+ // state is defined as uid === null at :581). Same guard DSH ships in
298
+ // mcp-manager:249 and client-ui-cot-translation mount.ts:123.
299
+ ctx.on("internal/plugin", (fiber) => { if (fiber === ctx.fiber && fiber.uid === null) disposedAt = Date.now(); });
300
+ } catch {
301
+ /* lifecycle events unavailable on this ctx — flag stays 0, behavior unchanged */
302
+ }
303
+ const disposedSince = (since) => disposedAt !== 0 && disposedAt >= since;
304
+
257
305
  // Per-process pipeline state (process-wide by design: ledgers persist on
258
306
  // disk per session, metrics/captures live per session in memory).
259
307
  const store = createStore({ stateDir: readString(config, "trimStateDir", null) ?? undefined, warn });
@@ -276,21 +324,6 @@ export function applyContextTrim(ctx, config) {
276
324
  return false;
277
325
  }
278
326
  };
279
- // Durable surface-channel EVIDENCE for the banner: a block that already
280
- // SHIPPED a replacement (surfaceReplaced) proves the channel reaches the
281
- // wire, even across a process restart and after the in-memory recency
282
- // window expired. A commit-time capture (surfaceSeqs) does NOT — the block
283
- // may never ship, and a capture must not silence the honest wire=raw
284
- // warning. Display only — the fold gate uses the live capability probe.
285
- const ledgerShowsShippedReplacement = (sessionId) => {
286
- try {
287
- const blocks = store.ledgerOf(sessionId)?.blocks;
288
- if (!Array.isArray(blocks)) return false;
289
- return blocks.some((block) => block?.surfaceReplaced !== null && block?.surfaceReplaced !== undefined);
290
- } catch {
291
- return false;
292
- }
293
- };
294
327
  const metrics = createMetrics();
295
328
 
296
329
  /** Most recent provider prompt size for one session, or null when unknown.
@@ -448,8 +481,47 @@ export function applyContextTrim(ctx, config) {
448
481
  pressurePercent: readNumber(config, "trimPressurePercent", 60),
449
482
  emergencyPercent: readNumber(config, "trimEmergencyPercent", 95),
450
483
  protectedTail: Math.max(0, Math.round(readNumber(config, "trimProtectedTail", 6))),
451
- maxFoldsPerHour: readNumber(config, "trimMaxFoldsPerHour", 6),
484
+ maxFoldsPerHour: readNumber(config, "trimMaxFoldsPerHour", 16),
485
+ ladderT2Tokens: readNumber(config, "trimLadderT2Tokens", 8_000),
486
+ ladderT2MinBlocks: readNumber(config, "trimLadderT2MinBlocks", 3),
487
+ ladderT3Tokens: readNumber(config, "trimLadderT3Tokens", 6_000),
488
+ ladderT3MinBlocks: readNumber(config, "trimLadderT3MinBlocks", 2),
452
489
  });
490
+ const ladderEnabledNow = () => readBool(config, "trimLadderEnabled", true);
491
+ /** CJK-aware summary-mass estimator for the ladder census (same units
492
+ * the view estimate speaks, applied to summary text). */
493
+ const summaryTokensOf = (text) => messageTokens({ role: "user", content: [{ type: "text", text: typeof text === "string" ? text : "" }] });
494
+ /** Maximal runs of consecutive ACTIVE tier-1 / tier-2 blocks in ledger
495
+ * order — the ladder's candidate consumes sets (docs/ladder-fold.md).
496
+ * A run breaks on an inactive member, a tier change, or a tier-3 block
497
+ * interleaving. Runs carry { consumes, mass, count }; mass is the
498
+ * CJK-aware summary-mass estimate. Pure over the ledger. */
499
+ function ladderRunsOf(ledger) {
500
+ const runs = { t2: [], t3: [] };
501
+ if (ledger === null || !Array.isArray(ledger.blocks)) return runs;
502
+ let cur = null;
503
+ const flush = () => {
504
+ if (cur !== null && cur.ids.length > 0) {
505
+ const run = { consumes: cur.ids, mass: cur.mass, count: cur.ids.length };
506
+ if (cur.tier === 1) runs.t2.push(run);
507
+ else if (cur.tier === 2) runs.t3.push(run);
508
+ }
509
+ cur = null;
510
+ };
511
+ for (const block of ledger.blocks) {
512
+ const tier = block?.active === true && (block.tier === 1 || block.tier === 2) ? block.tier : null;
513
+ if (tier === null || (cur !== null && cur.tier !== tier)) {
514
+ flush();
515
+ }
516
+ if (tier !== null && cur === null) cur = { tier, ids: [], mass: 0 };
517
+ if (cur !== null) {
518
+ cur.ids.push(block.id);
519
+ cur.mass += summaryTokensOf(block.summary);
520
+ }
521
+ }
522
+ flush();
523
+ return runs;
524
+ }
453
525
  /** Effective-window cap: explicit trimWindowTokens, else auto-follow
454
526
  * wmForceTokens (keeps fold tiers ahead of the handover tier). */
455
527
  const windowCapNow = () => {
@@ -530,7 +602,11 @@ export function applyContextTrim(ctx, config) {
530
602
  // some dispatch options are frozen on top of that. injectView picks the
531
603
  // best strategy per dispatch and fails open; the view-applied line
532
604
  // records which strategy was used.
533
- const injected = injectView(options, messages, view);
605
+ // [2026-11-07]-[trimMode "off" means measure only]-[the view build runs
606
+ // for the meter, but the fold must not reach the wire — inspection
607
+ // report P0-2: off previously still spliced the folded view into the
608
+ // dispatch.]
609
+ const injected = modeNow() !== "off" ? injectView(options, messages, view) : "nothing-to-apply";
534
610
  const viewTokens = estimateTokensOf(view.messages);
535
611
  const blocksCount = Array.isArray(ledger.blocks) ? ledger.blocks.length : 0;
536
612
  // Wire truth in the row: the most recent provider prompt size (from the
@@ -558,8 +634,17 @@ export function applyContextTrim(ctx, config) {
558
634
  // history below the floor, so the guard must not stick: after
559
635
  // SIDE_DISPATCH_SKIP_BUDGET consecutive skips the dispatch is accepted
560
636
  // as main, which re-arms capture/baseline on the compacted history.
561
- const sideCandidate = inFlight.has(sessionId)
562
- || (view.applied === 0 && messages.length < SIDE_DISPATCH_MIN_MESSAGES && (blocksCount > 0 || captures.has(sessionId) || trimViewBySession.has(sessionId)));
637
+ // [2026-11-07]-[fold-span dispatches are self-noise, not side
638
+ // candidates]-[an in-flight fold cycle's own summarizer re-sends used
639
+ // to consume the side-dispatch skip budget: three consecutive span
640
+ // dispatches got "budget exhausted — accepting as main" and polluted
641
+ // the capture/baseline (the 2026-11-04 regression path). They now
642
+ // bypass unconditionally — never counted, never main.]
643
+ if (inFlight.has(sessionId)) {
644
+ decisionLog(`session=${sessionId} fold-span dispatch skipped view=${Math.round(viewTokens)} msgs=${messages.length}`);
645
+ return { sessionId, viewTokens, sideDispatch: true };
646
+ }
647
+ const sideCandidate = view.applied === 0 && messages.length < SIDE_DISPATCH_MIN_MESSAGES && (blocksCount > 0 || captures.has(sessionId) || trimViewBySession.has(sessionId));
563
648
  const skipCount = sideDispatchSkipsBySession.get(sessionId) ?? 0;
564
649
  if (sideCandidate && skipCount < SIDE_DISPATCH_SKIP_BUDGET) {
565
650
  sideDispatchSkipsBySession.set(sessionId, skipCount + 1);
@@ -613,11 +698,60 @@ export function applyContextTrim(ctx, config) {
613
698
  return null;
614
699
  }
615
700
 
701
+ /** Resolve the fold-fallback route (trimFoldFallbackModel): the second
702
+ * chance when the side/session route fails, and the last model stop
703
+ * before a raw pressure fold degrades to mechanical. Null = no fallback. */
704
+ function foldFallbackRouteFor() {
705
+ return readRoute(config, "trimFoldFallbackModel");
706
+ }
707
+
708
+ /** True when two resolved routes name the same provider+model (a fallback
709
+ * identical to the primary route would only double the failure latency). */
710
+ function sameRoute(a, b) {
711
+ return a !== null && b !== null && a.provider === b.provider && a.model === b.model;
712
+ }
713
+
616
714
  /** Run one fold cycle for a session (called at the idle boundary ONLY).
617
715
  * Returns a short outcome string for logging. */
618
- async function runFoldCycle(sessionId, decision) {
716
+ async function runFoldCycle(sessionId, decision, cycleStartedAt) {
619
717
  const capture = captures.get(sessionId);
620
718
  if (capture === undefined) return "no-capture";
719
+ const { protectedTail } = thresholdsNow();
720
+ // Ladder cycle (docs/ladder-fold.md): distill the decision's consumed
721
+ // run of block summaries into ONE replacement. Never mechanical — a
722
+ // failed distillation skips the cycle (fail-open + failure ladder).
723
+ if (decision.kind === "ladder") {
724
+ const ledgerBefore = store.ledgerOf(sessionId);
725
+ const byId = new Map(Array.isArray(ledgerBefore.blocks) ? ledgerBefore.blocks.map((block) => [block.id, block]) : []);
726
+ const consumed = decision.consumes.map((id) => byId.get(id)).filter((block) => block !== undefined);
727
+ if (consumed.length !== decision.consumes.length) return "ladder-consumed-missing";
728
+ const llm = ctx.get("llm", false);
729
+ const route = sideRouteFor(sessionId);
730
+ const fallback = foldFallbackRouteFor();
731
+ const approxTokens = consumed.reduce((sum, block) => sum + (Number.isFinite(block.approxTokens) && block.approxTokens > 0 ? block.approxTokens : 0), 0);
732
+ const summarize = (r, tag) => summarizeWithModel(llm, r, renderLadderMaterial(consumed), {
733
+ systemPrompt: LADDER_SYSTEM_PROMPT,
734
+ onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} blocks=${consumed.length} ~${approxTokens}t ladder=${decision.tier}`),
735
+ });
736
+ let summary = route !== null ? await summarize(route, "side") : null;
737
+ if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
738
+ // Ladder folds never degrade to mechanical (docs/ladder-fold.md):
739
+ // after both routes fail the cycle stays fail-open with backoff.
740
+ if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
741
+ const result = store.commitFold(
742
+ sessionId,
743
+ { consumes: decision.consumes.slice(), summary, kind: "summary", tier: decision.tier === "ladder-3" ? 3 : 2, approxTokens },
744
+ null, // ladder commits validate against the ledger, never the capture
745
+ );
746
+ if (!result.ok) return `commit-rejected:${result.reason}`;
747
+ // Post-fold low baseline: the ladder shrinks the view; re-anchor
748
+ // the growth gate at the new low so it does not re-fire on stale
749
+ // anchor math (same pair semantics as raw folds).
750
+ const lowLedger = store.ledgerOf(sessionId);
751
+ const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail }).messages);
752
+ store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
753
+ return `folded:${decision.tier}:${decision.consumes.length} blocks (~${approxTokens} tokens)`;
754
+ }
621
755
  const { startIdx, endIdx } = decision;
622
756
  const slice = capture.messages.slice(startIdx, endIdx + 1);
623
757
  if (slice.length === 0) return "empty-slice";
@@ -626,6 +760,7 @@ export function applyContextTrim(ctx, config) {
626
760
  const approxTokens = estimateTokensOf(slice);
627
761
  let summary = null;
628
762
  let kind = "summary";
763
+ let degraded = false; // set when a PRESSURE fold falls back to mechanical
629
764
  if (decision.action === "emergency") {
630
765
  // Emergency cooldown timestamp set BEFORE the cycle: a commitFold
631
766
  // rejection (e.g. duplicate-range) must cool the emergency tier
@@ -639,10 +774,25 @@ export function applyContextTrim(ctx, config) {
639
774
  } else {
640
775
  const llm = ctx.get("llm", false);
641
776
  const route = sideRouteFor(sessionId);
642
- summary = await summarizeWithModel(llm, route, renderMaterial(capture.messages, startIdx, endIdx, refOf), {
643
- onFail: (why) => decisionLog(`session=${sessionId} summary-fail why=${why}`),
777
+ const fallback = foldFallbackRouteFor();
778
+ const material = renderMaterial(capture.messages, startIdx, endIdx, refOf);
779
+ const summarize = (r, tag) => summarizeWithModel(llm, r, material, {
780
+ onFail: (why) => decisionLog(`session=${sessionId} summary-fail route=${tag} why=${why} msgs=${count} ~${approxTokens}t`),
644
781
  });
645
- if (summary === null) return "summary-unavailable"; // skip cycle, fail-open
782
+ summary = route !== null ? await summarize(route, "side") : null;
783
+ if (summary === null && fallback !== null && !sameRoute(fallback, route)) summary = await summarize(fallback, "fallback");
784
+ // Last resort for PRESSURE folds only: relief beats fidelity when
785
+ // the window is nearly gone; growth folds stay fail-open (a
786
+ // mechanical growth fold would burn fold budget on junk). The
787
+ // outcome row marks the degrade so the log shows why no model
788
+ // summary landed.
789
+ if (summary === null && decision.tier === "pressure") {
790
+ if (disposedSince(cycleStartedAt)) return "reload-aborted";
791
+ summary = mechanicalSummary(capture.messages, startIdx, endIdx, refOf);
792
+ kind = "mechanical";
793
+ degraded = true;
794
+ }
795
+ if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
646
796
  }
647
797
  const positions = refPositions(capture.messages);
648
798
  const result = store.commitFold(
@@ -659,9 +809,9 @@ export function applyContextTrim(ctx, config) {
659
809
  // post-fold low (tier-a anchor — keeps the gate armed after folds
660
810
  // that commit at high view points).
661
811
  const lowLedger = store.ledgerOf(sessionId);
662
- const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
812
+ const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail }).messages);
663
813
  store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
664
- return `folded:${decision.tier}:${count} msgs (~${approxTokens} tokens)`;
814
+ return `folded:${decision.tier}:${count} msgs (~${approxTokens} tokens)${degraded ? " fallback=mechanical" : ""}`;
665
815
  }
666
816
 
667
817
  /** Idle-boundary hook (also re-used by the stream-path fallback): decide
@@ -692,18 +842,22 @@ export function applyContextTrim(ctx, config) {
692
842
  return;
693
843
  }
694
844
  inFlight.add(sessionId);
845
+ const cycleStartedAt = Date.now();
846
+ // Hoisted for the catch below: a dispose-cut throw may land before the
847
+ // trigger ever ran, and the catch must not touch a dead binding.
848
+ let decision = null;
695
849
  try {
696
850
  const { protectedTail, ...thresholds } = thresholdsNow();
697
- const foldable = computeFoldableRange(capture.messages, store.ledgerOf(sessionId), protectedTail);
698
- const windowTokens = await windowTokensFor(capture.model);
699
851
  const ledger = store.ledgerOf(sessionId);
852
+ const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
853
+ const windowTokens = await windowTokensFor(capture.model);
700
854
  // Pressure tiers run on the REAL prompt size when the tally is known:
701
855
  // max(view, wire). The view estimate misses the system/context
702
856
  // sections, so a 136k wire once read as a 50k view and the tiers
703
857
  // stayed silent. Unknown tally → exactly today's behavior.
704
858
  const wireTokens = wireTokensOf(sessionId);
705
859
  const pressureTokens = wireTokens !== null ? Math.max(capture.viewTokens, wireTokens) : capture.viewTokens;
706
- const decision = evaluateTrigger({
860
+ decision = evaluateTrigger({
707
861
  foldable,
708
862
  messages: capture.messages,
709
863
  viewTokens: pressureTokens,
@@ -714,23 +868,31 @@ export function applyContextTrim(ctx, config) {
714
868
  foldsThisHour: ledger.foldsThisHour,
715
869
  lastEmergencyAt: lastEmergencyAtBySession.get(sessionId) ?? 0,
716
870
  ledger,
871
+ ladderRuns: ladderEnabledNow() ? ladderRunsOf(ledger) : { t2: [], t3: [] },
717
872
  });
718
873
  decisionLog(`session=${sessionId} via=${via} action=${decision.action} tier=${decision.tier ?? "-"} reason=${decision.reason || "-"} view=${Math.round(capture.viewTokens)}${wireTokens === null ? "" : ` wire=${wireTokens}`} window=${windowTokens ?? "null"}`);
719
874
  if (decision.action === "none") return;
720
- const outcome = await runFoldCycle(sessionId, decision);
875
+ const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt);
721
876
  // Failure ladder: commit resets it; a summarize/commit failure
722
877
  // climbs it (the no-backoff loop this closes — those outcomes
723
- // advance no cadence gate on their own).
878
+ // advance no cadence gate on their own). A ladder run whose
879
+ // consumed blocks vanished between trigger and commit is the same
880
+ // family: without backoff it would re-fire at every boundary.
724
881
  if (outcome.startsWith("folded:")) clearFoldFailure(sessionId);
725
- else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:")) recordFoldFailure(sessionId);
882
+ else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:") || outcome === "ladder-consumed-missing") recordFoldFailure(sessionId);
726
883
  // Outcome lands in the decision log too: warn() has no reachable
727
884
  // sink on some hosts, and the commit/fail boundary is exactly what
728
885
  // live diagnosis needs.
729
- decisionLog(`session=${sessionId} outcome=${outcome} tier=${decision.tier}`);
886
+ decisionLog(`session=${sessionId} outcome=${outcome} tier=${decision.tier ?? "-"}`);
730
887
  warn(`[context-trim] ${sessionId}: ${outcome} (${decision.reason})`);
731
888
  } catch (error) {
732
- recordFoldFailure(sessionId); // a thrown cycle counts as failed too
733
- warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
889
+ // A dispose mid-cycle throws through here too (ctx/service teardown) —
890
+ // that is a reload cut, not a model failure: log it and skip the ladder.
891
+ if (disposedSince(cycleStartedAt)) decisionLog(`session=${sessionId} outcome=reload-aborted tier=${decision?.tier ?? "-"}`);
892
+ else {
893
+ recordFoldFailure(sessionId); // a thrown cycle counts as failed too
894
+ warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
895
+ }
734
896
  } finally {
735
897
  inFlight.delete(sessionId);
736
898
  }
@@ -738,63 +900,17 @@ export function applyContextTrim(ctx, config) {
738
900
 
739
901
  // ---- host registrations (each individually fail-open) --------------------
740
902
 
903
+ // MODEL-VISIBILITY INVARIANT: only the byte-stable protocol head is
904
+ // registered. The previous live context line (view tokens, % of window,
905
+ // folds, growth) is gone by design — water levels and progress numbers
906
+ // steer model judgment (bili lesson #2, docs/ladder-fold.md). Human
907
+ // diagnostics live in decisions.log (view-applied / decision / outcome
908
+ // rows) and the client badge.
741
909
  try {
742
- ctx.systemPrompt?.section?.({ name: "switchman:trim", order: 10450, interpolate: false, text: () => TRIM_PROTOCOL_TEXT });
910
+ ctx.systemPrompt?.section?.({ name: "switchman:trim", order: 10450, interpolate: false, text: () => (enabledNow() && modeNow() !== "off" ? TRIM_PROTOCOL_TEXT : "") });
743
911
  } catch (error) {
744
912
  warn(`[context-trim] systemPrompt.section failed: ${error?.message ?? error}`);
745
913
  }
746
- try {
747
- ctx.systemPrompt?.context?.({
748
- name: "switchman:trim",
749
- order: 299,
750
- required: true,
751
- interpolate: false,
752
- text: (context) => {
753
- try {
754
- const agent = context?.agent ?? null;
755
- // context.agent is also the agent OBJECT — derive the id plus a
756
- // display label so the banner never prints [object Object].
757
- const sessionId = agentSessionIdOf(agent);
758
- const label = agentLabelOf(agent);
759
- const record = sessionId !== null ? metrics.of(sessionId) : null;
760
- const ledger = sessionId !== null ? store.ledgerOf(sessionId) : null;
761
- const model = captureModelOf(sessionId);
762
- const windowTokens = model === null ? null : cachedWindowOf(model);
763
- const growthBaseline = ledger !== null && Number.isFinite(ledger.lastFoldLowTokens) && ledger.lastFoldLowTokens > 0 ? ledger.lastFoldLowTokens : ledger !== null && Number.isFinite(ledger.lastFoldViewTokens) && ledger.lastFoldViewTokens > 0 ? ledger.lastFoldViewTokens : null;
764
- // Honest wire status: the wire is only "raw" when NEITHER channel
765
- // can reach it — a capable surface pass, or a ledger that already
766
- // proves a SHIPPED replacement (a capture alone does not: it may
767
- // never ship), keeps the marker off even after the in-memory
768
- // recency window expired.
769
- const wireRaw = recentTrimInjectOf(sessionId) === "none" && !surfaceChannelCapable(sessionId) && !ledgerShowsShippedReplacement(sessionId);
770
- return renderTrimContextLine(label, {
771
- enabled: enabledNow(),
772
- mode: modeNow(),
773
- viewTokens: record?.viewTokens ?? null,
774
- windowTokens,
775
- folds: Array.isArray(ledger?.blocks) ? ledger.blocks.length : 0,
776
- growthTokens: record?.viewTokens !== null && record !== undefined && growthBaseline !== null ? record.viewTokens - growthBaseline : null,
777
- lastModel: record?.lastModel ?? null,
778
- wireRaw,
779
- });
780
- } catch {
781
- return "";
782
- }
783
- },
784
- });
785
- } catch (error) {
786
- warn(`[context-trim] systemPrompt.context failed: ${error?.message ?? error}`);
787
- }
788
-
789
- /** Synchronous window lookup for the banner: raw cache composed with
790
- * the cap (async resolution happens in the idle path / stream path). */
791
- function captureModelOf(sessionId) {
792
- return captures.get(sessionId)?.model ?? null;
793
- }
794
- function cachedWindowOf(model) {
795
- const resolved = model !== null && typeof model.provider === "string" && typeof model.model === "string" ? windowCache.get(`${model.provider}/${model.model}`)?.windowTokens ?? null : null;
796
- return effectiveWindowTokens(resolved, windowCapNow());
797
- }
798
914
 
799
915
  try {
800
916
  ctx.on("agent/status", (payload) => {
@@ -860,29 +976,56 @@ export function applyContextTrim(ctx, config) {
860
976
 
861
977
  // ---- tools (L1 advisory surface) ------------------------------------------
862
978
 
979
+ /** Render the fold ledger for the status tool: ACTIVE blocks with true
980
+ * sizes, absorbed history as one line. NO water level, NO fill
981
+ * percentage, NO window math, NO threshold percentages — sizes of
982
+ * concrete ranges are the model-facing interface (bili mechanism #5);
983
+ * global progress numbers are not (lesson #2). */
984
+ function renderStatusLedger(ledger) {
985
+ const blocks = Array.isArray(ledger?.blocks) ? ledger.blocks : [];
986
+ const active = blocks.filter((block) => block?.active === true);
987
+ const absorbed = blocks.length - active.length;
988
+ const lines = [];
989
+ if (blocks.length === 0) {
990
+ lines.push("fold ledger: empty (nothing folded yet)");
991
+ return lines;
992
+ }
993
+ lines.push(`fold ledger: ${active.length} active block(s)${absorbed > 0 ? `, ${absorbed} absorbed by higher tiers` : ""} (oldest first)`);
994
+ for (const block of active) {
995
+ const tier = block.tier === 3 ? "T3" : block.tier === 2 ? "T2" : "T1";
996
+ const range = block.refRange !== null && block.refRange !== undefined ? ` refs [m${block.refRange.firstId}]..[m${block.refRange.lastId}]` : ` distills ${block.count} lower-tier block(s)`;
997
+ lines.push(`- ${block.id} ${tier}: ~${Math.round(Number.isFinite(block.approxTokens) ? block.approxTokens : 0)} tokens of material${range}${block.tier >= 2 ? ` — foldable into a higher tier together with its sibling blocks` : ""}`);
998
+ }
999
+ return lines;
1000
+ }
1001
+
863
1002
  try {
864
1003
  ctx.tools?.register?.({
865
1004
  name: "ctx_trim_status",
866
- description: "Inspect the context-trim state of the current session: mode, thresholds, view token estimate, fold ledger, per-message refs of the captured history. Read-only.",
1005
+ description: "List this session's compressible context ranges with their true sizes: the fold ledger, the raw range a fold would cover, and distillation targets. Read-only. Contains sizes only — never progress percentages.",
867
1006
  parameters: { type: "object", properties: {}, additionalProperties: false },
868
1007
  required: [],
869
1008
  async execute() {
870
1009
  const sessionId = sessionIdOf({});
871
1010
  if (sessionId === null) return "No session context available.";
1011
+ if (!enabledNow()) return "Context trimming is disabled for this deployment.";
872
1012
  const ledger = store.ledgerOf(sessionId);
873
- const record = metrics.of(sessionId);
874
1013
  const capture = captures.get(sessionId);
875
- const { protectedTail, ...thresholds } = thresholdsNow();
876
- const lines = [
877
- "ctx_trim_status",
878
- `mode: ${modeNow()} (enabled: ${enabledNow()})`,
879
- `view tokens (estimate): ${record?.viewTokens ?? "n/a"}`,
880
- `window tokens: ${cachedWindowOf(capture?.model ?? null) ?? "unknown"}`,
881
- `folds: ${Array.isArray(ledger.blocks) ? ledger.blocks.length : 0}, frontier: ${ledger.frontier ?? "none"}, last fold view tokens: ${ledger.lastFoldViewTokens || "n/a"}`,
882
- `thresholds: growth>${thresholds.growthTokens} increment>=${thresholds.incrementTokens} pressure=${thresholds.pressurePercent}% emergency=${thresholds.emergencyPercent}% maxPerHour=${thresholds.maxFoldsPerHour} protectedTail=${protectedTail}`,
883
- ];
1014
+ const { protectedTail } = thresholdsNow();
1015
+ const lines = [`ctx_trim_status — mode: ${modeNow()} (enabled: ${enabledNow()})`];
1016
+ lines.push(...renderStatusLedger(ledger));
1017
+ // The raw range a fold would cover next, with its true size.
884
1018
  if (capture !== undefined) {
885
- lines.push("", `captured history (${capture.messages.length} messages; refs [m...] are fold anchors):`);
1019
+ const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
1020
+ if (foldable !== null) {
1021
+ const firstRef = refOf(capture.messages[foldable.startIdx], foldable.startIdx);
1022
+ const lastRef = refOf(capture.messages[foldable.endIdx], foldable.endIdx);
1023
+ lines.push("", `compressible raw range: [m${firstRef}]..[m${lastRef}] (${foldable.endIdx - foldable.startIdx + 1} messages, ~${estimateTokensOf(capture.messages, foldable.startIdx, foldable.endIdx)} tokens) — a ctx_trim_fold over this range replaces it with your summary.`);
1024
+ } else {
1025
+ lines.push("", "compressible raw range: none right now (everything settled is already folded).");
1026
+ }
1027
+ // Message refs stay available for fold targeting (sizes only).
1028
+ lines.push("", `captured history (${capture.messages.length} messages; refs [m...] are fold anchors, per-message sizes are estimates):`);
886
1029
  capture.messages.forEach((message, i) => {
887
1030
  lines.push(`- [m${refOf(message, i)}] (${message?.role ?? "?"}) ~${estimateTokensOf(capture.messages, i, i)} tokens`);
888
1031
  });
@@ -914,6 +1057,7 @@ export function applyContextTrim(ctx, config) {
914
1057
  async execute(args) {
915
1058
  const sessionId = sessionIdOf({});
916
1059
  if (sessionId === null) return "No session context available.";
1060
+ if (!enabledNow() || modeNow() === "off") return "Context trimming is disabled for this deployment.";
917
1061
  const capture = captures.get(sessionId);
918
1062
  if (capture === undefined) return "No captured history yet — fold is unavailable until the next model call.";
919
1063
  const positions = refPositions(capture.messages);
@@ -945,7 +1089,7 @@ export function applyContextTrim(ctx, config) {
945
1089
  approxTokens: estimateTokensOf(capture.messages, firstIdx, lastIdx),
946
1090
  summary: String(args.summary ?? ""),
947
1091
  kind: "summary",
948
- tier: "model",
1092
+ tier: 1,
949
1093
  },
950
1094
  positions,
951
1095
  );
@@ -968,10 +1112,14 @@ export function applyContextTrim(ctx, config) {
968
1112
 
969
1113
  function streamListener(options, next) {
970
1114
  let viewTokens = null;
1115
+ let mainDispatch = false;
971
1116
  try {
972
1117
  if (enabledNow()) {
973
1118
  const applied = applyViewToOptions(options);
974
- if (applied !== null) viewTokens = applied.viewTokens;
1119
+ if (applied !== null) {
1120
+ viewTokens = applied.viewTokens;
1121
+ mainDispatch = applied.sideDispatch !== true;
1122
+ }
975
1123
  }
976
1124
  } catch (error) {
977
1125
  decisionLog(`view-build-failed why=${error?.message ?? error}`);
@@ -985,7 +1133,10 @@ export function applyContextTrim(ctx, config) {
985
1133
  if (wrappedSession === null) return result;
986
1134
  return wrapStreamForMetrics(result, (chunk) => {
987
1135
  const usage = extractUsage(chunk);
988
- if (usage !== null) metrics.recordUsage(wrappedSession, usage);
1136
+ if (usage !== null) {
1137
+ metrics.recordUsage(wrappedSession, usage);
1138
+ if (mainDispatch) noteTrimWireTokens(wrappedSession, usage);
1139
+ }
989
1140
  });
990
1141
  } catch (error) {
991
1142
  warn(`[context-trim] stream wrap failed: ${error?.message ?? error}`);
@@ -1050,5 +1201,5 @@ export function applyContextTrim(ctx, config) {
1050
1201
  warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
1051
1202
  }
1052
1203
 
1053
- return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass, renderTrimContextLine: (agent) => renderTrimContextLine(agent, { enabled: enabledNow(), mode: modeNow(), viewTokens: null, windowTokens: null, folds: 0, growthTokens: null, lastModel: null, wireRaw: false }) };
1204
+ return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
1054
1205
  }