dsh-switchman 1.11.5 → 1.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,7 +24,8 @@
24
24
  *
25
25
  * Config fields (host/config.js, all volatile): trimEnabled, trimMode
26
26
  * ("off"|"advisory"|"auto"), trimGrowthTokens, trimIncrementTokens,
27
- * trimPressurePercent, trimEmergencyPercent, trimWindowTokens,
27
+ * trimPressurePercent, trimEmergencyPercent, trimAdvisoryPercent,
28
+ * trimAdvisoryCriticalPercent, trimWindowTokens,
28
29
  * trimProtectedTail, trimMaxFoldsPerHour, trimSideModel, trimFoldFallbackModel, trimStateDir,
29
30
  * trimLadderEnabled, trimLadderT2Tokens, trimLadderT2MinBlocks,
30
31
  * trimLadderT3Tokens, trimLadderT3MinBlocks.
@@ -32,7 +33,7 @@
32
33
 
33
34
  import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
34
35
  import { join } from "node:path";
35
- import { buildView, estimateTokensOf, injectView, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
36
+ import { buildView, estimateTokensOf, injectView, isFoldSummaryMessage, ledgerIsStale, messageTokens, rangeIsSegmentAligned, refOf, refPositions } from "./view.js";
36
37
  import { computeFoldableRange, evaluateTrigger, EMERGENCY_COOLDOWN_MS } from "./trigger.js";
37
38
  import { LADDER_SYSTEM_PROMPT, mechanicalSummary, renderLadderMaterial, renderMaterial, summarizeWithModel } from "./summarizer.js";
38
39
  import { createStore } from "./state.js";
@@ -274,6 +275,44 @@ export const TRIM_PROTOCOL_TEXT = [
274
275
  "- Tool-call pairs (an assistant tool call and its results) must always be folded together or not at all.",
275
276
  ].join("\n");
276
277
 
278
+ // Advisory nudge thresholds: the default speak line (65%) deliberately sits
279
+ // below the protocol's own 80% fold line so the model is nudged while it
280
+ // still has comfortable headroom; critical wording escalates at 95%. At most
281
+ // one nudge per session per 10 minutes (an instant re-nudge when the level
282
+ // band changes). Both lines are config-tunable via trimAdvisoryPercent /
283
+ // trimAdvisoryCriticalPercent.
284
+ export const ADVISORY_NUDGE_PERCENT = 65;
285
+ export const ADVISORY_NUDGE_CRITICAL_PERCENT = 95;
286
+ export const ADVISORY_NUDGE_REPEAT_MS = 600_000;
287
+
288
+ /** Effective advisory lines for one dispatch: config-tunable, falling back to
289
+ * the configured defaults (65/95) when absent, inverted, or out of range —
290
+ * a broken pair never silences the nudge. */
291
+ function advisoryNudgeLines(config) {
292
+ const line = readNumber(config, "trimAdvisoryPercent", ADVISORY_NUDGE_PERCENT);
293
+ const critical = readNumber(config, "trimAdvisoryCriticalPercent", ADVISORY_NUDGE_CRITICAL_PERCENT);
294
+ const sane = (v) => Number.isFinite(v) && v > 0 && v <= 100;
295
+ if (!sane(line) || !sane(critical) || line >= critical) return { line: ADVISORY_NUDGE_PERCENT, critical: ADVISORY_NUDGE_CRITICAL_PERCENT };
296
+ return { line, critical };
297
+ }
298
+
299
+ /** Advisory-mode nudge text. Deliberate, bounded exception to the
300
+ * digit-free disclosure policy of TRIM_PROTOCOL_TEXT above: advisory mode has
301
+ * NO engine-initiated folds (the modeNow gate), so the model's own fold is
302
+ * the only relief channel. At threshold, the live-number instruction is
303
+ * appended to the session surface tail, not injected into the registered
304
+ * system section. */
305
+ export function advisoryNudgeText(pressurePercent, critical, foldLine = ADVISORY_NUDGE_PERCENT, criticalLine = ADVISORY_NUDGE_CRITICAL_PERCENT) {
306
+ const action = critical
307
+ ? `Pressure is CRITICAL (${pressurePercent}% ≥ ${criticalLine}%): fold down to under ~50% of the context IN THIS TURN.`
308
+ : `At ${pressurePercent}% (fold line ${foldLine}%): ${foldLine}–${foldLine + 9}% → fold roughly half of the older history now; ≥${foldLine + 10}% → fold down to under ~50%.`;
309
+ return [
310
+ '[SWITCHMAN:TRIM] Advisory nudge — pinned to the session tail until pressure falls below the fold line.',
311
+ `Live context pressure on this session has reached the fold line. ${action}`,
312
+ "Act now: call ctx_trim_status to list compressible ranges, pick one contiguous range, and fold it with ctx_trim_fold (tool-call pairs fold together or not at all; existing summaries stay). Do not wait for a routine moment.",
313
+ ].join("\n");
314
+ }
315
+
277
316
  /** Effective window for the pressure/emergency tier math:
278
317
  * min(model-resolved window, configured cap). Null only when BOTH inputs
279
318
  * are unusable — the trigger then skips those tiers (fail-open). */
@@ -359,8 +398,158 @@ export function applyContextTrim(ctx, config) {
359
398
  return null;
360
399
  }
361
400
  };
