dsh-switchman 1.8.1 → 1.10.2

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.
@@ -0,0 +1,129 @@
1
+ /** dsh-switchman context-trim trigger tiers (pure decision logic).
2
+ *
3
+ * The trigger half of the port (design §4/§5): decides whether a fold
4
+ * should run NOW. Called only at idle boundaries (agent/status idle) —
5
+ * never inside llm/stream, never while a fold for the same session is in
6
+ * flight. Three tiers, checked emergency-first:
7
+ *
8
+ * - emergency : view usage >= trimEmergencyPercent of the context window.
9
+ * Forced MECHANICAL fold (no side-model round trip — at 95%
10
+ * the session cannot afford the latency or the failure).
11
+ * - pressure : view usage >= trimPressurePercent. Side-model fold.
12
+ * - growth : growth since the last fold's post-fold low (the anchor
13
+ * that keeps the gate armed after folds committing at high
14
+ * view points) > trimGrowthTokens AND the contiguous
15
+ * compressible increment >= trimIncrementTokens. The
16
+ * primary, cache-friendly gate (the bili heuristic).
17
+ *
18
+ * Side-model folds are additionally throttled by trimMaxFoldsPerHour and a
19
+ * hard minimum interval between folds; emergency folds have their own
20
+ * cooldown so a stuck-at-95% session folds at most once per window.
21
+ * Everything is derived from measured inputs — no clock, no host access —
22
+ * which keeps the decision unit-testable (golden thresholds).
23
+ */
24
+
25
+ import { estimateTokensOf, refPositions, segmentBounds } from "./view.js";
26
+
27
+ /** Hard minimum wall-clock interval between side-model folds. */
28
+ export const FOLD_MIN_INTERVAL_MS = 60_000;
29
+
30
+ /** Minimum interval between emergency (mechanical) folds. */
31
+ export const EMERGENCY_COOLDOWN_MS = 90_000;
32
+
33
+ /** The foldable span of a captured messages array for one ledger: from the
34
+ * first segment boundary strictly after the frontier to the last segment
35
+ * end that still clears the protected tail. Returns { startIdx, endIdx }
36
+ * or null when nothing is foldable (empty history, everything folded, or
37
+ * protected tail covers it all). */
38
+ export function computeFoldableRange(messages, ledger, protectedTail) {
39
+ if (!Array.isArray(messages) || messages.length === 0) return null;
40
+ const tail = Number.isInteger(protectedTail) && protectedTail > 0 ? protectedTail : 0;
41
+ const tailStart = messages.length - tail;
42
+ if (tailStart <= 0) return null;
43
+ // first candidate segment start: strictly after the frontier message
44
+ let startFrom = 0;
45
+ if (typeof ledger?.frontier === "string" && ledger.frontier !== "") {
46
+ const positions = refPositions(messages);
47
+ const list = positions.get(ledger.frontier);
48
+ if (list !== undefined && list.length > 0) startFrom = list[list.length - 1] + 1;
49
+ }
50
+ // A leading system message is the frozen head of the wire array: a fold
51
+ // anchored on it would put a surface replace op (and a view splice) over
52
+ // the system prompt, which the host rejects outright
53
+ // (assertSystemHeadRewrite — node 0 may be rewritten only by a
54
+ // system/message over exactly that node). Covers both the frontier-null
55
+ // first fold and any frontier-set fold that lands on the head.
56
+ if (messages[0]?.role === "system") startFrom = Math.max(startFrom, 1);
57
+ let best = null;
58
+ for (const [start, end] of segmentBounds(messages)) {
59
+ if (start < startFrom) continue; // behind the frontier
60
+ if (end >= tailStart) break; // segment would cut into the protected tail
61
+ if (best === null) best = { startIdx: start, endIdx: end };
62
+ else best.endIdx = end; // extend the contiguous span
63
+ }
64
+ return best;
65
+ }
66
+
67
+ /** Evaluate the trigger. input: {
68
+ * foldable: { startIdx, endIdx } | null, // computeFoldableRange result
69
+ * messages, // captured raw messages (increment measurement)
70
+ * viewTokens, // outbound-view token estimate of the current history
71
+ * windowTokens, // context window size, or null when unknown
72
+ * thresholds: { growthTokens, incrementTokens, pressurePercent, emergencyPercent, maxFoldsPerHour },
73
+ * now, lastFoldAt, foldsThisHour, lastEmergencyAt,
74
+ * }.
75
+ * Returns { action: "none"|"fold"|"emergency", tier, reason, startIdx, endIdx, incrementTokens }. */
76
+ export function evaluateTrigger(input) {
77
+ const none = { action: "none", tier: null, reason: "", startIdx: -1, endIdx: -1, incrementTokens: 0 };
78
+ const foldable = input?.foldable ?? null;
79
+ if (foldable === null) return { ...none, reason: "nothing-foldable" };
80
+ const { startIdx, endIdx } = foldable;
81
+ const thresholds = input?.thresholds ?? {};
82
+ const now = Number.isFinite(input?.now) ? input.now : Date.now();
83
+ const incrementTokens = estimateTokensOf(input?.messages ?? [], startIdx, endIdx);
84
+ if (incrementTokens < 1) return { ...none, reason: "empty-increment" };
85
+
86
+ // Tier c — emergency: window pressure, mechanical, cooldown-gated.
87
+ const windowTokens = Number.isFinite(input?.windowTokens) && input.windowTokens > 0 ? input.windowTokens : null;
88
+ const emergencyPercent = Number.isFinite(thresholds.emergencyPercent) && thresholds.emergencyPercent > 0 ? thresholds.emergencyPercent : 95;
89
+ if (windowTokens !== null) {
90
+ const usagePct = (input.viewTokens / windowTokens) * 100;
91
+ if (usagePct >= emergencyPercent) {
92
+ const lastEmergencyAt = Number.isFinite(input?.lastEmergencyAt) ? input.lastEmergencyAt : 0;
93
+ if (now - lastEmergencyAt < EMERGENCY_COOLDOWN_MS) return { ...none, reason: "emergency-cooldown" };
94
+ return { action: "emergency", tier: "emergency", reason: `usage ${usagePct.toFixed(1)}% >= ${emergencyPercent}%`, startIdx, endIdx, incrementTokens };
95
+ }
96
+ }
97
+
98
+ // Side-model folds (pressure + growth) share the cadence gates.
99
+ const lastFoldAt = Number.isFinite(input?.lastFoldAt) ? input.lastFoldAt : 0;
100
+ const hourly = Array.isArray(input?.foldsThisHour) ? input.foldsThisHour.filter((ts) => now - ts < 3_600_000).length : 0;
101
+ const maxFoldsPerHour = Number.isInteger(thresholds.maxFoldsPerHour) && thresholds.maxFoldsPerHour > 0 ? thresholds.maxFoldsPerHour : 6;
102
+ if (hourly >= maxFoldsPerHour) return { ...none, reason: "hourly-cap" };
103
+ if (now - lastFoldAt < FOLD_MIN_INTERVAL_MS) return { ...none, reason: "min-interval" };
104
+
105
+ // Tier b — pressure: soft window threshold, side-model.
106
+ const pressurePercent = Number.isFinite(thresholds.pressurePercent) && thresholds.pressurePercent > 0 ? thresholds.pressurePercent : 60;
107
+ if (windowTokens !== null) {
108
+ const usagePct = (input.viewTokens / windowTokens) * 100;
109
+ if (usagePct >= pressurePercent) {
110
+ return { action: "fold", tier: "pressure", reason: `usage ${usagePct.toFixed(1)}% >= ${pressurePercent}%`, startIdx, endIdx, incrementTokens };
111
+ }
112
+ }
113
+
114
+ // Tier a — growth gate: the primary cache-friendly heuristic.
115
+ const growthTokens = Number.isFinite(thresholds.growthTokens) && thresholds.growthTokens > 0 ? thresholds.growthTokens : 22_500;
116
+ const incrementGate = Number.isFinite(thresholds.incrementTokens) && thresholds.incrementTokens > 0 ? thresholds.incrementTokens : 50_000;
117
+ // Growth anchor: the post-fold low when known (keeps the gate armed
118
+ // after folds that commit at high view points), else the pre-fold
119
+ // view for ledgers written before lastFoldLowTokens existed.
120
+ const anchor = Number.isFinite(input?.ledger?.lastFoldLowTokens) && input.ledger.lastFoldLowTokens > 0
121
+ ? input.ledger.lastFoldLowTokens
122
+ : Number.isFinite(input?.ledger?.lastFoldViewTokens) ? input.ledger.lastFoldViewTokens : 0;
123
+ const growth = input.viewTokens - anchor;
124
+ if (growth > growthTokens && incrementTokens >= incrementGate) {
125
+ return { action: "fold", tier: "growth", reason: `growth ~${Math.round(growth)} > ${growthTokens} and increment ~${incrementTokens} >= ${incrementGate}`, startIdx, endIdx, incrementTokens };
126
+ }
127
+
128
+ return { ...none, reason: "below-thresholds" };
129
+ }
@@ -0,0 +1,362 @@
1
+ /** dsh-switchman context-trim view builder (pure).
2
+ *
3
+ * The outbound-view half of the context-trim port (design:
4
+ * .dsh-switchman/analysis/context-trim-port-design.md §4): folds stored in
5
+ * a session ledger replace contiguous message ranges with ONE synthetic
6
+ * user message each. Everything here is pure and deterministic — the same
7
+ * (messages, ledger) pair must render a byte-identical view on every call
8
+ * (the golden test pins this), because a stable serialized prefix between
9
+ * folds is exactly what keeps provider prompt-cache hits alive.
10
+ *
11
+ * Message refs: messages at the llm/stream boundary are plain JSON-ish
12
+ * objects ({id?, role, content blocks / text}). An explicit id is trusted
13
+ * only when it looks addressable (short, no whitespace/control chars — the
14
+ * same validation billion-context applies); otherwise the ref is derived as
15
+ * a content hash of the canonicalized message (stable key order, volatile
16
+ * fields dropped), which survives the full-history re-send the agent loop
17
+ * performs every turn. Fold blocks anchor on refs, so a ledger keeps
18
+ * applying across process restarts without a parallel session log.
19
+ *
20
+ * Fail-open: blocks whose refs cannot be located (history rewritten by a
21
+ * racing compaction, content mutated) or that would reach into the
22
+ * protected tail are skipped, never guessed at — a stale ledger can only
23
+ * degrade to "no fold applied", never to lost messages.
24
+ */
25
+
26
+ import { createHash } from "node:crypto";
27
+
28
+ /** Cheap char → token estimate (the brief's char/4 heuristic). Applies to
29
+ * ASCII/latin text; CJK text tokenizes far denser (see below). */
30
+ export const TOKENS_PER_CHAR = 1 / 4;
31
+
32
+ /** CJK/fullwidth chars cost roughly ONE token each in the glm/deepseek BPE
33
+ * vocabularies (vs 0.25 for the char/4 ASCII heuristic). Pinned at 1.0:
34
+ * slightly high for common hanzi bigrams, which errs toward earlier folds
35
+ * (the safe direction). NOT the 2026-11-04 incident root cause (that session
36
+ * was only 4.8% CJK — the real bug was frozen-dispatch view injection); this
37
+ * is a general accuracy fix for CJK-heavy sessions. */
38
+ export const TOKENS_PER_CJK_CHAR = 1;
39
+
40
+ /** CJK-ish ranges: kana, Hangul jamo+syllables, CJK radicals/blocks and
41
+ * unified ideographs (incl. Ext-A), compat ideographs, vertical forms,
42
+ * fullwidth forms, and the supplementary-plane CJK extensions (Ext-B..F,
43
+ * /u flag — astral chars otherwise count as two ascii units). Halfwidth
44
+ * katakana (\uFF61-\uFF9F) rides in the fullwidth range at the CJK weight:
45
+ * slightly high, the safe direction for fold scheduling. */
46
+ const CJK_CHAR_RE = /[\u1100-\u11FF\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFFEF\u{20000}-\u{2FA1F}]/gu;
47
+
48
+ /** Split one text into (asciiChars, cjkChars) in one pass over the string.
49
+ * The replace allocates a second string — same order of cost as the
50
+ * JSON.stringify this sits next to. */
51
+ function countChars(text) {
52
+ let plain;
53
+ try {
54
+ plain = text.replace(CJK_CHAR_RE, "");
55
+ } catch {
56
+ return { ascii: text.length, cjk: 0 };
57
+ }
58
+ return { ascii: plain.length, cjk: text.length - plain.length };
59
+ }
60
+
61
+ /** Object keys dropped before hashing (volatility would churn refs). */
62
+ const VOLATILE_KEYS = new Set(["ts", "time", "timestamp", "createdAt", "updatedAt", "receivedAt", "sentAt"]);
63
+
64
+ /** Canonicalization depth cap (well beyond any real message nesting). */
65
+ const MAX_CANON_DEPTH = 8;
66
+
67
+ /** Canonical JSON of one value: object keys sorted, volatile keys dropped,
68
+ * arrays kept in order. Used ONLY for ref hashing, never for output. */
69
+ function canonicalValue(value, depth) {
70
+ if (value === null || typeof value !== "object") {
71
+ if (typeof value === "bigint") return String(value);
72
+ if (typeof value === "number" && !Number.isFinite(value)) return null;
73
+ return value;
74
+ }
75
+ if (depth >= MAX_CANON_DEPTH) return "[depth]";
76
+ if (Array.isArray(value)) return value.map((item) => canonicalValue(item, depth + 1));
77
+ const out = {};
78
+ for (const key of Object.keys(value).sort()) {
79
+ if (VOLATILE_KEYS.has(key)) continue;
80
+ out[key] = canonicalValue(value[key], depth + 1);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Canonical JSON text of one message (ref-hash input). */
86
+ function canonicalizeMessage(message) {
87
+ try {
88
+ return JSON.stringify(canonicalValue(message ?? null, 0)) ?? "null";
89
+ } catch {
90
+ return String(message);
91
+ }
92
+ }
93
+
94
+ /** True when an explicit message id is safe to address directly. */
95
+ function usableId(id) {
96
+ if (typeof id !== "string" || id === "" || id.length > 512) return false;
97
+ for (const char of id) {
98
+ const code = char.charCodeAt(0);
99
+ if (code <= 0x20 || code === 0x7f) return false;
100
+ }
101
+ return true;
102
+ }
103
+
104
+ /** Stable ref of one message: the explicit id when usable, else a short
105
+ * content hash (prefixed "h" so the two families can be told apart in
106
+ * logs). Index is unused for hashing — refs are position-independent on
107
+ * purpose (the host may prepend/insert nothing, but ids may be absent). */
108
+ export function refOf(message, _index) {
109
+ const id = message?.id;
110
+ if (usableId(id)) return id;
111
+ return `h${createHash("sha256").update(canonicalizeMessage(message)).digest("hex").slice(0, 12)}`;
112
+ }
113
+
114
+ /** ref -> ascending index list for one messages array. */
115
+ export function refPositions(messages) {
116
+ const positions = new Map();
117
+ messages.forEach((message, index) => {
118
+ const ref = refOf(message, index);
119
+ const list = positions.get(ref);
120
+ if (list === undefined) positions.set(ref, [index]);
121
+ else list.push(index);
122
+ });
123
+ return positions;
124
+ }
125
+
126
+ /** Estimated token count of one message (text content + tool-call payloads
127
+ * + small per-message framing overhead). CJK-aware — see TOKENS_PER_CJK_CHAR.
128
+ * Never throws. */
129
+ export function messageTokens(message) {
130
+ let ascii = 8; // per-message framing overhead, counted in the ascii pool
131
+ let cjk = 0;
132
+ const content = message?.content;
133
+ try {
134
+ if (typeof content === "string") {
135
+ const counted = countChars(content);
136
+ ascii += counted.ascii;
137
+ cjk += counted.cjk;
138
+ } else if (Array.isArray(content)) {
139
+ for (const block of content) {
140
+ const text = block !== null && typeof block === "object" && typeof block.text === "string" ? block.text : JSON.stringify(block ?? null);
141
+ const counted = countChars(text);
142
+ ascii += counted.ascii;
143
+ cjk += counted.cjk;
144
+ }
145
+ }
146
+ if (Array.isArray(message?.tool_calls)) {
147
+ const counted = countChars(JSON.stringify(message.tool_calls));
148
+ ascii += counted.ascii;
149
+ cjk += counted.cjk;
150
+ }
151
+ } catch {
152
+ /* unserializable block: keep the framing estimate */
153
+ }
154
+ return Math.max(1, Math.round(ascii * TOKENS_PER_CHAR + cjk * TOKENS_PER_CJK_CHAR));
155
+ }
156
+
157
+ /** Estimated token count of a message range (inclusive indices). */
158
+ export function estimateTokensOf(messages, startIdx = 0, endIdx = messages.length - 1) {
159
+ let total = 0;
160
+ for (let i = Math.max(0, startIdx); i <= Math.min(messages.length - 1, endIdx); i++) total += messageTokens(messages[i]);
161
+ return total;
162
+ }
163
+
164
+ // ---- tool-pair segmentation -------------------------------------------------
165
+
166
+ /** True when the message OPENS tool calls (either wire shape). */
167
+ function opensToolCalls(message) {
168
+ if (message?.role !== "assistant") return false;
169
+ if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return true;
170
+ if (Array.isArray(message.content)) return message.content.some((block) => block?.type === "tool_use");
171
+ return false;
172
+ }
173
+
174
+ /** True when the message CARRIES tool results (either wire shape). */
175
+ function carriesToolResults(message) {
176
+ if (message?.role === "tool") return true;
177
+ if (Array.isArray(message.content)) return message.content.some((block) => block?.type === "tool_result");
178
+ return false;
179
+ }
180
+
181
+ /** Atomic segments of a messages array: an assistant message that opens
182
+ * tool calls plus every directly following tool-result message form ONE
183
+ * segment (they must live or fold together — a split pair is rejected by
184
+ * the upstream API, dsh-compaction/lib/index.js:557 validates the same
185
+ * rule); every other message is its own segment. Returns [start, end]
186
+ * index pairs covering the array in order. */
187
+ export function segmentBounds(messages) {
188
+ const bounds = [];
189
+ let i = 0;
190
+ while (i < messages.length) {
191
+ let end = i;
192
+ if (opensToolCalls(messages[i])) {
193
+ let j = i + 1;
194
+ while (j < messages.length && carriesToolResults(messages[j])) j++;
195
+ end = j - 1;
196
+ }
197
+ bounds.push([i, end]);
198
+ i = end + 1;
199
+ }
200
+ return bounds;
201
+ }
202
+
203
+ /** True when [startIdx, endIdx] aligns with segment boundaries (a fold
204
+ * range must never cut inside a tool pair). */
205
+ export function rangeIsSegmentAligned(messages, startIdx, endIdx) {
206
+ if (!Number.isInteger(startIdx) || !Number.isInteger(endIdx) || startIdx < 0 || endIdx < startIdx) return false;
207
+ // The range must START at a segment start and END at a segment end (it may
208
+ // span any number of whole segments — segments tile the array contiguously,
209
+ // so aligned ends guarantee no tool pair is cut).
210
+ let startHit = false;
211
+ for (const [start, end] of segmentBounds(messages)) {
212
+ if (start === startIdx) startHit = true;
213
+ if (end === endIdx) return startHit && start <= endIdx;
214
+ if (start > endIdx) break;
215
+ }
216
+ return false;
217
+ }
218
+
219
+ // ---- fold blocks ------------------------------------------------------------
220
+
221
+ /** Deterministic wire text of one fold block's synthetic message. */
222
+ export function renderSummaryText(block) {
223
+ return `[SWITCHMAN:TRIM] ${block.summary}`;
224
+ }
225
+
226
+ /** The ONE synthetic message replacing a folded range (brief: synthetic
227
+ * user message; id carries the acp_summary_ prefix downstream tooling
228
+ * skips). Pure — same block, same bytes. */
229
+ export function summaryMessageOf(block) {
230
+ return {
231
+ id: `acp_summary_${block.id}`,
232
+ role: "user",
233
+ content: [{ type: "text", text: renderSummaryText(block) }],
234
+ };
235
+ }
236
+
237
+ /** Locate a block's range in a positions map at/after afterIdx. Strict:
238
+ * both endpoint refs must resolve and the located span must equal the
239
+ * block's stored message count (guards against re-sent histories that no
240
+ * longer match the folded range). Returns [firstIdx, lastIdx] or null. */
241
+ function locateBlock(positions, block, afterIdx) {
242
+ const range = block?.refRange;
243
+ if (range === null || typeof range !== "object") return null;
244
+ const firstList = positions.get(range.firstId);
245
+ const lastList = positions.get(range.lastId);
246
+ if (firstList === undefined || lastList === undefined) return null;
247
+ const anchored = Number.isInteger(block.count) && block.count > 0;
248
+ let firstIdx = null;
249
+ let lastIdx = null;
250
+ for (const start of firstList) {
251
+ if (start <= afterIdx) continue;
252
+ if (anchored) {
253
+ // Duplicate-content messages share one content-addressed ref, so
254
+ // resolve the pair anchored on the stored count, not on extremes.
255
+ const end = start + block.count - 1;
256
+ if (!lastList.includes(end)) continue;
257
+ firstIdx = start;
258
+ lastIdx = end;
259
+ } else {
260
+ firstIdx = start;
261
+ }
262
+ break;
263
+ }
264
+ if (firstIdx === null) return null;
265
+ if (lastIdx === null) {
266
+ for (const index of lastList) {
267
+ if (index >= firstIdx) {
268
+ lastIdx = index;
269
+ break;
270
+ }
271
+ }
272
+ if (lastIdx === null) return null;
273
+ }
274
+ const span = lastIdx - firstIdx + 1;
275
+ const expected = anchored ? block.count : span;
276
+ return span === expected ? [firstIdx, lastIdx] : null;
277
+ }
278
+
279
+ /** Build the outbound view: apply every applicable fold block to a copy of
280
+ * the messages array. Pure (never mutates the input; the assembly splices
281
+ * the result into the live request array). Blocks apply in positional
282
+ * order; a block that cannot be located, overlaps an already-applied one,
283
+ * or reaches into the last protectedTail messages is skipped. Returns
284
+ * { messages, applied, foldedMessages } (foldedMessages = live messages
285
+ * the applied blocks replaced). */
286
+ export function buildView(messages, ledger, options = {}) {
287
+ const protectedTail = Number.isInteger(options.protectedTail) && options.protectedTail > 0 ? options.protectedTail : 0;
288
+ const blocks = Array.isArray(ledger?.blocks) ? ledger.blocks : [];
289
+ const positions = refPositions(messages);
290
+ const located = [];
291
+ let unlocated = 0;
292
+ for (const block of blocks) {
293
+ const range = locateBlock(positions, block, -1);
294
+ if (range !== null) located.push({ block, firstIdx: range[0], lastIdx: range[1] });
295
+ else unlocated += 1;
296
+ }
297
+ located.sort((a, b) => a.firstIdx - b.firstIdx || b.lastIdx - a.lastIdx);
298
+ const out = [];
299
+ let at = 0;
300
+ let applied = 0;
301
+ let foldedMessages = 0;
302
+ for (const entry of located) {
303
+ if (entry.firstIdx < at) continue; // overlaps an applied block — skip
304
+ if (protectedTail > 0 && entry.lastIdx >= messages.length - protectedTail) continue;
305
+ // The system prompt at index 0 is never foldable: splicing it off the
306
+ // wire would drop the prompt itself. Plain skip (not unlocated) so
307
+ // the ledgerIsStale epoch semantics stay untouched.
308
+ if (entry.firstIdx === 0 && messages[0]?.role === "system") continue;
309
+ if (!rangeIsSegmentAligned(messages, entry.firstIdx, entry.lastIdx)) continue; // never split a tool pair (design §4)
310
+ for (let i = at; i < entry.firstIdx; i++) out.push(messages[i]);
311
+ out.push(summaryMessageOf(entry.block));
312
+ foldedMessages += entry.lastIdx - entry.firstIdx + 1;
313
+ at = entry.lastIdx + 1;
314
+ applied += 1;
315
+ }
316
+ for (let i = at; i < messages.length; i++) out.push(messages[i]);
317
+ return { messages: out, applied, foldedMessages, unlocated };
318
+ }
319
+
320
+ /** Epoch-boundary detection: every block failed to LOCATE inside the current
321
+ * re-send — the host replaced history (its own compaction) and the ledger
322
+ * aged out as a whole. The fail-open skip stays correct, but a growth
323
+ * baseline carried over from the previous epoch does not: the caller
324
+ * re-anchors it exactly once per epoch. */
325
+ export function ledgerIsStale(view, ledger) {
326
+ const blocksCount = Array.isArray(ledger?.blocks) ? ledger.blocks.length : 0;
327
+ return blocksCount > 0 && view.applied === 0 && view.unlocated === blocksCount;
328
+ }
329
+
330
+ /** Install the built view for one dispatch. Host immutability varies: probe
331
+ * and fresh re-sends hand out mutable arrays (splice in place — cheapest,
332
+ * identity-preserving), resumed sessions re-send FROZEN host-assembled
333
+ * history (splice throws on its internal index deletes), and some dispatch
334
+ * options are frozen objects on top of that. Returns the strategy used so
335
+ * the caller can log it: "in-place" | "swapped" | "none" (a fold existed but
336
+ * the dispatch could not carry it — the wire ships raw history) |
337
+ * "nothing-to-apply" (no located fold blocks; the raw history IS the view). */
338
+ export function injectView(options, messages, view) {
339
+ if (view.applied <= 0) return "nothing-to-apply";
340
+ try {
341
+ replaceInPlace(messages, view.messages);
342
+ return "in-place";
343
+ } catch {
344
+ /* frozen array — fall through to the reference swap */
345
+ }
346
+ try {
347
+ options.messages = view.messages;
348
+ return "swapped";
349
+ } catch {
350
+ /* frozen options — this dispatch cannot carry the view; fail open */
351
+ }
352
+ return "none";
353
+ }
354
+
355
+ /** Replace an array's CONTENT in place (identity preserved — the llm/stream
356
+ * contract mutates the live request array). Chunked pushes keep huge
357
+ * histories clear of the argument-count limit. */
358
+ export function replaceInPlace(target, source) {
359
+ target.splice(0, target.length);
360
+ for (let i = 0; i < source.length; i += 2048) target.push(...source.slice(i, i + 2048));
361
+ return target;
362
+ }