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.
- package/README.de.md +13 -10
- package/README.es.md +13 -10
- package/README.fr.md +13 -10
- package/README.it.md +13 -10
- package/README.ja.md +13 -10
- package/README.ko.md +13 -10
- package/README.md +12 -9
- package/README.pt.md +13 -10
- package/README.ru.md +13 -10
- package/README.zh-TW.md +13 -10
- package/README.zh.md +13 -10
- package/client.js +56 -56
- package/host/config.js +8 -2
- package/host/context-trim/fork.js +16 -6
- package/host/context-trim/index.js +239 -15
- package/host/context-trim/metrics.js +32 -8
- package/host/context-trim/summarizer.js +1 -1
- package/host/context-trim/surface.js +920 -0
- package/host/context-trim/trigger.js +7 -0
- package/host/context-trim/view.js +81 -27
- package/host/context-watch.js +17 -67
- package/host/handover-state.js +2 -2
- package/host/routes.js +1 -1
- package/index.js +3 -3
- package/package.json +1 -1
|
@@ -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
|
+
}
|