401
+
402
+ // Advisory nudge state: sessionId -> { at, critical, seq }. Bounded like the
403
+ // other per-session registries (200 entries).
404
+ const advisoryNudgeBySession = new Map();
405
+
406
+ // Single-flight for nudge warm-ups: during resolveRawWindow's <=10s window
407
+ // every main dispatch would otherwise re-fire the same resolve. Entries
408
+ // self-delete on settle; the expiry is only a belt-and-suspenders bound.
409
+ const windowWarmupInFlight = new Map(); // "provider/model" -> expiresAt
410
+ function warmWindowOnce(model, now) {
411
+ const key = `${model.provider}/${model.model}`;
412
+ const until = windowWarmupInFlight.get(key);
413
+ if (until !== undefined && now < until) return;
414
+ windowWarmupInFlight.set(key, now + 15_000); // > resolveRawWindow's 10s ceiling
415
+ void windowTokensFor(model).catch(() => {}).finally(() => {
416
+ if (windowWarmupInFlight.get(key) === until) windowWarmupInFlight.delete(key);
417
+ });
418
+ }
419
+
420
+ /** Advisory-mode pressure nudge (text: advisoryNudgeText). Appends the live
421
+ * instruction to the session surface tail once pressure crosses the fold
422
+ * line. Pressure reuses the meter-duality bridge (wire preferred, view
423
+ * estimate as floor) against the session model's window read from the SYNC
424
+ * cache — a cache miss fires an async warm-up (windowTokensFor) and defers
425
+ * the nudge to a later dispatch instead of blocking this one — and it only
426
+ * reaches the wire on the NEXT dispatch (the current request is already
427
+ * assembled). The speak is a pure suffix, so the prompt prefix stays
428
+ * untouched; a later clear or upgrade prunes only the nudge nodes, so
429
+ * cache reuse survives up to the nudge instead of invalidating the whole
430
+ * prefix as the old system-head splice did. */
431
+ function deliverAdvisoryNudge(sessionId, viewTokens) {
432
+ if (modeNow() !== "advisory") return;
433
+ const model = capturedModelBySession.get(sessionId) ?? null;
434
+ if (model === null) {
435
+ decisionLog("advisory-nudge skip why=no-captured-model");
436
+ return;
437
+ }
438
+ if (typeof model.provider !== "string" || typeof model.model !== "string") {
439
+ decisionLog("advisory-nudge skip why=bad-model-shape");
440
+ return;
441
+ }
442
+ const cached = windowCache.get(model.provider + "/" + model.model);
443
+ let resolvedWindow; // model-resolved window, or the configured cap when unknown
444
+ if (cached === undefined) {
445
+ // Unknown: warm up single-flight but stay AUDIBLE — fall back to the
446
+ // configured cap (the same denominator the emergency tier uses), so a
447
+ // model whose metadata never resolves still gets nudged at the fold
448
+ // line instead of burning to force unseen (2026-11-14: window-unknown
449
+ // swallowed every nudge at 89% pressure).
450
+ warmWindowOnce(model, Date.now());
451
+ resolvedWindow = windowCapNow();
452
+ } else {
453
+ // Stale-but-known still gates correctly: effectiveWindowTokens binds to the
454
+ // configured cap for every model larger than it, so the pressure math is
455
+ // unchanged. Re-warm in the background instead of burning this boundary.
456
+ if (Date.now() - cached.at >= 600_000) warmWindowOnce(model, Date.now());
457
+ // Resolution failed inside the TTL: same cap fallback instead of going
458
+ // quiet (no retry hammering — the warm-up stays single-flight).
459
+ resolvedWindow = cached.windowTokens ?? windowCapNow();
460
+ }
461
+ // Bind the resolved window to the configured cap before computing pressure.
462
+ const windowTokens = effectiveWindowTokens(resolvedWindow, windowCapNow());
463
+ if (windowTokens === null) {
464
+ // Cap unavailable too (wmForceTokens=0 and no explicit cap): nothing to
465
+ // measure against — the one remaining legitimate silence.
466
+ decisionLog("advisory-nudge skip why=window-unknown");
467
+ return;
468
+ }
469
+ const wire = wireTokensOf(sessionId);
470
+ const mass = Math.max(Number.isFinite(wire) ? wire : 0, Number.isFinite(viewTokens) ? viewTokens : 0);
471
+ if (mass <= 0) return;
472
+ const pressurePercent = (mass / windowTokens) * 100;
473
+ const nudgeLines = advisoryNudgeLines(config);
474
+ const prior = advisoryNudgeBySession.get(sessionId);
475
+ if (pressurePercent < nudgeLines.line) {
476
+ // Below the line every live nudge must go. The sweep is NOT gated
477
+ // on `prior`: restarts and registry evictions lose the episode while
478
+ // the node stays visible. The recorded seq joins the union even when
479
+ // it fell out of the 50-node scan window; nudgePrune skips seqs a
480
+ // fold already consumed, so a vanished prior.seq cannot wedge this.
481
+ const seqs = new Set(surfacePass.nudgeScan(sessionId));
482
+ if (Number.isFinite(prior?.seq)) seqs.add(prior.seq);
483
+ if (seqs.size > 0) {
484
+ if (!surfacePass.nudgePrune(sessionId, [...seqs])) {
485
+ decisionLog("advisory-nudge skip why=prune-failed pressure=" + Math.round(pressurePercent) + "%");
486
+ return;
487
+ }
488
+ advisoryNudgeBySession.delete(sessionId);
489
+ decisionLog("advisory-nudge below-line pressure=" + Math.round(pressurePercent) + "% mass=" + Math.round(mass) + " resolved=" + cached.windowTokens + " cap=" + windowCapNow() + " pruned=" + seqs.size);
490
+ } else if (prior !== undefined) {
491
+ advisoryNudgeBySession.delete(sessionId); // nothing visible left; close the episode quietly
492
+ }
493
+ return; // fresh episode on the next crossing
494
+ }
495
+ const critical = pressurePercent >= nudgeLines.critical;
496
+ const now = Date.now();
497
+ // Any band CHANGE re-speaks immediately: escalation high->critical, and
498
+ // intentionally also a downgrade back into high after a partial fold.
499
+ if (prior !== undefined && prior.critical === critical && now - prior.at < ADVISORY_NUDGE_REPEAT_MS) return;
500
+ if (!surfacePass.nudgeSupported(sessionId)) {
501
+ decisionLog("advisory-nudge skip why=surface-unavailable pressure=" + Math.round(pressurePercent) + "%");
502
+ return;
503
+ }
504
+
505
+ // Replace the recorded prior nudge, or clear orphans a restart left
506
+ // behind, before speaking fresh. nudgePrune only touches seqs still
507
+ // live on the surface, so a prior a fold already consumed is a no-op.
508
+ const priorSeq = Number.isFinite(prior?.seq) ? prior.seq : null;
509
+ if (priorSeq !== null) {
510
+ if (!surfacePass.nudgePrune(sessionId, [priorSeq])) {
511
+ decisionLog("advisory-nudge skip why=prune-failed pressure=" + Math.round(pressurePercent) + "%");
512
+ return;
513
+ }
514
+ } else {
515
+ const orphans = surfacePass.nudgeScan(sessionId);
516
+ if (orphans.length > 0) {
517
+ if (!surfacePass.nudgePrune(sessionId, orphans)) {
518
+ decisionLog("advisory-nudge skip why=prune-failed pressure=" + Math.round(pressurePercent) + "%");
519
+ return;
520
+ }
521
+ decisionLog("advisory-nudge orphan-pruned seqs=" + orphans.length + " pressure=" + Math.round(pressurePercent) + "%");
522
+ }
523
+ }
524
+ const nudge = advisoryNudgeText(Math.round(pressurePercent), critical, nudgeLines.line, nudgeLines.critical);
525
+ const appended = surfacePass.nudgeAppend(sessionId, nudge);
526
+ if (appended.error !== null || !Number.isFinite(appended.seq)) {
527
+ advisoryNudgeBySession.delete(sessionId); // a pruned prior must not throttle the retry
528
+ decisionLog("advisory-nudge skip why=append-failed err=" + (appended.error ?? "unknown") + " pressure=" + Math.round(pressurePercent) + "%");
529
+ return;
530
+ }
531
+ const seq = appended.seq;
532
+ advisoryNudgeBySession.delete(sessionId); // re-insert: refresh recency so eviction drops the least recently nudged
533
+ advisoryNudgeBySession.set(sessionId, { at: now, critical, seq });
534
+ if (advisoryNudgeBySession.size > 200) advisoryNudgeBySession.delete(advisoryNudgeBySession.keys().next().value);
535
+ decisionLog("session=" + sessionId + " advisory-nudge pressure=" + Math.round(pressurePercent) + "% mass=" + Math.round(mass) + " window=" + windowTokens + " level=" + (critical ? "critical" : "high") + " seq=" + seq);
536
+ }
537
+
362
538
  const captures = new Map(); // sessionId -> { messages, refs, viewTokens, model, at }
