dsh-switchman 1.10.3 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,23 @@
1
1
  /** dsh-switchman context-trim summarizer.
2
2
  *
3
- * Produces the summary text for one fold block, two ways:
3
+ * Produces the summary text for one fold block, three ways:
4
4
  *
5
5
  * - summarizeWithModel — the L3 path (design §5): a side-model call via
6
6
  * llm.prepareCall + call.stream (receiver-bound on the llm service, the
7
7
  * same waterfall pattern intent classification uses). Guarded by a hard
8
8
  * deadline (AbortController); ANY failure (timeout, error chunk, empty
9
9
  * output) returns null and the caller skips the fold cycle — fail-open.
10
+ * A length-finished (output-limit) summary is NOT a failure: it returns
11
+ * the text prefixed with TRUNCATED_SUMMARY_MARK so the missing tail
12
+ * stays visible (inspection S2).
13
+ * Used by BOTH the raw T1 folds (renderMaterial) and the ladder T2/T3
14
+ * distillations (renderLadderMaterial, LADDER_SYSTEM_PROMPT).
10
15
  * - mechanicalSummary — the deterministic fallback used by the emergency
11
- * tier: structural heads of each message, no model round trip.
16
+ * tier: structural heads of each message, no model round trip. Never
17
+ * used for ladder folds (mechanical summary-of-summaries reclaims little
18
+ * and corrupts craft — docs/ladder-fold.md).
12
19
  *
13
- * The summary prompt carries the bili compression rules: message refs and
20
+ * The summary prompts carry the bili compression rules: message refs and
14
21
  * file paths verbatim, decisions WITH rationale, exact values, open
15
22
  * objectives carry forward, verbose logs drop.
16
23
  */
@@ -25,6 +32,14 @@ export const SUMMARY_TIMEOUT_MS = 120_000;
25
32
  * (a 1M-token material would burn the side-model call for no fidelity). */
26
33
  export const MAX_MATERIAL_CHARS = 400_000;
27
34
 
35
+ /** Prefix for a summary whose side-model output ended early (finish=length):
36
+ * the text otherwise reads as settled fact while its tail is missing — and
37
+ * ladder T2/T3 distillation builds on summaries as established facts, so
38
+ * the gap must stay visible on the wire. Prefixed, not rejected: the fold
39
+ * still relieves pressure, and re-running at the same maxTokens would
40
+ * truncate again. (inspection S2) */
41
+ export const TRUNCATED_SUMMARY_MARK = "[truncated summary: the summarizer's output hit its limit (finish=length) — content after the cut may be missing] ";
42
+
28
43
  /** System prompt for the side-model summarizer. English per repo docs
29
44
  * style; the OUTPUT language follows the summarized material. */
