dsh-switchman 1.9.0 → 1.10.3

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,920 @@
1
+ /** dsh-switchman context-trim surface-replace pass (design:
2
+ * docs/surface-fold.md, v1.10.0).
3
+ *
4
+ * The splice-level view (view.js injectView) cannot reach the wire while the
5
+ * host deep-freezes assembled requests — those sessions log wire=raw and the
6
+ * folds stay meter-only phantoms. The host's own compaction proves the escape
7
+ * hatch: history is a DERIVED projection of the append-only event log, and
8
+ * appending ONE event with surfaceOp {op:'replace', startSeq, endSeq} shadows
9
+ * a range in that projection (Session.deriveMessages walks surface.nodes,
10
+ * core/session/src/index.ts:999-1020). This pass clones that mechanism for
11
+ * fold blocks: at a serial boundary it appends a shadow-price metering event
12
+ * plus ONE replacement user/message whose body is the block's rendered text,
13
+ * so the fold reaches the wire from the source level — frozen dispatches
14
+ * included, because deriveMessages runs before every assembly.
15
+ *
16
+ * RANGE-MAPPING STRATEGY (design §2 / requirement 4): commit-time capture is
17
+ * PRIMARY — the moment a fold commits, captureFoldSeqs resolves the block's
18
+ * ref range against the LIVE session surface and stores the covered event
19
+ * seqs on the ledger block (block.surfaceSeqs). Event seqs are immutable log
20
+ * indexes, so the capture stays stable across later appends, and it is the
21
+ * cheapest truth at append time. REPLACE-TIME LIVE LOCATION is the fallback
22
+ * for blocks whose capture could not run (session unreachable at commit, or
23
+ * a ledger inherited/cloned without seqs): the block's firstId/lastId refs
24
+ * are re-located over surface.nodes with the same count-anchored resolution
25
+ * the view builder uses.
26
+ *
27
+ * Surface node seqs are MODEL-VISIBLE order, NOT log order: a replace op
28
+ * splices a NEW (higher) seq into the middle of the list, so a span crossing
29
+ * a replacement is legitimately NON-monotonic (a real fold captured
30
+ * [376, 229, 230, ...] because event 376 replaced the range around it).
31
+ * Captures and validators therefore treat a span as an ordered SET of unique
32
+ * seqs — never as an ascending interval. The host validates the same way:
33
+ * sourceEventSeqs only need to be earlier, unique, and cover every shadowed
34
+ * node (core/session/src/surface.ts:329-363).
35
+ *
36
+ * A capture that EXISTS but no longer validates against the live surface
37
+ * (endpoints missing, span not densely covered — e.g. a host compaction
38
+ * already shadowed part of the range) is never silently re-targeted. The hard
39
+ * skip is relaxed ONLY when the re-located range is provably the same fold:
40
+ * the block's refs still resolve count-anchored on the visible surface, the
41
+ * stored first/last seqs are still live surface nodes AND equal the located
42
+ * span's endpoints (content-addressed refs make an EARLIER look-alike span
43
+ * locate just as well), AND the located node span shares no event seq with
44
+ * any range another block has already REPLACED (this block excepted; the
45
+ * marker's shipped seq set when present, then the other block's capture, then
46
+ * the numeric surfaceReplaced.startSeq..endSeq interval for legacy markers).
47
+ * A capture-less legacy block keeps the plain live-location path. Anything
48
+ * else keeps the `incomplete-seqs` tombstone: re-targeting a different range
49
+ * would shadow messages the fold never covered.
50
+ *
51
+ * Protocol details mirrored from the host recon:
52
+ * - sourceEventSeqs must densely cover EVERY shadowed surface node
53
+ * (core/session/src/surface.ts:329-363) — the span slice guarantees this.
54
+ * - The metering event precedes the replacement SYNCHRONOUSLY-adjacent
55
+ * (shadow-price protocol; ToolResultPruner precedent,
56
+ * compaction-tool-result-pruner/src/index.ts:159-172). 'compaction/prune'
57
+ * is a core-known log-only type (core/session/src/known-event-types.ts:33)
58
+ * whose data shape carries shadowedRange/shadowedSeqs/shadowedTokenCount.
59
+ * - The replacement message's source stays a plain {kind:'user'}. It used
60
+ * to clone the host compaction checkpoint marker {kind:'compact-checkpoint'}
61
+ * (compaction/src/checkpoint.ts), but the installed v4 session validator
62
+ * (session-format-v3-to-v4/src/relationships.ts) requires that kind to ride
63
+ * an open compaction/start..end pair and rejects the event otherwise
64
+ * ("compaction checkpoint has no matching compaction/start"). Downstream
65
+ * recognition of the summary row rides its acp_summary_ id prefix and its
66
+ * [SWITCHMAN:TRIM] content prefix (view.js summaryMessageOf), never the
67
+ * source kind.
68
+ * - Never append while a durable compaction/start..compaction/end lock pair
69
+ * is open for the session (design constraint).
70
+ *
71
+ * Everything fails open: a missing agents service / session.append / surface
72
+ * support degrades to today's behavior (wire=raw banner + tally watermark),
73
+ * and every no-op with pending work is logged to decisions.log.
74
+ */
75
+
76
+ import { refOf, summaryMessageOf } from "./view.js";
77
+
78
+ // ---- tool-pair closure ------------------------------------------------------
79
+
80
+ /** Call ids OPENED by one message, across every wire shape the plugin can
81
+ * see: the DSH-native content block {type:'tool-call', id} (the shape that
82
+ * reaches real captures — packages/llm/llm/src/types.ts ToolCallBlock), the
83
+ * provider-shaped {type:'tool_use', id} block, and the OpenAI
84
+ * message.tool_calls array. Duplicate ids are kept — the caller only asks
85
+ * "is this id answered somewhere in the span". */
86
+ function toolCallIdsOf(message) {
87
+ const ids = [];
88
+ if (message === null || typeof message !== "object") return ids;
89
+ if (Array.isArray(message.tool_calls)) {
90
+ for (const call of message.tool_calls) {
91
+ if (typeof call?.id === "string" && call.id !== "") ids.push(call.id);
92
+ }
93
+ }
94
+ if (Array.isArray(message.content)) {
95
+ for (const block of message.content) {
96
+ if ((block?.type === "tool-call" || block?.type === "tool_use") && typeof block.id === "string" && block.id !== "") ids.push(block.id);
97
+ }
98
+ }
99
+ return ids;
100
+ }
101
+
102
+ /** Call ids ANSWERED by one message: the DSH-native role:'tool' message's
103
+ * toolCallId, the provider-shaped tool_call_id field, and tool_result /
104
+ * tool-result content blocks' tool_use_id. */
105
+ function toolResultIdsOf(message) {
106
+ const ids = [];
107
+ if (message === null || typeof message !== "object") return ids;
108
+ if (typeof message.toolCallId === "string" && message.toolCallId !== "") ids.push(message.toolCallId);
109
+ if (typeof message.tool_call_id === "string" && message.tool_call_id !== "") ids.push(message.tool_call_id);
110
+ if (Array.isArray(message.content)) {
111
+ for (const block of message.content) {
112
+ if (block?.type !== "tool_result" && block?.type !== "tool-result") continue;
113
+ const id = block.tool_use_id ?? block.toolCallId ?? block.tool_call_id;
114
+ if (typeof id === "string" && id !== "") ids.push(id);
115
+ }
116
+ }
117
+ return ids;
118
+ }
119
+
120
+ /** Dense node-index-aligned message list built from derivingPairsOf: entry i
121
+ * is the message surface node i derives, or null for a node that derives
122
+ * none (empty assistant step, ...). closeToolPairSpan indexes this list by
123
+ * SURFACE NODE INDEX while derivingPairsOf compacts that space — this is the
124
+ * bridge, and it reuses the already-resolved messages. */
125
+ function denseMessagesOf(pairs, nodeCount) {
126
+ const messages = new Array(nodeCount).fill(null);
127
+ for (const pair of pairs) messages[pair.nodeIndex] = pair.message;
128
+ return messages;
129
+ }
130
+
131
+ /** Close a surface span over complete tool pairs. A shipped fold must never
132
+ * separate an assistant tool call from its tool results: the provider wire
133
+ * serializers reject the orphan half ("DeepSeek Messages tool result has no
134
+ * matching call", llm-deepseek/src/serialize.ts:130-150), and the host's own
135
+ * compaction validates the same rule (dsh-compaction/lib/index.js:557).
136
+ * view.js segments pairwise, but a fold block's range can still be captured
137
+ * or resolved off a surface whose boundary sits between the two halves
138
+ * (trigger.js computeFoldableRange stops at a segment end; a pre-fix capture,
139
+ * a host compaction, or a later block all can leave the boundary there), so
140
+ * this pass re-closes the span against the LIVE node list.
141
+ *
142
+ * pairs: DENSE message list aligned to surface node indexes (null entries for
143
+ * nodes that derive no message); nodeCount: surface node count (indices past
144
+ * pairs.length are treated as absent). Returns { startIdx, endIdx } or null
145
+ * when the span cannot be closed without leaving the surface.
146
+ *
147
+ * FORWARD: every call opened INSIDE the span must be answered inside it;
148
+ * otherwise the results directly following the span end are pulled in (one
149
+ * node per iteration, so several consecutive role:'tool' nodes for one
150
+ * assistant step all land). A call whose next surface node does not answer it
151
+ * (the surface ends first, or a different message sits between) is
152
+ * unclosable: null.
153
+ *
154
+ * BACKWARD: a result at the span start whose call still lives earlier on the
155
+ * surface pulls the start back over that assistant node (and the run of
156
+ * results after it), so shipping the fold cannot orphan a result it leaves
157
+ * behind. When that call node is already shadowed/absent, the leading result
158
+ * is a PRE-EXISTING orphan: removing it is the healing direction and the
159
+ * span stays as captured (the earlier fold's shadowed set already owns the
160
+ * call). A node that both opens calls and carries results is handled by both
161
+ * passes defensively. */
162
+ function closeToolPairSpan(pairs, startIdx, endIdx, nodeCount) {
163
+ const count = Number.isInteger(nodeCount) ? nodeCount : pairs.length;
164
+ if (!Number.isInteger(startIdx) || !Number.isInteger(endIdx)) return null;
165
+ if (startIdx < 0 || endIdx < startIdx || endIdx >= count) return null;
166
+ const messageAt = (index) => (index >= 0 && index < count ? pairs[index] ?? null : null);
167
+ let start = startIdx;
168
+ let end = endIdx;
169
+
170
+ // Backward pass (bounded by count: every step moves start strictly earlier).
171
+ for (let guard = 0; guard <= count; guard++) {
172
+ const resultIds = toolResultIdsOf(messageAt(start));
173
+ if (resultIds.length === 0) break;
174
+ let owner = -1;
175
+ for (let i = start - 1; i >= 0; i--) {
176
+ const callIds = toolCallIdsOf(messageAt(i));
177
+ if (callIds.some((id) => resultIds.includes(id))) {
178
+ owner = i;
179
+ break;
180
+ }
181
+ }
182
+ if (owner < 0) break; // call already shadowed/absent: healing direction, span unchanged
183
+ start = owner;
184
+ }
185
+
186
+ // Forward pass: recompute the open calls over the current span each
187
+ // iteration, so a multi-result assistant step extends over all of them.
188
+ for (let guard = 0; guard <= count; guard++) {
189
+ const open = new Set();
190
+ for (let i = start; i <= end; i++) for (const id of toolCallIdsOf(messageAt(i))) open.add(id);
191
+ for (let i = start; i <= end; i++) for (const id of toolResultIdsOf(messageAt(i))) open.delete(id);
192
+ if (open.size === 0) return { startIdx: start, endIdx: end };
193
+ const next = end + 1;
194
+ if (next >= count) return null; // surface ends before the results: unclosable
195
+ const nextIds = toolResultIdsOf(messageAt(next));
196
+ if (!nextIds.some((id) => open.has(id))) return null; // next node answers a different call: unclosable
197
+ end = next;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ /** Config field reader (fail-open default), same pattern as index.js. */
203
+ function readBool(config, key, fallback) {
204
+ try {
205
+ const value = config?.[key];
206
+ if (typeof value === "boolean") return value;
207
+ if (value !== null && typeof value === "object" && typeof value.get === "function") {
208
+ const got = value.get();
209
+ if (typeof got === "boolean") return got;
210
+ }
211
+ } catch {
212
+ /* fall through */
213
+ }
214
+ return fallback;
215
+ }
216
+
217
+ /** Session id from an agent reference (object → agent.session.id, string →
218
+ * itself). Local twin of index.js agentSessionIdOf — kept here to avoid an
219
+ * import cycle (index.js already imports this module). */
220
+ function sessionIdOfAgent(agent) {
221
+ if (typeof agent === "string") return agent !== "" ? agent : null;
222
+ if (agent !== null && typeof agent === "object") {
223
+ const id = agent.session?.id ?? agent.id;
224
+ return typeof id === "string" && id !== "" ? id : null;
225
+ }
226
+ return null;
227
+ }
228
+
229
+ /** The replacement message event body: the block's rendered summary text in
230
+ * a user message whose id carries the acp_summary_ prefix (downstream
231
+ * tooling skips it) and a PLAIN {kind:'user'} source — a compact-checkpoint
232
+ * source on a non-append surfaceOp is rejected by the v4 session validator
233
+ * unless an open compaction/start..end pair covers it. The summary row stays
234
+ * findable via its acp_summary_ id and [SWITCHMAN:TRIM] content prefix.
235
+ * Deterministic — no timestamps (replay determinism constraint). */
236
+ function replacementMessageOf(block) {
237
+ return {
238
+ ...summaryMessageOf(block),
239
+ source: { kind: "user" },
240
+ };
241
+ }
242
+
243
+ /** Derive the message of one session event without depending on host helper
244
+ * availability: session.deriveEventMessage when present, else the same
245
+ * projection rules inline (user/message IS the message; message events nest
246
+ * it under data.message; empty content derives null). Returns the message or
247
+ * null. */
248
+ function messageOfEvent(session, event) {
249
+ try {
250
+ if (typeof session?.deriveEventMessage === "function") return session.deriveEventMessage(event);
251
+ } catch {
252
+ /* fall through to the inline projection */
253
+ }
254
+ const type = event?.type;
255
+ const data = event?.data;
256
+ if (type === "user/message") return data ?? null;
257
+ if (type === "system/message" || type === "developer/message" || type === "assistant/message" || type === "tool/result") {
258
+ const message = data?.message;
259
+ if (message === null || typeof message !== "object") return null;
260
+ if (Array.isArray(message.content) && message.content.length === 0) return null;
261
+ return message;
262
+ }
263
+ return null;
264
+ }
265
+
266
+ /** Full event snapshot of a session: snapshotEvents() when available (cached
267
+ * host side), else a walk over eventAt up to session.seq. Null when neither
268
+ * accessor exists (pre-surface host). Never throws. */
269
+ function eventsOf(session) {
270
+ try {
271
+ if (typeof session?.snapshotEvents === "function") return session.snapshotEvents();
272
+ if (typeof session?.eventAt === "function" && Number.isFinite(session?.seq)) {
273
+ const events = [];
274
+ for (let seq = 0; seq < session.seq; seq++) {
275
+ const event = session.eventAt(seq);
276
+ if (event === undefined) break;
277
+ events.push(event);
278
+ }
279
+ return events;
280
+ }
281
+ } catch {
282
+ /* unreadable log → treat as no events (fail open) */
283
+ }
284
+ return null;
285
+ }
286
+
287
+ /** True while a durable compaction/start..compaction/end pair is open for
288
+ * the session (log-only marker scan; the surface never shows them). Fail-open
289
+ * false when the log is unreadable. */
290
+ function compactionLockOpen(session) {
291
+ const events = eventsOf(session);
292
+ if (events === null) return false;
293
+ const open = new Set();
294
+ try {
295
+ for (const event of events) {
296
+ const id = event?.data?.compactionId;
297
+ if (typeof id !== "string" || id === "") continue;
298
+ if (event.type === "compaction/start") open.add(id);
299
+ else if (event.type === "compaction/end") open.delete(id);
300
+ }
301
+ } catch {
302
+ return false;
303
+ }
304
+ return open.size > 0;
305
+ }
306
+
307
+ /** (nodeIndex, message) pairs for the surface nodes that derive a message,
308
+ * in surface order — this subsequence corresponds 1:1 with deriveMessages
309
+ * (which skips null-deriving nodes like empty assistant steps), so count
310
+ * anchoring is valid in MESSAGE space while spans stay NODE space. */
311
+ function derivingPairsOf(session, nodes) {
312
+ const pairs = [];
313
+ for (let i = 0; i < nodes.length; i++) {
314
+ const event = (() => {
315
+ try {
316
+ return typeof session?.eventAt === "function" ? session.eventAt(nodes[i]) : undefined;
317
+ } catch {
318
+ return undefined;
319
+ }
320
+ })();
321
+ const message = event === undefined ? null : messageOfEvent(session, event);
322
+ if (message !== null && message !== undefined) pairs.push({ nodeIndex: i, message });
323
+ }
324
+ return pairs;
325
+ }
326
+
327
+ /** Count-anchored ref resolution over the deriving pairs (same anchoring as
328
+ * view.js locateBlock / state.js commitFold: duplicate content collides on
329
+ * one content-addressed ref, so the pair is resolved anchored on block.count
330
+ * rather than on occurrence extremes). Returns [firstNodeIndex, lastNodeIndex]
331
+ * or null. */
332
+ function locateOnSurface(pairs, block) {
333
+ const firstId = block?.refRange?.firstId;
334
+ const lastId = block?.refRange?.lastId;
335
+ if (typeof firstId !== "string" || typeof lastId !== "string") return null;
336
+ const firstList = [];
337
+ const lastList = [];
338
+ pairs.forEach((pair, messageIndex) => {
339
+ const ref = refOf(pair.message, messageIndex);
340
+ if (ref === firstId) firstList.push(messageIndex);
341
+ if (ref === lastId) lastList.push(messageIndex);
342
+ });
343
+ if (firstList.length === 0 || lastList.length === 0) return null;
344
+ // Anchor strictly on block.count (matching view.js's anchored path). A
345
+ // block without a usable count is a hard skip below (not-located), never
346
+ // a guessed last-occurrence range — the fallback path only serves blocks
347
+ // captured before the stored-seqs strategy existed, and guessing there
348
+ // could shadow an unrelated range on a collided content-addressed ref.
349
+ const count = Number.isInteger(block.count) && block.count > 0 ? block.count : null;
350
+ if (count === null) return null;
351
+ for (const start of firstList) {
352
+ const end = start + count - 1;
353
+ if (end >= start && lastList.includes(end)) return [pairs[start].nodeIndex, pairs[end].nodeIndex];
354
+ }
355
+ return null;
356
+ }
357
+
358
+ /** Validate a stored capture against the live surface: seqs must be unique
359
+ * non-negative safe integers, both endpoints present in surface.nodes, the
360
+ * covered node span must be EXACTLY the stored array (dense coverage — this
361
+ * is what makes sourceEventSeqs complete, and what detects a surface that
362
+ * moved on), and the events AT the endpoint seqs must still derive messages
363
+ * whose refs equal the block's refRange endpoints (identity re-check: a
364
+ * host log migration that renumbered events while the plugin ledger
365
+ * survived would otherwise densely validate over UNRELATED events and
366
+ * shadow the wrong range). Returns { startSeq, endSeq, seqs } or null.
367
+ *
368
+ * The seqs are NOT required to ascend: surface.nodes is model-visible order,
369
+ * so a span crossing a replace op mixes a new replacement seq with older
370
+ * seqs (see header). An ascending check here rejected captures that
371
+ * captureFoldSeqs itself had just produced (the real 2026-11-05
372
+ * incomplete-seqs deadlock). */
373
+ function validateStoredSeqs(session, nodes, stored, block) {
374
+ if (!Array.isArray(stored) || stored.length === 0) return null;
375
+ const seen = new Set();
376
+ for (const seq of stored) {
377
+ if (!Number.isInteger(seq) || seq < 0 || seen.has(seq)) return null;
378
+ seen.add(seq);
379
+ }
380
+ const indexOfSeq = new Map(nodes.map((seq, index) => [seq, index]));
381
+ const startIdx = indexOfSeq.get(stored[0]);
382
+ const endIdx = indexOfSeq.get(stored[stored.length - 1]);
383
+ if (startIdx === undefined || endIdx === undefined || startIdx > endIdx) return null;
384
+ const span = nodes.slice(startIdx, endIdx + 1);
385
+ if (span.length !== stored.length) return null;
386
+ for (let i = 0; i < span.length; i++) if (span[i] !== stored[i]) return null;
387
+ // Endpoint identity re-check (see header): the anchored refs must still
388
+ // resolve at the stored endpoint seqs; any mismatch routes the block to
389
+ // the incomplete-seqs hard skip — never a silent re-target. A
390
+ // capture-clamped block (surfaceHeadClamped) deliberately dropped its
391
+ // first endpoint (the system head seq), so only the last endpoint stays
392
+ // identity-checked; the dense span check covers the clamped start.
393
+ const firstId = block?.refRange?.firstId;
394
+ const lastId = block?.refRange?.lastId;
395
+ if (typeof firstId === "string" && typeof lastId === "string") {
396
+ const endpoints = block?.surfaceHeadClamped === true
397
+ ? [[stored[stored.length - 1], lastId]]
398
+ : [[stored[0], firstId], [stored[stored.length - 1], lastId]];
399
+ for (const [seq, id] of endpoints) {
400
+ let event = undefined;
401
+ try {
402
+ event = typeof session?.eventAt === "function" ? session.eventAt(seq) : undefined;
403
+ } catch {
404
+ event = undefined;
405
+ }
406
+ const message = event === undefined ? null : messageOfEvent(session, event);
407
+ if (message === null || refOf(message, 0) !== id) return null;
408
+ }
409
+ }
410
+ return { startIdx, endIdx, startSeq: stored[0], endSeq: stored[stored.length - 1], seqs: stored.slice() };
411
+ }
412
+
413
+ /** True when a re-located node span collides with a range ANOTHER block has
414
+ * already replaced. Precedence: the marker's own shipped seq set
415
+ * (surfaceReplaced.seqs, written at ship time), then the other block's
416
+ * persisted capture, then a numeric start..end interval for legacy markers
417
+ * that only recorded the endpoints. Only the last tier guesses, and it
418
+ * over-approximates NON-monotonic spans (a span crossing a replacement mixes
419
+ * a high seq with older ones), so it may tombstone a would-be valid
420
+ * re-location — the safe direction: never ship a guessed range. Fails closed
421
+ * (true) on an unreadable ledger: a safety check must never wave a collision
422
+ * through. */
423
+ function overlapsReplacedRange(block, seqs, blocks) {
424
+ try {
425
+ const set = new Set(seqs);
426
+ let lo = Infinity;
427
+ let hi = -Infinity;
428
+ for (const seq of seqs) {
429
+ if (seq < lo) lo = seq;
430
+ if (seq > hi) hi = seq;
431
+ }
432
+ for (const other of Array.isArray(blocks) ? blocks : []) {
433
+ if (other === block) continue;
434
+ const mark = other?.surfaceReplaced;
435
+ if (mark === null || mark === undefined) continue;
436
+ const exact = Array.isArray(mark.seqs) ? mark.seqs : Array.isArray(other.surfaceSeqs) ? other.surfaceSeqs : null;
437
+ if (exact !== null) {
438
+ if (exact.some((seq) => set.has(seq))) return true;
439
+ continue; // exact shadow set known — no numeric guessing
440
+ }
441
+ const a = Math.min(mark.startSeq, mark.endSeq);
442
+ const b = Math.max(mark.startSeq, mark.endSeq);
443
+ if (Number.isFinite(a) && Number.isFinite(b) && lo <= b && a <= hi) return true;
444
+ }
445
+ return false;
446
+ } catch {
447
+ return true;
448
+ }
449
+ }
450
+
451
+ /** Build the surface-replace pass. deps: { ctx, config, store, decisionLog,
452
+ * warn }. Returns { sweep, captureFoldSeqs, supported } — sweep is async and
453
+ * never rejects; all failures are contained and logged. Registers its own
454
+ * agent/turn-stopping listener (serial boundary, no request in flight). */
455
+ export function createSurfaceFoldPass(deps = {}) {
456
+ const ctx = deps.ctx;
457
+ const config = deps.config;
458
+ const store = deps.store;
459
+ const decisionLog = typeof deps.decisionLog === "function" ? deps.decisionLog : () => {};
460
+ const warn = typeof deps.warn === "function" ? deps.warn : () => {};
461
+
462
+ /** Whole-pass gate: surfaceFold flag AND the trim master switch. */
463
+ const enabledNow = () => readBool(config, "surfaceFold", true) && readBool(config, "trimEnabled", true);
464
+
465
+ /** Live session object for a session id (agents registry lookup). */
466
+ function sessionOf(sessionId) {
467
+ try {
468
+ const session = ctx?.agents?.get?.(sessionId)?.session;
469
+ return session !== null && typeof session === "object" ? session : null;
470
+ } catch {
471
+ return null;
472
+ }
473
+ }
474
+
475
+ /** Feature detection (fail-open legacy when anything is missing): an
476
+ * agents registry that resolves the session, session.append, and the
477
+ * surface architecture (surface.nodes) whose presence implies surfaceOp
478
+ * support — deriveMessages/surfaceOp landed as one mechanism. */
479
+ function surfaceCapable(session) {
480
+ try {
481
+ return session !== null
482
+ && typeof session === "object"
483
+ && typeof session.append === "function"
484
+ && session.surface !== null
485
+ && typeof session.surface === "object"
486
+ && Array.isArray(session.surface.nodes);
487
+ } catch {
488
+ return false;
489
+ }
490
+ }
491
+
492
+ /** True when the surface's leading node derives the frozen system prompt
493
+ * (the host's protected head: the v4 validator rejects any prune whose
494
+ * shadowedSeqs contain it — "compaction cannot shadow the protected
495
+ * system head"). Fail-open false when the head is unreadable or not a
496
+ * system message: sessions without a leading system message fold their
497
+ * head node like any other. */
498
+ function headNodeIsSystem(session, nodes) {
499
+ if (!Array.isArray(nodes) || nodes.length === 0) return false;
500
+ try {
501
+ const event = typeof session?.eventAt === "function" ? session.eventAt(nodes[0]) : undefined;
502
+ const message = event === undefined ? null : messageOfEvent(session, event);
503
+ return message !== null && message !== undefined && message.role === "system";
504
+ } catch {
505
+ return false;
506
+ }
507
+ }
508
+
509
+ /** Compaction-lock probe with a per-session cache keyed on session.seq.
510
+ * supported() runs on every idle boundary and a full log scan per call is
511
+ * wasted work on an unchanged log; the log is append-only, so "same seq →
512
+ * same lock state" holds and a changed seq invalidates. Bounded like the
513
+ * other registries. Fail-open false on any error (an unreadable log must
514
+ * not reinstate the fold deadlock). */
515
+ const lockCache = new Map(); // sessionId -> { seq, locked }
516
+ function lockOpenCached(sessionId, session) {
517
+ try {
518
+ const seq = Number.isFinite(session?.seq) ? session.seq : null;
519
+ if (seq === null) return compactionLockOpen(session);
520
+ const hit = lockCache.get(sessionId);
521
+ if (hit !== undefined && hit.seq === seq) return hit.locked;
522
+ const locked = compactionLockOpen(session);
523
+ if (lockCache.size >= 200) lockCache.delete(lockCache.keys().next().value);
524
+ lockCache.set(sessionId, { seq, locked });
525
+ return locked;
526
+ } catch {
527
+ return false;
528
+ }
529
+ }
530
+
531
+ /** In-memory idempotence belt over the durable block marker: the same
532
+ * (session, range, contentGeneration) is never replaced twice even if a
533
+ * crash lost the ledger persist. Bounded like the other registries. */
534
+ const replacedKeys = new Map(); // key -> epoch ms
535
+ function replacedKey(sessionId, startSeq, endSeq, generation) {
536
+ return `${sessionId}:${startSeq}..${endSeq}:g${generation}`;
537
+ }
538
+ function rememberReplaced(key) {
539
+ replacedKeys.set(key, Date.now());
540
+ if (replacedKeys.size > 500) replacedKeys.delete(replacedKeys.keys().next().value);
541
+ }
542
+ function wasReplaced(key) {
543
+ const at = replacedKeys.get(key);
544
+ return at !== undefined && Date.now() - at < 3_600_000;
545
+ }
546
+
547
+ /** Commit-time capture (PRIMARY strategy): resolve the block's ref range
548
+ * against the live surface and store the covered event seqs on the block.
549
+ * Best-effort — a failed capture just leaves the fallback path. */
550
+ function captureFoldSeqs(sessionId, block) {
551
+ try {
552
+ if (block === null || typeof block !== "object") return false;
553
+ const session = sessionOf(sessionId);
554
+ if (!surfaceCapable(session)) return false;
555
+ const nodes = session.surface.nodes.slice();
556
+ const pairs = derivingPairsOf(session, nodes);
557
+ const located = locateOnSurface(pairs, block);
558
+ if (located === null) return false;
559
+ let startIdx = located[0];
560
+ let endIdx = located[1];
561
+ // Head clamp at CAPTURE time: the stored range must never include
562
+ // the leading system message, so a block captured here stays clean
563
+ // even when it ships from a later process. surfaceHeadClamped tells
564
+ // validateStoredSeqs that stored[0] is deliberately no longer the
565
+ // block's firstId endpoint (the head seq was dropped).
566
+ if (headNodeIsSystem(session, nodes) && startIdx === 0) {
567
+ if (endIdx < 1) return false; // head-only range: nothing shippable
568
+ startIdx = 1;
569
+ block.surfaceHeadClamped = true;
570
+ }
571
+ // NOTE: the capture stores the LOCATED span only — it must not be
572
+ // pre-closed over tool pairs. The ship-time closure runs after
573
+ // validateStoredSeqs re-checks the stored endpoints against the
574
+ // block's refRange, so an extended capture (whose endpoints no
575
+ // longer derive the ref ids) would fail validation, fall back to
576
+ // live location and tombstone as a relocated range — a silently
577
+ // unshippable block. The sweep is the authoritative closure pass.
578
+ block.surfaceSeqs = nodes.slice(startIdx, endIdx + 1);
579
+ try {
580
+ store.persist(store.ledgerOf(sessionId));
581
+ } catch {
582
+ /* persist is best-effort; the in-memory block carries the capture */
583
+ }
584
+ return true;
585
+ } catch {
586
+ return false;
587
+ }
588
+ }
589
+
590
+ /** Per-block skip tombstone: contentGeneration-keyed, so a surface
591
+ * rewrite re-opens the attempt while a stable surface stays quiet (no
592
+ * per-turn spam for permanently unshippable blocks). */
593
+ function tombstoneOf(block) {
594
+ const mark = block?.surfaceSkip;
595
+ return mark !== null && typeof mark === "object" && typeof mark.reason === "string" && mark.reason !== "" ? mark : null;
596
+ }
597
+
598
+ /** One no-op row (only when the pass had pending work — a vacuous sweep
599
+ * with nothing to ship stays silent, matching the early-return style of
600
+ * the fold cycle's own action=none rows). Whole-pass reasons (disabled /
601
+ * no-service / compaction-lock) repeat on EVERY turn-stopping + idle
602
+ * boundary while the state lasts, so identical signatures are logged
603
+ * once per state change — a fresh pending count re-opens the row. */
604
+ const skipSeen = new Map(); // `${sessionId}:${reason}` -> last extra signature (FIFO-bounded)
605
+ const skip = (sessionId, reason, via, extra = "", dedupe = true) => {
606
+ if (dedupe) {
607
+ const key = `${sessionId}:${reason}`;
608
+ if (skipSeen.get(key) === extra) return;
609
+ if (skipSeen.size >= 200) skipSeen.delete(skipSeen.keys().next().value);
610
+ skipSeen.set(key, extra);
611
+ }
612
+ decisionLog(`session=${sessionId} surface-replace skipped reason=${reason} via=${via}${extra === "" ? "" : ` ${extra}`}`);
613
+ };
614
+
615
+ /** The replace pass for one session. Appends at most one metering +
616
+ * replacement pair per pending block; returns the number shipped. */
617
+ async function sweep(sessionId, via = "idle", sessionHint = null) {
618
+ if (typeof sessionId !== "string" || sessionId === "") return 0;
619
+ const ledger = store.ledgerOf(sessionId);
620
+ const blocks = Array.isArray(ledger.blocks) ? ledger.blocks : [];
621
+ const generation = (() => {
622
+ try {
623
+ return Number.isFinite(sessionHint?.surface?.contentGeneration) ? sessionHint.surface.contentGeneration : null;
624
+ } catch {
625
+ return null;
626
+ }
627
+ })();
628
+ const pending = blocks.filter((block) => {
629
+ if (block?.surfaceReplaced !== null && block?.surfaceReplaced !== undefined) return false;
630
+ const tomb = tombstoneOf(block);
631
+ if (tomb === null) return true;
632
+ // Re-open only when the surface content changed since the skip.
633
+ return generation !== null && tomb.contentGeneration !== generation;
634
+ });
635
+ if (pending.length === 0) return 0;
636
+ if (!enabledNow()) {
637
+ skip(sessionId, "disabled", via, `blocks=${pending.length}`);
638
+ return 0;
639
+ }
640
+ const session = surfaceCapable(sessionHint) ? sessionHint : sessionOf(sessionId);
641
+ if (!surfaceCapable(session)) {
642
+ // Legacy hosts (or unregistered sessions): keep today's behavior —
643
+ // the wire=raw banner and the tally-driven watermark relief path.
644
+ skip(sessionId, "no-service", via, `blocks=${pending.length}`);
645
+ return 0;
646
+ }
647
+ const surface = session.surface;
648
+ if (lockOpenCached(sessionId, session)) {
649
+ skip(sessionId, "compaction-lock", via, `blocks=${pending.length}`);
650
+ return 0;
651
+ }
652
+ // Live surface snapshot — REFRESHED after every successful ship: the
653
+ // shadowed seqs leave surface.nodes, so the next pending block must
654
+ // resolve/close against the new node list (a stale snapshot validated
655
+ // or closed a span that no longer exists).
656
+ let nodes = surface.nodes.slice();
657
+ let pairs = derivingPairsOf(session, nodes);
658
+ let nodeMessages = denseMessagesOf(pairs, nodes.length);
659
+ // A ship is a replace op, which bumps surface.contentGeneration: refresh
660
+ // it with the snapshot so a tombstone written later in this sweep keys
661
+ // on the post-ship generation instead of re-opening on the next sweep.
662
+ let liveGeneration = Number.isFinite(surface.contentGeneration) ? surface.contentGeneration : 0;
663
+ // The host rejects a replace op over node 0 only when node 0 actually
664
+ // carries the system prompt (assertSystemHeadRewrite). Resolve that
665
+ // head role once per sweep — sessions without a leading system
666
+ // message fold their head node like any other.
667
+ let headIsSystem = headNodeIsSystem(session, nodes);
668
+ let shipped = 0;
669
+ for (const block of pending) {
670
+ // Resolve the shadow range: stored capture first (validated dense
671
+ // against the LIVE surface). A stale/incomplete capture — or a
672
+ // capture-less legacy block — falls back to live location, but ONLY
673
+ // when the located span provably cannot collide with a range another
674
+ // block already replaced (safety rule, header). Otherwise the block
675
+ // tombstones as `incomplete-seqs` (or `not-located` when its refs
676
+ // are simply gone): never a silent re-target onto a different range.
677
+ let startSeq = null;
678
+ let endSeq = null;
679
+ let startIdx = null;
680
+ let endIdx = null;
681
+ let seqs = null;
682
+ const stored = Array.isArray(block.surfaceSeqs) ? validateStoredSeqs(session, nodes, block.surfaceSeqs, block) : null;
683
+ if (stored !== null) {
684
+ ({ startIdx, endIdx, startSeq, endSeq, seqs } = stored);
685
+ } else {
686
+ const located = locateOnSurface(pairs, block);
687
+ if (located === null) {
688
+ if (Array.isArray(block.surfaceSeqs)) {
689
+ block.surfaceSkip = { reason: "incomplete-seqs", contentGeneration: liveGeneration, at: Date.now() };
690
+ skip(sessionId, "incomplete-seqs", via, `block=${block.id}`, false);
691
+ } else {
692
+ block.surfaceSkip = { reason: "not-located", contentGeneration: liveGeneration, at: Date.now() };
693
+ skip(sessionId, "not-located", via, `block=${block.id}`, false);
694
+ }
695
+ continue;
696
+ }
697
+ const locatedSeqs = nodes.slice(located[0], located[1] + 1);
698
+ // A stale capture may only be re-located onto a provably IDENTICAL
699
+ // span: refOf is content-addressed for messages without ids, so an
700
+ // EARLIER look-alike span locates just as well (locateOnSurface
701
+ // takes the first count-anchored occurrence) and re-targeting it
702
+ // would shadow a range the fold never covered. Accept only when
703
+ // the stored endpoints are still live surface nodes AND equal the
704
+ // located endpoints; otherwise tombstone (never ship a guess).
705
+ if (Array.isArray(block.surfaceSeqs)) {
706
+ const storedFirst = block.surfaceSeqs[0];
707
+ const storedLast = block.surfaceSeqs[block.surfaceSeqs.length - 1];
708
+ const endpointsMatch = nodes.includes(storedFirst)
709
+ && nodes.includes(storedLast)
710
+ && nodes[located[0]] === storedFirst
711
+ && nodes[located[1]] === storedLast;
712
+ if (!endpointsMatch) {
713
+ block.surfaceSkip = { reason: "incomplete-seqs", contentGeneration: liveGeneration, at: Date.now() };
714
+ skip(sessionId, "incomplete-seqs", via, `block=${block.id} relocated-range`, false);
715
+ continue;
716
+ }
717
+ }
718
+ if (overlapsReplacedRange(block, locatedSeqs, blocks)) {
719
+ block.surfaceSkip = { reason: "incomplete-seqs", contentGeneration: liveGeneration, at: Date.now() };
720
+ skip(sessionId, "incomplete-seqs", via, `block=${block.id} overlap`, false);
721
+ continue;
722
+ }
723
+ startIdx = located[0];
724
+ endIdx = located[1];
725
+ startSeq = nodes[startIdx];
726
+ endSeq = nodes[endIdx];
727
+ seqs = locatedSeqs;
728
+ }
729
+ // Tool-pair closure over the LIVE surface (both resolution paths):
730
+ // a shipped range must never separate an assistant's tool call from
731
+ // its results — the provider wire serializers reject the orphan half
732
+ // ("DeepSeek Messages tool result has no matching call",
733
+ // llm-deepseek/src/serialize.ts:130-150). Extend forward over the
734
+ // results of calls opened inside the range, and backward over the
735
+ // live call of an orphan result at the range start. Unclosable (a
736
+ // call with no result on this surface, a range whose next node
737
+ // answers a different call) tombstones as tool-pair-unclosed — the
738
+ // same safe hard-skip family as incomplete-seqs: never ship a
739
+ // pair-splitting range. The block re-opens on the next surface
740
+ // content change (tool-pair-unclosed is generation-keyed like every
741
+ // other skip).
742
+ const closed = closeToolPairSpan(nodeMessages, startIdx, endIdx, nodes.length);
743
+ if (closed === null) {
744
+ block.surfaceSkip = { reason: "tool-pair-unclosed", contentGeneration: liveGeneration, at: Date.now() };
745
+ try {
746
+ store.persist(ledger);
747
+ } catch {
748
+ /* best effort */
749
+ }
750
+ skip(sessionId, "tool-pair-unclosed", via, `block=${block.id}`);
751
+ continue;
752
+ }
753
+ if (closed.startIdx !== startIdx || closed.endIdx !== endIdx) {
754
+ startIdx = closed.startIdx;
755
+ endIdx = closed.endIdx;
756
+ startSeq = nodes[startIdx];
757
+ endSeq = nodes[endIdx];
758
+ seqs = nodes.slice(startIdx, endIdx + 1);
759
+ }
760
+ // System head clamp (ship time, BOTH resolution paths): when the
761
+ // first surface node carries the system prompt, the v4 validator
762
+ // rejects any prune whose shadowedSeqs contain it
763
+ // (assertSystemHeadRewrite — node 0 may be rewritten only by a
764
+ // system/message over exactly that node). Drop the head seq so the
765
+ // rest of the range still ships; this re-runs over the LIVE surface
766
+ // at every ship, so it also guards stored captures persisted by an
767
+ // earlier process (legacy ranges that predate the capture clamp).
768
+ // A range that is ONLY the head has nothing left: tombstone like an
769
+ // append rejection instead of shipping an op that can never land;
770
+ // a surface content change re-opens the attempt via the generation.
771
+ if (headIsSystem && startSeq === nodes[0]) {
772
+ if (endSeq === nodes[0]) {
773
+ block.surfaceSkip = { reason: "system-head", contentGeneration: liveGeneration, at: Date.now() };
774
+ try {
775
+ store.persist(ledger);
776
+ } catch {
777
+ /* best effort */
778
+ }
779
+ skip(sessionId, "system-head", via, `block=${block.id}`);
780
+ continue;
781
+ }
782
+ startSeq = nodes[1];
783
+ seqs = seqs.slice(1);
784
+ }
785
+ const key = replacedKey(sessionId, startSeq, endSeq, liveGeneration);
786
+ if (wasReplaced(key)) continue; // belt over the durable marker
787
+ try {
788
+ // Shadow-price protocol: the metering event and its replacement
789
+ // are appended synchronously adjacent (pruner precedent). If the
790
+ // replacement append throws after the metering event landed, the
791
+ // orphan metering row is log-only and harmless — a pure consumer
792
+ // pairs it with the replacement that never follows, pricing
793
+ // nothing. Everything is pre-validated above, so this is a
794
+ // host-drift backstop only.
795
+ //
796
+ // CRASH WINDOW (documented, deliberately not restructured): the
797
+ // compaction/prune below is DURABLE the moment append() returns,
798
+ // while surfaceReplaced only persists at the end of this try —
799
+ // a process death in between leaves the pair half-written and
800
+ // the block still pending. Recovery is safe without a reorder:
801
+ // (1) an orphan compaction/prune pairs with no replacement and
802
+ // prices nothing (above); (2) if the replacement DID land before
803
+ // the crash, the shadowed seqs have left surface.nodes, so the
804
+ // stored capture fails validateStoredSeqs' dense check on the
805
+ // next sweep and the block hard-skips to the incomplete-seqs
806
+ // tombstone instead of re-shipping over a moved surface; and
807
+ // (3) within one process the replacedKeys belt blocks an
808
+ // immediate replay. Worst case across a crash: one duplicated
809
+ // log-only prune row.
810
+ session.append("compaction/prune", {
811
+ shadowedRange: { start: startSeq, end: endSeq },
812
+ shadowedSeqs: seqs,
813
+ shadowedTokenCount: Number.isFinite(block.approxTokens) && block.approxTokens > 0 ? Math.round(block.approxTokens) : seqs.length,
814
+ });
815
+ const event = session.append("user/message", replacementMessageOf(block), {
816
+ surfaceOp: { op: "replace", startSeq, endSeq },
817
+ sourceEventSeqs: seqs,
818
+ });
819
+ // Idempotence marker (durable, keyed by range + contentGeneration
820
+ // per the design): a second sweep filters the block out here. The
821
+ // exact shadowed seq set rides along so later re-location safety
822
+ // checks can intersect exactly instead of guessing from
823
+ // min/max (non-monotonic spans make the interval an over-approximation).
824
+ block.surfaceReplaced = {
825
+ startSeq,
826
+ endSeq,
827
+ seqs: seqs.slice(),
828
+ contentGeneration: liveGeneration,
829
+ atSeq: Number.isFinite(event?.seq) ? event.seq : null,
830
+ via,
831
+ at: Date.now(),
832
+ };
833
+ delete block.surfaceSkip;
834
+ rememberReplaced(key);
835
+ shipped += 1;
836
+ // The shadowed seqs have just left surface.nodes: refresh the
837
+ // snapshot every later pending block resolves against, so its
838
+ // dense validation / re-location / pair closure reads the
839
+ // POST-ship surface instead of a range that no longer exists.
840
+ nodes = surface.nodes.slice();
841
+ pairs = derivingPairsOf(session, nodes);
842
+ nodeMessages = denseMessagesOf(pairs, nodes.length);
843
+ headIsSystem = headNodeIsSystem(session, nodes);
844
+ liveGeneration = Number.isFinite(surface.contentGeneration) ? surface.contentGeneration : 0;
845
+ decisionLog(`session=${sessionId} surface-replace startSeq=${startSeq} endSeq=${endSeq} seqs=${seqs.length} tokens=${Number.isFinite(block.approxTokens) ? Math.round(block.approxTokens) : "?"} generation=${liveGeneration} via=${via}`);
846
+ try {
847
+ store.persist(ledger);
848
+ } catch {
849
+ /* the in-memory marker still guards this process */
850
+ }
851
+ } catch (error) {
852
+ const message = String(error?.message ?? error).replace(/\s+/g, " ").slice(0, 160);
853
+ // Tombstone like the resolution failures: a permanently
854
+ // rejecting block must not retry (and re-log a full lock
855
+ // scan) on every sweep; a surface content change re-opens it.
856
+ block.surfaceSkip = { reason: `append-rejected:${message}`, contentGeneration: liveGeneration, at: Date.now() };
857
+ try {
858
+ store.persist(ledger);
859
+ } catch {
860
+ /* best effort */
861
+ }
862
+ skip(sessionId, `append-rejected:${message}`, via, `block=${block.id}`);
863
+ }
864
+ }
865
+ if (shipped > 0) {
866
+ try {
867
+ store.persist(ledger);
868
+ } catch {
869
+ /* best effort */
870
+ }
871
+ warn(`[context-trim] ${sessionId}: surface-replace shipped ${shipped} fold block(s) via ${via}`);
872
+ }
873
+ return shipped;
874
+ }
875
+
876
+ /** Durable capability probe for the fold gate (index.js maybeFold): true
877
+ * when the surface-replace channel can carry a fold for this session right
878
+ * now — the pass is enabled, a surface-capable live session exists, AND no
879
+ * durable compaction lock is open. The lock matters: sweep defers under an
880
+ * open/orphaned compaction/start..end pair, so a supported() that ignored
881
+ * it would let maybeFold keep burning summary-model calls (≤6/hour) for
882
+ * folds that can never reach the wire. Lock state is cached per session
883
+ * seq, so idle-frequency calls stay O(1).
884
+ * Deliberately NOT a recency/freshness question: a channel that last
885
+ * shipped an hour ago (or before a process restart) and still exposes
886
+ * append + surface.nodes is capable, and the very next fold ships a new
887
+ * surface event. Recency must never gate folding — that deadlock is what
888
+ * froze real sessions at 135k tokens while decisions.log reported a 50k
889
+ * view (2026-11-05). */
890
+ function supported(sessionId) {
891
+ try {
892
+ if (!enabledNow()) return false;
893
+ const session = sessionOf(sessionId);
894
+ if (!surfaceCapable(session)) return false;
895
+ return !lockOpenCached(sessionId, session);
896
+ } catch {
897
+ return false;
898
+ }
899
+ }
900
+
901
+ // Trigger 1: agent/turn-stopping — serial, no request in flight, and the
902
+ // session object rides the payload (agent.session). Fail-open when the
903
+ // host does not dispatch the event.
904
+ try {
905
+ ctx?.on?.("agent/turn-stopping", (payload) => {
906
+ try {
907
+ const sessionId = sessionIdOfAgent(payload?.agent);
908
+ if (sessionId === null) return;
909
+ const hint = payload?.agent !== null && typeof payload?.agent === "object" ? payload.agent.session : null;
910
+ void sweep(sessionId, "turn-stopping", hint);
911
+ } catch {
912
+ /* listener must never throw */
913
+ }
914
+ });
915
+ } catch (error) {
916
+ warn(`[context-trim] agent/turn-stopping hook failed: ${error?.message ?? error}`);
917
+ }
918
+
919
+ return { sweep, captureFoldSeqs, enabledNow, supported };
920
+ }