363
- const inFlight = new Set(); // sessionIds with a fold running
539
+ // sessionId -> generation token of the running fold cycle. A plain Set
540
+ // pinned a session forever once a cycle's await never settled; the
541
+ // maybeFold watchdog swaps the token so wedged cycles release the lock.
542
+ const inFlight = new Map();
543
+ // Fold-cycle watchdog: a cycle still "in flight" after this long logs a
544
+ // cycle-stuck row, releases the lock, and lets the next trigger retry.
545
+ // The ceiling must exceed the worst LEGITIMATE cycle — a raw fold can
546
+ // summarize on side AND fallback routes (2 x SUMMARY_TIMEOUT_MS = 240s) —
547
+ // so a healthy slow cycle is never preempted.
548
+ const CYCLE_WATCHDOG_MS = 300_000;
549
+ // Hard ceiling for one host model-info lookup: some llm builds never
550
+ // settle resolveModelInfo for unresolvable routes, and that wedged
551
+ // await used to pin the whole fold cycle until restart.
552
+ const WINDOW_RESOLVE_TIMEOUT_MS = 10_000;
364
553
  const windowCache = new Map(); // "provider/model" -> { windowTokens, at }
365
554
  const lastEmergencyAtBySession = new Map(); // sessionId -> epoch ms of last emergency fold (per-session cooldown)
