dsh-switchman 1.11.4 → 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.
package/cordis.patch.yml CHANGED
@@ -136,6 +136,9 @@
136
136
  name: '@deepseek-ai/dsh-tool-schedule'
137
137
  - id: skill-filesystem
138
138
  name: '@deepseek-ai/dsh-skill-filesystem'
139
+ config:
140
+ customSkillDirs:
141
+ - !!js process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:path').dirname(process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('@deepseek-ai/dsh-agent-preset/package.json')), 'skills')
139
142
  - id: tool-skill
140
143
  name: '@deepseek-ai/dsh-tool-skill'
141
144
  - id: command-goal
@@ -196,6 +199,8 @@
196
199
  config:
197
200
  - id: tool-subagent-control
198
201
  name: '@deepseek-ai/dsh-tool-subagent-control'
202
+ config:
203
+ interrupt: false
199
204
  - id: tool-subagent
200
205
  name: '@deepseek-ai/dsh-tool-subagent'
201
206
  config:
@@ -227,6 +232,8 @@
227
232
  searchTimeoutMs: 60000
228
233
  - id: present
229
234
  name: '@deepseek-ai/dsh-tool-present'
235
+ - id: tool-office
236
+ name: '@deepseek-ai/dsh-tool-office'
230
237
  - id: tool-plugin-manager
231
238
  name: '@deepseek-ai/dsh-plugin-manager/tools'
232
239
  disabled: !!js "!ctx.get('profileContext')"
@@ -335,6 +342,8 @@
335
342
  config:
336
343
  - id: tool-subagent-control
337
344
  name: '@deepseek-ai/dsh-tool-subagent-control'
345
+ config:
346
+ interrupt: false
338
347
  - id: tool-subagent
339
348
  name: '@deepseek-ai/dsh-tool-subagent'
340
349
  config:
@@ -370,6 +379,8 @@
370
379
  name: '@deepseek-ai/dsh-tool-executor-ptc'
371
380
  - id: present
372
381
  name: '@deepseek-ai/dsh-tool-present'
382
+ - id: tool-office
383
+ name: '@deepseek-ai/dsh-tool-office'
373
384
  - id: tool-plugin-manager
374
385
  name: '@deepseek-ai/dsh-plugin-manager/tools'
375
386
  disabled: true
@@ -488,7 +499,7 @@
488
499
  name: '@deepseek-ai/dsh-persona'
489
500
  config:
490
501
  prefix: >-
491
- You are a coding agent powered by the {{model}} model.
502
+ You are a coding agent powered by the {{model}} model. You help the user customize DeepSeek Harness itself.
492
503
  suffix: *switchman-team-suffix
493
504
  - id: agent-instructions
494
505
  name: '@deepseek-ai/dsh-agent-instructions'
@@ -572,6 +583,8 @@
572
583
  config:
573
584
  - id: tool-subagent-control
574
585
  name: '@deepseek-ai/dsh-tool-subagent-control'
586
+ config:
587
+ interrupt: false
575
588
  - id: tool-subagent
576
589
  name: '@deepseek-ai/dsh-tool-subagent'
577
590
  config:
@@ -653,10 +666,13 @@
653
666
  trimIncrementTokens: 25000
654
667
  trimPressurePercent: 65
655
668
  trimEmergencyPercent: 95
669
+ trimAdvisoryPercent: 65
670
+ trimAdvisoryCriticalPercent: 95
656
671
  trimWindowTokens: 0
657
672
  trimProtectedTail: 10
658
673
  trimMaxFoldsPerHour: 30
659
674
  trimLadderEnabled: true
675
+ trimProtectTaskIntent: true
660
676
  trimLadderT2Tokens: 8000
661
677
  trimLadderT2MinBlocks: 3
662
678
  trimLadderT3Tokens: 6000