30
45
  export const SUMMARY_SYSTEM_PROMPT = [
@@ -47,6 +62,33 @@ export const SUMMARY_SYSTEM_PROMPT = [
47
62
  "- Output ONLY the summary text.",
48
63
  ].join("\n");
49
64
 
65
+ /** Flat text body of one message (content blocks joined, unserializable
66
+ * values tolerated). Shared by renderMaterial and the zero-material
67
+ * rescue path. */
68
+ function renderMessageBody(message) {
69
+ const content = message?.content;
70
+ if (typeof content === "string") return content;
71
+ if (Array.isArray(content)) {
72
+ const chunks = [];
73
+ for (const block of content) {
74
+ if (block !== null && typeof block === "object" && typeof block.text === "string") chunks.push(block.text);
75
+ else {
76
+ try {
77
+ chunks.push(JSON.stringify(block ?? null));
78
+ } catch {
79
+ chunks.push("[unserializable]");
80
+ }
81
+ }
82
+ }
83
+ return chunks.join("\n");
84
+ }
85
+ try {
86
+ return JSON.stringify(message ?? null);
87
+ } catch {
88
+ return "[unserializable]";
89
+ }
90
+ }
91
+
50
92
  /** Render the fold material for one message range: labeled, ref-stamped
51
93
  * blocks. Deterministic (same slice, same bytes) — refs come from refOf so
52
94
  * id-less messages stay stable across re-sends. */
@@ -58,29 +100,7 @@ export function renderMaterial(messages, startIdx, endIdx, refOf) {
58
100
  const message = messages[i];
59
101
  const ref = refOf(message, i);
60
102
  const role = typeof message?.role === "string" ? message.role : "unknown";
61
- let text;
62
- const content = message?.content;
63
- if (typeof content === "string") text = content;
64
- else if (Array.isArray(content)) {
65
- const chunks = [];
66
- for (const block of content) {
67
- if (block !== null && typeof block === "object" && typeof block.text === "string") chunks.push(block.text);
68
- else {
69
- try {
70
- chunks.push(JSON.stringify(block ?? null));
71
- } catch {
72
- chunks.push("[unserializable]");
73
- }
74
- }
75
- }
76
- text = chunks.join("\n");
77
- } else {
78
- try {
79
- text = JSON.stringify(message ?? null);
80
- } catch {
81
- text = "[unserializable]";
82
- }
83
- }
103
+ let text = renderMessageBody(message);
84
104
  if (Array.isArray(message?.tool_calls) && message.tool_calls.length > 0) {
85
105
  try {
86
106
  text += `\n[tool_calls] ${JSON.stringify(message.tool_calls)}`;
@@ -97,7 +117,71 @@ export function renderMaterial(messages, startIdx, endIdx, refOf) {
97
117
  parts.push(head + body);
98
118
  chars += head.length + body.length;
99
119
  }
100
- if (truncated) parts.push(`--- [...material truncated after ${chars} chars; ${endIdx - startIdx + 1 - parts.length} messages dropped from this render...] ---\n`);
120
+ if (truncated) {
121
+ // [2026-11-07]-[a single over-ceiling message used to render ZERO
122
+ // material]-[parts stayed empty, the side model was asked to compress
123
+ // nothing, and the giant message got permanently replaced by a
124
+ // content-free summary. Keep the FIRST message's head plus a capped
125
+ // prefix so the summary at least carries what the message is.]
126
+ if (parts.length === 0 && startIdx <= endIdx) {
127
+ const first = messages[startIdx];
128
+ const firstRef = refOf(first, startIdx);
129
+ const role = typeof first?.role === "string" ? first.role : "unknown";
130
+ parts.push(`--- [m${firstRef}] (${role}) ---\n${renderMessageBody(first).slice(0, 2_000)}\n\n`);
131
+ }
132
+ parts.push(`--- [...material truncated after ${chars} chars; ${endIdx - startIdx + 1 - parts.length} messages dropped from this render...] ---\n`);
133
+ }
134
+ return parts.join("");
135
+ }
136
+
137
+ /** Distillation system prompt for the ladder tiers (T2 merges active T1
138
+ * summaries; T3 merges active T2 summaries). The output replaces its
139
+ * inputs PERMANENTLY — the craft rules are the same KEEP/DROP priorities,
140
+ * applied one level up. */
141
+ export const LADDER_SYSTEM_PROMPT = [
142
+ "You distill several context-fold summaries of one coding-agent session into ONE shorter self-contained plain-text summary that permanently replaces them.",
143
+ "",
144
+ "Treat the input summaries as facts already settled: never re-derive, never contradict, merge overlapping points.",
145
+ "",
146
+ "KEEP VERBATIM:",
147
+ "- Full file paths with line numbers (lib/hooks.ts:347) — never bare filenames.",
148
+ "- Function/class/type signatures and the critical code line that encodes a finding.",
149
+ "- Error messages and stack-trace key lines (exact text).",
150
+ "- Exact values: versions, config keys, thresholds, magic numbers.",
151
+ "- Decisions WITH their rationale (the because is load-bearing).",
152
+ "- Open objectives, unresolved TODOs, and open questions, each tagged with its original message refs.",
153
+ "",
154
+ "DROP (one line of lesson per failure stays): verbose detail once the exact value is captured, repetition between the summaries, settled exploration, process narration.",
155
+ "",
156
+ "FORMAT:",
157
+ "- Dense bullets, short thematic headers; no narrative prose.",
158
+ "- Keep the [m<ref>] anchors for anything a later step may need to unfold.",
159
+ "- Write the summary in the same language as the input summaries.",
160
+ "- Output ONLY the summary text.",
161
+ ].join("\n");
162
+
163
+ /** Render the ladder distillation material for one run of consumed blocks:
164
+ * labeled, block-stamped sections (deterministic — same blocks, same
165
+ * bytes). Capped by the same material ceiling as renderMaterial. */
166
+ export function renderLadderMaterial(blocks) {
167
+ const parts = [];
168
+ let chars = 0;
169
+ let truncated = false;
170
+ for (const block of blocks) {
171
+ const tier = block?.tier === 3 ? 3 : block?.tier === 2 ? 2 : 1;
172
+ const tokens = Number.isFinite(block?.approxTokens) ? Math.round(block.approxTokens) : "?";
173
+ const count = Number.isInteger(block?.count) && block.count > 0 ? block.count : "?";
174
+ const summary = typeof block?.summary === "string" ? block.summary : "";
175
+ const head = `--- [block ${block?.id ?? "?"}] (tier ${tier}, ~${tokens} tokens, ${count} msgs) ---\n`;
176
+ const body = `${summary}\n\n`;
177
+ if (chars + head.length + body.length > MAX_MATERIAL_CHARS) {
178
+ truncated = true;
179
+ break;
180
+ }
181
+ parts.push(head + body);
182
+ chars += head.length + body.length;
183
+ }
184
+ if (truncated) parts.push(`--- [...material truncated after ${chars} chars; ${blocks.length - parts.length} blocks dropped from this render...] ---\n`);
101
185
  return parts.join("");
102
186
  }
103
187
 
@@ -127,16 +211,32 @@ export function mechanicalSummary(messages, startIdx, endIdx, refOf) {
127
211
  return lines.join("\n");
128
212
  }
129
213
 
214
+ /** One-line suffix carrying the adapter's failure detail: DSH terminal
215
+ * finishes ride { reason: { kind, failure: { message, code, status? } } } —
216
+ * without this the decisions.log only ever sees finishKind, and upstream
217
+ * rejections (quota/concurrency/risk) stay invisible. Single-lined, capped. */
218
+ function describeFailure(chunk) {
219
+ const failure = chunk?.reason?.failure ?? chunk?.failure ?? null;
220
+ const raw = typeof failure?.message === "string" && failure.message !== ""
221
+ ? failure.message
222
+ : (typeof failure?.code === "string" && failure.code !== "" ? failure.code : "");
223
+ if (raw === "") return "";
224
+ return ` (${raw.replace(/\s+/gu, " ").trim().slice(0, 300)})`;
225
+ }
226
+
130
227
  /** Side-model summary. llm: the dsh llm service (receiver-bound methods);
131
228
  * route: { provider, model } | null (null → caller skips); materialText:
132
- * renderMaterial output; deadlineMs: hard budget. Returns the summary
133
- * text, or null on ANY failure (caller skips the fold cycle). */
229
+ * renderMaterial output; options.systemPrompt overrides the default
230
+ * compression prompt (the ladder distillation passes its own); deadlineMs:
231
+ * hard budget. Returns the summary text, or null on ANY failure (caller
232
+ * skips the fold cycle). */
134
233
  export async function summarizeWithModel(llm, route, materialText, options = {}) {
135
234
  const fail = typeof options.onFail === "function" ? options.onFail : null;
136
235
  if (route === null || route === undefined) { fail?.("no-route"); return null; }
137
236
  if (typeof llm?.prepareCall !== "function") { fail?.("no-prepare-call"); return null; }
138
237
  const deadlineMs = Number.isFinite(options.deadlineMs) && options.deadlineMs > 0 ? options.deadlineMs : SUMMARY_TIMEOUT_MS;
139
238
  const maxTokens = Number.isFinite(options.maxTokens) && options.maxTokens > 0 ? options.maxTokens : 8_192;
239
+ const systemPrompt = typeof options.systemPrompt === "string" && options.systemPrompt !== "" ? options.systemPrompt : SUMMARY_SYSTEM_PROMPT;
140
240
  const controller = new AbortController();
141
241
  const timer = setTimeout(() => controller.abort(new Error("trim summary deadline")), deadlineMs);
142
242
  try {
@@ -146,7 +246,7 @@ export async function summarizeWithModel(llm, route, materialText, options = {})
146
246
  (controls) => controls,
147
247
  );
148
248
  let text = "";
149
- let finishKind = null;
249
+ let finishKind = null;
150
250
  // stream options MUST carry the prepared call config — the adapter
151
251
  // dispatch binds credentials/routing from it (same waterfall shape as
152
252
  // the intent classifier; dropping it fails prepare-time integrity).
@@ -154,7 +254,7 @@ export async function summarizeWithModel(llm, route, materialText, options = {})
154
254
  // source tag, exactly like the intent classifier's proven shape.
155
255
  const streamOptions = {
156
256
  ...call.config,
157
- system: SUMMARY_SYSTEM_PROMPT,
257
+ system: systemPrompt,
158
258
  messages: [
159
259
  { id: randomUUID(), role: "user", content: [{ type: "text", text: materialText }], source: { kind: "dsh-switchman-trim-summary" } },
160
260
  ],
@@ -163,12 +263,26 @@ export async function summarizeWithModel(llm, route, materialText, options = {})
163
263
  for await (const chunk of call.stream(streamOptions)) {
164
264
  if (chunk?.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
165
265
  else if (chunk?.type === "finish") {
166
- finishKind = chunk.kind ?? null;
167
- if (chunk.kind === "error" || chunk.kind === "aborted") { fail?.(`stream-${chunk.kind}`); return null; }
266
+ // The translator's clean stop is { type:'finish', reason:{kind:'stop'} } —
267
+ // no top-level kind. Read the nested reason first so clean EOFs log
268
+ // finish=stop (not finish=none), and treat abort/error finishes as
269
+ // explicit failures in either shape.
270
+ const topKind = chunk.kind ?? null;
271
+ finishKind = chunk.reason?.kind ?? topKind;
272
+ if (topKind === "error" || topKind === "aborted" || finishKind === "error" || finishKind === "aborted") {
273
+ fail?.(`stream-${finishKind}${describeFailure(chunk)}`);
274
+ return null;
275
+ }
168
276
  }
169
277
  }
170
278
  const summary = text.trim();
171
279
  if (summary === "") { fail?.(`empty-output finish=${finishKind ?? "none"}`); return null; }
280
+ // A length/max-tokens finish means the tail of the material never got
281
+ // summarized, yet the text that DID land reads as settled fact — mark it
282
+ // so every later consumer (view rendering, ladder distillation) sees the
283
+ // gap. DSH's FinishReasonMap has no "length" kind: adapters map
284
+ // finish_reason=length to kind "max-tokens" (llm-deepseek translate).
285
+ if (finishKind === "length" || finishKind === "max-tokens") return `${TRUNCATED_SUMMARY_MARK}${summary}`;
172
286
  return summary;
173
287
  } catch (error) {
174
288
  fail?.(`error: ${error?.message ?? error}`);
@@ -68,6 +68,17 @@
68
68
  * - Never append while a durable compaction/start..compaction/end lock pair
69
69
  * is open for the session (design constraint).
70
70
  *
71
+ * LADDER SHIP (docs/ladder-fold.md): tier ≥ 2 blocks ride the same pair,
72
+ * but their material is the REPLACEMENT NODES their consumed blocks shipped
73
+ * (each consumed block's surfaceReplaced marker carries atSeq). The span
74
+ * from the first to the last replacement node must contain EXACTLY the
75
+ * consumed replacement nodes — engine folds are frontier-ordered, nothing
76
+ * can legitimately interleave, and any mismatch tombstones
77
+ * (ladder-span-mismatch / ladder-span-gone / ladder-consumed-skipped)
78
+ * instead of guessing. A ladder block whose consumed blocks have not
79
+ * shipped yet stays pending: they sit EARLIER in the same sweep (ledger
80
+ * order) and ship before it is reached again on a later sweep.
81
+ *
71
82
  * Everything fails open: a missing agents service / session.append / surface
72
83
  * support degrades to today's behavior (wire=raw banner + tally watermark),
73
84
  * and every no-op with pending work is logged to decisions.log.
@@ -75,6 +86,12 @@
75
86
 
76
87
  import { refOf, summaryMessageOf } from "./view.js";
77
88
 
89
+ /** Ladder-block probe (tier ≥ 2): a distillation whose surface material is
90
+ * the REPLACEMENT NODES of its consumed blocks, not a raw ref range. */
91
+ function isLadderBlock(block) {
92
+ return block !== null && typeof block === "object" && (block.tier === 2 || block.tier === 3);
93
+ }
94
+
78
95
  // ---- tool-pair closure ------------------------------------------------------
79
96
 
80
97
  /** Call ids OPENED by one message, across every wire shape the plugin can
@@ -618,12 +635,25 @@ export function createSurfaceFoldPass(deps = {}) {
618
635
  if (typeof sessionId !== "string" || sessionId === "") return 0;
619
636
  const ledger = store.ledgerOf(sessionId);
620
637
  const blocks = Array.isArray(ledger.blocks) ? ledger.blocks : [];
638
+ // [2026-11-07]-[hintless sweeps used a null generation, so
639
+ // generation-keyed tombstones could NEVER re-open]-[the idle sweep
640
+ // passes no sessionHint: every tombstone (incomplete-seqs,
641
+ // tool-pair-unclosed, append-rejected, ...) was filtered out forever
642
+ // even after the surface changed (inspection P1-8). Fall back to the
643
+ // live session's generation when the hint does not carry one.]
621
644
  const generation = (() => {
622
645
  try {
623
- return Number.isFinite(sessionHint?.surface?.contentGeneration) ? sessionHint.surface.contentGeneration : null;
646
+ if (Number.isFinite(sessionHint?.surface?.contentGeneration)) return sessionHint.surface.contentGeneration;
647
+ } catch {
648
+ /* fall through to the live lookup */
649
+ }
650
+ try {
651
+ const live = surfaceCapable(sessionHint) ? sessionHint : sessionOf(sessionId);
652
+ if (live !== null && typeof live === "object" && Number.isFinite(live.surface?.contentGeneration)) return live.surface.contentGeneration;
624
653
  } catch {
625
- return null;
654
+ /* unreadable session: null, fail-open */
626
655
  }
656
+ return null;
627
657
  })();
628
658
  const pending = blocks.filter((block) => {
629
659
  if (block?.surfaceReplaced !== null && block?.surfaceReplaced !== undefined) return false;
@@ -667,6 +697,196 @@ export function createSurfaceFoldPass(deps = {}) {
667
697
  let headIsSystem = headNodeIsSystem(session, nodes);
668
698
  let shipped = 0;
669
699
  for (const block of pending) {
700
+ // ---- LADDER BRANCH (tier ≥ 2, docs/ladder-fold.md) --------------
701
+ // A distillation's surface material is the REPLACEMENT NODES of its
702
+ // consumed blocks (each carries a surfaceReplaced marker with the
703
+ // replacement event seq). The span from the first to the last
704
+ // replacement node must contain EXACTLY those nodes — engine folds
705
+ // are frontier-ordered, so nothing can legitimately interleave —
706
+ // and every mismatch tombstones instead of guessing.
707
+ if (isLadderBlock(block)) {
708
+ const consumedIds = Array.isArray(block.consumes) ? block.consumes : [];
709
+ const byId = new Map(blocks.map((entry) => [entry?.id, entry]));
710
+ const consumed = consumedIds.map((id) => byId.get(id));
711
+ if (consumed.some((entry) => entry === undefined || entry === null)) {
712
+ block.surfaceSkip = { reason: "ladder-consumed-skipped", contentGeneration: liveGeneration, at: Date.now() };
713
+ try {
714
+ store.persist(ledger);
715
+ } catch {
716
+ /* best effort */
717
+ }
718
+ skip(sessionId, "ladder-consumed-skipped", via, `block=${block.id}`);
719
+ continue;
720
+ }
721
+ const waiting = consumed.find((entry) => entry.surfaceReplaced === null || entry.surfaceReplaced === undefined);
722
+ if (waiting !== undefined) {
723
+ if (tombstoneOf(waiting) !== null) {
724
+ // A consumed block is hard-skipped on this generation: its
725
+ // replacement node can never materialize, so the ladder span
726
+ // can never be exact. Generation-keyed like every skip — a
727
+ // surface change re-opens both blocks.
728
+ block.surfaceSkip = { reason: "ladder-consumed-skipped", contentGeneration: liveGeneration, at: Date.now() };
729
+ try {
730
+ store.persist(ledger);
731
+ } catch {
732
+ /* best effort */
733
+ }
734
+ skip(sessionId, "ladder-consumed-skipped", via, `block=${block.id} consumed=${waiting.id}`);
735
+ }
736
+ // Otherwise the consumed block is still pending and sits EARLIER
737
+ // in this loop (ledger order): it ships — or skips — before this
738
+ // iteration reaches the ladder block again on a later sweep.
739
+ continue;
740
+ }
741
+ const indexOfSeq = new Map(nodes.map((seq, index) => [seq, index]));
742
+ // Summary-message id positions on the LIVE surface: the
743
+ // replacement message (acp_summary_<blockId>, deterministic body)
744
+ // is content-stable, so a branch/compaction that re-projected the
745
+ // surface invalidates the stored atSeqs but NOT the identity of
746
+ // the replacement nodes — the same re-location-not-re-target rule
747
+ // the T1 stale-capture path follows.
748
+ // [2026-11-07]-[observed live: a DSH branch rebuilt the surface
749
+ // projection, orphaning every T1 atSeq and tombstoning the pending
750
+ // T2 as ladder-span-gone forever]-[id re-location lets the ladder
751
+ // ship after any surface re-projection; the spanExact id-set check
752
+ // below keeps it exact.]
753
+ const idIndexOf = new Map();
754
+ for (let i = 0; i < nodeMessages.length; i++) {
755
+ const message = nodeMessages[i];
756
+ if (message !== null && typeof message === "object" && typeof message.id === "string" && message.id.startsWith("acp_summary_")) {
757
+ idIndexOf.set(message.id, i);
758
+ }
759
+ }
760
+ const atSeqs = [];
761
+ let unresolved = null;
762
+ for (const entry of consumed) {
763
+ const atSeq = Number.isFinite(entry.surfaceReplaced.atSeq) ? entry.surfaceReplaced.atSeq : null;
764
+ if (atSeq !== null && indexOfSeq.has(atSeq)) {
765
+ atSeqs.push(atSeq);
766
+ continue;
767
+ }
768
+ const relocatedIdx = idIndexOf.get(`acp_summary_${entry.id}`);
769
+ if (relocatedIdx === undefined) {
770
+ unresolved = entry;
771
+ break;
772
+ }
773
+ atSeqs.push(nodes[relocatedIdx]);
774
+ }
775
+ if (unresolved !== null) {
776
+ // A consumed replacement node left the live surface (host
777
+ // compaction shadowed it): the span is unreconstructable.
778
+ block.surfaceSkip = { reason: "ladder-span-gone", contentGeneration: liveGeneration, at: Date.now() };
779
+ try {
780
+ store.persist(ledger);
781
+ } catch {
782
+ /* best effort */
783
+ }
784
+ skip(sessionId, "ladder-span-gone", via, `block=${block.id} consumed=${unresolved.id}`);
785
+ continue;
786
+ }
787
+ const nodeIndexes = atSeqs.map((seq) => indexOfSeq.get(seq));
788
+ const ladderFirstIdx = Math.min(...nodeIndexes);
789
+ const ladderLastIdx = Math.max(...nodeIndexes);
790
+ const spanSeqs = nodes.slice(ladderFirstIdx, ladderLastIdx + 1);
791
+ const expectedSeqs = new Set(atSeqs);
792
+ let spanExact = spanSeqs.length === atSeqs.length && spanSeqs.every((seq) => expectedSeqs.has(seq));
793
+ // Id-set exactness: after id re-location the span must contain
794
+ // EXACTLY the consumed blocks' summary messages — a span that
795
+ // picked up a stranger's acp_summary node is not this fold's
796
+ // material (the length check alone cannot tell them apart).
797
+ if (spanExact) {
798
+ const expectedIds = new Set(consumed.map((entry) => `acp_summary_${entry.id}`));
799
+ for (let i = ladderFirstIdx; i <= ladderLastIdx; i++) {
800
+ const message = nodeMessages[i];
801
+ if (message === null || message === undefined || typeof message.id !== "string" || !expectedIds.has(message.id)) {
802
+ spanExact = false;
803
+ break;
804
+ }
805
+ }
806
+ }
807
+ if (spanExact) {
808
+ for (let i = ladderFirstIdx; i <= ladderLastIdx; i++) {
809
+ const message = nodeMessages[i];
810
+ if (message === null || message === undefined || typeof message.id !== "string" || !message.id.startsWith("acp_summary_")) {
811
+ spanExact = false;
812
+ break;
813
+ }
814
+ }
815
+ }
816
+ if (!spanExact) {
817
+ block.surfaceSkip = { reason: "ladder-span-mismatch", contentGeneration: liveGeneration, at: Date.now() };
818
+ try {
819
+ store.persist(ledger);
820
+ } catch {
821
+ /* best effort */
822
+ }
823
+ skip(sessionId, "ladder-span-mismatch", via, `block=${block.id}`);
824
+ continue;
825
+ }
826
+ // Defensive system-head clamp (a summary node can never be node
827
+ // 0 — T1 folds start after the head — but a drift-hostile guard
828
+ // costs one comparison).
829
+ if (headIsSystem && ladderFirstIdx === 0) {
830
+ block.surfaceSkip = { reason: "system-head", contentGeneration: liveGeneration, at: Date.now() };
831
+ try {
832
+ store.persist(ledger);
833
+ } catch {
834
+ /* best effort */
835
+ }
836
+ skip(sessionId, "system-head", via, `block=${block.id} ladder`);
837
+ continue;
838
+ }
839
+ const ladderStartSeq = nodes[ladderFirstIdx];
840
+ const ladderEndSeq = nodes[ladderLastIdx];
841
+ const ladderKey = replacedKey(sessionId, ladderStartSeq, ladderEndSeq, liveGeneration);
842
+ if (wasReplaced(ladderKey)) continue; // belt over the durable marker
843
+ try {
844
+ session.append("compaction/prune", {
845
+ shadowedRange: { start: ladderStartSeq, end: ladderEndSeq },
846
+ shadowedSeqs: spanSeqs,
847
+ shadowedTokenCount: Number.isFinite(block.approxTokens) && block.approxTokens > 0 ? Math.round(block.approxTokens) : spanSeqs.length,
848
+ });
849
+ const event = session.append("user/message", replacementMessageOf(block), {
850
+ surfaceOp: { op: "replace", startSeq: ladderStartSeq, endSeq: ladderEndSeq },
851
+ sourceEventSeqs: spanSeqs,
852
+ });
853
+ block.surfaceReplaced = {
854
+ startSeq: ladderStartSeq,
855
+ endSeq: ladderEndSeq,
856
+ seqs: spanSeqs.slice(),
857
+ contentGeneration: liveGeneration,
858
+ atSeq: Number.isFinite(event?.seq) ? event.seq : null,
859
+ via,
860
+ at: Date.now(),
861
+ };
862
+ delete block.surfaceSkip;
863
+ rememberReplaced(ladderKey);
864
+ shipped += 1;
865
+ // Same post-ship snapshot refresh as the T1 branch: the
866
+ // shadowed seqs left surface.nodes.
867
+ nodes = surface.nodes.slice();
868
+ pairs = derivingPairsOf(session, nodes);
869
+ nodeMessages = denseMessagesOf(pairs, nodes.length);
870
+ headIsSystem = headNodeIsSystem(session, nodes);
871
+ liveGeneration = Number.isFinite(surface.contentGeneration) ? surface.contentGeneration : 0;
872
+ decisionLog(`session=${sessionId} surface-replace startSeq=${ladderStartSeq} endSeq=${ladderEndSeq} seqs=${spanSeqs.length} tokens=${Number.isFinite(block.approxTokens) ? Math.round(block.approxTokens) : "?"} generation=${liveGeneration} ladder=${block.tier} via=${via}`);
873
+ try {
874
+ store.persist(ledger);
875
+ } catch {
876
+ /* the in-memory marker still guards this process */
877
+ }
878
+ } catch (error) {
879
+ const message = String(error?.message ?? error).replace(/\s+/g, " ").slice(0, 160);
880
+ block.surfaceSkip = { reason: `append-rejected:${message}`, contentGeneration: liveGeneration, at: Date.now() };
881
+ try {
882
+ store.persist(ledger);
883
+ } catch {
884
+ /* best effort */
885
+ }
886
+ skip(sessionId, `append-rejected:${message}`, via, `block=${block.id} ladder=${block.tier}`);
887
+ }
888
+ continue;
889
+ }
670
890
  // Resolve the shadow range: stored capture first (validated dense
671
891
  // against the LIVE surface). A stale/incomplete capture — or a
672
892
  // capture-less legacy block — falls back to live location, but ONLY