366
555
  const lastStreamCheckAt = new Map(); // sessionId -> epoch ms of last stream-path fold check (throttle)
@@ -505,6 +694,7 @@ export function applyContextTrim(ctx, config) {
505
694
  ladderT2MinBlocks: readNumber(config, "trimLadderT2MinBlocks", 3),
506
695
  ladderT3Tokens: readNumber(config, "trimLadderT3Tokens", 6_000),
507
696
  ladderT3MinBlocks: readNumber(config, "trimLadderT3MinBlocks", 2),
697
+ protectTaskIntent: readBool(config, "trimProtectTaskIntent", true),
508
698
  });
509
699
  const ladderEnabledNow = () => readBool(config, "trimLadderEnabled", true);
510
700
  /** CJK-aware summary-mass estimator for the ladder census (same units
@@ -573,20 +763,41 @@ export function applyContextTrim(ctx, config) {
573
763
  return effectiveWindowTokens(resolved, windowCapNow());
574
764
  }
575
765
 
576
- /** Async raw-window resolution (fills the cache; null when unknown). */
766
+ /** Async raw-window resolution (fills the cache; null when unknown).
767
+ * Two fixes over the historical behavior: the host contract is
768
+ * resolveModelInfo(provider, model) — two strings; passing the model
769
+ * object threw NO_ADAPTER on every route, so every decision row ran on
770
+ * the cap fallback. And the lookup is raced against a hard timeout:
771
+ * a never-settling promise must not pin the fold cycle. */
577
772
  async function resolveRawWindow(model, key, now) {
773
+ let timer = null;
578
774
  try {
579
775
  const llm = ctx.get("llm", false);
580
- const info = await llm?.resolveModelInfo?.(model);
776
+ const lookup = Promise.resolve(llm?.resolveModelInfo?.(model.provider, model.model));
777
+ const info = await Promise.race([
778
+ lookup,
779
+ new Promise((_, reject) => {
780
+ timer = setTimeout(() => reject(new Error(`no settle within ${WINDOW_RESOLVE_TIMEOUT_MS}ms`)), WINDOW_RESOLVE_TIMEOUT_MS);
781
+ timer.unref?.();
782
+ }),
783
+ ]);
581
784
  const windowTokens = info?.context?.contextWindow;
582
785
  const value = Number.isFinite(windowTokens) && windowTokens > 0 ? windowTokens : null;
786
+ if (value === null) decisionLog(`window-resolve-failed key=${key} why=no context metadata`);
583
787
  windowCache.set(key, { windowTokens: value, at: now });
584
788
  if (windowCache.size > 200) windowCache.delete(windowCache.keys().next().value);
789
+ if (value !== null) decisionLog(`window-resolved key=${key} window=${value}`);
585
790
  return value;
586
- } catch {
791
+ } catch (error) {
587
792
  windowCache.set(key, { windowTokens: null, at: now });
588
793
  if (windowCache.size > 200) windowCache.delete(windowCache.keys().next().value);
794
+ // One row per model per 10-min cache window (plus rare concurrent
795
+ // misses inside the 10s race window): a silently failing resolution
796
+ // is what hid the wrong-arity call for months.
797
+ decisionLog(`window-resolve-failed key=${key} why=${error?.message ?? error}`);
589
798
  return null;
799
+ } finally {
800
+ if (timer !== null) clearTimeout(timer);
590
801
  }
591
802
  }
