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.
- package/README.de.md +5 -4
- package/README.es.md +5 -4
- package/README.fr.md +5 -4
- package/README.it.md +5 -4
- package/README.ja.md +5 -4
- package/README.ko.md +5 -4
- package/README.md +14 -5
- package/README.pt.md +5 -4
- package/README.ru.md +5 -4
- package/README.zh-TW.md +5 -4
- package/README.zh.md +5 -4
- package/client.js +323 -40
- package/cordis.patch.yml +23 -20
- package/host/config.js +51 -7
- package/host/context-trim/fork.js +5 -1
- package/host/context-trim/index.js +329 -178
- package/host/context-trim/metrics.js +7 -7
- package/host/context-trim/state.js +351 -50
- package/host/context-trim/summarizer.js +147 -33
- package/host/context-trim/surface.js +222 -2
- package/host/context-trim/trigger.js +170 -51
- package/host/context-trim/view.js +132 -16
- package/host/context-watch.js +80 -73
- package/host/routes.js +64 -4
- package/package.json +2 -2
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/** dsh-switchman context-trim summarizer.
|
|
2
2
|
*
|
|
3
|
-
* Produces the summary text for one fold block,
|
|
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
|
|
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)
|
|
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;
|
|
133
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|