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,736 @@
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
+ /** Config field reader (fail-open default), same pattern as index.js. */
79
+ function readBool(config, key, fallback) {
80
+ try {
81
+ const value = config?.[key];
82
+ if (typeof value === "boolean") return value;
83
+ if (value !== null && typeof value === "object" && typeof value.get === "function") {
84
+ const got = value.get();
85
+ if (typeof got === "boolean") return got;
86
+ }
87
+ } catch {
88
+ /* fall through */
89
+ }
90
+ return fallback;
91
+ }
92
+
93
+ /** Session id from an agent reference (object → agent.session.id, string →
94
+ * itself). Local twin of index.js agentSessionIdOf — kept here to avoid an
95
+ * import cycle (index.js already imports this module). */
96
+ function sessionIdOfAgent(agent) {
97
+ if (typeof agent === "string") return agent !== "" ? agent : null;
98
+ if (agent !== null && typeof agent === "object") {
99
+ const id = agent.session?.id ?? agent.id;
100
+ return typeof id === "string" && id !== "" ? id : null;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /** The replacement message event body: the block's rendered summary text in
106
+ * a user message whose id carries the acp_summary_ prefix (downstream
107
+ * tooling skips it) and a PLAIN {kind:'user'} source — a compact-checkpoint
108
+ * source on a non-append surfaceOp is rejected by the v4 session validator
109
+ * unless an open compaction/start..end pair covers it. The summary row stays
110
+ * findable via its acp_summary_ id and [SWITCHMAN:TRIM] content prefix.
111
+ * Deterministic — no timestamps (replay determinism constraint). */
112
+ function replacementMessageOf(block) {
113
+ return {
114
+ ...summaryMessageOf(block),
115
+ source: { kind: "user" },
116
+ };
117
+ }
118
+
119
+ /** Derive the message of one session event without depending on host helper
120
+ * availability: session.deriveEventMessage when present, else the same
121
+ * projection rules inline (user/message IS the message; message events nest
122
+ * it under data.message; empty content derives null). Returns the message or
123
+ * null. */
124
+ function messageOfEvent(session, event) {
125
+ try {
126
+ if (typeof session?.deriveEventMessage === "function") return session.deriveEventMessage(event);
127
+ } catch {
128
+ /* fall through to the inline projection */
129
+ }
130
+ const type = event?.type;
131
+ const data = event?.data;
132
+ if (type === "user/message") return data ?? null;
133
+ if (type === "system/message" || type === "developer/message" || type === "assistant/message" || type === "tool/result") {
134
+ const message = data?.message;
135
+ if (message === null || typeof message !== "object") return null;
136
+ if (Array.isArray(message.content) && message.content.length === 0) return null;
137
+ return message;
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /** Full event snapshot of a session: snapshotEvents() when available (cached
143
+ * host side), else a walk over eventAt up to session.seq. Null when neither
144
+ * accessor exists (pre-surface host). Never throws. */
145
+ function eventsOf(session) {
146
+ try {
147
+ if (typeof session?.snapshotEvents === "function") return session.snapshotEvents();
148
+ if (typeof session?.eventAt === "function" && Number.isFinite(session?.seq)) {
149
+ const events = [];
150
+ for (let seq = 0; seq < session.seq; seq++) {
151
+ const event = session.eventAt(seq);
152
+ if (event === undefined) break;
153
+ events.push(event);
154
+ }
155
+ return events;
156
+ }
157
+ } catch {
158
+ /* unreadable log → treat as no events (fail open) */
159
+ }
160
+ return null;
161
+ }
162
+
163
+ /** True while a durable compaction/start..compaction/end pair is open for
164
+ * the session (log-only marker scan; the surface never shows them). Fail-open
165
+ * false when the log is unreadable. */
166
+ function compactionLockOpen(session) {
167
+ const events = eventsOf(session);
168
+ if (events === null) return false;
169
+ const open = new Set();
170
+ try {
171
+ for (const event of events) {
172
+ const id = event?.data?.compactionId;
173
+ if (typeof id !== "string" || id === "") continue;
174
+ if (event.type === "compaction/start") open.add(id);
175
+ else if (event.type === "compaction/end") open.delete(id);
176
+ }
177
+ } catch {
178
+ return false;
179
+ }
180
+ return open.size > 0;
181
+ }
182
+
183
+ /** (nodeIndex, message) pairs for the surface nodes that derive a message,
184
+ * in surface order — this subsequence corresponds 1:1 with deriveMessages
185
+ * (which skips null-deriving nodes like empty assistant steps), so count
186
+ * anchoring is valid in MESSAGE space while spans stay NODE space. */
187
+ function derivingPairsOf(session, nodes) {
188
+ const pairs = [];
189
+ for (let i = 0; i < nodes.length; i++) {
190
+ const event = (() => {
191
+ try {
192
+ return typeof session?.eventAt === "function" ? session.eventAt(nodes[i]) : undefined;
193
+ } catch {
194
+ return undefined;
195
+ }
196
+ })();
197
+ const message = event === undefined ? null : messageOfEvent(session, event);
198
+ if (message !== null && message !== undefined) pairs.push({ nodeIndex: i, message });
199
+ }
200
+ return pairs;
201
+ }
202
+
203
+ /** Count-anchored ref resolution over the deriving pairs (same anchoring as
204
+ * view.js locateBlock / state.js commitFold: duplicate content collides on
205
+ * one content-addressed ref, so the pair is resolved anchored on block.count
206
+ * rather than on occurrence extremes). Returns [firstNodeIndex, lastNodeIndex]
207
+ * or null. */
208
+ function locateOnSurface(pairs, block) {
209
+ const firstId = block?.refRange?.firstId;
210
+ const lastId = block?.refRange?.lastId;
211
+ if (typeof firstId !== "string" || typeof lastId !== "string") return null;
212
+ const firstList = [];
213
+ const lastList = [];
214
+ pairs.forEach((pair, messageIndex) => {
215
+ const ref = refOf(pair.message, messageIndex);
216
+ if (ref === firstId) firstList.push(messageIndex);
217
+ if (ref === lastId) lastList.push(messageIndex);
218
+ });
219
+ if (firstList.length === 0 || lastList.length === 0) return null;
220
+ // Anchor strictly on block.count (matching view.js's anchored path). A
221
+ // block without a usable count is a hard skip below (not-located), never
222
+ // a guessed last-occurrence range — the fallback path only serves blocks
223
+ // captured before the stored-seqs strategy existed, and guessing there
224
+ // could shadow an unrelated range on a collided content-addressed ref.
225
+ const count = Number.isInteger(block.count) && block.count > 0 ? block.count : null;
226
+ if (count === null) return null;
227
+ for (const start of firstList) {
228
+ const end = start + count - 1;
229
+ if (end >= start && lastList.includes(end)) return [pairs[start].nodeIndex, pairs[end].nodeIndex];
230
+ }
231
+ return null;
232
+ }
233
+
234
+ /** Validate a stored capture against the live surface: seqs must be unique
235
+ * non-negative safe integers, both endpoints present in surface.nodes, the
236
+ * covered node span must be EXACTLY the stored array (dense coverage — this
237
+ * is what makes sourceEventSeqs complete, and what detects a surface that
238
+ * moved on), and the events AT the endpoint seqs must still derive messages
239
+ * whose refs equal the block's refRange endpoints (identity re-check: a
240
+ * host log migration that renumbered events while the plugin ledger
241
+ * survived would otherwise densely validate over UNRELATED events and
242
+ * shadow the wrong range). Returns { startSeq, endSeq, seqs } or null.
243
+ *
244
+ * The seqs are NOT required to ascend: surface.nodes is model-visible order,
245
+ * so a span crossing a replace op mixes a new replacement seq with older
246
+ * seqs (see header). An ascending check here rejected captures that
247
+ * captureFoldSeqs itself had just produced (the real 2026-11-05
248
+ * incomplete-seqs deadlock). */
249
+ function validateStoredSeqs(session, nodes, stored, block) {
250
+ if (!Array.isArray(stored) || stored.length === 0) return null;
251
+ const seen = new Set();
252
+ for (const seq of stored) {
253
+ if (!Number.isInteger(seq) || seq < 0 || seen.has(seq)) return null;
254
+ seen.add(seq);
255
+ }
256
+ const indexOfSeq = new Map(nodes.map((seq, index) => [seq, index]));
257
+ const startIdx = indexOfSeq.get(stored[0]);
258
+ const endIdx = indexOfSeq.get(stored[stored.length - 1]);
259
+ if (startIdx === undefined || endIdx === undefined || startIdx > endIdx) return null;
260
+ const span = nodes.slice(startIdx, endIdx + 1);
261
+ if (span.length !== stored.length) return null;
262
+ for (let i = 0; i < span.length; i++) if (span[i] !== stored[i]) return null;
263
+ // Endpoint identity re-check (see header): the anchored refs must still
264
+ // resolve at the stored endpoint seqs; any mismatch routes the block to
265
+ // the incomplete-seqs hard skip — never a silent re-target. A
266
+ // capture-clamped block (surfaceHeadClamped) deliberately dropped its
267
+ // first endpoint (the system head seq), so only the last endpoint stays
268
+ // identity-checked; the dense span check covers the clamped start.
269
+ const firstId = block?.refRange?.firstId;
270
+ const lastId = block?.refRange?.lastId;
271
+ if (typeof firstId === "string" && typeof lastId === "string") {
272
+ const endpoints = block?.surfaceHeadClamped === true
273
+ ? [[stored[stored.length - 1], lastId]]
274
+ : [[stored[0], firstId], [stored[stored.length - 1], lastId]];
275
+ for (const [seq, id] of endpoints) {
276
+ let event = undefined;
277
+ try {
278
+ event = typeof session?.eventAt === "function" ? session.eventAt(seq) : undefined;
279
+ } catch {
280
+ event = undefined;
281
+ }
282
+ const message = event === undefined ? null : messageOfEvent(session, event);
283
+ if (message === null || refOf(message, 0) !== id) return null;
284
+ }
285
+ }
286
+ return { startIdx, endIdx, startSeq: stored[0], endSeq: stored[stored.length - 1], seqs: stored.slice() };
287
+ }
288
+
289
+ /** True when a re-located node span collides with a range ANOTHER block has
290
+ * already replaced. Precedence: the marker's own shipped seq set
291
+ * (surfaceReplaced.seqs, written at ship time), then the other block's
292
+ * persisted capture, then a numeric start..end interval for legacy markers
293
+ * that only recorded the endpoints. Only the last tier guesses, and it
294
+ * over-approximates NON-monotonic spans (a span crossing a replacement mixes
295
+ * a high seq with older ones), so it may tombstone a would-be valid
296
+ * re-location — the safe direction: never ship a guessed range. Fails closed
297
+ * (true) on an unreadable ledger: a safety check must never wave a collision
298
+ * through. */
299
+ function overlapsReplacedRange(block, seqs, blocks) {
300
+ try {
301
+ const set = new Set(seqs);
302
+ let lo = Infinity;
303
+ let hi = -Infinity;
304
+ for (const seq of seqs) {
305
+ if (seq < lo) lo = seq;
306
+ if (seq > hi) hi = seq;
307
+ }
308
+ for (const other of Array.isArray(blocks) ? blocks : []) {
309
+ if (other === block) continue;
310
+ const mark = other?.surfaceReplaced;
311
+ if (mark === null || mark === undefined) continue;
312
+ const exact = Array.isArray(mark.seqs) ? mark.seqs : Array.isArray(other.surfaceSeqs) ? other.surfaceSeqs : null;
313
+ if (exact !== null) {
314
+ if (exact.some((seq) => set.has(seq))) return true;
315
+ continue; // exact shadow set known — no numeric guessing
316
+ }
317
+ const a = Math.min(mark.startSeq, mark.endSeq);
318
+ const b = Math.max(mark.startSeq, mark.endSeq);
319
+ if (Number.isFinite(a) && Number.isFinite(b) && lo <= b && a <= hi) return true;
320
+ }
321
+ return false;
322
+ } catch {
323
+ return true;
324
+ }
325
+ }
326
+
327
+ /** Build the surface-replace pass. deps: { ctx, config, store, decisionLog,
328
+ * warn }. Returns { sweep, captureFoldSeqs, supported } — sweep is async and
329
+ * never rejects; all failures are contained and logged. Registers its own
330
+ * agent/turn-stopping listener (serial boundary, no request in flight). */
331
+ export function createSurfaceFoldPass(deps = {}) {
332
+ const ctx = deps.ctx;
333
+ const config = deps.config;
334
+ const store = deps.store;
335
+ const decisionLog = typeof deps.decisionLog === "function" ? deps.decisionLog : () => {};
336
+ const warn = typeof deps.warn === "function" ? deps.warn : () => {};
337
+
338
+ /** Whole-pass gate: surfaceFold flag AND the trim master switch. */
339
+ const enabledNow = () => readBool(config, "surfaceFold", true) && readBool(config, "trimEnabled", true);
340
+
341
+ /** Live session object for a session id (agents registry lookup). */
342
+ function sessionOf(sessionId) {
343
+ try {
344
+ const session = ctx?.agents?.get?.(sessionId)?.session;
345
+ return session !== null && typeof session === "object" ? session : null;
346
+ } catch {
347
+ return null;
348
+ }
349
+ }
350
+
351
+ /** Feature detection (fail-open legacy when anything is missing): an
352
+ * agents registry that resolves the session, session.append, and the
353
+ * surface architecture (surface.nodes) whose presence implies surfaceOp
354
+ * support — deriveMessages/surfaceOp landed as one mechanism. */
355
+ function surfaceCapable(session) {
356
+ try {
357
+ return session !== null
358
+ && typeof session === "object"
359
+ && typeof session.append === "function"
360
+ && session.surface !== null
361
+ && typeof session.surface === "object"
362
+ && Array.isArray(session.surface.nodes);
363
+ } catch {
364
+ return false;
365
+ }
366
+ }
367
+
368
+ /** True when the surface's leading node derives the frozen system prompt
369
+ * (the host's protected head: the v4 validator rejects any prune whose
370
+ * shadowedSeqs contain it — "compaction cannot shadow the protected
371
+ * system head"). Fail-open false when the head is unreadable or not a
372
+ * system message: sessions without a leading system message fold their
373
+ * head node like any other. */
374
+ function headNodeIsSystem(session, nodes) {
375
+ if (!Array.isArray(nodes) || nodes.length === 0) return false;
376
+ try {
377
+ const event = typeof session?.eventAt === "function" ? session.eventAt(nodes[0]) : undefined;
378
+ const message = event === undefined ? null : messageOfEvent(session, event);
379
+ return message !== null && message !== undefined && message.role === "system";
380
+ } catch {
381
+ return false;
382
+ }
383
+ }
384
+
385
+ /** Compaction-lock probe with a per-session cache keyed on session.seq.
386
+ * supported() runs on every idle boundary and a full log scan per call is
387
+ * wasted work on an unchanged log; the log is append-only, so "same seq →
388
+ * same lock state" holds and a changed seq invalidates. Bounded like the
389
+ * other registries. Fail-open false on any error (an unreadable log must
390
+ * not reinstate the fold deadlock). */
391
+ const lockCache = new Map(); // sessionId -> { seq, locked }
392
+ function lockOpenCached(sessionId, session) {
393
+ try {
394
+ const seq = Number.isFinite(session?.seq) ? session.seq : null;
395
+ if (seq === null) return compactionLockOpen(session);
396
+ const hit = lockCache.get(sessionId);
397
+ if (hit !== undefined && hit.seq === seq) return hit.locked;
398
+ const locked = compactionLockOpen(session);
399
+ if (lockCache.size >= 200) lockCache.delete(lockCache.keys().next().value);
400
+ lockCache.set(sessionId, { seq, locked });
401
+ return locked;
402
+ } catch {
403
+ return false;
404
+ }
405
+ }
406
+
407
+ /** In-memory idempotence belt over the durable block marker: the same
408
+ * (session, range, contentGeneration) is never replaced twice even if a
409
+ * crash lost the ledger persist. Bounded like the other registries. */
410
+ const replacedKeys = new Map(); // key -> epoch ms
411
+ function replacedKey(sessionId, startSeq, endSeq, generation) {
412
+ return `${sessionId}:${startSeq}..${endSeq}:g${generation}`;
413
+ }
414
+ function rememberReplaced(key) {
415
+ replacedKeys.set(key, Date.now());
416
+ if (replacedKeys.size > 500) replacedKeys.delete(replacedKeys.keys().next().value);
417
+ }
418
+ function wasReplaced(key) {
419
+ const at = replacedKeys.get(key);
420
+ return at !== undefined && Date.now() - at < 3_600_000;
421
+ }
422
+
423
+ /** Commit-time capture (PRIMARY strategy): resolve the block's ref range
424
+ * against the live surface and store the covered event seqs on the block.
425
+ * Best-effort — a failed capture just leaves the fallback path. */
426
+ function captureFoldSeqs(sessionId, block) {
427
+ try {
428
+ if (block === null || typeof block !== "object") return false;
429
+ const session = sessionOf(sessionId);
430
+ if (!surfaceCapable(session)) return false;
431
+ const nodes = session.surface.nodes.slice();
432
+ const located = locateOnSurface(derivingPairsOf(session, nodes), block);
433
+ if (located === null) return false;
434
+ let startIdx = located[0];
435
+ const endIdx = located[1];
436
+ // Head clamp at CAPTURE time: the stored range must never include
437
+ // the leading system message, so a block captured here stays clean
438
+ // even when it ships from a later process. surfaceHeadClamped tells
439
+ // validateStoredSeqs that stored[0] is deliberately no longer the
440
+ // block's firstId endpoint (the head seq was dropped).
441
+ if (headNodeIsSystem(session, nodes) && startIdx === 0) {
442
+ if (endIdx < 1) return false; // head-only range: nothing shippable
443
+ startIdx = 1;
444
+ block.surfaceHeadClamped = true;
445
+ }
446
+ block.surfaceSeqs = nodes.slice(startIdx, endIdx + 1);
447
+ try {
448
+ store.persist(store.ledgerOf(sessionId));
449
+ } catch {
450
+ /* persist is best-effort; the in-memory block carries the capture */
451
+ }
452
+ return true;
453
+ } catch {
454
+ return false;
455
+ }
456
+ }
457
+
458
+ /** Per-block skip tombstone: contentGeneration-keyed, so a surface
459
+ * rewrite re-opens the attempt while a stable surface stays quiet (no
460
+ * per-turn spam for permanently unshippable blocks). */
461
+ function tombstoneOf(block) {
462
+ const mark = block?.surfaceSkip;
463
+ return mark !== null && typeof mark === "object" && typeof mark.reason === "string" && mark.reason !== "" ? mark : null;
464
+ }
465
+
466
+ /** One no-op row (only when the pass had pending work — a vacuous sweep
467
+ * with nothing to ship stays silent, matching the early-return style of
468
+ * the fold cycle's own action=none rows). Whole-pass reasons (disabled /
469
+ * no-service / compaction-lock) repeat on EVERY turn-stopping + idle
470
+ * boundary while the state lasts, so identical signatures are logged
471
+ * once per state change — a fresh pending count re-opens the row. */
472
+ const skipSeen = new Map(); // `${sessionId}:${reason}` -> last extra signature (FIFO-bounded)
473
+ const skip = (sessionId, reason, via, extra = "", dedupe = true) => {
474
+ if (dedupe) {
475
+ const key = `${sessionId}:${reason}`;
476
+ if (skipSeen.get(key) === extra) return;
477
+ if (skipSeen.size >= 200) skipSeen.delete(skipSeen.keys().next().value);
478
+ skipSeen.set(key, extra);
479
+ }
480
+ decisionLog(`session=${sessionId} surface-replace skipped reason=${reason} via=${via}${extra === "" ? "" : ` ${extra}`}`);
481
+ };
482
+
483
+ /** The replace pass for one session. Appends at most one metering +
484
+ * replacement pair per pending block; returns the number shipped. */
485
+ async function sweep(sessionId, via = "idle", sessionHint = null) {
486
+ if (typeof sessionId !== "string" || sessionId === "") return 0;
487
+ const ledger = store.ledgerOf(sessionId);
488
+ const blocks = Array.isArray(ledger.blocks) ? ledger.blocks : [];
489
+ const generation = (() => {
490
+ try {
491
+ return Number.isFinite(sessionHint?.surface?.contentGeneration) ? sessionHint.surface.contentGeneration : null;
492
+ } catch {
493
+ return null;
494
+ }
495
+ })();
496
+ const pending = blocks.filter((block) => {
497
+ if (block?.surfaceReplaced !== null && block?.surfaceReplaced !== undefined) return false;
498
+ const tomb = tombstoneOf(block);
499
+ if (tomb === null) return true;
500
+ // Re-open only when the surface content changed since the skip.
501
+ return generation !== null && tomb.contentGeneration !== generation;
502
+ });
503
+ if (pending.length === 0) return 0;
504
+ if (!enabledNow()) {
505
+ skip(sessionId, "disabled", via, `blocks=${pending.length}`);
506
+ return 0;
507
+ }
508
+ const session = surfaceCapable(sessionHint) ? sessionHint : sessionOf(sessionId);
509
+ if (!surfaceCapable(session)) {
510
+ // Legacy hosts (or unregistered sessions): keep today's behavior —
511
+ // the wire=raw banner and the tally-driven watermark relief path.
512
+ skip(sessionId, "no-service", via, `blocks=${pending.length}`);
513
+ return 0;
514
+ }
515
+ const surface = session.surface;
516
+ if (lockOpenCached(sessionId, session)) {
517
+ skip(sessionId, "compaction-lock", via, `blocks=${pending.length}`);
518
+ return 0;
519
+ }
520
+ const nodes = surface.nodes.slice();
521
+ const pairs = derivingPairsOf(session, nodes);
522
+ const liveGeneration = Number.isFinite(surface.contentGeneration) ? surface.contentGeneration : 0;
523
+ // The host rejects a replace op over node 0 only when node 0 actually
524
+ // carries the system prompt (assertSystemHeadRewrite). Resolve that
525
+ // head role once per sweep — sessions without a leading system
526
+ // message fold their head node like any other.
527
+ const headIsSystem = headNodeIsSystem(session, nodes);
528
+ let shipped = 0;
529
+ for (const block of pending) {
530
+ // Resolve the shadow range: stored capture first (validated dense
531
+ // against the LIVE surface). A stale/incomplete capture — or a
532
+ // capture-less legacy block — falls back to live location, but ONLY
533
+ // when the located span provably cannot collide with a range another
534
+ // block already replaced (safety rule, header). Otherwise the block
535
+ // tombstones as `incomplete-seqs` (or `not-located` when its refs
536
+ // are simply gone): never a silent re-target onto a different range.
537
+ let startSeq = null;
538
+ let endSeq = null;
539
+ let seqs = null;
540
+ const stored = Array.isArray(block.surfaceSeqs) ? validateStoredSeqs(session, nodes, block.surfaceSeqs, block) : null;
541
+ if (stored !== null) {
542
+ ({ startSeq, endSeq, seqs } = stored);
543
+ } else {
544
+ const located = locateOnSurface(pairs, block);
545
+ if (located === null) {
546
+ if (Array.isArray(block.surfaceSeqs)) {
547
+ block.surfaceSkip = { reason: "incomplete-seqs", contentGeneration: liveGeneration, at: Date.now() };
548
+ skip(sessionId, "incomplete-seqs", via, `block=${block.id}`, false);
549
+ } else {
550
+ block.surfaceSkip = { reason: "not-located", contentGeneration: liveGeneration, at: Date.now() };
551
+ skip(sessionId, "not-located", via, `block=${block.id}`, false);
552
+ }
553
+ continue;
554
+ }
555
+ const locatedSeqs = nodes.slice(located[0], located[1] + 1);
556
+ // A stale capture may only be re-located onto a provably IDENTICAL
557
+ // span: refOf is content-addressed for messages without ids, so an
558
+ // EARLIER look-alike span locates just as well (locateOnSurface
559
+ // takes the first count-anchored occurrence) and re-targeting it
560
+ // would shadow a range the fold never covered. Accept only when
561
+ // the stored endpoints are still live surface nodes AND equal the
562
+ // located endpoints; otherwise tombstone (never ship a guess).
563
+ if (Array.isArray(block.surfaceSeqs)) {
564
+ const storedFirst = block.surfaceSeqs[0];
565
+ const storedLast = block.surfaceSeqs[block.surfaceSeqs.length - 1];
566
+ const endpointsMatch = nodes.includes(storedFirst)
567
+ && nodes.includes(storedLast)
568
+ && nodes[located[0]] === storedFirst
569
+ && nodes[located[1]] === storedLast;
570
+ if (!endpointsMatch) {
571
+ block.surfaceSkip = { reason: "incomplete-seqs", contentGeneration: liveGeneration, at: Date.now() };
572
+ skip(sessionId, "incomplete-seqs", via, `block=${block.id} relocated-range`, false);
573
+ continue;
574
+ }
575
+ }
576
+ if (overlapsReplacedRange(block, locatedSeqs, blocks)) {
577
+ block.surfaceSkip = { reason: "incomplete-seqs", contentGeneration: liveGeneration, at: Date.now() };
578
+ skip(sessionId, "incomplete-seqs", via, `block=${block.id} overlap`, false);
579
+ continue;
580
+ }
581
+ startSeq = nodes[located[0]];
582
+ endSeq = nodes[located[1]];
583
+ seqs = locatedSeqs;
584
+ }
585
+ // System head clamp (ship time, BOTH resolution paths): when the
586
+ // first surface node carries the system prompt, the v4 validator
587
+ // rejects any prune whose shadowedSeqs contain it
588
+ // (assertSystemHeadRewrite — node 0 may be rewritten only by a
589
+ // system/message over exactly that node). Drop the head seq so the
590
+ // rest of the range still ships; this re-runs over the LIVE surface
591
+ // at every ship, so it also guards stored captures persisted by an
592
+ // earlier process (legacy ranges that predate the capture clamp).
593
+ // A range that is ONLY the head has nothing left: tombstone like an
594
+ // append rejection instead of shipping an op that can never land;
595
+ // a surface content change re-opens the attempt via the generation.
596
+ if (headIsSystem && startSeq === nodes[0]) {
597
+ if (endSeq === nodes[0]) {
598
+ block.surfaceSkip = { reason: "system-head", contentGeneration: liveGeneration, at: Date.now() };
599
+ try {
600
+ store.persist(ledger);
601
+ } catch {
602
+ /* best effort */
603
+ }
604
+ skip(sessionId, "system-head", via, `block=${block.id}`);
605
+ continue;
606
+ }
607
+ startSeq = nodes[1];
608
+ seqs = seqs.slice(1);
609
+ }
610
+ const key = replacedKey(sessionId, startSeq, endSeq, liveGeneration);
611
+ if (wasReplaced(key)) continue; // belt over the durable marker
612
+ try {
613
+ // Shadow-price protocol: the metering event and its replacement
614
+ // are appended synchronously adjacent (pruner precedent). If the
615
+ // replacement append throws after the metering event landed, the
616
+ // orphan metering row is log-only and harmless — a pure consumer
617
+ // pairs it with the replacement that never follows, pricing
618
+ // nothing. Everything is pre-validated above, so this is a
619
+ // host-drift backstop only.
620
+ //
621
+ // CRASH WINDOW (documented, deliberately not restructured): the
622
+ // compaction/prune below is DURABLE the moment append() returns,
623
+ // while surfaceReplaced only persists at the end of this try —
624
+ // a process death in between leaves the pair half-written and
625
+ // the block still pending. Recovery is safe without a reorder:
626
+ // (1) an orphan compaction/prune pairs with no replacement and
627
+ // prices nothing (above); (2) if the replacement DID land before
628
+ // the crash, the shadowed seqs have left surface.nodes, so the
629
+ // stored capture fails validateStoredSeqs' dense check on the
630
+ // next sweep and the block hard-skips to the incomplete-seqs
631
+ // tombstone instead of re-shipping over a moved surface; and
632
+ // (3) within one process the replacedKeys belt blocks an
633
+ // immediate replay. Worst case across a crash: one duplicated
634
+ // log-only prune row.
635
+ session.append("compaction/prune", {
636
+ shadowedRange: { start: startSeq, end: endSeq },
637
+ shadowedSeqs: seqs,
638
+ shadowedTokenCount: Number.isFinite(block.approxTokens) && block.approxTokens > 0 ? Math.round(block.approxTokens) : seqs.length,
639
+ });
640
+ const event = session.append("user/message", replacementMessageOf(block), {
641
+ surfaceOp: { op: "replace", startSeq, endSeq },
642
+ sourceEventSeqs: seqs,
643
+ });
644
+ // Idempotence marker (durable, keyed by range + contentGeneration
645
+ // per the design): a second sweep filters the block out here. The
646
+ // exact shadowed seq set rides along so later re-location safety
647
+ // checks can intersect exactly instead of guessing from
648
+ // min/max (non-monotonic spans make the interval an over-approximation).
649
+ block.surfaceReplaced = {
650
+ startSeq,
651
+ endSeq,
652
+ seqs: seqs.slice(),
653
+ contentGeneration: liveGeneration,
654
+ atSeq: Number.isFinite(event?.seq) ? event.seq : null,
655
+ via,
656
+ at: Date.now(),
657
+ };
658
+ delete block.surfaceSkip;
659
+ rememberReplaced(key);
660
+ shipped += 1;
661
+ 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}`);
662
+ try {
663
+ store.persist(ledger);
664
+ } catch {
665
+ /* the in-memory marker still guards this process */
666
+ }
667
+ } catch (error) {
668
+ const message = String(error?.message ?? error).replace(/\s+/g, " ").slice(0, 160);
669
+ // Tombstone like the resolution failures: a permanently
670
+ // rejecting block must not retry (and re-log a full lock
671
+ // scan) on every sweep; a surface content change re-opens it.
672
+ block.surfaceSkip = { reason: `append-rejected:${message}`, contentGeneration: liveGeneration, at: Date.now() };
673
+ try {
674
+ store.persist(ledger);
675
+ } catch {
676
+ /* best effort */
677
+ }
678
+ skip(sessionId, `append-rejected:${message}`, via, `block=${block.id}`);
679
+ }
680
+ }
681
+ if (shipped > 0) {
682
+ try {
683
+ store.persist(ledger);
684
+ } catch {
685
+ /* best effort */
686
+ }
687
+ warn(`[context-trim] ${sessionId}: surface-replace shipped ${shipped} fold block(s) via ${via}`);
688
+ }
689
+ return shipped;
690
+ }
691
+
692
+ /** Durable capability probe for the fold gate (index.js maybeFold): true
693
+ * when the surface-replace channel can carry a fold for this session right
694
+ * now — the pass is enabled, a surface-capable live session exists, AND no
695
+ * durable compaction lock is open. The lock matters: sweep defers under an
696
+ * open/orphaned compaction/start..end pair, so a supported() that ignored
697
+ * it would let maybeFold keep burning summary-model calls (≤6/hour) for
698
+ * folds that can never reach the wire. Lock state is cached per session
699
+ * seq, so idle-frequency calls stay O(1).
700
+ * Deliberately NOT a recency/freshness question: a channel that last
701
+ * shipped an hour ago (or before a process restart) and still exposes
702
+ * append + surface.nodes is capable, and the very next fold ships a new
703
+ * surface event. Recency must never gate folding — that deadlock is what
704
+ * froze real sessions at 135k tokens while decisions.log reported a 50k
705
+ * view (2026-11-05). */
706
+ function supported(sessionId) {
707
+ try {
708
+ if (!enabledNow()) return false;
709
+ const session = sessionOf(sessionId);
710
+ if (!surfaceCapable(session)) return false;
711
+ return !lockOpenCached(sessionId, session);
712
+ } catch {
713
+ return false;
714
+ }
715
+ }
716
+
717
+ // Trigger 1: agent/turn-stopping — serial, no request in flight, and the
718
+ // session object rides the payload (agent.session). Fail-open when the
719
+ // host does not dispatch the event.
720
+ try {
721
+ ctx?.on?.("agent/turn-stopping", (payload) => {
722
+ try {
723
+ const sessionId = sessionIdOfAgent(payload?.agent);
724
+ if (sessionId === null) return;
725
+ const hint = payload?.agent !== null && typeof payload?.agent === "object" ? payload.agent.session : null;
726
+ void sweep(sessionId, "turn-stopping", hint);
727
+ } catch {
728
+ /* listener must never throw */
729
+ }
730
+ });
731
+ } catch (error) {
732
+ warn(`[context-trim] agent/turn-stopping hook failed: ${error?.message ?? error}`);
733
+ }
734
+
735
+ return { sweep, captureFoldSeqs, enabledNow, supported };
736
+ }