592
803
 
@@ -595,7 +806,8 @@ export function applyContextTrim(ctx, config) {
595
806
  function captureOf(sessionId, messages, model, viewTokens) {
596
807
  captures.set(sessionId, { messages: messages.slice(), model, viewTokens, at: Date.now() });
597
808
  if (model !== null && typeof model === "object" && typeof model.provider === "string" && typeof model.model === "string") {
598
- capturedModelBySession.set(sessionId, { provider: model.provider, model: model.model, at: Date.now() });
809
+ capturedModelBySession.set(sessionId, { provider: model.provider, model: model.model, at: Date.now() });
810
+ if (!windowCache.has(model.provider + "/" + model.model)) warmWindowOnce(model, Date.now());
599
811
  if (capturedModelBySession.size > 200) capturedModelBySession.delete(capturedModelBySession.keys().next().value);
600
812
  }
601
813
  // keep the capture map bounded (200 sessions)
@@ -744,7 +956,7 @@ export function applyContextTrim(ctx, config) {
744
956
 
745
957
  /** Run one fold cycle for a session (called at the idle boundary ONLY).
746
958
  * Returns a short outcome string for logging. */
747
- async function runFoldCycle(sessionId, decision, cycleStartedAt) {
959
+ async function runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken) {
748
960
  const capture = captures.get(sessionId);
749
961
  if (capture === undefined) return "no-capture";
750
962
  const { protectedTail } = thresholdsNow();
@@ -770,6 +982,7 @@ export function applyContextTrim(ctx, config) {
770
982
  // Ladder folds never degrade to mechanical (docs/ladder-fold.md):
771
983
  // after both routes fail the cycle stays fail-open with backoff.
772
984
  if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
985
+ if (inFlight.get(sessionId) !== cycleToken) return "superseded"; // watchdog released us; a retry owns the session
773
986
  const result = store.commitFold(
774
987
  sessionId,
775
988
  { consumes: decision.consumes.slice(), summary, kind: "summary", tier: decision.tier === "ladder-3" ? 3 : 2, approxTokens },
@@ -828,6 +1041,7 @@ export function applyContextTrim(ctx, config) {
828
1041
  if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
829
1042
  }
830
1043
  const positions = refPositions(capture.messages);
1044
+ if (inFlight.get(sessionId) !== cycleToken) return "superseded"; // watchdog released us; a retry owns the session
831
1045
  const result = store.commitFold(
832
1046
  sessionId,
833
1047
  { refRange: { firstId: refs[0], lastId: refs[refs.length - 1] }, count, approxTokens, summary, kind, tier: decision.tier },
@@ -874,15 +1088,28 @@ export function applyContextTrim(ctx, config) {
874
1088
  decisionLog(`session=${sessionId} via=${via} action=none reason=injection-unavailable view=${Math.round(capture.viewTokens)}`);
875
1089
  return;
876
1090
  }
877
- inFlight.add(sessionId);
878
1091
  const cycleStartedAt = Date.now();
1092
+ // Generation token lock (was a plain Set): a cycle wedged on an await
1093
+ // that never settles used to pin this session's fold path until
1094
+ // restart. The watchdog logs the wedge and releases the lock; an
1095
+ // abandoned cycle returns "superseded" at its commit guard, and its
1096
+ // finally can no longer delete a retry's entry.
1097
+ const cycleToken = {};
1098
+ inFlight.set(sessionId, cycleToken);
1099
+ const cycleWatchdog = setTimeout(() => {
1100
+ if (inFlight.get(sessionId) !== cycleToken) return;
1101
+ inFlight.delete(sessionId);
1102
+ decisionLog(`session=${sessionId} via=${via} outcome=cycle-stuck elapsedMs=${Date.now() - cycleStartedAt}`);
1103
+ warn(`[context-trim] ${sessionId}: fold cycle stuck >${CYCLE_WATCHDOG_MS}ms — lock released for the next trigger`);
1104
+ }, CYCLE_WATCHDOG_MS);
1105
+ cycleWatchdog.unref?.();
879
1106
  // Hoisted for the catch below: a dispose-cut throw may land before the
880
1107
  // trigger ever ran, and the catch must not touch a dead binding.
881
1108
  let decision = null;
882
1109
  try {
883
- const { protectedTail, ...thresholds } = thresholdsNow();
1110
+ const { protectedTail, protectTaskIntent, ...thresholds } = thresholdsNow();
884
1111
  const ledger = store.ledgerOf(sessionId);
885
- const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
1112
+ const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
886
1113
  const windowTokens = await windowTokensFor(capture.model);
887
1114
  // Pressure tiers run on the REAL prompt size when the tally is known:
888
1115
  // max(view, wire). The view estimate misses the system/context
@@ -905,12 +1132,15 @@ export function applyContextTrim(ctx, config) {
905
1132
  });
906
1133
  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"}`);
907
1134
  if (decision.action === "none") return;
908
- const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt);
1135
+ const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken);
909
1136
  // Failure ladder: commit resets it; a summarize/commit failure
910
1137
  // climbs it (the no-backoff loop this closes — those outcomes
911
1138
  // advance no cadence gate on their own). A ladder run whose
912
1139
  // consumed blocks vanished between trigger and commit is the same
913
1140
  // family: without backoff it would re-fire at every boundary.
1141
+ // "superseded" (watchdog released the lock mid-cycle) and neutral
1142
+ // skips advance no ladder either — the retry that owns the session
1143
+ // is judged alone.
914
1144
  if (outcome.startsWith("folded:")) clearFoldFailure(sessionId);
915
1145
  else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:") || outcome === "ladder-consumed-missing") recordFoldFailure(sessionId);
916
1146
  // Outcome lands in the decision log too: warn() has no reachable
@@ -927,7 +1157,10 @@ export function applyContextTrim(ctx, config) {
927
1157
  warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
928
1158
  }
929
1159
  } finally {
930
- inFlight.delete(sessionId);
1160
+ clearTimeout(cycleWatchdog);
1161
+ // Only the cycle owning the CURRENT token may release the lock —
1162
+ // a watchdog-superseded wedged cycle must not delete a retry's entry.
1163
+ if (inFlight.get(sessionId) === cycleToken) inFlight.delete(sessionId);
931
1164
  }
932
1165
  }
933
1166
 
@@ -1037,19 +1270,27 @@ export function applyContextTrim(ctx, config) {
1037
1270
  name: "ctx_trim_status",
1038
1271
  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.",
1039
1272
  parameters: { type: "object", properties: {}, additionalProperties: false },
1040
- required: [],
1273
+ // Register-level shape: bare ctx.tools.register runs no defineTool DSL
1274
+ // compilation and REJECTS a definition without an output block — the
1275
+ // missing block silently killed both tools in every session (native
1276
+ // and PTC). The execute bodies return plain strings, rendered as one
1277
+ // text block.
1278
+ output: {
1279
+ schema: { type: "string" },
1280
+ render: (_args, value) => [{ type: "text", text: value }],
1281
+ },
1041
1282
  async execute() {
1042
1283
  const sessionId = sessionIdOf({});
1043
1284
  if (sessionId === null) return "No session context available.";
1044
1285
  if (!enabledNow()) return "Context trimming is disabled for this deployment.";
1045
1286
  const ledger = store.ledgerOf(sessionId);
1046
1287
  const capture = captures.get(sessionId);
1047
- const { protectedTail } = thresholdsNow();
1288
+ const { protectedTail, protectTaskIntent } = thresholdsNow();
1048
1289
  const lines = [`ctx_trim_status — mode: ${modeNow()} (enabled: ${enabledNow()})`];
1049
1290
  lines.push(...renderStatusLedger(ledger));
1050
1291
  // The raw range a fold would cover next, with its true size.
1051
1292
  if (capture !== undefined) {
1052
- const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
1293
+ const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
1053
1294
  if (foldable !== null) {
1054
1295
  const firstRef = refOf(capture.messages[foldable.startIdx], foldable.startIdx);
1055
1296
  const lastRef = refOf(capture.messages[foldable.endIdx], foldable.endIdx);
@@ -1087,6 +1328,11 @@ export function applyContextTrim(ctx, config) {
1087
1328
  required: ["firstId", "lastId", "count", "summary"],
1088
1329
  additionalProperties: false,
1089
1330
  },
1331
+ // Register-level output block — see ctx_trim_status above.
1332
+ output: {
1333
+ schema: { type: "string" },
1334
+ render: (_args, value) => [{ type: "text", text: value }],
1335
+ },
1090
1336
  async execute(args) {
1091
1337
  const sessionId = sessionIdOf({});
1092
1338
  if (sessionId === null) return "No session context available.";
@@ -1110,6 +1356,15 @@ export function applyContextTrim(ctx, config) {
1110
1356
  }
1111
1357
  if (firstIdx < 0) { firstIdx = firstList[0]; lastIdx = lastList[lastList.length - 1]; }
1112
1358
  if (lastIdx < firstIdx) return "lastId sorts before firstId in the current history.";
1359
+ // Prior fold summaries are never raw-fold material: a T1 fold
1360
+ // covering an acp_summary_* node would shadow the replacement
1361
+ // node and break the ladder's consumed-run bookkeeping.
1362
+ // Summaries consolidate only through ladder T2/T3 distillation.
1363
+ for (let i = firstIdx; i <= lastIdx; i++) {
1364
+ if (isFoldSummaryMessage(capture.messages[i])) {
1365
+ return "Range covers a previous fold's summary (acp_summary_*) — summaries are preserved and consolidate via T2/T3 ladder; pick a range that excludes them.";
1366
+ }
1367
+ }
1113
1368
  const { protectedTail } = thresholdsNow();
1114
1369
  if (lastIdx >= capture.messages.length - Math.max(0, protectedTail)) return `Range reaches into the protected tail (last ${protectedTail} messages are never folded).`;
1115
1370
  if (!rangeIsSegmentAligned(capture.messages, firstIdx, lastIdx)) return "Range splits a tool-call pair — fold whole assistant/tool segments (extend firstId/lastId to the segment boundaries).";
@@ -1134,7 +1389,18 @@ export function applyContextTrim(ctx, config) {
1134
1389
  const lowLedger = store.ledgerOf(sessionId);
1135
1390
  const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
1136
1391
  store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
1137
- return `Folded ${count} messages into block ${result.block.id}. The view applies on the next model call.`;
1392
+ // Ship the fresh block onto the session surface NOW instead of waiting for
1393
+ // the next idle/turn-stopping boundary: a fold committed mid-turn used to
1394
+ // stay meter-only for the rest of the turn (2026-11-14 live test: two
1395
+ // tool-path folds left the wire carrying raw history for ~11 min at 97%
1396
+ // pressure — injected=none on frozen dispatches, both blocks pending).
1397
+ // The sweep's own guards (enabled flag, capable session, compaction lock,
1398
+ // per-block validation) fail closed to pending; the boundary sweeps
1399
+ // remain the closure pass.
1400
+ void surfacePass.sweep(sessionId, "fold-commit").catch((error) => {
1401
+ decisionLog(`session=${sessionId} surface-replace failed via=fold-commit why=${error?.message ?? error}`);
1402
+ });
1403
+ return `Folded ${count} messages into block ${result.block.id}. An immediate surface ship was queued (via=fold-commit): when it lands, even frozen dispatches carry the reduced history; if the surface cannot accept it right now the block stays pending and the boundary sweeps retry.`;
1138
1404
  },
1139
1405
  });
1140
1406
  } catch (error) {
@@ -1152,6 +1418,16 @@ export function applyContextTrim(ctx, config) {
1152
1418
  if (applied !== null) {
1153
1419
  viewTokens = applied.viewTokens;
1154
1420
  mainDispatch = applied.sideDispatch !== true;
1421
+ if (mainDispatch) {
1422
+ // Advisory pressure nudge (modeNow gates inside): the model's
1423
+ // own fold is advisory mode's ONLY relief channel — speak at
1424
+ // the fold line. Fail-open, never breaks the dispatch.
1425
+ try {
1426
+ deliverAdvisoryNudge(applied.sessionId, applied.viewTokens);
1427
+ } catch (error) {
1428
+ decisionLog(`advisory-nudge-failed why=${error?.message ?? error}`);
1429
+ }
1430
+ }
1155
1431
  }
1156
1432
  }
1157
1433
  } catch (error) {
@@ -1239,5 +1515,5 @@ export function applyContextTrim(ctx, config) {
1239
1515
  warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
1240
1516
  }
1241
1517
 
1242
- return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
1518
+ return { store, metrics, captures, inFlight, windowCache, advisoryNudgeBySession, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
1243
1519
  }