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.
@@ -224,15 +224,25 @@ function describeFailure(chunk) {
224
224
  return ` (${raw.replace(/\s+/gu, " ").trim().slice(0, 300)})`;
225
225
  }
226
226
 
227
- /** Side-model summary. llm: the dsh llm service (receiver-bound methods);
228
- * route: { provider, model, effort? } | null (null → caller skips); materialText:
229
- * compression prompt (the ladder distillation passes its own); deadlineMs:
230
- * hard budget. Returns the summary text, or null on ANY failure (caller
231
- * skips the fold cycle). */
227
+ /** Choose the lowest enabled tier when several reasoning levels are available.
228
+ * A model with only an on/off switch is pinned to "off"; when reasoning
229
+ * metadata is absent, leave the model's adapter default unchanged. */
230
+ export function recommendedReasoningEffort(modelInfo) {
231
+ const efforts = modelInfo?.reasoning?.efforts;
232
+ if (!Array.isArray(efforts) || efforts.length === 0) return null;
233
+ const ids = efforts.map((entry) => typeof entry === "string" ? entry : entry?.id).filter((id) => typeof id === "string" && id !== "");
234
+ const enabled = ids.filter((id) => id !== "off");
235
+ if (enabled.length > 1) return enabled[0];
236
+ return ids.includes("off") ? "off" : null;
237
+ }
238
+
239
+ /** Side-model summary. llm: the dsh llm service; route: {provider, model, effort?}.
240
+ * An empty effort pin is resolved from model reasoning metadata; unavailable
241
+ * metadata leaves the adapter default unchanged. Returns summary text or null. */
232
242
  export async function summarizeWithModel(llm, route, materialText, options = {}) {
233
243
  const fail = typeof options.onFail === "function" ? options.onFail : null;
234
- // Effort-retry notice: fires BEFORE the low-pinned retry, whether or not
235
- // the retry lands. Falls back to fail when the caller doesn't supply one.
244
+ // Effort-retry notice: fires before a one-time effort fallback, whether
245
+ // or not that retry lands. Falls back to fail when no notice is supplied.
236
246
  const notice = typeof options.onNotice === "function" ? options.onNotice : null;
237
247
  if (route === null || route === undefined) { fail?.("no-route"); return null; }
238
248
  if (typeof llm?.prepareCall !== "function") { fail?.("no-prepare-call"); return null; }
@@ -242,39 +252,40 @@ export async function summarizeWithModel(llm, route, materialText, options = {})
242
252
  const controller = new AbortController();
243
253
  const timer = setTimeout(() => controller.abort(new Error("trim summary deadline")), deadlineMs);
244
254
  try {
245
- // Effort policy: inherit the route's effort verbatim when it carries
246
- // one (session-inherited routes carry the session's own DSH-resolved
247
- // effort; explicit trimSideModel configs may pin theirs), else pin
248
- // "low" — summaries are auxiliary work. NEVER send without an effort,
249
- // and never send "off": pi-ai maps both to thinking:{type:"disabled"}
250
- // (profileOptions turns "off" back into an absent reasoning option),
251
- // which newer Anthropic endpoints reject with 400. A DSH session run
252
- // with thinking disabled resolves its effort to "off", so an inherited
253
- // route can carry exactly that value — treat it like empty here.
254
- // The effort rides the prepareCall proposal so it resolves into
255
- // call.config, keeping stream options consistent with the prepared
256
- // call (INVALID_PREPARED_CALL guard).
257
- const requestedEffort = typeof route.effort === "string" && route.effort !== "" && route.effort !== "off" ? route.effort : "low";
255
+ // Resolve from the selected model itself: an empty pin means the lowest
256
+ // advertised thinking effort, while absent reasoning metadata leaves
257
+ // the adapter default (no thinking) untouched. Explicit effort pins win.
258
+ let modelInfo = null;
259
+ if (typeof llm.resolveModelInfo === "function") {
260
+ try { modelInfo = await llm.resolveModelInfo(route.provider, route.model, controller.signal); }
261
+ catch { /* Metadata is advisory; prepareCall remains authoritative. */ }
262
+ }
263
+ const automaticEffort = recommendedReasoningEffort(modelInfo);
264
+ const hasOffEffort = Array.isArray(modelInfo?.reasoning?.efforts) && modelInfo.reasoning.efforts.some((entry) => (typeof entry === "string" ? entry : entry?.id) === "off");
265
+ const explicitEffort = typeof route.effort === "string" && route.effort !== "";
266
+ const requestedEffort = explicitEffort ? route.effort : automaticEffort;
267
+ const proposalFor = (effort) => ({
268
+ provider: route.provider,
269
+ model: route.model,
270
+ maxTokens,
271
+ ...(typeof effort === "string" && effort !== "" ? { reasoningEffort: effort } : {}),
272
+ });
258
273
  let call;
259
274
  try {
260
- call = await llm.prepareCall(
261
- { provider: route.provider, model: route.model, maxTokens, reasoningEffort: requestedEffort },
262
- controller.signal,
263
- (controls) => controls,
264
- );
275
+ call = await llm.prepareCall(proposalFor(requestedEffort), controller.signal, (controls) => controls);
265
276
  } catch (prepareError) {
266
- // An inherited session effort can exceed what the route's adapter
267
- // supports: retry once pinned low before giving up (the outer
268
- // catch then turns the failure into a skipped fold cycle).
269
277
  const code = prepareError?.code ?? null;
270
278
  const named = typeof prepareError?.message === "string" && prepareError.message.includes("UNSUPPORTED_REASONING_EFFORT");
271
- if (requestedEffort === "low" || (code !== "UNSUPPORTED_REASONING_EFFORT" && !named)) throw prepareError;
272
- (notice ?? fail)?.(`effort "${requestedEffort}" unsupported, retrying low`);
273
- call = await llm.prepareCall(
274
- { provider: route.provider, model: route.model, maxTokens, reasoningEffort: "low" },
275
- controller.signal,
276
- (controls) => controls,
277
- );
279
+ if (code !== "UNSUPPORTED_REASONING_EFFORT" && !named) throw prepareError;
280
+ // Stale metadata or a stale pin should not prevent a summary: retry
281
+ // once at the model's automatic minimum, or without an effort. Never
282
+ // turn an explicitly requested "off" into enabled reasoning.
283
+ const retryEffort = requestedEffort === "off"
284
+ ? null
285
+ : (explicitEffort && automaticEffort !== requestedEffort ? automaticEffort : (hasOffEffort ? "off" : null));
286
+ if (retryEffort === requestedEffort) throw prepareError;
287
+ (notice ?? fail)?.(`effort "${requestedEffort ?? "default"}" unsupported, retrying ${retryEffort ?? "without reasoning"}`);
288
+ call = await llm.prepareCall(proposalFor(retryEffort), controller.signal, (controls) => controls);
278
289
  }
279
290
  let text = "";
280
291
  let finishKind = null;
@@ -84,7 +84,7 @@
84
84
  * and every no-op with pending work is logged to decisions.log.
85
85
  */
86
86
 
87
- import { refOf, summaryMessageOf } from "./view.js";
87
+ import { refOf, summaryMessageOf, isFoldSummaryMessage, FOLD_SUMMARY_ID_PREFIX } from "./view.js";
88
88
 
89
89
  /** Ladder-block probe (tier ≥ 2): a distillation whose surface material is
90
90
  * the REPLACEMENT NODES of its consumed blocks, not a raw ref range. */
@@ -545,6 +545,147 @@ export function createSurfaceFoldPass(deps = {}) {
545
545
  }
546
546
  }
547
547
 
548
+ function nudgeTextOf(message) {
549
+ try {
550
+ const content = message?.content;
551
+ if (typeof content === "string") return content;
552
+ if (!Array.isArray(content)) return "";
553
+ return content.map((part) => typeof part?.text === "string" ? part.text : "").join("");
554
+ } catch {
555
+ return "";
556
+ }
557
+ }
558
+ let nudgeCounter = 0; // unique suffix per speak: colliding explicit ids would collide in ref resolution
559
+ function nudgeMessage(sessionId, text) {
560
+ const token = String(sessionId ?? "").replace(/[^A-Za-z0-9_-]/gu, "_").slice(0, 128) || "session";
561
+ return {
562
+ id: "trim_nudge_" + token + "_" + (++nudgeCounter),
563
+ role: "user",
564
+ content: [{ type: "text", text: String(text ?? "") }],
565
+ source: { kind: "user" },
566
+ };
567
+ }
568
+ /** Surface nudge availability excludes sessions with an open compaction pair. */
569
+ function nudgeSupported(sessionId) {
570
+ try {
571
+ const session = sessionOf(sessionId);
572
+ return surfaceCapable(session) && !lockOpenCached(sessionId, session);
573
+ } catch {
574
+ return false;
575
+ }
576
+ }
577
+ /** Append one advisory nudge as a suffix-only user message. Returns
578
+ * { seq, error }: seq is null when the host rejected the append, and error
579
+ * carries the rejection message so the skip row can name the real cause
580
+ * (a bare append-failed row once hid a v4 turn-discipline rejection). */
581
+ function nudgeAppend(sessionId, text) {
582
+ const session = sessionOf(sessionId);
583
+ if (!surfaceCapable(session)) return { seq: null, error: null };
584
+ try {
585
+ const event = session.append("user/message", nudgeMessage(sessionId, text), { surfaceOp: "append" });
586
+ return { seq: Number.isFinite(event?.seq) ? event.seq : null, error: null };
587
+ } catch (error) {
588
+ return { seq: null, error: String(error?.message ?? error) };
589
+ }
590
+ }
591
+ /** Prune advisory nudge nodes with point-sized compaction events. Only
592
+ * seqs still live on the surface are pruned: a fold may already have
593
+ * consumed one, and re-shadowing a vanished seq fails the v4 validator.
594
+ * Each seq gets its own prune event (start === end) so a non-contiguous
595
+ * set can never cover unlisted live turns — matching the contiguous
596
+ * range/seq pairing of the ladder and T1 passes. The metering prune is
597
+ * paired with its replacement (shadow-price protocol, mirroring the
598
+ * host's compaction-tool-result-pruner): the surfaceOp replace on the
599
+ * replacement is what actually retires the node from surface.nodes —
600
+ * a bare compaction/prune never moves the live surface, so without
601
+ * the pair the same nudge was re-found and re-pruned on EVERY boundary
602
+ * (one duplicate fold-marker event per step, 2026-11-14). */
603
+ /** Retired-nudge stub: id and text intentionally miss both nudgeScan
604
+ * signatures (id prefix "trim_nudge_", text prefix "[SWITCHMAN:TRIM]
605
+ * Advisory nudge") so a retired stub is never re-discovered. */
606
+ function retiredNudgeMessage(sessionId) {
607
+ const token = String(sessionId ?? "").replace(/[^A-Za-z0-9_-]/gu, "_").slice(0, 128) || "session";
608
+ return {
609
+ id: "trim_retired_nudge_" + token + "_" + (++nudgeCounter),
610
+ role: "user",
611
+ content: [{ type: "text", text: "[SWITCHMAN:TRIM] (advisory nudge retired — pressure is back below the fold line)" }],
612
+ source: { kind: "user" },
613
+ };
614
+ }
615
+ function nudgePrune(sessionId, seqs) {
616
+ try {
617
+ const session = sessionOf(sessionId);
618
+ if (!surfaceCapable(session) || !Array.isArray(seqs)) return false;
619
+ const nodes = session.surface.nodes;
620
+ if (!Array.isArray(nodes)) return false;
621
+ const live = [...new Set(seqs.filter((seq) => Number.isFinite(seq)))].filter((seq) => nodes.includes(seq));
622
+ if (live.length === 0) return true; // already consumed by a fold: nothing to undo
623
+ if (live.includes(nodes[0])) {
624
+ let headEvent;
625
+ try {
626
+ headEvent = typeof session.eventAt === "function" ? session.eventAt(nodes[0]) : undefined;
627
+ } catch {
628
+ headEvent = undefined;
629
+ }
630
+ const headMessage = headEvent === undefined ? null : messageOfEvent(session, headEvent);
631
+ // Fail closed: an unreadable head node or the protected system head
632
+ // must never be shadowed ("compaction cannot shadow the protected
633
+ // system head").
634
+ if (headMessage === null || headMessage === undefined || headMessage.role === "system") return false;
635
+ }
636
+ for (const seq of live) {
637
+ const event = session.append("compaction/prune", {
638
+ shadowedRange: { start: seq, end: seq },
639
+ shadowedSeqs: [seq],
640
+ shadowedTokenCount: 1,
641
+ });
642
+ if (!Number.isFinite(event?.seq)) return false;
643
+ // Shadow-price pairing: the replacement — not the metering
644
+ // prune — retires the node from the live surface.
645
+ let retired;
646
+ try {
647
+ retired = session.append("user/message", retiredNudgeMessage(sessionId), {
648
+ surfaceOp: { op: "replace", startSeq: seq, endSeq: seq },
649
+ sourceEventSeqs: [seq],
650
+ });
651
+ } catch {
652
+ return false;
653
+ }
654
+ if (!Number.isFinite(retired?.seq)) return false;
655
+ }
656
+ return true;
657
+ } catch {
658
+ return false;
659
+ }
660
+ }
661
+ /** Find visible advisory nudges from the tail, bounded to recent surface nodes. */
662
+ function nudgeScan(sessionId) {
663
+ try {
664
+ const session = sessionOf(sessionId);
665
+ if (!surfaceCapable(session) || typeof session.eventAt !== "function") return [];
666
+ const nodes = session.surface.nodes;
667
+ const first = Math.max(0, nodes.length - 50);
668
+ const found = [];
669
+ for (let index = nodes.length - 1; index >= first; index--) {
670
+ const seq = nodes[index];
671
+ let event;
672
+ try {
673
+ event = session.eventAt(seq);
674
+ } catch {
675
+ continue;
676
+ }
677
+ const message = event === undefined ? null : messageOfEvent(session, event);
678
+ if (message === null || message === undefined) continue;
679
+ const hasNudgeId = typeof message.id === "string" && message.id.startsWith("trim_nudge_");
680
+ const hasNudgeText = nudgeTextOf(message).startsWith("[SWITCHMAN:TRIM] Advisory nudge");
681
+ if (hasNudgeId || hasNudgeText) found.push(seq);
682
+ }
683
+ return found;
684
+ } catch {
685
+ return [];
686
+ }
687
+ }
688
+
548
689
  /** In-memory idempotence belt over the durable block marker: the same
549
690
  * (session, range, contentGeneration) is never replaced twice even if a
550
691
  * crash lost the ledger persist. Bounded like the other registries. */
@@ -612,6 +753,21 @@ export function createSurfaceFoldPass(deps = {}) {
612
753
  return mark !== null && typeof mark === "object" && typeof mark.reason === "string" && mark.reason !== "" ? mark : null;
613
754
  }
614
755
 
756
+ /** Terminal supersede test: EVERY captured event seq has left the live
757
+ * surface, so the fold's material is already off the wire (host
758
+ * compaction consumed the range, or an earlier ship whose marker was
759
+ * later lost). Such a capture can never validate again —
760
+ * the block terminates as surfaceSuperseded instead of living forever
761
+ * in the generation-keyed incomplete-seqs retry loop. */
762
+ function captureFullyShadowed(nodes, block) {
763
+ // An unreadable/empty surface is fail-safe: nothing is proven gone,
764
+ // so never terminalize on it (a valid block would be lost forever).
765
+ if (!Array.isArray(nodes) || nodes.length === 0) return false;
766
+ if (!Array.isArray(block?.surfaceSeqs) || block.surfaceSeqs.length === 0) return false;
767
+ const live = new Set(nodes);
768
+ return block.surfaceSeqs.every((seq) => !live.has(seq));
769
+ }
770
+
615
771
  /** One no-op row (only when the pass had pending work — a vacuous sweep
616
772
  * with nothing to ship stays silent, matching the early-return style of
617
773
  * the fold cycle's own action=none rows). Whole-pass reasons (disabled /
@@ -657,6 +813,8 @@ export function createSurfaceFoldPass(deps = {}) {
657
813
  })();
658
814
  const pending = blocks.filter((block) => {
659
815
  if (block?.surfaceReplaced !== null && block?.surfaceReplaced !== undefined) return false;
816
+ // Terminal supersede (see captureFullyShadowed): never pending again.
817
+ if (block?.surfaceSuperseded !== null && block?.surfaceSuperseded !== undefined) return false;
660
818
  const tomb = tombstoneOf(block);
661
819
  if (tomb === null) return true;
662
820
  // Re-open only when the surface content changed since the skip.
@@ -720,6 +878,21 @@ export function createSurfaceFoldPass(deps = {}) {
720
878
  }
721
879
  const waiting = consumed.find((entry) => entry.surfaceReplaced === null || entry.surfaceReplaced === undefined);
722
880
  if (waiting !== undefined) {
881
+ if (waiting?.surfaceSuperseded !== null && waiting?.surfaceSuperseded !== undefined) {
882
+ // The consumed block's material left the surface entirely: its
883
+ // replacement node can never materialize, so the distillation
884
+ // is terminal too (same proof as the T1 supersede — no
885
+ // generation-keyed retry can revive either block).
886
+ block.surfaceSuperseded = { reason: "ladder-consumed-superseded", at: Date.now() };
887
+ delete block.surfaceSkip;
888
+ try {
889
+ store.persist(ledger);
890
+ } catch {
891
+ /* best effort */
892
+ }
893
+ skip(sessionId, "superseded-ladder-consumed", via, `block=${block.id} consumed=${waiting.id}`, false);
894
+ continue;
895
+ }
723
896
  if (tombstoneOf(waiting) !== null) {
724
897
  // A consumed block is hard-skipped on this generation: its
725
898
  // replacement node can never materialize, so the ladder span
@@ -753,7 +926,7 @@ export function createSurfaceFoldPass(deps = {}) {
753
926
  const idIndexOf = new Map();
754
927
  for (let i = 0; i < nodeMessages.length; i++) {
755
928
  const message = nodeMessages[i];
756
- if (message !== null && typeof message === "object" && typeof message.id === "string" && message.id.startsWith("acp_summary_")) {
929
+ if (isFoldSummaryMessage(message)) {
757
930
  idIndexOf.set(message.id, i);
758
931
  }
759
932
  }
@@ -765,7 +938,7 @@ export function createSurfaceFoldPass(deps = {}) {
765
938
  atSeqs.push(atSeq);
766
939
  continue;
767
940
  }
768
- const relocatedIdx = idIndexOf.get(`acp_summary_${entry.id}`);
941
+ const relocatedIdx = idIndexOf.get(`${FOLD_SUMMARY_ID_PREFIX}${entry.id}`);
769
942
  if (relocatedIdx === undefined) {
770
943
  unresolved = entry;
771
944
  break;
@@ -795,7 +968,7 @@ export function createSurfaceFoldPass(deps = {}) {
795
968
  // picked up a stranger's acp_summary node is not this fold's
796
969
  // material (the length check alone cannot tell them apart).
797
970
  if (spanExact) {
798
- const expectedIds = new Set(consumed.map((entry) => `acp_summary_${entry.id}`));
971
+ const expectedIds = new Set(consumed.map((entry) => `${FOLD_SUMMARY_ID_PREFIX}${entry.id}`));
799
972
  for (let i = ladderFirstIdx; i <= ladderLastIdx; i++) {
800
973
  const message = nodeMessages[i];
801
974
  if (message === null || message === undefined || typeof message.id !== "string" || !expectedIds.has(message.id)) {
@@ -807,7 +980,7 @@ export function createSurfaceFoldPass(deps = {}) {
807
980
  if (spanExact) {
808
981
  for (let i = ladderFirstIdx; i <= ladderLastIdx; i++) {
809
982
  const message = nodeMessages[i];
810
- if (message === null || message === undefined || typeof message.id !== "string" || !message.id.startsWith("acp_summary_")) {
983
+ if (!isFoldSummaryMessage(message)) {
811
984
  spanExact = false;
812
985
  break;
813
986
  }
@@ -906,6 +1079,23 @@ export function createSurfaceFoldPass(deps = {}) {
906
1079
  const located = locateOnSurface(pairs, block);
907
1080
  if (located === null) {
908
1081
  if (Array.isArray(block.surfaceSeqs)) {
1082
+ if (captureFullyShadowed(nodes, block)) {
1083
+ // Every captured seq has left the live surface: the range was
1084
+ // already shadowed wholesale (host compaction, a prior ship
1085
+ // whose marker was later lost). The material is
1086
+ // off the wire and this capture can never validate again —
1087
+ // terminate the block instead of re-tombstoning it on every
1088
+ // generation bump (the pending filter drops it for good).
1089
+ block.surfaceSuperseded = { reason: "shadowed", at: Date.now() };
1090
+ delete block.surfaceSkip;
1091
+ try {
1092
+ store.persist(ledger);
1093
+ } catch {
1094
+ /* best effort */
1095
+ }
1096
+ skip(sessionId, "superseded-shadowed", via, `block=${block.id}`, false);
1097
+ continue;
1098
+ }
909
1099
  block.surfaceSkip = { reason: "incomplete-seqs", contentGeneration: liveGeneration, at: Date.now() };
910
1100
  skip(sessionId, "incomplete-seqs", via, `block=${block.id}`, false);
911
1101
  } else {
@@ -930,6 +1120,23 @@ export function createSurfaceFoldPass(deps = {}) {
930
1120
  && nodes[located[0]] === storedFirst
931
1121
  && nodes[located[1]] === storedLast;
932
1122
  if (!endpointsMatch) {
1123
+ if (captureFullyShadowed(nodes, block)) {
1124
+ // Every captured seq has left the live surface: the range was
1125
+ // already shadowed wholesale (host compaction, a prior ship
1126
+ // whose marker was later lost). The material is
1127
+ // off the wire and this capture can never validate again —
1128
+ // terminate the block instead of re-tombstoning it on every
1129
+ // generation bump (the pending filter drops it for good).
1130
+ block.surfaceSuperseded = { reason: "shadowed", at: Date.now() };
1131
+ delete block.surfaceSkip;
1132
+ try {
1133
+ store.persist(ledger);
1134
+ } catch {
1135
+ /* best effort */
1136
+ }
1137
+ skip(sessionId, "superseded-shadowed", via, `block=${block.id} relocated-range`, false);
1138
+ continue;
1139
+ }
933
1140
  block.surfaceSkip = { reason: "incomplete-seqs", contentGeneration: liveGeneration, at: Date.now() };
934
1141
  skip(sessionId, "incomplete-seqs", via, `block=${block.id} relocated-range`, false);
935
1142
  continue;
@@ -1136,5 +1343,5 @@ export function createSurfaceFoldPass(deps = {}) {
1136
1343
  warn(`[context-trim] agent/turn-stopping hook failed: ${error?.message ?? error}`);
1137
1344
  }
1138
1345
 
1139
- return { sweep, captureFoldSeqs, enabledNow, supported };
1346
+ return { sweep, captureFoldSeqs, enabledNow, supported, nudgeSupported, nudgeAppend, nudgePrune, nudgeScan };
1140
1347
  }
@@ -32,7 +32,7 @@
32
32
  * which keeps the decision unit-testable (golden thresholds).
33
33
  */
34
34
 
35
- import { estimateTokensOf, refPositions, segmentBounds } from "./view.js";
35
+ import { estimateTokensOf, isFoldSummaryMessage, refPositions, segmentBounds } from "./view.js";
36
36
 
37
37
  /** Hard minimum wall-clock interval between side-model folds. */
38
38
  export const FOLD_MIN_INTERVAL_MS = 60_000;
@@ -50,10 +50,16 @@ export const LADDER_MIN_INTERVAL_MS = 120_000;
50
50
  * first segment boundary strictly after the frontier AND after the task
51
51
  * intent (the first user message — bili's protected zone: the session's
52
52
  * opening task statement is never folded) to the last segment end that
53
- * still clears the protected tail. Returns { startIdx, endIdx } or null
54
- * when nothing is foldable (empty history, everything folded, or
55
- * protected tail covers it all). */
56
- export function computeFoldableRange(messages, ledger, protectedTail) {
53
+ * still clears the protected tail. Prior fold summaries (acp_summary_*,
54
+ * which the surface pass relocates right after the intent segment) are
55
+ * NEVER inside the range: they stay live on the surface and consolidate
56
+ * only through ladder T2/T3 distillation. This exclusion is load-bearing —
57
+ * the frontier message is itself shadowed once its fold ships, so without
58
+ * it the fallback start slid onto the last summary and every later fold
59
+ * re-absorbed the previous summary (the rolling-absorb regression).
60
+ * Returns { startIdx, endIdx } or null when nothing is foldable (empty
61
+ * history, everything folded, or protected tail covers it all). */
62
+ export function computeFoldableRange(messages, ledger, protectedTail, options = {}) {
57
63
  if (!Array.isArray(messages) || messages.length === 0) return null;
58
64
  const tail = Number.isInteger(protectedTail) && protectedTail > 0 ? protectedTail : 0;
59
65
  const tailStart = messages.length - tail;
@@ -75,19 +81,30 @@ export function computeFoldableRange(messages, ledger, protectedTail) {
75
81
  // Task-intent protection: the first user message states the task; the
76
82
  // segment containing it is never folded (its summary would become the
77
83
  // only carrier of the task for the rest of the session — the dominant
78
- // early-drift failure this guard prevents).
79
- const firstUserIdx = messages.findIndex((message) => message?.role === "user");
80
- if (firstUserIdx >= 0) {
81
- for (const [start, end] of segmentBounds(messages)) {
82
- if (start <= firstUserIdx && firstUserIdx <= end) {
83
- startFrom = Math.max(startFrom, end + 1);
84
- break;
84
+ // early-drift failure this guard prevents). Opt-out: trimProtectTaskIntent
85
+ // =false lets the intent segment fold like any other raw content; the
86
+ // system-head clamp above is unaffected — it applies only when the head is actually a system message.
87
+ if (options?.protectTaskIntent !== false) {
88
+ const firstUserIdx = messages.findIndex((message) => message?.role === "user");
89
+ if (firstUserIdx >= 0) {
90
+ for (const [start, end] of segmentBounds(messages)) {
91
+ if (start <= firstUserIdx && firstUserIdx <= end) {
92
+ startFrom = Math.max(startFrom, end + 1);
93
+ break;
94
+ }
85
95
  }
86
96
  }
87
97
  }
88
98
  let best = null;
89
99
  for (const [start, end] of segmentBounds(messages)) {
90
100
  if (start < startFrom) continue; // behind the frontier / intent
101
+ if (isFoldSummaryMessage(messages[start])) {
102
+ // A summary is its own segment (plain user message, no tool pair).
103
+ // Ahead of the first raw segment: skip the summary run. After raw
104
+ // segments have begun: stop — never bridge a span across one.
105
+ if (best !== null) break;
106
+ continue;
107
+ }
91
108
  if (end >= tailStart) break; // segment would cut into the protected tail
92
109
  if (best === null) best = { startIdx: start, endIdx: end };
93
110
  else best.endIdx = end; // extend the contiguous span
@@ -250,18 +250,33 @@ export function renderSummaryText(block) {
250
250
  return `[SWITCHMAN:TRIM] ${block.summary}`;
251
251
  }
252
252
 
253
+ /** Wire-id prefix of every fold-summary synthetic message. The single
254
+ * definition: summaryMessageOf builds ids from it, isFoldSummaryMessage
255
+ * recognizes them, and ladder relocation re-derives the same ids. */
256
+ export const FOLD_SUMMARY_ID_PREFIX = "acp_summary_";
257
+
253
258
  /** The ONE synthetic message replacing a folded range (brief: synthetic
254
259
  * user message; id carries the acp_summary_ prefix downstream tooling
255
260
  * skips). Pure — same block, same bytes. The ladder replaces messages BY
256
261
  * this id (id-suffix = block id), which is why ids must stay stable. */
257
262
  export function summaryMessageOf(block) {
258
263
  return {
259
- id: `acp_summary_${block.id}`,
264
+ id: `${FOLD_SUMMARY_ID_PREFIX}${block.id}`,
260
265
  role: "user",
261
266
  content: [{ type: "text", text: renderSummaryText(block) }],
262
267
  };
263
268
  }
264
269
 
270
+ /** True when one wire message is a prior fold's synthetic summary
271
+ * (acp_summary_<blockId>). Recognition rides the id prefix only — never
272
+ * the source kind (the v4 validator rejects compact-checkpoint sources
273
+ * outside a compaction/start..end pair). Range selection and the advisory
274
+ * fold tool use this to KEEP summaries out of new raw folds: summaries
275
+ * stay live on the surface and consolidate only through ladder T2/T3. */
276
+ export function isFoldSummaryMessage(message) {
277
+ return typeof message?.id === "string" && message.id.startsWith(FOLD_SUMMARY_ID_PREFIX);
278
+ }
279
+
265
280
  /** Locate a block's range in a positions map at/after afterIdx. Strict:
266
281
  * both endpoint refs must resolve and the located span must equal the
267
282
  * block's stored message count (guards against re-sent histories that no
@@ -310,7 +325,7 @@ function locateBlock(positions, block, afterIdx) {
310
325
  * -> index, for every synthetic message. One index per id (a block renders
311
326
  * at most once — single consumption). */
312
327
  function summaryPositionsOf(messages) {
313
- const prefix = "acp_summary_";
328
+ const prefix = FOLD_SUMMARY_ID_PREFIX;
314
329
  const positions = new Map();
315
330
  messages.forEach((message, index) => {
316
331
  const id = message?.id;
@@ -276,7 +276,11 @@ function subagentWindowTokens(ctx, state, sessionId) {
276
276
  async function primeSubagentWindow(ctx, state, route, key) {
277
277
  try {
278
278
  const llm = ctx.get?.("llm", false);
279
- const info = await llm?.resolveModelInfo?.({ provider: route.provider, model: route.model });
279
+ // Host contract: resolveModelInfo(provider, model) takes TWO strings. The
280
+ // object shape threw NO_ADAPTER and subagent window scaling silently fell
281
+ // back to static tiers (found 2026-11-14; same contract every other
282
+ // call site already follows).
283
+ const info = await llm?.resolveModelInfo?.(route.provider, route.model);
280
284
  const windowTokens = info?.context?.contextWindow;
281
285
  const value = Number.isFinite(windowTokens) && windowTokens > 0 ? windowTokens : null;
282
286
  state.subWindow.set(key, { value, at: Date.now() });
package/host/ptc-guard.js CHANGED
@@ -89,6 +89,12 @@ function renderPtcGuard() {
89
89
  `- Every other tool MUST be invoked as an awaited SDK function inside a ${RUN_CODE_NAME} program: \`await tools.name(args)\` — e.g. \`await tools.skill({ name: "git-commit-message" })\` or \`await tools.spawn_teammate({ ... })\`.`,
90
90
  `- A direct call fails with: unknown tool "x": only \`${RUN_CODE_NAME}\` is callable directly — call \`x\` from inside a \`${RUN_CODE_NAME}\` program instead. Never retry the direct form; wrap the call.`,
91
91
  `- Wherever this prompt says "call the X tool" (skill catalog, doctrines, per-turn reminders), read it as "call X inside a ${RUN_CODE_NAME} program". Batch independent calls as parallel awaits inside ONE program.`,
92
+ `- \`grep.pattern\` is a regular expression. In a normal JavaScript string inside \`${RUN_CODE_NAME}\`, double backslashes: write \`"\\\\("\` to pass \`\\(\` to grep; \`String.raw\` tagged templates also preserve backslashes. If grep reports \`unclosed group\`, fix the pattern before retrying; never repeat it unchanged.`,
93
+ `- Unless this call must intentionally retain state across calls (e.g. the one-time CUA bridge initialization), MUST wrap the entire ${RUN_CODE_NAME} program body in an async IIFE: \`await (async () => { ... })();\`. Keep all temporary \`const\`/\`let\`/\`class\` declarations inside it; this makes reusing local names such as \`res\` safe even when an earlier call used them. Top-level bindings persist for this Agent: only create them for intentionally shared state, and never redeclare existing top-level \`const\`/\`let\`/\`class\`; reassign an existing top-level \`var\` or choose a fresh unique name. If \`Identifier ... has already been declared\` occurs, rewrite the program in the IIFE rather than merely rerunning it—the SyntaxError happens before the body runs.`,
94
+ `- For intentional top-level bindings, maintain a ledger of declared names at \`globalThis.__dshSwitchmanPtcBindings\` (initialize idempotently with \`globalThis.__dshSwitchmanPtcBindings ??= new Set()\`). Before emitting a program with top-level declarations, check each name against this ledger and the visible prior \`${RUN_CODE_NAME}\` snippets; after a successful declaration, add the name with \`globalThis.__dshSwitchmanPtcBindings.add("name")\`. This ledger is manually maintained, not automatic introspection: if the old history or ledger is incomplete, do not guess—keep the code in an IIFE or choose a fresh name.`,
95
+ `- For routine \`${RUN_CODE_NAME}\` calls — including programs that only call SDK tools such as \`tools.skill(...)\` — pass only \`code\`, \`description\`, and \`timeoutMs\`; omit both \`sandbox_permissions\` and \`justification\`. If a call is rejected for invalid justification, never repeat it unchanged; retry without those fields.`,
96
+ `- If ${RUN_CODE_NAME} returns a parser error such as "Expected ident", treat it as a syntax issue in the submitted code; when present, the appended "File sandbox: ..." reports execution context, not the cause. Correct the code before retrying; never repeat it unchanged.`,
97
+ `- When a ${RUN_CODE_NAME} program embeds long prompt text, do not hand-write it as a double-quoted JavaScript string if it contains quotes (for example, getResource(".") or System.getProperty("user.dir")): build the text as data, escape the inner quotes, or serialize it with JSON.stringify(text); inspect or syntax-check the complete source before retrying.`,
92
98
  `- Never set \`sandbox_permissions\` on any call, direct or SDK: it is a privilege-escalation request and fails with "not strictly wider than this call's current ... mode" whenever the session already runs at least that access level — retry without the field.`,
93
99
  ].join("\n");
94
100
  }
package/host/routes.js CHANGED
@@ -111,6 +111,8 @@ const TRIM_NUMBER_FIELDS = [
111
111
  { field: "trimIncrementTokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 25000 },
112
112
  { field: "trimPressurePercent", min: 5, max: 100, def: 65 },
113
113
  { field: "trimEmergencyPercent", min: 5, max: 100, def: 95 },
114
+ { field: "trimAdvisoryPercent", min: 5, max: 100, def: 65 },
115
+ { field: "trimAdvisoryCriticalPercent", min: 5, max: 100, def: 95 },
114
116
  { field: "trimWindowTokens", min: 0, max: Number.POSITIVE_INFINITY, def: 0 },
115
117
  { field: "trimProtectedTail", min: 0, max: 100, def: 10 },
116
118
  { field: "trimLadderT2Tokens", min: 1000, max: Number.POSITIVE_INFINITY, def: 8000 },
@@ -137,6 +139,7 @@ const BOOLEAN_FIELDS = [
137
139
  // Context trim switches (mirrors the trim group in host/config.js).
138
140
  "trimEnabled",
139
141
  "trimLadderEnabled",
142
+ "trimProtectTaskIntent",
140
143
  "surfaceFold",
141
144
  ];
142
145
  /** Intent numbers carry their own schema bounds (config.js), so the shared
@@ -150,13 +153,12 @@ const INTENT_NUMBER_FIELDS = [
150
153
  { field: "intentHistoryMaxChars", min: 200, max: 20000, def: 2400 },
151
154
  { field: "intentContextWindow", min: 1024, max: Number.POSITIVE_INFINITY, def: 200000 },
152
155
  ];
153
- /** Intent route references round-trip the "unset" {provider:"", model:""}
156
+ /** Intent classifier routes round-trip the "unset" {provider:"", model:""}
154
157
  * pair, so they validate against the loosened cleanIntentRoute (both keys
155
- * must be strings; either may be empty); intent routes never carry effort. */
156
- const INTENT_ROUTE_FIELDS = ["intentClassifierRoute", "intentFallbackRoute"];
157
- /** Trim side routes additionally round-trip an optional effort pin — the
158
- * settings page's reasoning-effort select — via cleanTrimRoute below. */
159
- const TRIM_ROUTE_FIELDS = ["trimSideModel", "trimFoldFallbackModel"];
158
+ * must be strings; either may be empty). */
159
+ const INTENT_ROUTE_FIELDS = ["intentClassifierRoute"];
160
+ /** Summary routes and the intent fallback route can carry an effort pin. */
161
+ const EFFORT_ROUTE_FIELDS = ["trimSideModel", "trimFoldFallbackModel", "intentFallbackRoute"];
160
162
  /** Intent candidate list (route + optional effort/note per entry). */
161
163
  const INTENT_CANDIDATE_FIELDS = ["intentCandidates"];
162
164
 
@@ -315,11 +317,8 @@ function cleanIntentRoute(entry) {
315
317
  return provider === null || model === null ? null : { provider, model };
316
318
  }
317
319
 
318
- /** Coerce one TRIM side route: like cleanIntentRoute, but a non-empty string
319
- * effort rides along — the trim summarizer pins reasoning effort on its side
320
- * model, and stripping it here made every settings save silently revert the
321
- * settings page's effort select to the default. Intent routes omit effort. */
322
- function cleanTrimRoute(entry) {
320
+ /** Coerce one route that may carry an optional reasoning-effort pin. */
321
+ function cleanEffortRoute(entry) {
323
322
  const route = cleanIntentRoute(entry);
324
323
  if (route === null) return null;
325
324
  const effort = typeof entry.effort === "string" ? entry.effort.trim() : "";
@@ -370,7 +369,7 @@ export function snapshotOf(config) {
370
369
  ? rawOf(config[field]).map(cleanEffortEntry).filter(Boolean)
371
370
  : [];
372
371
  for (const field of INTENT_ROUTE_FIELDS) values[field] = cleanIntentRoute(rawOf(config?.[field])) ?? { provider: "", model: "" };
373
- for (const field of TRIM_ROUTE_FIELDS) values[field] = cleanTrimRoute(rawOf(config?.[field])) ?? { provider: "", model: "" };
372
+ for (const field of EFFORT_ROUTE_FIELDS) values[field] = cleanEffortRoute(rawOf(config?.[field])) ?? { provider: "", model: "" };
374
373
  for (const field of INTENT_CANDIDATE_FIELDS)
375
374
  values[field] = mergeIntentCandidates(rawOf(config?.[field]));
376
375
  values.intentClassifierKind = ENUM_FIELDS.intentClassifierKind.includes(rawOf(config?.intentClassifierKind))
@@ -445,8 +444,8 @@ export function coerceValues(input) {
445
444
  if (route === null) return null;
446
445
  values[field] = route;
447
446
  }
448
- for (const field of TRIM_ROUTE_FIELDS) {
449
- const route = cleanTrimRoute(input[field]);
447
+ for (const field of EFFORT_ROUTE_FIELDS) {
448
+ const route = cleanEffortRoute(input[field]);
450
449
  if (route === null) return null;
451
450
  values[field] = route;
452
451
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-switchman",
3
- "version": "1.11.4",
3
+ "version": "1.11.6",
4
4
  "description": "Autonomous Agent Teams doctrine for the four shipped DeepSeek Harness presets, plus language preferences, dispatch pools with model ranking, and context watermark control.",
5
5
  "license": "MIT",
6
6
  "keywords": [