package/host/config.js CHANGED
@@ -67,10 +67,9 @@ export const IntentCandidateSchema = z.object({
67
67
  * ModelRouteSchema this accepts the "unset" `{provider:"", model:""}`
68
68
  * pair the settings page round-trips — ModelRouteSchema's `.min(1)`
69
69
  * would reject that exact declared default on every settings write.
70
- * Consumers validate non-empty before trusting the route. effort follows
71
- * the optional no-`.required()` style (schemastery passes undefined
72
- * through): the trim summarizer reads it as a reasoning-effort pin;
73
- * intent-layer routes leave it unset. */
70
+ * Consumers validate non-empty before trusting the route. `effort` is an
71
+ * optional override for summary routes and intent fallback; the classifier
72
+ * route remains effort-free in its route cleaner. */
74
73
  const IntentRouteSchema = z.object({
75
74
  provider: z.string(),
76
75
  model: z.string(),
@@ -240,8 +239,9 @@ export const Config = z.object({
240
239
  * the 0.8×-window auto-compaction (see effectiveContextWindow in
241
240
  * host/intent.js). */
242
241
  intentContextWindow: z.number().min(1024).default(200000).volatile(),
243
- /** Optional last-resort route when the classifier fails and no previous
244
- * routed model exists; empty = the first candidate. */
242
+ /** Optional fallback route after classification fails when no prior route
243
+ * is available; empty = the first candidate. An optional effort pin
244
+ * overrides the fallback model's default. */
245
245
  intentFallbackRoute: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
246
246
 
247
247
  // --- Context trim: fully-automatic conversation compression ------------
@@ -267,6 +267,14 @@ export const Config = z.object({
267
267
  /** Emergency tier (tier c): forced MECHANICAL fold (no side-model call)
268
268
  * at this percent of the window; cooldown-gated. */
269
269
  trimEmergencyPercent: z.number().min(1).max(100).default(95).volatile(),
270
+ /** Advisory mode speak line: the model is nudged to fold when pressure
271
+ * reaches this percent of the window (default sits below the protocol's
272
+ * own 80% fold line, nudging while folding headroom is still comfortable). */
273
+ trimAdvisoryPercent: z.number().min(1).max(100).default(65).volatile(),
274
+ /** Advisory mode escalation line: at this percent the nudge escalates to a
275
+ * fold-down-this-turn order; must stay above trimAdvisoryPercent (an
276
+ * inverted or out-of-range pair falls back to 65/95). */
277
+ trimAdvisoryCriticalPercent: z.number().min(1).max(100).default(95).volatile(),
270
278
  /** [2026-11-07]-[growth/increment constants are absolute tokens
271
279
  * calibrated for the default 150k effective window]-[scaling
272
280
  * trimWindowTokens down without scaling trimGrowthTokens /
@@ -292,18 +300,26 @@ export const Config = z.object({
292
300
  * budget and the ladder starved. */
293
301
  trimMaxFoldsPerHour: z.number().min(1).max(60).default(30).volatile(),
294
302
  /** Route for the side-model summarizer (L3). Empty = inherit the
295
- * session's current model. */
303
+ * session's current provider/model; an optional effort pin overrides
304
+ * the model's automatic recommended effort. */
296
305
  trimSideModel: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
297
306
  /** Route for the fold-fallback summarizer. When the primary side route
298
307
  * (trimSideModel or the inherited session model) fails, raw folds retry
299
308
  * on this route before degrading to a mechanical fold; ladder folds
300
- * retry but never go mechanical. Pick a route with headroom on cooldown,
309
+ * retry but never go mechanical. An optional effort pin overrides the
310
+ * model's automatic recommendation. Pick a route with headroom on cooldown,
301
311
  * risk-control, and concurrency limits. Empty = no fallback leg. */
302
312
  trimFoldFallbackModel: IntentRouteSchema.default({ provider: "", model: "" }).volatile(),
303
313
  /** T1/T2/T3 ladder folding (docs/ladder-fold.md): master switch for the
304
314
  * T2/T3 distillation tiers. T1 raw-range folds are governed by
305
315
  * trimMode; the ladder only adds the summary-maintenance tiers. */
306
316
  trimLadderEnabled: z.boolean().default(true).volatile(),
317
+ /** Protect the task-intent segment (the first user message's whole
318
+ * segment) from raw folds. true (default) keeps the anti-drift guard;
319
+ * false lets the intent segment enter computeFoldableRange's candidate
320
+ * span like any other raw content. The system-head clamp is unaffected
321
+ * either way. */
322
+ trimProtectTaskIntent: z.boolean().default(true).volatile(),
307
323
  /** Ladder T2 gate: minimum active-T1 summary token mass before a T2
308
324
  * distillation fires (the need signal — bili #379: block count alone
309
325
  * is not a need signal). */
@@ -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)
@@ -695,10 +907,9 @@ export function applyContextTrim(ctx, config) {
695
907
  } else if (view.applied > 0) {
696
908
  epochResetDoneBySession.delete(sessionId);
697
909
  }
698
- // Capture the session's own resolved reasoningEffort alongside
699
- // provider/model: at stream-listener time DSH has already resolved the
700
- // effective effort for this dispatch, so the side route can inherit it
701
- // verbatim instead of re-deriving (and losing) it.
910
+ // Record the active request's route and resolved effort for diagnostics only.
911
+ // The side summarizer follows provider/model only and resolves effort from its
912
+ // model metadata rather than inheriting this dispatch's effort.
702
913
  const model = {
703
914
  provider: options.provider ?? null,
704
915
  model: options.model ?? null,
@@ -718,16 +929,14 @@ export function applyContextTrim(ctx, config) {
718
929
  }
719
930
 
720
931
  /** Resolve the side-model route: explicit trimSideModel route, else the
721
- * session's own model (inherit — design §5). */
932
+ * session's own provider/model. Effort is resolved from that model's
933
+ * supported thinking metadata, not inherited from the active session call. */
722
934
  function sideRouteFor(sessionId) {
723
935
  const explicit = readRoute(config, "trimSideModel");
724
936
  if (explicit !== null) return explicit;
725
937
  const model = metrics.of(sessionId)?.lastModel ?? null;
726
938
  if (model !== null && typeof model.provider === "string" && model.provider !== "" && typeof model.model === "string" && model.model !== "") {
727
- // Inherit the session's own resolved effort when one was captured.
728
- return typeof model.effort === "string" && model.effort !== ""
729
- ? { provider: model.provider, model: model.model, effort: model.effort }
730
- : { provider: model.provider, model: model.model };
939
+ return { provider: model.provider, model: model.model };
731
940
  }
732
941
  return null;
733
942
  }
@@ -747,7 +956,7 @@ export function applyContextTrim(ctx, config) {
747
956
 
748
957
  /** Run one fold cycle for a session (called at the idle boundary ONLY).
749
958
  * Returns a short outcome string for logging. */
750
- async function runFoldCycle(sessionId, decision, cycleStartedAt) {
959
+ async function runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken) {
751
960
  const capture = captures.get(sessionId);
752
961
  if (capture === undefined) return "no-capture";
753
962
  const { protectedTail } = thresholdsNow();
@@ -773,6 +982,7 @@ export function applyContextTrim(ctx, config) {
773
982
  // Ladder folds never degrade to mechanical (docs/ladder-fold.md):
774
983
  // after both routes fail the cycle stays fail-open with backoff.
775
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
776
986
  const result = store.commitFold(
777
987
  sessionId,
778
988
  { consumes: decision.consumes.slice(), summary, kind: "summary", tier: decision.tier === "ladder-3" ? 3 : 2, approxTokens },
@@ -831,6 +1041,7 @@ export function applyContextTrim(ctx, config) {
831
1041
  if (summary === null) return disposedSince(cycleStartedAt) ? "reload-aborted" : "summary-unavailable"; // skip cycle, fail-open; a dispose cut is not a model failure
832
1042
  }
833
1043
  const positions = refPositions(capture.messages);
1044
+ if (inFlight.get(sessionId) !== cycleToken) return "superseded"; // watchdog released us; a retry owns the session
834
1045
  const result = store.commitFold(
835
1046
  sessionId,
836
1047
  { refRange: { firstId: refs[0], lastId: refs[refs.length - 1] }, count, approxTokens, summary, kind, tier: decision.tier },
@@ -877,15 +1088,28 @@ export function applyContextTrim(ctx, config) {
877
1088
  decisionLog(`session=${sessionId} via=${via} action=none reason=injection-unavailable view=${Math.round(capture.viewTokens)}`);
878
1089
  return;
879
1090
  }
880
- inFlight.add(sessionId);
881
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?.();
882
1106
  // Hoisted for the catch below: a dispose-cut throw may land before the
883
1107
  // trigger ever ran, and the catch must not touch a dead binding.
884
1108
  let decision = null;
885
1109
  try {
886
- const { protectedTail, ...thresholds } = thresholdsNow();
1110
+ const { protectedTail, protectTaskIntent, ...thresholds } = thresholdsNow();
887
1111
  const ledger = store.ledgerOf(sessionId);
888
- const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
1112
+ const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
889
1113
  const windowTokens = await windowTokensFor(capture.model);
890
1114
  // Pressure tiers run on the REAL prompt size when the tally is known:
891
1115
  // max(view, wire). The view estimate misses the system/context
@@ -908,12 +1132,15 @@ export function applyContextTrim(ctx, config) {
908
1132
  });
909
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"}`);
910
1134
  if (decision.action === "none") return;
911
- const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt);
1135
+ const outcome = await runFoldCycle(sessionId, decision, cycleStartedAt, cycleToken);
912
1136
  // Failure ladder: commit resets it; a summarize/commit failure
913
1137
  // climbs it (the no-backoff loop this closes — those outcomes
914
1138
  // advance no cadence gate on their own). A ladder run whose
915
1139
  // consumed blocks vanished between trigger and commit is the same
916
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.
917
1144
  if (outcome.startsWith("folded:")) clearFoldFailure(sessionId);
918
1145
  else if (outcome === "summary-unavailable" || outcome.startsWith("commit-rejected:") || outcome === "ladder-consumed-missing") recordFoldFailure(sessionId);
919
1146
  // Outcome lands in the decision log too: warn() has no reachable
@@ -930,7 +1157,10 @@ export function applyContextTrim(ctx, config) {
930
1157
  warn(`[context-trim] fold cycle failed: ${error?.message ?? error}`);
931
1158
  }
932
1159
  } finally {
933
- 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);
934
1164
  }
935
1165
  }
936
1166
 
@@ -1040,19 +1270,27 @@ export function applyContextTrim(ctx, config) {
1040
1270
  name: "ctx_trim_status",
1041
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.",
1042
1272
  parameters: { type: "object", properties: {}, additionalProperties: false },
1043
- 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
+ },
1044
1282
  async execute() {
1045
1283
  const sessionId = sessionIdOf({});
1046
1284
  if (sessionId === null) return "No session context available.";
1047
1285
  if (!enabledNow()) return "Context trimming is disabled for this deployment.";
1048
1286
  const ledger = store.ledgerOf(sessionId);
1049
1287
  const capture = captures.get(sessionId);
1050
- const { protectedTail } = thresholdsNow();
1288
+ const { protectedTail, protectTaskIntent } = thresholdsNow();
1051
1289
  const lines = [`ctx_trim_status — mode: ${modeNow()} (enabled: ${enabledNow()})`];
1052
1290
  lines.push(...renderStatusLedger(ledger));
1053
1291
  // The raw range a fold would cover next, with its true size.
1054
1292
  if (capture !== undefined) {
1055
- const foldable = computeFoldableRange(capture.messages, ledger, protectedTail);
1293
+ const foldable = computeFoldableRange(capture.messages, ledger, protectedTail, { protectTaskIntent });
1056
1294
  if (foldable !== null) {
1057
1295
  const firstRef = refOf(capture.messages[foldable.startIdx], foldable.startIdx);
1058
1296
  const lastRef = refOf(capture.messages[foldable.endIdx], foldable.endIdx);
@@ -1090,6 +1328,11 @@ export function applyContextTrim(ctx, config) {
1090
1328
  required: ["firstId", "lastId", "count", "summary"],
1091
1329
  additionalProperties: false,
1092
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
+ },
1093
1336
  async execute(args) {
1094
1337
  const sessionId = sessionIdOf({});
1095
1338
  if (sessionId === null) return "No session context available.";
@@ -1113,6 +1356,15 @@ export function applyContextTrim(ctx, config) {
1113
1356
  }
1114
1357
  if (firstIdx < 0) { firstIdx = firstList[0]; lastIdx = lastList[lastList.length - 1]; }
1115
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
+ }
1116
1368
  const { protectedTail } = thresholdsNow();
1117
1369
  if (lastIdx >= capture.messages.length - Math.max(0, protectedTail)) return `Range reaches into the protected tail (last ${protectedTail} messages are never folded).`;
1118
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).";
@@ -1137,7 +1389,18 @@ export function applyContextTrim(ctx, config) {
1137
1389
  const lowLedger = store.ledgerOf(sessionId);
1138
1390
  const lowView = estimateTokensOf(buildView(capture.messages, lowLedger, { protectedTail: thresholdsNow().protectedTail }).messages);
1139
1391
  store.recordFoldBaseline(sessionId, capture.viewTokens, lowView);
1140
- 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.`;
1141
1404
  },
1142
1405
  });
1143
1406
  } catch (error) {
@@ -1155,6 +1418,16 @@ export function applyContextTrim(ctx, config) {
1155
1418
  if (applied !== null) {
1156
1419
  viewTokens = applied.viewTokens;
1157
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
+ }
1158
1431
  }
1159
1432
  }
1160
1433
  } catch (error) {
@@ -1242,5 +1515,5 @@ export function applyContextTrim(ctx, config) {
1242
1515
  warn(`[context-trim] ctx.inject(llm) failed: ${error?.message ?? error}`);
1243
1516
  }
1244
1517
 
1245
- return { store, metrics, captures, inFlight, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
1518
+ return { store, metrics, captures, inFlight, windowCache, advisoryNudgeBySession, maybeFold, maybeInherit, applyViewToOptions, surfacePass };
1246
1519
  }