dsh-switchman 1.9.0 → 1.10.3

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.
@@ -30,8 +30,9 @@ import { buildView, estimateTokensOf, injectView, ledgerIsStale, rangeIsSegmentA
30
30
  import { computeFoldableRange, evaluateTrigger, EMERGENCY_COOLDOWN_MS } from "./trigger.js";
31
31
  import { mechanicalSummary, renderMaterial, summarizeWithModel } from "./summarizer.js";
32
32
  import { createStore } from "./state.js";
33
- import { createMetrics, extractUsage, wrapStreamForMetrics } from "./metrics.js";
33
+ import { createMetrics, extractUsage, promptTokensOf, wrapStreamForMetrics } from "./metrics.js";
34
34
  import { forkLinkOf, loadForkLinks, recordForkLink, resolveInheritance, INHERIT_WINDOW_MS } from "./fork.js";
35
+ import { createSurfaceFoldPass } from "./surface.js";
35
36
  import { childSessionIdOf } from "../subagent-model.js";
36
37
 
37
38
  // ---- agent reference helpers --------------------------------------------------
@@ -47,24 +48,73 @@ import { childSessionIdOf } from "../subagent-model.js";
47
48
  // stored tally while it is fresh, so the watermark tracks what the model
48
49
  // actually sees after billion-context folding instead of over-triggering
49
50
  // handovers off a stale stored measure.
50
- const trimViewBySession = new Map(); // sessionId -> { viewTokens, at }
51
+ const trimViewBySession = new Map(); // sessionId -> { viewTokens, at, inject, lastNoneAt }
52
+ /** Freshness TTL for the in-memory view/injection registry (watermark and
53
+ * banner display only). NEVER a fold gate: gating folds on "did the surface
54
+ * channel ship recently" deadlocks resumed sessions (2026-11-05). The fold
55
+ * gate asks surfacePass.supported() — a durable capability question. */
51
56
  const TRIM_VIEW_MAX_AGE_MS = 600_000; // 10 minutes
52
57
 
58
+ /** Dispatches below this message count are never treated as the session's
59
+ * main history (the side-dispatch guard in applyViewToOptions): auxiliary
60
+ * llm calls (web-search summaries, title generation) inherit the session id
61
+ * with 1-6 message arrays and would clobber every per-session meter. */
62
+ const SIDE_DISPATCH_MIN_MESSAGES = 12;
63
+
64
+ /** Consecutive side-dispatch skips tolerated before one is accepted as the
65
+ * main history anyway — the release valve for a genuine host-compaction
66
+ * epoch that shrank the history below SIDE_DISPATCH_MIN_MESSAGES (without
67
+ * it the guard would stick: no capture refresh, no epoch reset, folds run
68
+ * against a stale capture forever). */
69
+ const SIDE_DISPATCH_SKIP_BUDGET = 2;
70
+
71
+ /** Consecutive side-dispatch skips per session (bounded; reset on every
72
+ * accepted main dispatch). */
73
+ const sideDispatchSkipsBySession = new Map();
74
+
53
75
  /** Most recent outbound-view token estimate for a session, or null when
54
- * missing, non-positive, or older than maxAgeMs. Read by the watermark
55
- * layer (meter duality); itself fail-open. */
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. */
56
85
  export function recentTrimViewTokensOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
57
86
  try {
58
87
  if (typeof sessionId !== "string" || sessionId === "") return null;
59
88
  const entry = trimViewBySession.get(sessionId);
60
89
  if (entry === undefined) return null;
61
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;
62
96
  return Number.isFinite(entry.viewTokens) && entry.viewTokens > 0 ? entry.viewTokens : null;
63
97
  } catch {
64
98
  return null;
65
99
  }
66
100
  }
67
101
 
