dsh-context-guardian 0.1.0-alpha.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,455 @@
1
+ /**
2
+ * Surface retention selection and the shared log-recorded compaction
3
+ * transaction for `dsh-compaction-instant`.
4
+ *
5
+ * This module ports the durable protocol of `@deepseek-ai/dsh-compaction-basic`
6
+ * verbatim — lock entry assertions, `compaction/start|summary|end` bracketing,
7
+ * checkpoint replacement provenance, surface-stability checks, flush handling,
8
+ * and the `busy`/`changed`/`summary`/`commit`/`persistence` failure vocabulary —
9
+ * so the instant backend remains a contract-exact drop-in replacement for the
10
+ * `/compact` command and every other `ctx.compaction` consumer.
11
+ *
12
+ * The ONLY replaced step is summarization: where basic awaits an LLM replay,
13
+ * this backend runs the deterministic VCC-style compiler (see compiler.js),
14
+ * which never awaits a model and therefore completes "instantly".
15
+ *
16
+ * @module dsh-compaction-instant/region
17
+ */
18
+ import { CompactionId, ManualCompactionError, compactCheckpointSource, toolPairingBalancedAfter, toolPairingBalancedBefore } from "@deepseek-ai/dsh-compaction";
19
+ import { createUserMessage, errorChain } from "@deepseek-ai/dsh-llm";
20
+ import { randomUUID } from "node:crypto";
21
+ import { isDeepStrictEqual } from "node:util";
22
+ import { frameCheckpoint, joinCompiledEntries } from "./compiler.js";
23
+
24
+ /**
25
+ * Rejects a compiled checkpoint whose replacement boundaries are no longer
26
+ * the ones it was built from, distinguished from compile and shrink failures
27
+ * so a manual caller can report the two causes differently.
28
+ */
29
+ export class SurfaceChangedError extends Error {}
30
+
31
+ /**
32
+ * Turn index of every surface node, read from the log's `turn/start` events.
33
+ * Nodes before the first turn boundary get turn 0; sessions with no turn
34
+ * events at all produce an all-zero map (selection then keeps everything).
35
+ * @param session - session supplying the event log.
36
+ * @param surfaceNodes - current surface sequences.
37
+ * @returns the per-node turn numbers, in surface order.
38
+ */
39
+ function surfaceTurns(session, surfaceNodes) {
40
+ const turnOfSeq = new Map();
41
+ let turn = 0;
42
+ for (const event of session.events ?? []) {
43
+ if (event.type === "turn/start") turn = event.data.turn;
44
+ turnOfSeq.set(event.seq, turn);
45
+ }
46
+ return surfaceNodes.map((seq) => turnOfSeq.get(seq) ?? 0);
47
+ }
48
+
49
+ /** First surface index of the turn that contains `index`. */
50
+ function turnStartIndex(turns, index) {
51
+ const turn = turns[index];
52
+ let start = index;
53
+ while (start > 0 && turns[start - 1] === turn) start -= 1;
54
+ return start;
55
+ }
56
+
57
+ /**
58
+ * Resolve the next head-anchored range while retaining a recent tail and
59
+ * never splitting an assistant tool-call/result pair.
60
+ *
61
+ * The retained region is selected backward from the latest turn:
62
+ *
63
+ * - `retainTokens` (`> 0`) is an **absolute ceiling**: complete turns are
64
+ * kept while their total fits, and when even the latest turn alone exceeds
65
+ * the ceiling the region collapses to the latest node suffix that fits
66
+ * (then the tool-pair guard may recede one node further). The retained
67
+ * total never exceeds the ceiling by a whole turn; the balance guard is the
68
+ * only permitted overshoot. `retainTurns` is the preferred number of whole
69
+ * turns — it never overrides the ceiling.
70
+ * - `retainTokens` (`0`) keeps exactly `retainTurns` complete recent turns.
71
+ * @param session - session supplying authoritative current surface positions.
72
+ * @param measurement - unified pressure and surface measurement from the conversation meter.
73
+ * @param retainTurns - preferred complete recent turns kept verbatim (>= 1).
74
+ * @param retainTokens - hard retained-region token ceiling; 0 keeps only the preferred turns.
75
+ * @returns the inclusive positional seq range to compact, or `null`.
76
+ */
77
+ export function selectCompactableRange(session, measurement, retainTurns, retainTokens) {
78
+ const pricedNodes = measurement.nodes;
79
+ if (pricedNodes.length === 0) return null;
80
+ const surfaceNodes = session.surface.nodes;
81
+ if (surfaceNodes.length !== pricedNodes.length || surfaceNodes.some((seq, index) => seq !== pricedNodes[index]?.seq)) throw new Error("compaction: token-meter surface does not match the current session surface");
82
+ const turns = surfaceTurns(session, surfaceNodes);
83
+ const lastTurn = turns[turns.length - 1];
84
+ if (lastTurn === 0) return null;
85
+ const preferredTurns = Math.max(1, retainTurns);
86
+ let keepFromIdx;
87
+ if (retainTokens > 0) {
88
+ // Absolute ceiling: roll whole turns backward while they fit.
89
+ let scanIdx = turns.length;
90
+ let total = 0;
91
+ let latestWholeIdx;
92
+ while (scanIdx > 0) {
93
+ const startIdx = turnStartIndex(turns, scanIdx - 1);
94
+ let added = 0;
95
+ for (let index = startIdx; index < scanIdx; index += 1) added += pricedNodes[index].tokens;
96
+ if (total + added > retainTokens) break;
97
+ total += added;
98
+ scanIdx = startIdx;
99
+ }
100
+ latestWholeIdx = scanIdx; // first kept node index under the whole-turn rule
101
+ if (scanIdx === turns.length) {
102
+ // Even the latest turn exceeds the ceiling: keep the node suffix that
103
+ // fits inside it (then the balance guard recedes as usual).
104
+ let nodeTotal = 0;
105
+ keepFromIdx = turns.length;
106
+ const latestStart = turnStartIndex(turns, turns.length - 1);
107
+ for (let index = turns.length - 1; index >= latestStart; index -= 1) {
108
+ if (nodeTotal + pricedNodes[index].tokens > retainTokens) break;
109
+ nodeTotal += pricedNodes[index].tokens;
110
+ keepFromIdx = index;
111
+ }
112
+ } else {
113
+ keepFromIdx = latestWholeIdx;
114
+ }
115
+ // Preferred-turn rule: when the ceiling still has room and fewer than
116
+ // preferredTurns whole turns were kept, additional whole turns already
117
+ // cannot fit (loop above broke) — nothing more to do.
118
+ void preferredTurns;
119
+ } else {
120
+ const mandatoryTurnFloor = Math.max(0, lastTurn - preferredTurns + 1);
121
+ keepFromIdx = 0;
122
+ while (keepFromIdx < turns.length && turns[keepFromIdx] < mandatoryTurnFloor) keepFromIdx += 1;
123
+ }
124
+ while (keepFromIdx > 0) {
125
+ if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx])) break;
126
+ keepFromIdx -= 1;
127
+ }
128
+ if (keepFromIdx === 0) return null;
129
+ return {
130
+ start: surfaceNodes[0],
131
+ end: surfaceNodes[keepFromIdx - 1]
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Run the single compaction transaction over one selected positional span.
137
+ * Selection and validation are read-only. Idle/log validation and
138
+ * `compaction/start` are synchronously adjacent, so the durable opening marker
139
+ * is the compaction lock before the compile yields. Every later failure makes
140
+ * exactly one `compaction/end` attempt; a failed close deliberately leaves the
141
+ * unmatched start detectable.
142
+ * @param dependencies - conversation meter and the deterministic compile hook.
143
+ * @param session - session whose surface is mutated.
144
+ * @param start - inclusive first surface-node seq.
145
+ * @param end - inclusive last surface-node seq.
146
+ * @param agent - retained for signature parity with other backends; the
147
+ * instant compiler never routes a model call through it.
148
+ * @param options - bracket owner, stability rule, and optional durability checkpoint.
149
+ * @param signal - optional cancellation signal checked at the safe boundaries.
150
+ * @returns the successful durable compaction result.
151
+ */
152
+ export async function compactSurfaceRegion(dependencies, session, start, end, agent, options, signal) {
153
+ if (options.owner === null) signal?.throwIfAborted();
154
+ const selection = validateSurfaceRegion(session, start, end);
155
+ const entryState = inspectCompactionEntryState(session.events);
156
+ assertCompactionInactive(entryState.unmatchedCompactionStart, entryState.latestEndSeedSeq, "compaction");
157
+ let owner;
158
+ if (options.owner === null) {
159
+ if (entryState.openTurn !== null) throw new ManualCompactionError("busy", "manual compaction: the session already has an open turn");
160
+ owner = null;
161
+ } else {
162
+ if (entryState.openTurn === null) throw new Error("compactRegion: no open turn — automatic compaction events must be enclosed in a turn");
163
+ owner = entryState.openTurn;
164
+ }
165
+ const compactionId = CompactionId(randomUUID());
166
+ const lifecycle = {
167
+ compactionId,
168
+ ...options.sourceCommandId === undefined ? {} : { sourceCommandId: options.sourceCommandId },
169
+ turn: owner
170
+ };
171
+ const startEvent = session.append("compaction/start", lifecycle);
172
+ const assertStable = options.stability === "whole-surface" ? assertWholeSurfaceUnchanged : assertSelectedSpanStable;
173
+ let failure;
174
+ let flushFailure;
175
+ let result;
176
+ let closed = false;
177
+ let closing = false;
178
+ let stage = "summary";
179
+ try {
180
+ const prepared = prepareCompaction(dependencies, session, selection);
181
+ const compiled = await compileCompaction(dependencies, prepared, agent, compactionId, options.sourceCommandId, signal);
182
+ if (options.owner === null) signal?.throwIfAborted();
183
+ assertStable(dependencies, session, compiled);
184
+ stage = "commit";
185
+ const pending = commitCompactionBody(session, startEvent, compiled);
186
+ closing = true;
187
+ const endEvent = session.append("compaction/end", lifecycle);
188
+ closed = true;
189
+ result = completeCompaction(pending, endEvent);
190
+ } catch (error) {
191
+ failure = {
192
+ error,
193
+ stage: closing ? "commit" : stage
194
+ };
195
+ if (!closing) {
196
+ closing = true;
197
+ try {
198
+ session.append("compaction/end", {
199
+ ...lifecycle,
200
+ error: errorChain(error)
201
+ });
202
+ closed = true;
203
+ } catch (closeError) {
204
+ failure = {
205
+ error: closeError,
206
+ stage: "commit"
207
+ };
208
+ }
209
+ }
210
+ }
211
+ if (closed && options.flush !== undefined) try {
212
+ await options.flush();
213
+ } catch (error) {
214
+ flushFailure = error;
215
+ }
216
+ if (options.owner === null) signal?.throwIfAborted();
217
+ if (failure !== undefined) {
218
+ if (options.owner === null) throwManualFailure(failure);
219
+ throw failure.error;
220
+ }
221
+ if (flushFailure !== undefined) throw new ManualCompactionError("persistence", "manual compaction durability checkpoint failed", { cause: flushFailure });
222
+ /* v8 ignore next -- every path without a result records and throws a failure above. */
223
+ if (result === undefined) throw new Error("compaction committed without a result");
224
+ return result;
225
+ }
226
+
227
+ /** Classify one closed manual attempt without weakening cancellation precedence. */
228
+ function throwManualFailure(failure) {
229
+ if (failure.stage === "commit") throw new ManualCompactionError("commit", "manual compaction did not commit cleanly", { cause: failure.error });
230
+ if (failure.error instanceof SurfaceChangedError) throw new ManualCompactionError("changed", "the compacted history changed during manual compaction", { cause: failure.error });
231
+ throw new ManualCompactionError("summary", "manual compaction could not produce a smaller checkpoint", { cause: failure.error });
232
+ }
233
+
234
+ /**
235
+ * Reject a durable unmatched compaction marker unless a later constructor-seed
236
+ * boundary proves that its owner belongs to an earlier session lifecycle.
237
+ * @param unmatchedCompactionStart - latest unmatched opening marker, if any.
238
+ * @param latestEndSeedSeq - newest constructor-seed boundary, if any.
239
+ * @param stage - operation label included in the busy diagnostic.
240
+ */
241
+ function assertCompactionInactive(unmatchedCompactionStart, latestEndSeedSeq, stage) {
242
+ if (unmatchedCompactionStart === undefined || latestEndSeedSeq !== undefined && latestEndSeedSeq > unmatchedCompactionStart.seq) return;
243
+ throw new ManualCompactionError("busy", `${stage}: compaction already in progress; the session compaction lock is already active`);
244
+ }
245
+
246
+ /**
247
+ * Recheck the durable compaction lock after an asynchronous policy decision.
248
+ * @param session - session whose latest marker state is inspected.
249
+ * @param stage - operation label included in the busy diagnostic.
250
+ */
251
+ export function assertNoActiveCompaction(session, stage) {
252
+ const entryState = inspectCompactionEntryState(session.events);
253
+ assertCompactionInactive(entryState.unmatchedCompactionStart, entryState.latestEndSeedSeq, stage);
254
+ }
255
+
256
+ /** Validate one requested surface-position span before any work begins. */
257
+ export function validateSurfaceRegion(session, start, end) {
258
+ const nodes = session.surface.nodes;
259
+ const startIdx = nodes.indexOf(start);
260
+ const endIdx = nodes.indexOf(end);
261
+ if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`);
262
+ if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`);
263
+ if (startIdx > endIdx) throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`);
264
+ if (!toolPairingBalancedBefore(session, nodes[startIdx])) throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`);
265
+ if (!toolPairingBalancedAfter(session, nodes[endIdx])) throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`);
266
+ return {
267
+ start,
268
+ end,
269
+ startIdx,
270
+ endIdx,
271
+ shadowedSeqs: nodes.slice(startIdx, endIdx + 1)
272
+ };
273
+ }
274
+
275
+ /** Snapshot pricing for a validated surface range. */
276
+ export function prepareCompaction(dependencies, session, selection) {
277
+ const measurement = dependencies.meter.measure(session);
278
+ const selectedNodes = measurement.nodes.slice(selection.startIdx, selection.endIdx + 1);
279
+ if (selectedNodes.length !== selection.shadowedSeqs.length || selectedNodes.some((node, index) => node.seq !== selection.shadowedSeqs[index])) throw new SurfaceChangedError("compaction: selected surface changed before compilation began");
280
+ return {
281
+ ...selection,
282
+ session,
283
+ measurement,
284
+ selectedNodes,
285
+ shadowedTokenCount: selectedNodes.reduce((total, node) => total + node.tokens, 0)
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Run the deterministic region compiler, frame its checkpoint, and price the
291
+ * replacement under the singleton token meter. Mirrors basic's shrink
292
+ * guarantee: a checkpoint that would not reduce the surface is rejected.
293
+ * @param dependencies - conversation meter and the compile hook.
294
+ * @param prepared - priced selection snapshot.
295
+ * @param agent - retained for signature parity; the compiler never routes it.
296
+ * @param compactionId - owning transaction identity for checkpoint provenance.
297
+ * @param sourceCommandId - initiating manual command, when present.
298
+ * @param signal - optional cancellation signal.
299
+ * @returns the compiled summary, provenance, and framed checkpoint message.
300
+ */
301
+ async function compileCompaction(dependencies, prepared, agent, compactionId, sourceCommandId, signal) {
302
+ const compiled = await dependencies.compile(prepared, agent, signal);
303
+ // The UI-facing summary IS the compiled body: the checkpoint row expands to
304
+ // exactly the entries the model sees. The body is joined with separators
305
+ // and wrapped in an adaptive Markdown fence, so the UI renders the whole
306
+ // expansion as one tidy code block even when messages contain markdown.
307
+ const verb = sourceCommandId === undefined ? "自动压缩" : "手动 /compact";
308
+ const introLine = `${verb}: 将 ${prepared.shadowedSeqs.length} 个节点 / ~${prepared.shadowedTokenCount} tokens 编译为 ${compiled.entries.length} 条目 / ~${compiled.stats.tokens} tokens`;
309
+ const headerLine = `## Compiled checkpoint: ${prepared.shadowedSeqs.length} nodes (seqs ${prepared.start}-${prepared.end}, ~${prepared.shadowedTokenCount} tokens) — ${compiled.entries.length} entries, ~${compiled.stats.tokens} tokens compiled`;
310
+ // Verbatim retention footer: nodes after the compiled span were never
311
+ // compiled, so they stay in the live surface as original text.
312
+ const retainedNodes = prepared.measurement.nodes.slice(prepared.endIdx + 1);
313
+ const retainedTokenCount = retainedNodes.reduce((total, node) => total + node.tokens, 0);
314
+ const footerLine = retainedNodes.length === 0 ? undefined
315
+ : `尾部原文保留: ${retainedNodes.length} 节点 / ~${retainedTokenCount} tokens(未被压缩,仍在对话中)`;
316
+ const bodyEntries = [
317
+ introLine,
318
+ headerLine,
319
+ ...compiled.entries,
320
+ ...(footerLine === undefined ? [] : [footerLine])
321
+ ];
322
+ const summary = [{ type: "text", text: fenceCode(joinCompiledEntries(bodyEntries)) }];
323
+ const checkpointMessage = createUserMessage({
324
+ content: frameCheckpoint(compiled.entries, headerLine, introLine, footerLine),
325
+ source: compactCheckpointSource(compactionId, sourceCommandId)
326
+ });
327
+ const framedTokenCount = dependencies.meter.estimateMessage(checkpointMessage);
328
+ if (framedTokenCount >= prepared.shadowedTokenCount) throw new Error(`compiled checkpoint is not smaller than the shadowed content (${framedTokenCount} estimated framed tokens >= ${prepared.shadowedTokenCount})`);
329
+ return {
330
+ ...prepared,
331
+ summary,
332
+ provider: compiled.provider,
333
+ model: compiled.model,
334
+ checkpointMessage,
335
+ framedTokenCount
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Wrap text in a Markdown code fence for tidy UI rendering. The fence length
341
+ * adapts to the longest backtick run inside the text — the Markdown-correct
342
+ * way to "escape" embedded ``` blocks (a 3-backtick fence would terminate at
343
+ * them), keeping the content bytes untouched.
344
+ * @param text - body text to fence.
345
+ * @returns the fenced text.
346
+ */
347
+ export function fenceCode(text) {
348
+ const runs = text.match(/`+/gu);
349
+ const longest = runs === null ? 0 : Math.max(...runs.map((run) => run.length));
350
+ const fence = "`".repeat(Math.max(3, longest + 1));
351
+ return `${fence}\n${text}\n${fence}`;
352
+ }
353
+
354
+ /** Reject a checkpoint prepared against any earlier surface generation. */
355
+ export function assertWholeSurfaceUnchanged(dependencies, session, prepared) {
356
+ if (!isDeepStrictEqual(dependencies.meter.measure(session).nodes, prepared.measurement.nodes)) throw new SurfaceChangedError("compaction: session surface changed during compilation");
357
+ }
358
+
359
+ /**
360
+ * Require only that the selected span remain the same present, contiguous,
361
+ * equally priced, balanced replacement target. Nodes added outside it remain
362
+ * visible and do not invalidate the checkpoint.
363
+ */
364
+ export function assertSelectedSpanStable(dependencies, session, prepared) {
365
+ let current;
366
+ try {
367
+ current = validateSurfaceRegion(session, prepared.start, prepared.end);
368
+ } catch (error) {
369
+ throw new SurfaceChangedError("compaction: the selected span is no longer a valid replacement target", { cause: error });
370
+ }
371
+ if (!isDeepStrictEqual([...current.shadowedSeqs], [...prepared.shadowedSeqs])) throw new SurfaceChangedError("compaction: the selected span changed during compilation");
372
+ if (!isDeepStrictEqual(dependencies.meter.measure(session).nodes.slice(current.startIdx, current.endIdx + 1), prepared.selectedNodes)) throw new SurfaceChangedError("compaction: the selected span was rewritten during compilation");
373
+ }
374
+
375
+ /** Append one completed checkpoint record and replacement body without yielding. */
376
+ function commitCompactionBody(session, startEvent, compiled) {
377
+ const { start, end, shadowedSeqs, shadowedTokenCount, summary, provider, model, checkpointMessage } = compiled;
378
+ const summaryEvent = session.append("compaction/summary", {
379
+ compactionId: startEvent.data.compactionId,
380
+ ...startEvent.data.sourceCommandId === undefined ? {} : { sourceCommandId: startEvent.data.sourceCommandId },
381
+ summary,
382
+ shadowedRange: {
383
+ start,
384
+ end
385
+ },
386
+ shadowedSeqs: [...shadowedSeqs],
387
+ shadowedTokenCount,
388
+ provider,
389
+ model
390
+ });
391
+ session.append("user/message", checkpointMessage, {
392
+ surfaceOp: {
393
+ op: "replace",
394
+ start,
395
+ end
396
+ },
397
+ sourceEventSeqs: [
398
+ startEvent.seq,
399
+ summaryEvent.seq,
400
+ ...shadowedSeqs
401
+ ]
402
+ });
403
+ return {
404
+ compactionId: startEvent.data.compactionId,
405
+ ...startEvent.data.sourceCommandId === undefined ? {} : { sourceCommandId: startEvent.data.sourceCommandId },
406
+ startSeq: startEvent.seq,
407
+ summarySeq: summaryEvent.seq,
408
+ summary,
409
+ shadowedRange: {
410
+ start,
411
+ end
412
+ },
413
+ shadowedSeqs: [...shadowedSeqs],
414
+ shadowedTokenCount
415
+ };
416
+ }
417
+
418
+ /** Attach the successfully appended close event to a pending result. */
419
+ function completeCompaction(pending, endEvent) {
420
+ return {
421
+ ...pending,
422
+ endSeq: endEvent.seq
423
+ };
424
+ }
425
+
426
+ /** Inspect open-turn, unmatched-compaction, and latest seed-boundary state independently. */
427
+ export function inspectCompactionEntryState(events) {
428
+ let openTurn = null;
429
+ let openTurnStateKnown = false;
430
+ let unmatchedCompactionStart;
431
+ let compactionEntryStateKnown = false;
432
+ let latestEndSeedSeq;
433
+ for (let index = events.length - 1; index >= 0; index -= 1) {
434
+ const event = events[index];
435
+ if (latestEndSeedSeq === undefined && event.type === "session/end-seed") latestEndSeedSeq = event.seq;
436
+ if (!compactionEntryStateKnown) {
437
+ if (event.type === "compaction/start") {
438
+ unmatchedCompactionStart = event;
439
+ compactionEntryStateKnown = true;
440
+ } else if (event.type === "compaction/end") compactionEntryStateKnown = true;
441
+ }
442
+ if (!openTurnStateKnown) {
443
+ if (event.type === "turn/start") {
444
+ openTurn = event.data.turn;
445
+ openTurnStateKnown = true;
446
+ } else if (event.type === "turn/end") openTurnStateKnown = true;
447
+ }
448
+ if (openTurnStateKnown && compactionEntryStateKnown && latestEndSeedSeq !== undefined) break;
449
+ }
450
+ return {
451
+ openTurn,
452
+ unmatchedCompactionStart,
453
+ latestEndSeedSeq
454
+ };
455
+ }