102
+ /** Injection outcome of the last dispatch for one session ("in-place" |
103
+ * "swapped" | "none"), or null when unknown or stale. Read by maybeFold:
104
+ * when the wire cannot carry the view, folding cannot relieve anything and
105
+ * only burns a summarizer call — the watermark's tally-driven auto-handover
106
+ * is the correct relief path instead. */
107
+ export function recentTrimInjectOf(sessionId, maxAgeMs = TRIM_VIEW_MAX_AGE_MS) {
108
+ try {
109
+ if (typeof sessionId !== "string" || sessionId === "") return null;
110
+ const entry = trimViewBySession.get(sessionId);
111
+ if (entry === undefined || Date.now() - entry.at > maxAgeMs) return null;
112
+ return typeof entry.inject === "string" ? entry.inject : null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
68
118
  export function agentSessionIdOf(agent) {
69
119
  if (typeof agent === "string") return agent !== "" ? agent : null;
70
120
  if (agent !== null && typeof agent === "object") {
@@ -160,8 +210,11 @@ export function effectiveWindowTokens(resolved, cap) {
160
210
  }
161
211
 
162
212
  /** Live context line renderer (pure; exported for tests). Silent in "off"
163
- * mode and whenever nothing has been measured yet. */
164
- export function renderTrimContextLine(agent, { enabled, mode, viewTokens, windowTokens, folds, growthTokens, lastModel }) {
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 }) {
165
218
  if (!enabled) return "";
166
219
  if (mode === "off") return "";
167
220
  if (agent === null || agent === undefined || agent === "") return "";
@@ -175,6 +228,7 @@ export function renderTrimContextLine(agent, { enabled, mode, viewTokens, window
175
228
  if (Number.isFinite(windowTokens) && windowTokens > 0) usage = ` (${Math.round((viewTokens / windowTokens) * 100)}% of window ${kilo(windowTokens)})`;
176
229
  else usage = " (window unknown — pressure tiers off)";
177
230
  parts.push(`view ~${kilo(viewTokens)} tokens${usage}`);
231
+ if (wireRaw) parts.push("wire=raw (folds not shipping; watermark reads the tally)");
178
232
  }
179
233
  if (hasFolds) {
180
234
  parts.push(`folds ${folds}`);
@@ -203,7 +257,56 @@ export function applyContextTrim(ctx, config) {
203
257
  // Per-process pipeline state (process-wide by design: ledgers persist on
204
258
  // disk per session, metrics/captures live per session in memory).
205
259
  const store = createStore({ stateDir: readString(config, "trimStateDir", null) ?? undefined, warn });
260
+
261
+ // Surface-replace pass (docs/surface-fold.md): ships committed fold blocks
262
+ // onto the session surface so folds reach the wire even for frozen
263
+ // dispatches. Registers its own agent/turn-stopping trigger; the idle
264
+ // sweep below runs after each fold cycle.
265
+ const surfacePass = createSurfaceFoldPass({ ctx, config, store, decisionLog, warn });
266
+
267
+ // Durable capability probe (surface.js supported): the fold gate asks THIS,
268
+ // never a recency window. An in-memory "did we ship in the last 10 minutes"
269
+ // test deadlocked resumed sessions: dispatches report injected=none, so
270
+ // after the window expired no fold ran, no fold meant no new ship, and the
271
+ // gate stayed shut forever while the provider prompt grew to 135k.
272
+ const surfaceChannelCapable = (sessionId) => {
273
+ try {
274
+ return surfacePass.supported(sessionId);
275
+ } catch {
276
+ return false;
277
+ }
278
+ };
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
+ };
206
294
  const metrics = createMetrics();
295
+
296
+ /** Most recent provider prompt size for one session, or null when unknown.
297
+ * The tally is the FULL prompt: the host reports TokenUsage as
298
+ * { inputTokens (uncached), cacheReadTokens, cacheWriteTokens }, so a
299
+ * heavily cached session (~130k of 136k cached) would otherwise read as
300
+ * the uncached delta and keep max(view, wire) silent. The view estimate
301
+ * only counts the messages array, so it under-reports by the system and
302
+ * context sections; pressure tiers must not run blind on it. */
303
+ const wireTokensOf = (sessionId) => {
304
+ try {
305
+ return promptTokensOf(metrics.of(sessionId));
306
+ } catch {
307
+ return null;
308
+ }
309
+ };
207
310
  const captures = new Map(); // sessionId -> { messages, refs, viewTokens, model, at }
208
311
  const inFlight = new Set(); // sessionIds with a fold running
209
312
  const windowCache = new Map(); // "provider/model" -> { windowTokens, at }
@@ -211,6 +314,37 @@ export function applyContextTrim(ctx, config) {
211
314
  const lastStreamCheckAt = new Map(); // sessionId -> epoch ms of last stream-path fold check (throttle)
212
315
  const epochResetDoneBySession = new Map(); // sessionId -> ledger-stale baseline reset already done for this host epoch (cleared when a fold applies again)
213
316
 
317
+ // Per-session fold-failure cooldown: a failing summarize/commit cycle
318
+ // advances NONE of the cadence gates (lastFoldAt / foldsThisHour only
319
+ // move on a successful commitFold), so without this the same failure
320
+ // re-fires at every idle boundary plus every >=60s stream check forever.
321
+ // In-memory only by design — a process restart re-arms the cycle open.
322
+ const foldFailureBySession = new Map(); // sessionId -> { count, lastAt }
323
+ const FOLD_FAILURE_BASE_MS = 60_000; // backoff ladder start (2^(n-1) growth)
324
+ const FOLD_FAILURE_MAX_MS = 900_000; // 15 min ceiling
325
+
326
+ /** Record one failed fold cycle (summary unavailable / commit rejected /
327
+ * thrown): consecutive-attempt count + timestamp drive the backoff. */
328
+ function recordFoldFailure(sessionId) {
329
+ const prior = foldFailureBySession.get(sessionId);
330
+ foldFailureBySession.set(sessionId, { count: Math.min((prior?.count ?? 0) + 1, 16), lastAt: Date.now() });
331
+ if (foldFailureBySession.size > 200) foldFailureBySession.delete(foldFailureBySession.keys().next().value);
332
+ }
333
+
334
+ /** Remaining cooldown ms for one session (0 when not cooling): the delay
335
+ * doubles per consecutive failure from 60s, capped at 15 min. */
336
+ function foldFailureCooldownOf(sessionId, now = Date.now()) {
337
+ const entry = foldFailureBySession.get(sessionId);
338
+ if (entry === undefined || entry.count <= 0) return 0;
339
+ const backoff = Math.min(FOLD_FAILURE_BASE_MS * 2 ** (entry.count - 1), FOLD_FAILURE_MAX_MS);
340
+ return Math.max(0, entry.lastAt + backoff - now);
341
+ }
342
+
343
+ /** A committed fold resets the failure ladder. */
344
+ function clearFoldFailure(sessionId) {
345
+ foldFailureBySession.delete(sessionId);
346
+ }
347
+
214
348
  /** Append one fold-decision line to <stateDir>/decisions.log (fail-open,
215
349
  * best-effort observability — console warn stays reserved for outcomes). */
216
350
  function decisionLog(entry) {
@@ -399,10 +533,42 @@ export function applyContextTrim(ctx, config) {
399
533
  const injected = injectView(options, messages, view);
400
534
  const viewTokens = estimateTokensOf(view.messages);
401
535
  const blocksCount = Array.isArray(ledger.blocks) ? ledger.blocks.length : 0;
536
+ // Wire truth in the row: the most recent provider prompt size (from the
537
+ // previous response's usage) makes the view-vs-wire divergence visible
538
+ // instead of leaving the view estimate as the only lullaby in the log.
539
+ const wireTokens = wireTokensOf(sessionId);
402
540
  // Per-request application evidence: whether the ledger's blocks actually
403
541
  // locate inside THIS re-send (the host may compact/replace history, in
404
542
  // which case applied=0 explains a stuck-high view without any failure).
405
- decisionLog(`session=${sessionId} view-applied applied=${view.applied} foldedMsgs=${view.foldedMessages} view=${Math.round(viewTokens)} blocks=${blocksCount} injected=${injected}`);
543
+ decisionLog(`session=${sessionId} view-applied applied=${view.applied} foldedMsgs=${view.foldedMessages} view=${Math.round(viewTokens)} blocks=${blocksCount} injected=${injected}${wireTokens === null ? "" : ` wire=${wireTokens}`}`);
544
+ // Side-dispatch guard: auxiliary llm calls that inherit the session id
545
+ // (web-search summaries, title generation, fold-span material) present
546
+ // tiny arrays in which the ledger cannot locate. Accepting them as the
547
+ // session view clobbers the capture, the cross-layer registry that
548
+ // measureNow reads, and the growth baseline (observed 2026-11-04: a
549
+ // baseline reset to ~150 tokens mid-session). Fold-span dispatches are
550
+ // already covered by inFlight. A tiny dispatch is a side candidate
551
+ // whenever the session ALREADY carries trim state — fold blocks, a
552
+ // capture, or a view registry entry — regardless of blocksCount,
553
+ // which is exactly 0 in the young zero-fold sessions an auxiliary
554
+ // dispatch most often hits. A session's very FIRST dispatch is
555
+ // indistinguishable from a real one and stays accepted: it cannot
556
+ // clobber state that does not exist yet, and the next real dispatch
557
+ // overwrites the capture. A genuine host-compaction epoch ALSO shrinks
558
+ // history below the floor, so the guard must not stick: after
559
+ // SIDE_DISPATCH_SKIP_BUDGET consecutive skips the dispatch is accepted
560
+ // 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)));
563
+ const skipCount = sideDispatchSkipsBySession.get(sessionId) ?? 0;
564
+ if (sideCandidate && skipCount < SIDE_DISPATCH_SKIP_BUDGET) {
565
+ sideDispatchSkipsBySession.set(sessionId, skipCount + 1);
566
+ if (sideDispatchSkipsBySession.size > 200) sideDispatchSkipsBySession.delete(sideDispatchSkipsBySession.keys().next().value);
567
+ decisionLog(`session=${sessionId} side-dispatch skipped n=${skipCount + 1} msgs=${messages.length} view=${Math.round(viewTokens)} blocks=${blocksCount}`);
568
+ return { sessionId, viewTokens, sideDispatch: true };
569
+ }
570
+ if (sideCandidate) decisionLog(`session=${sessionId} side-dispatch budget exhausted — accepting as main msgs=${messages.length}`);
571
+ sideDispatchSkipsBySession.delete(sessionId);
406
572
  // Host-compaction epoch boundary: every block failed to LOCATE inside
407
573
  // this re-send, so the ledger aged out as a whole. Re-anchor the growth
408
574
  // baseline at the true post-epoch view once per epoch — carrying the
@@ -424,7 +590,9 @@ export function applyContextTrim(ctx, config) {
424
590
  const model = { provider: options.provider ?? null, model: options.model ?? null };
425
591
  captureOf(sessionId, rawSnapshot, model, viewTokens);
426
592
  metrics.noteView(sessionId, viewTokens, model);
427
- trimViewBySession.set(sessionId, { viewTokens, at: Date.now() });
593
+ const priorEntry = trimViewBySession.get(sessionId);
594
+ const lastNoneAt = injected === "none" ? Date.now() : Number.isFinite(priorEntry?.lastNoneAt) ? priorEntry.lastNoneAt : 0;
595
+ trimViewBySession.set(sessionId, { viewTokens, at: Date.now(), inject: injected, lastNoneAt });
428
596
  // keep the view registry bounded (200 sessions, same as captures)
429
597
  if (trimViewBySession.size > 200) {
430
598
  const oldest = trimViewBySession.keys().next().value;
@@ -459,6 +627,13 @@ export function applyContextTrim(ctx, config) {
459
627
  let summary = null;
460
628
  let kind = "summary";
461
629
  if (decision.action === "emergency") {
630
+ // Emergency cooldown timestamp set BEFORE the cycle: a commitFold
631
+ // rejection (e.g. duplicate-range) must cool the emergency tier
632
+ // down like a success, or a rejected emergency fold retries at
633
+ // every idle boundary forever. The 90s trigger gate semantics
634
+ // (EMERGENCY_COOLDOWN_MS) stay untouched.
635
+ lastEmergencyAtBySession.set(sessionId, Date.now());
636
+ if (lastEmergencyAtBySession.size > 200) lastEmergencyAtBySession.delete(lastEmergencyAtBySession.keys().next().value);
462
637
  summary = mechanicalSummary(capture.messages, startIdx, endIdx, refOf);
463
638
  kind = "mechanical";
464
639
  } else {
@@ -476,14 +651,16 @@ export function applyContextTrim(ctx, config) {
476
651
  positions,
477
652
  );
478
653
  if (!result.ok) return `commit-rejected:${result.reason}`;
654
+ // Commit-time surface capture (primary range-mapping strategy, see
655
+ // surface.js header): store the covered event seqs while the live
656
+ // surface still resolves the folded range.
657
+ surfacePass.captureFoldSeqs(sessionId, result.block);
479
658
  // Growth-gate baseline pair: pre-fold view (banner display) plus the
480
659
  // post-fold low (tier-a anchor — keeps the gate armed after folds
481
660
  // that commit at high view points).
482
661
  const lowLedger = store.ledgerOf(sessionId);
483
662
  const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
484
663
  store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
485
- if (decision.action === "emergency") lastEmergencyAtBySession.set(sessionId, Date.now());
486
- if (lastEmergencyAtBySession.size > 200) lastEmergencyAtBySession.delete(lastEmergencyAtBySession.keys().next().value);
487
664
  return `folded:${decision.tier}:${count} msgs (~${approxTokens} tokens)`;
488
665
  }
489
666
 
@@ -494,18 +671,42 @@ export function applyContextTrim(ctx, config) {
494
671
  if (sessionId === null || sessionId === undefined || sessionId === "") return;
495
672
  if (!enabledNow() || modeNow() !== "auto") return;
496
673
  if (inFlight.has(sessionId)) return;
674
+ // Failure backoff: a session whose recent cycles failed stays quiet
675
+ // for 60s*2^(n-1) (cap 15 min) instead of re-firing at every idle
676
+ // boundary and stream check. Silent gate — the outcome row that armed
677
+ // the cooldown is already in decisions.log.
678
+ if (foldFailureCooldownOf(sessionId) > 0) return;
497
679
  const capture = captures.get(sessionId);
498
680
  if (capture === undefined) return;
681
+ // Injection-unavailable: the last dispatch could not carry the view
682
+ // (frozen options AND messages — the wire always ships raw history).
683
+ // Folding is pointless ONLY when NEITHER channel can reach the wire:
684
+ // the spliced view (inject !== "none") OR the surface-replace pass
685
+ // (durable capability probe, not a recency window — a proven channel
686
+ // that last shipped before the process restarted is still capable and
687
+ // the next fold ships a fresh surface event). When neither can, the
688
+ // watermark's tally-driven force tier + auto-handover is the relief
689
+ // path.
690
+ if (recentTrimInjectOf(sessionId) === "none" && !surfaceChannelCapable(sessionId)) {
691
+ decisionLog(`session=${sessionId} via=${via} action=none reason=injection-unavailable view=${Math.round(capture.viewTokens)}`);
692
+ return;
693
+ }
499
694
  inFlight.add(sessionId);
500
695
  try {
501
696
  const { protectedTail, ...thresholds } = thresholdsNow();
502
697
  const foldable = computeFoldableRange(capture.messages, store.ledgerOf(sessionId), protectedTail);
503
698
  const windowTokens = await windowTokensFor(capture.model);
504
699
  const ledger = store.ledgerOf(sessionId);
700
+ // Pressure tiers run on the REAL prompt size when the tally is known:
701
+ // max(view, wire). The view estimate misses the system/context
702
+ // sections, so a 136k wire once read as a 50k view and the tiers
703
+ // stayed silent. Unknown tally → exactly today's behavior.
704
+ const wireTokens = wireTokensOf(sessionId);
705
+ const pressureTokens = wireTokens !== null ? Math.max(capture.viewTokens, wireTokens) : capture.viewTokens;
505
706
  const decision = evaluateTrigger({
506
707
  foldable,
507
708
  messages: capture.messages,
508
- viewTokens: capture.viewTokens,
709
+ viewTokens: pressureTokens,
509
710
  windowTokens,
510
711
  thresholds,
511
712
  now: Date.now(),
@@ -514,15 +715,21 @@ export function applyContextTrim(ctx, config) {
514
715
  lastEmergencyAt: lastEmergencyAtBySession.get(sessionId) ?? 0,
515
716
  ledger,
516
717
  });
517
- decisionLog(`session=${sessionId} via=${via} action=${decision.action} tier=${decision.tier ?? "-"} reason=${decision.reason || "-"} view=${Math.round(capture.viewTokens)} window=${windowTokens ?? "null"}`);
718
+ 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"}`);
518
719
  if (decision.action === "none") return;
519
720
  const outcome = await runFoldCycle(sessionId, decision);
721
+ // Failure ladder: commit resets it; a summarize/commit failure
722
+ // climbs it (the no-backoff loop this closes — those outcomes
723
+ // advance no cadence gate on their own).
724
+ if (outcome.startsWith("folded:")) clearFoldFailure(sessionId);
725
+ else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:")) recordFoldFailure(sessionId);
520
726
  // Outcome lands in the decision log too: warn() has no reachable
521
727
  // sink on some hosts, and the commit/fail boundary is exactly what
522
728
  // live diagnosis needs.
523
729
  decisionLog(`session=${sessionId} outcome=${outcome} tier=${decision.tier}`);
524
730
  warn(`[context-trim] ${sessionId}: ${outcome} (${decision.reason})`);
525
731
  } catch (error) {
732
+ recordFoldFailure(sessionId); // a thrown cycle counts as failed too
526
733
  warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
527
734
  } finally {
528
735
  inFlight.delete(sessionId);
@@ -554,7 +761,13 @@ export function applyContextTrim(ctx, config) {
554
761
  const model = captureModelOf(sessionId);
555
762
  const windowTokens = model === null ? null : cachedWindowOf(model);
556
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;
557
- return renderTrimContextLine(label, {
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, {
558
771
  enabled: enabledNow(),
559
772
  mode: modeNow(),
560
773
  viewTokens: record?.viewTokens ?? null,
@@ -562,6 +775,7 @@ return renderTrimContextLine(label, {
562
775
  folds: Array.isArray(ledger?.blocks) ? ledger.blocks.length : 0,
563
776
  growthTokens: record?.viewTokens !== null && record !== undefined && growthBaseline !== null ? record.viewTokens - growthBaseline : null,
564
777
  lastModel: record?.lastModel ?? null,
778
+ wireRaw,
565
779
  });
566
780
  } catch {
567
781
  return "";
@@ -590,7 +804,16 @@ return renderTrimContextLine(label, {
590
804
  // agent }) — derive the session id per host/context-watch.js:122.
591
805
  const sessionId = agentSessionIdOf(payload?.agent);
592
806
  if (sessionId === null) return;
593
- void maybeFold(sessionId);
807
+ // Fold first, then the surface-replace idle sweep (design
808
+ // docs/surface-fold.md): a block committed by this idle pass
809
+ // ships on the same boundary.
810
+ void maybeFold(sessionId).finally(() => {
811
+ try {
812
+ void surfacePass.sweep(sessionId, "idle");
813
+ } catch {
814
+ /* sweep must never break the idle hook */
815
+ }
816
+ });
594
817
  } catch {
595
818
  /* idle hook must never throw */
596
819
  }
@@ -727,6 +950,7 @@ return renderTrimContextLine(label, {
727
950
  positions,
728
951
  );
729
952
  if (!result.ok) return `Fold rejected: ${result.reason}.`;
953
+ surfacePass.captureFoldSeqs(sessionId, result.block);
730
954
  // Growth-gate baseline pair: pre-fold view (banner display) plus the
731
955
  // post-fold low (tier-a anchor — keeps the gate armed after folds
732
956
  // that commit at high view points).
@@ -826,5 +1050,5 @@ return renderTrimContextLine(label, {
826
1050
  warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
827
1051
  }
828
1052
 
829
- return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, renderTrimContextLine: (agent) => renderTrimContextLine(agent, { enabled: enabledNow(), mode: modeNow(), viewTokens: null, windowTokens: null, folds: 0, growthTokens: null, lastModel: null }) };
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 }) };
830
1054
  }
@@ -10,28 +10,50 @@
10
10
  */
11
11
 
12
12
  /** Extract usage fields from a stream chunk. Returns
13
- * { inputTokens, cachedTokens, outputTokens } or null when the chunk
14
- * carries none. Defensive field walk — providers shape usage differently
15
- * (usage.input_tokens, usage.prompt_tokens, cached_tokens,
16
- * cache_read_input_tokens, ...). */
13
+ * { inputTokens, cachedTokens, cacheWriteTokens, outputTokens } or null when
14
+ * the chunk carries none. Defensive field walk — providers shape usage
15
+ * differently (usage.input_tokens, usage.prompt_tokens, cached_tokens,
16
+ * cache_read_input_tokens, ...).
17
+ *
18
+ * Two shapes matter here. The host's own stream chunks carry a normalized
19
+ * TokenUsage ({ inputTokens, cacheReadTokens, cacheWriteTokens, ... }) where
20
+ * inputTokens counts UNCACHED prompt tokens only — the camelCase buckets do
21
+ * not overlap (llm-deepseek/src/translate.ts). Raw provider payloads use the
22
+ * snake_case twins, which on the Anthropic side have the same meaning. Only
23
+ * the OpenAI-style prompt_tokens shape would make inputTokens the inclusive
24
+ * total; summing there would over-count the prompt, which merely folds
25
+ * earlier — the safe direction for this plugin. */
17
26
  export function extractUsage(chunk) {
18
27
  if (chunk === null || typeof chunk !== "object") return null;
19
28
  const candidates = [chunk.usage, chunk.metadata?.usage, chunk.response?.usage];
20
29
  for (const usage of candidates) {
21
30
  if (usage === null || typeof usage !== "object") continue;
22
31
  const input = usage.input_tokens ?? usage.prompt_tokens ?? usage.inputTokens;
23
- const cached = usage.cache_read_input_tokens ?? usage.cached_tokens ?? usage.cachedTokens ?? usage.prompt_tokens_details?.cached_tokens;
32
+ const cached = usage.cache_read_input_tokens ?? usage.cached_tokens ?? usage.cachedTokens ?? usage.cacheReadTokens ?? usage.prompt_tokens_details?.cached_tokens;
33
+ const cacheWrite = usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? usage.cacheWriteTokens;
24
34
  const output = usage.output_tokens ?? usage.completion_tokens ?? usage.outputTokens;
25
- if (!Number.isFinite(input) && !Number.isFinite(cached) && !Number.isFinite(output)) continue;
35
+ if (!Number.isFinite(input) && !Number.isFinite(cached) && !Number.isFinite(cacheWrite) && !Number.isFinite(output)) continue;
26
36
  return {
27
37
  inputTokens: Number.isFinite(input) ? input : null,
28
38
  cachedTokens: Number.isFinite(cached) ? cached : null,
39
+ cacheWriteTokens: Number.isFinite(cacheWrite) ? cacheWrite : null,
29
40
  outputTokens: Number.isFinite(output) ? output : null,
30
41
  };
31
42
  }
32
43
  return null;
33
44
  }
34
45
 
46
+ /** Total prompt tokens for one metrics record: uncached input + cache read +
47
+ * cache write (host TokenUsage buckets do not overlap). Null when no input
48
+ * sample has ever been recorded — callers must treat unknown as "no wire
49
+ * signal" and stay on the view estimate. Missing cache buckets count as 0. */
50
+ export function promptTokensOf(record) {
51
+ if (record === null || record === undefined) return null;
52
+ if (!Number.isFinite(record.lastInputTokens)) return null;
53
+ const total = record.lastInputTokens + (Number.isFinite(record.cachedTokens) ? record.cachedTokens : 0) + (Number.isFinite(record.cacheWriteTokens) ? record.cacheWriteTokens : 0);
54
+ return total > 0 ? Math.round(total) : null;
55
+ }
56
+
35
57
  /** Per-session metrics store. */
36
58
  export function createMetrics() {
37
59
  const sessions = new Map(); // sessionId -> record
@@ -40,8 +62,9 @@ export function createMetrics() {
40
62
  let record = sessions.get(sessionId);
41
63
  if (record === undefined) {
42
64
  record = {
43
- lastInputTokens: null,
44
- cachedTokens: null,
65
+ lastInputTokens: null, // UNCACHED prompt tokens of the last sample
66
+ cachedTokens: null, // cache-read bucket of the last sample
67
+ cacheWriteTokens: null, // cache-write bucket of the last sample
45
68
  outputTokens: null,
46
69
  viewTokens: null, // view estimate noted by the listener pre-request
47
70
  lastModel: null, // { provider, model } of the last stream
@@ -75,6 +98,7 @@ export function createMetrics() {
75
98
  const record = recordOf(sessionId);
76
99
  if (usage.inputTokens !== null) record.lastInputTokens = usage.inputTokens;
77
100
  if (usage.cachedTokens !== null) record.cachedTokens = usage.cachedTokens;
101
+ if (usage.cacheWriteTokens !== null) record.cacheWriteTokens = usage.cacheWriteTokens;
78
102
  if (usage.outputTokens !== null) record.outputTokens = usage.outputTokens;
79
103
  return record;
80
104
  },
@@ -97,7 +97,7 @@ export function renderMaterial(messages, startIdx, endIdx, refOf) {
97
97
  parts.push(head + body);
98
98
  chars += head.length + body.length;
99
99
  }
100
- if (truncated) parts.push(`--- [...material truncated after ${chars} chars; ${endIdx - startIdx + 1 - (parts.length - 1)} messages dropped from this render...] ---\n`);
100
+ if (truncated) parts.push(`--- [...material truncated after ${chars} chars; ${endIdx - startIdx + 1 - parts.length} messages dropped from this render...] ---\n`);
101
101
  return parts.join("");
102
102
  }
103
103