@falling-ts/dsh-force-compact 0.2.0

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,579 @@
1
+ /**
2
+ * dsh-force-compact's own region selection, modeled on the official
3
+ * `compaction-basic` region selection. A head-anchored span that retains a
4
+ * recent tail verbatim and ends on a TOOL-PAIRING BALANCED surface boundary —
5
+ * verified with the official pairing ledger (`core/pairing.js`), not assumed
6
+ * from a coarse `user/message` heuristic. Balanced cuts include any surface
7
+ * position with zero unanswered tool calls, so the delegated `compactRegion`
8
+ * never rejects the chosen bounds for unpaired tool calls.
9
+ *
10
+ * @module @falling-ts/dsh-force-compact/region
11
+ */
12
+
13
+ import { guardFn } from '../core/crashnet.js'
14
+ import {
15
+ toolPairingBalancedAfterSafe,
16
+ toolPairingBalancedBeforeSafe,
17
+ } from '../core/pairing.js'
18
+
19
+ /**
20
+ * Select the compactable region for a session.
21
+ * @param {import('@deepseek-ai/dsh-session').Session} session
22
+ * @param {Readonly<object>} config
23
+ * @returns {{start: number, end: number} | null} the head-anchored span to compact, or `null` when there is nothing worth compacting.
24
+ */
25
+ // Internal body of `selectRegion` — routed through the crash-net wrapper.
26
+ function __selectRegionBody(session, config) {
27
+ // A malformed surface (missing `session.surface` / non-array `nodes`) yields
28
+ // nothing to compact — return null rather than throw.
29
+ const nodes = (session && session.surface && Array.isArray(session.surface.nodes)) ? session.surface.nodes : []
30
+ const total = nodes.length
31
+ if (total < config.minNodes) return null
32
+
33
+ // Retain a recent tail (by surface-node count); the compactable prefix is everything before it.
34
+ const retainCount = Math.max(1, Math.round(total * config.retainRatio))
35
+ let keepFromIdx = total - retainCount
36
+ if (keepFromIdx < 1) return null
37
+
38
+ // Walk the tail boundary back to the NEAREST PRECEDING TOOL-PAIRING BALANCED
39
+ // position — the official criterion (ledger in `core/pairing.js`: cut-before
40
+ // the node at `keepFromIdx` is balanced iff zero tool calls straddle it).
41
+ // Any balanced position works, not only `user/message` nodes — such nodes
42
+ // are merely the historically used sufficient subset.
43
+ while (keepFromIdx > 1 && !toolPairingBalancedBeforeSafe(session, nodes[keepFromIdx])) {
44
+ keepFromIdx -= 1
45
+ }
46
+ if (keepFromIdx < 2) return null
47
+
48
+ const compactableCount = keepFromIdx - 1
49
+ if (compactableCount < config.minCompactableNodes) return null
50
+
51
+ // The compactable PREFIX is indices [0 .. keepFromIdx-2]. Emit its bounds as
52
+ // the MIN and MAX SEQ BY VALUE (not by array position). After a prior
53
+ // checkpoint REPLACES earlier nodes but APPENDS the new checkpoint node at a
54
+ // later log position, the surviving early nodes keep their ORIGINAL (higher)
55
+ // seqs at HIGHER array indices than the checkpoint — so `surface.nodes` is
56
+ // NOT necessarily in ascending-seq order. Reading `nodes[0]` / `nodes[i]`
57
+ // directly could therefore yield `start > end` (an INVERTED span), which
58
+ // downstream `compactRegion` rejects or mishandles. Taking the value-extremes
59
+ // guarantees `start <= end`, and the region still denotes the same leading
60
+ // segment of the projection (bounds are inclusive index segments interpreted
61
+ // by the session core, so the seq ORDERING within the segment is irrelevant).
62
+ // Snap the OUTWARD bounds to TOOL-PAIRING BALANCED positions so the
63
+ // replacement stays balanced: the LEADING position's cut-before must be
64
+ // balanced (trivially satisfied at position 0) and the TRAILING position's
65
+ // cut-after must be balanced (zero tool calls left dangling). Widen `start`
66
+ // downward / shrink `end` upward within the prefix to the nearest balanced
67
+ // positions; when neither bound can be made balanced (degenerate prefix),
68
+ // fall back to the raw value extremes so a valid span is preserved.
69
+ const prefix = nodes.slice(0, keepFromIdx - 1)
70
+ let start = Infinity
71
+ let end = -Infinity
72
+ for (const seq of prefix) {
73
+ if (seq < start) start = seq
74
+ if (seq > end) end = seq
75
+ }
76
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null
77
+ let snappedStart = start
78
+ let snappedEnd = end
79
+ // Leading bound: shrink the leading segment until its first node's cut-BEFORE
80
+ // is balanced (at position 0 this is trivially true).
81
+ let startIdx = 0
82
+ while (startIdx < prefix.length && !toolPairingBalancedBeforeSafe(session, prefix[startIdx])) {
83
+ startIdx += 1
84
+ }
85
+ if (startIdx < prefix.length) snappedStart = prefix[startIdx]
86
+ // Trailing bound: pull the tail inward until the last node's cut-AFTER is
87
+ // balanced (any `user/message` position qualifies; so do tool-boundary-closed
88
+ // positions such as a finished step's last node).
89
+ let endIdx = prefix.length - 1
90
+ while (endIdx > startIdx && !toolPairingBalancedAfterSafe(session, prefix[endIdx])) {
91
+ endIdx -= 1
92
+ }
93
+ snappedEnd = prefix[endIdx]
94
+ if (snappedStart > snappedEnd) return null
95
+ return { start: snappedStart, end: snappedEnd }
96
+ }
97
+
98
+ /** Public entry — wrapped by the universal crash net. */
99
+ export const selectRegion = guardFn('region.selectRegion', __selectRegionBody)
100
+
101
+
102
+
103
+ /**
104
+ * Validate one requested surface-position span — ported from the official
105
+ * `compaction-basic` `validateSurfaceRegion`. Rejects (throws) when either
106
+ * bound is not a CURRENT SURFACE NODE, the ordering is inverted by INDEX, or
107
+ * either bound's tool-pairing balance check fails (the official fail-loud
108
+ * behaviour: a candidate that would split a step's tool-call/result pair is
109
+ * refused BEFORE any expensive summarization begins).
110
+ *
111
+ * Plugin adaptation: our builtin transaction validates bounds through
112
+ * {@link validateReplacementBounds} (non-throwing, `null` on invalid) right
113
+ * before the replace append — that path also cross-checks that the bounds land
114
+ * on current surface nodes. This exported validator adds the PAIRING checks on
115
+ * top, mirroring the official double-gate.
116
+ * @param {import('@deepseek-ai/dsh-session').Session} session
117
+ * @param {number} start the first surface-node seq (inclusive).
118
+ * @param {number} end the last surface-node seq (inclusive).
119
+ * @returns {{start: number, end: number, startIdx: number, endIdx: number, shadowedSeqs: number[]}}
120
+ * @throws {Error} when the span is malformed or unbalanced (official semantics).
121
+ */
122
+ // Internal body of `validateSurfaceRegion` — routed through the crash-net wrapper.
123
+ function __validateSurfaceRegionBody(session, start, end) {
124
+ const surfaceNodes = (session && session.surface && Array.isArray(session.surface.nodes)) ? session.surface.nodes : []
125
+ const startIdx = surfaceNodes.indexOf(start)
126
+ const endIdx = surfaceNodes.lastIndexOf(end)
127
+ if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
128
+ if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
129
+ if (startIdx > endIdx) {
130
+ throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`)
131
+ }
132
+ // Official double gate: BOTH bounds must sit on tool-pairing balanced cuts,
133
+ // verified with the precise per-event ledger (not the coarse user-message
134
+ // assumption). The SAFE variants determine a corrupt-surface ledger failure
135
+ // as "balanced", so a damaged log degrades to the session core's own replace
136
+ // validation (its final line of defense) instead of wedging selection forever.
137
+ if (!toolPairingBalancedBeforeSafe(session, surfaceNodes[startIdx])) {
138
+ throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
139
+ }
140
+ if (!toolPairingBalancedAfterSafe(session, surfaceNodes[endIdx])) {
141
+ throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
142
+ }
143
+ return {
144
+ start,
145
+ end,
146
+ startIdx,
147
+ endIdx,
148
+ shadowedSeqs: surfaceNodes.slice(startIdx, endIdx + 1),
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Safe variant of {@link __validateSurfaceRegionBody} for hot paths: identical
154
+ * math, but ANY throw (unknown bound, inverted span, unbalanced cut, corrupt
155
+ * surface) resolves to `null` instead of propagating — callers skip the
156
+ * doomed compaction instead of crashing the trigger path. Mirrors the way the
157
+ * official code routes `validateSurfaceRegion` rejections into a clean
158
+ * `SurfaceChangedError`.
159
+ * @param {import('@deepseek-ai/dsh-session').Session} session
160
+ * @param {number} start
161
+ * @param {number} end
162
+ * @returns {{start: number, end: number, startIdx: number, endIdx: number, shadowedSeqs: number[]} | null}
163
+ */
164
+ // Public entries — wrapped by the universal crash net.
165
+ export const validateSurfaceRegion = guardFn('region.validateSurfaceRegion', __validateSurfaceRegionBody)
166
+ export const validateSurfaceRegionSafe = ((session, start, end) => {
167
+ try {
168
+ return validateSurfaceRegion(session, start, end)
169
+ } catch {
170
+ return null
171
+ }
172
+ })
173
+
174
+ /**
175
+ * Select the **earliest** `ratio` fraction of a **token meter measurement** as
176
+ * a head-anchored region to compact — the preferred, same-caliber variant of
177
+ * {@link selectEarliestByTokens}.
178
+ *
179
+ * Why this exists: the naive {@link selectEarliestByTokens} prices each node
180
+ * with its own char/4 heuristic on FLAT surface text only, which systematically
181
+ * UNDERCOUNTS relative to the gate's `totalTokens` (that figure includes the
182
+ * system-prompt + tools schema header, nested tool blocks, and JSON framing).
183
+ * Budgeting against the large `totalTokens` but accumulating a much smaller
184
+ * flat-text total means the budget is never met and the final boundary snap
185
+ * fails — so the selector returns `null` ("no earliest region") and the
186
+ * compaction silently gives up. Feeding it the meter's OWN per-node prices
187
+ * (each node's `tokens` comes from the same `estimateMessage` that feeds
188
+ * `totalTokens`) makes the accumulation reachable and the boundary well-defined.
189
+ *
190
+ * The walk is POSITIONAL over the ordered `measurement.nodes` (model-visible
191
+ * head-to-tail order, as maintained by the meter's surface fold) rather than by
192
+ * `seq` value: after a committed compaction the checkpoint node sits at a higher
193
+ * seq than some surviving early nodes, so nodes are NOT guaranteed ascending by
194
+ * seq. Positional order IS the meaningful "earliest-first" order. Once the
195
+ * running total meets the `totalTokens * ratio` budget, the span's **end** is
196
+ * walked BACKWARD to the NEAREST PRECEDING TOOL-PAIRING BALANCED position
197
+ * (verified with the official pairing ledger — a superset of `user/message`
198
+ * positions; the historical heuristic snapped to `user/message` only because
199
+ * such positions were believed sufficient, never necessary). Returns `null`
200
+ * when there is not enough surface to compact.
201
+ *
202
+ * @param {import('@deepseek-ai/dsh-session').Session} session
203
+ * @param {number} ratio a fraction in (0, 1].
204
+ * @param {Readonly<{
205
+ * totalTokens: number,
206
+ * nodes: ReadonlyArray<{ seq: number, tokens: number }>,
207
+ * }>} measurement a `tokenMeter.measure(session)` snapshot; `nodes` is the
208
+ * ordered per-node pricing and `totalTokens` is the figure to budget against.
209
+ * @param {number} [maxRegionNodes] a hard ceiling on the NUMBER OF SURFACE NODES
210
+ * the returned region may span (positional, from the head). When the
211
+ * token-derived 0.ratio crossing point lands beyond this many nodes — typical
212
+ * for a large `ratio` like 0.7 on a long, tool-heavy conversation — the
213
+ * region is CLAMPED DOWN to the largest head-aligned prefix that fits under
214
+ * the cap AND ends on a TOOL-PAIRING BALANCED boundary (official pairing
215
+ ledger — any cut-after-balanced node, not only `user/message`).
216
+ Rationale: the builtin
217
+ * summarization engine refuses regions whose projected message count exceeds
218
+ * its replay cap; clamping here (rather than refusing there) GUARANTEES a
219
+ * committable region on every threshold trip so the auto-gate never livelocks
220
+ * and the context can actually be pulled back down. Multiple successive gates
221
+ * chip away the head until the session settles below the threshold.
222
+ * @returns {{start: number, end: number} | null} the head-anchored span to compact, or `null`.
223
+ */
224
+ // Internal body of `selectEarliestByMeasurements` — routed through the
225
+ // crash-net wrapper.
226
+ function __selectEarliestByMeasurementsBody(session, ratio, measurement, maxRegionNodes) {
227
+ const nodes = (measurement && Array.isArray(measurement.nodes) && measurement.nodes.length > 0)
228
+ ? measurement.nodes
229
+ : []
230
+ const total = nodes.length
231
+ if (total < 2) return null
232
+ const clampedRatio = Number.isFinite(ratio) ? Math.min(Math.max(ratio, 0.01), 1) : 0.5
233
+
234
+ // Same caliber as the accumulation: budget from the SAME measurement whose
235
+ // nodes we accumulate. Fall back to the sum of the nodes' own prices when
236
+ // `totalTokens` is absent/malformed so the budget always stays reachable.
237
+ const nodeSum = nodes.reduce((acc, n) => acc + (Number(n.tokens) > 0 ? Number(n.tokens) : 0), 0)
238
+ const totalTokens = (typeof measurement.totalTokens === 'number' && Number.isFinite(measurement.totalTokens) && measurement.totalTokens > 0)
239
+ ? measurement.totalTokens
240
+ : nodeSum
241
+ const budget = Math.max(1, Math.round(totalTokens * clampedRatio))
242
+
243
+ // Upper positional bound on the region span: the smallest of (a) the last
244
+ // node, (b) the token-crossing point, (c) the optional node-count cap. All
245
+ // expressed as an INDEX into `nodes`. We then snap THAT index backward to the
246
+ // nearest PRECEDING TOOL-PAIRING BALANCED position BELOW it (ledger-verified
247
+ // END — any cut-after-balanced node, not only `user/message`), which may
248
+ // bring the span further inward.
249
+ const capBound = (Number.isFinite(maxRegionNodes) && maxRegionNodes > 0)
250
+ ? Math.min(total, Math.ceil(maxRegionNodes)) - 1
251
+ : total - 1
252
+
253
+ // Accumulate node-by-node (positional) until the budget is met OR the capped
254
+ // bound is reached, whichever comes FIRST.
255
+ let accumulated = 0
256
+ let endIdx = Math.min(capBound, total - 1)
257
+ for (let i = 0; i <= endIdx; i += 1) {
258
+ accumulated += Number(nodes[i].tokens) > 0 ? Number(nodes[i].tokens) : 0
259
+ if (i <= capBound && accumulated >= budget) {
260
+ endIdx = i
261
+ break
262
+ }
263
+ }
264
+
265
+ // Snap the span's end BACKWARD to the nearest PRECEDING TOOL-PAIRING
266
+ // BALANCED position at or before the crossing point so the compacted span
267
+ // ends balanced (official criterion — a strict superset of the old
268
+ // `user/message` heuristic: any node whose cut-after carries zero
269
+ // unanswered tool calls). Walking backward keeps the span WITHIN the cap.
270
+ // If no preceding balanced position exists, fall back to the raw crossing
271
+ // point so a valid region is preserved.
272
+ let settled = endIdx
273
+ while (settled > 0 && !toolPairingBalancedAfterSafe(session, nodes[settled].seq)) {
274
+ settled -= 1
275
+ }
276
+ const endNode = nodes[settled]
277
+ const startNode = nodes[0]
278
+ if (startNode === undefined || endNode === undefined) return null
279
+ const start = startNode.seq
280
+ const end = endNode.seq
281
+ if (!Number.isInteger(start) || !Number.isInteger(end)) return null
282
+ if (start === end) return null
283
+ return { start: Math.min(start, end), end: Math.max(start, end) }
284
+ }
285
+
286
+ /**
287
+ * Select a **tail-retained** region to compact using the TOKEN METER'S OWN
288
+ * per-node prices — the successor to {@link selectEarliestByMeasurements}'s
289
+ * ratio-of-total budgeting.
290
+ *
291
+ * Semantics (per the user-facing policy knob `retainLatestTokens`): starting
292
+ * FROM THE LATEST ENTRY of the measurement's `nodes` (the ordered
293
+ * model-visible head-to-tail surface, exactly the caliber that feeds
294
+ * `totalTokens`), ACCUMULATE node tokens BACKWARD (newest → oldest) until the
295
+ * accumulated sum REACHES OR EXCEEDS `retainLatestTokens` (stop condition:
296
+ * `>=`). Because a node cannot be split, the retained tail may overshoot
297
+ * `budget` by UP TO ONE node's weight — that is the closest achievable
298
+ * "exactly N" boundary given the discrete node granularity. The cutoff CUT
299
+ * POINT splits the window: nodes BEFORE the first fully-retained node form
300
+ * the head-anchored SPAN TO COMPACT (sent to the summarizer as one batch;
301
+ * the original span entries become shadowed / skipped in derived history).
302
+ * The cutoff is then SNAPPED BACKWARD to the nearest PRECEDING TOOL-PAIRING
303
+ * BALANCED position (cut-after-node semantics, verified with the official
304
+ * pairing ledger rather than assumed from a `user/message` heuristic) so the
305
+ * compacted span ends at a balanced, tool-call-safe point — the same
306
+ * invariant the other selectors maintain.
307
+ *
308
+ * Why this supersedes ratio-of-total: budgeting the RETAINED side against a
309
+ * FIXED absolute token amount (not `totalTokens × ratio`) decouples the cut
310
+ * from provider-usage inflation baked into `totalTokens` — the exact
311
+ * divergence that made `autoEarliestRatio` regions undersized against an
312
+ * inflated denominator (observed live: total=71270 dominated by a usage
313
+ * baseline of 61818 left the head region unable to ever reach below threshold
314
+ * no matter how well it summarized).
315
+ *
316
+ * Boundary cases:
317
+ * - `retainLatestTokens <= 0` → clamp to 1 node minimum retained (never
318
+ * compact the whole surface in one pass).
319
+ * - The retained tail ALREADY reaches the budget at the VERY LAST node
320
+ * (single huge trailing node ≥ budget) → nothing left to compact: `null`.
321
+ * - Fewer than 2 nodes total → `null`.
322
+ *
323
+ * @param {import('@deepseek-ai/dsh-session').Session} session
324
+ * @param {number} retainLatestTokens the absolute TOKEN COUNT to RETAIN at the
325
+ * latest end of the surface. Positive integer.
326
+ * @param {Readonly<{
327
+ * nodes: ReadonlyArray<{ seq: number, tokens: number }>,
328
+ * }>} measurement a `tokenMeter.measure(session)` snapshot whose `nodes` are
329
+ * the ordered per-node prices.
330
+ * @returns {{start: number, end: number, retainedTokens: number} | null} the
331
+ * head-anchored span to compact plus the actual retained tail's token sum,
332
+ * or `null` when there is not enough surface to compact.
333
+ */
334
+ // Internal body of `selectRetainingLatestTokens` — routed through the
335
+ // crash-net wrapper.
336
+ function __selectRetainingLatestTokensBody(session, retainLatestTokens, measurement) {
337
+ const nodes = (measurement && Array.isArray(measurement.nodes) && measurement.nodes.length > 0)
338
+ ? measurement.nodes
339
+ : []
340
+ const total = nodes.length
341
+ if (total < 2) return null
342
+ const budget = (Number.isFinite(retainLatestTokens) && retainLatestTokens > 0)
343
+ ? Math.max(1, Math.floor(retainLatestTokens))
344
+ : 1
345
+
346
+ // Walk FROM THE TAIL toward the head, accumulating node tokens. Stop as soon
347
+ // as the accumulated sum reaches OR EXCEEDS `budget` (the `>=` stop rule).
348
+ // The first node included in the accumulated tail is the cutoff point:
349
+ // everything STRICTLY BEFORE it (positionally) is compacted. Because a node
350
+ // cannot be split, the retained tail may overshoot `budget` by UP TO ONE
351
+ // node's weight — that is the closest achievable "exactly N" boundary.
352
+ const events = (session && Array.isArray(session.events)) ? session.events : []
353
+ let acc = 0
354
+ let tailStartIdx = total // exclusive: index just AFTER the last retained node
355
+ let crossingAccBefore = -1 // accumulator value JUST BEFORE the crossing node was added (-1 when the walk consumed the whole window)
356
+ let crossingNodeSize = -1 // size of the node that pushed the accumulator over budget
357
+ let crossingAccAfter = -1 // accumulator value AFTER the crossing node was added
358
+ for (let i = total - 1; i >= 0; i -= 1) {
359
+ tailStartIdx = i
360
+ const t = Number(nodes[i].tokens) > 0 ? Number(nodes[i].tokens) : 0
361
+ const before = acc
362
+ acc += t
363
+ if (acc >= budget) {
364
+ crossingAccBefore = before
365
+ crossingNodeSize = t
366
+ crossingAccAfter = acc
367
+ break
368
+ }
369
+ }
370
+ // The tail occupied indices [tailStartIdx .. total-1]; the compactable
371
+ // prefix occupies [0 .. tailStartIdx-1]. Need at least one node to compact.
372
+ if (tailStartIdx <= 0) return null
373
+
374
+ // Snap to the nearest PRECEDING TOOL-PAIRING BALANCED position (official
375
+ // criterion via the `core/pairing.js` ledger — a strict superset of the
376
+ // old `user/message` heuristic: any node whose cut-after carries zero
377
+ // unanswered tool calls). No balanced position in the prefix → keep the raw
378
+ // crossing index so a valid region survives.
379
+ let endIdx = tailStartIdx - 1
380
+ while (endIdx > 0 && !toolPairingBalancedAfterSafe(session, nodes[endIdx].seq)) {
381
+ endIdx -= 1
382
+ }
383
+ const endNode = nodes[endIdx]
384
+ const startNode = nodes[0]
385
+ if (startNode === undefined || endNode === undefined) return null
386
+ const start = startNode.seq
387
+ const end = endNode.seq
388
+ if (!Number.isInteger(start) || !Number.isInteger(end)) return null
389
+ if (start === end) return null
390
+
391
+ // Report the ACTUAL retained tail's token sum (indices [endIdx+1 .. total-1]
392
+ // after the boundary snap — nodes pulled onto the retained side during the
393
+ // snap are INCLUDED here, so this figure is a faithful lower bound on what
394
+ // remains verbatim after compaction).
395
+ let retainedTokens = 0
396
+ for (let i = endIdx + 1; i < total; i += 1) {
397
+ const t = Number(nodes[i].tokens) > 0 ? Number(nodes[i].tokens) : 0
398
+ retainedTokens += t
399
+ }
400
+ // DIAGNOSTIC FIELDS — expose the exact moment the backward walk crossed the
401
+ // `budget` boundary so callers can log WHY the retained tail overshoots:
402
+ // crossingAccBefore — the accumulated sum JUST BEFORE the crossing node
403
+ // (what the tail looked like one node earlier)
404
+ // crossingNodeSize — the size of the node that pushed the sum over budget
405
+ // (this single node is what makes "≥8000" become e.g.
406
+ // "~9423")
407
+ // crossingAccAfter — the accumulated sum INCLUDING the crossing node
408
+ // (= crossingAccBefore + crossingNodeSize)
409
+ // All three are -1 when the walk consumed the whole window without ever
410
+ // reaching the budget (the degenerate tiny-session case).
411
+ // boundaryKind classifies WHAT KIND of position the span finally settled on
412
+ // after the backward balance snap (feeds the REGION-PICK diagnostic line):
413
+ // 'pairing' — a ledger-verified balanced position that is NOT a
414
+ // `user/message` (the tighter cut the ledger
415
+ // enables)
416
+ // 'user-message' — a human-message position (always balanced too)
417
+ // 'crossing-fallback' — no balanced position ahead of the raw crossing;
418
+ // the raw crossing itself was kept
419
+ let boundaryKind
420
+ if (endIdx < total - 1) {
421
+ boundaryKind = 'crossing-fallback'
422
+ } else {
423
+ const endEvent = events[endIdx]
424
+ boundaryKind = (endEvent !== null && typeof endEvent === 'object' && endEvent.type === 'user/message')
425
+ ? 'user-message'
426
+ : 'pairing'
427
+ }
428
+ return {
429
+ start: Math.min(start, end),
430
+ end: Math.max(start, end),
431
+ retainedTokens,
432
+ crossingAccBefore,
433
+ crossingNodeSize,
434
+ crossingAccAfter,
435
+ boundaryKind,
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Select the **earliest** `ratio` fraction of the session's **tokens** as a
441
+ * head-anchored region to compact — the "earliest conversation token ratio"
442
+ * knob. **Legacy fallback**: used only when no `tokenMeter.measure` snapshot is
443
+ * available. Prefer {@link selectEarliestByMeasurements}, which prices from the
444
+ * same caliber as the gate's `totalTokens` and avoids the undercount/blank
445
+ * failure this char-heuristic variant exhibits on tool-heavy conversations.
446
+ *
447
+ * It walks surface events from the head, accumulating per-event token estimates
448
+ * (4 chars/token heuristic on flat surface text), until the accumulated tokens
449
+ * reach the absolute `totalTokens` budget (callers typically pass the char-based
450
+ * surface estimate; the threshold path passes the projection's
451
+ * `projectedTokens`-derived residual when a snapshot is available upstream).
452
+ * This mirrors the legacy
453
+ * ratio-of-total behavior where passing a ratio R is equivalent to passing
454
+ * `totalTokens*R` as the absolute token budget to compact from the head —
455
+ * for callers who lack a real `measure()` snapshot. The span covers every
456
+ * surface node from the first through the node that crosses the token budget,
457
+ * then walks the span's **end** FORWARD to the next TOOL-PAIRING BALANCED
458
+ * position (official pairing ledger — any cut-after-balanced node, a strict
459
+ * superset of the historical `user/message` heuristic) so the compacted span
460
+ * ends at a balanced, tool-call-safe point. Returns `null` when there is not
461
+ * enough surface history to compact.
462
+ *
463
+ * @param {import('@deepseek-ai/dsh-session').Session} session
464
+ * @param {number} totalTokens the ABSOLUTE token budget to compact from the
465
+ * head. Typically the session's estimated total context tokens (char-based
466
+ * fallback) — walking forward until accumulated per-event tokens reach this
467
+ * many compacts up to the point where the budget is consumed, leaving the
468
+ * remaining tail intact. When `totalTokens` exceeds the actual surface
469
+ * token sum, the entire surface is eligible (equivalent to a ratio of 1.0).
470
+ * @param {number|undefined} [maxRegionNodes] optional positional ceiling on
471
+ * the region's node count — same contract as
472
+ * {@link selectEarliestByMeasurements}.
473
+ * @returns {{start: number, end: number} | null} the head-anchored span to compact, or `null`.
474
+ */
475
+ // Internal body of `selectEarliestByTokens` — routed through the crash-net wrapper.
476
+ function __selectEarliestByTokensBody(session, totalTokens, maxRegionNodes) {
477
+ // A malformed surface yields nothing to compact — return null rather than
478
+ // throwing on a missing `session.surface.nodes`.
479
+ const nodes = (session && session.surface && Array.isArray(session.surface.nodes)) ? session.surface.nodes : []
480
+ const total = nodes.length
481
+ if (total < 2) return null
482
+ const budget = (typeof totalTokens === 'number' && Number.isFinite(totalTokens) && totalTokens > 0)
483
+ ? Math.round(totalTokens)
484
+ : estimateSurfaceTokens(session)
485
+
486
+ // Walk surface events from the head, accumulating tokens until the budget
487
+ // is reached. The span end is the last node whose cumulative tokens first
488
+ // meet or exceed the budget. Honor the optional `maxRegionNodes` positional
489
+ // ceiling (same contract as {@link selectEarliestByMeasurements}): the span
490
+ // can NEVER extend past the capped window.
491
+ const capBound = (Number.isFinite(maxRegionNodes) && maxRegionNodes > 0)
492
+ ? Math.min(total, Math.ceil(maxRegionNodes)) - 1
493
+ : total - 1
494
+ let accumulated = 0
495
+ let endIdx = 0
496
+ for (let i = 0; i <= capBound; i++) {
497
+ const seq = nodes[i]
498
+ accumulated += estimateEventTokens(session, seq)
499
+ endIdx = i
500
+ if (accumulated >= budget) break
501
+ }
502
+
503
+ // Walk the span's end FORWARD to the next TOOL-PAIRING BALANCED position
504
+ // (official ledger criterion — any cut-after-balanced node, not only
505
+ // `user/message`) so the compacted span ends balanced.
506
+ while (endIdx + 1 < total && !toolPairingBalancedAfterSafe(session, nodes[endIdx])) {
507
+ endIdx += 1
508
+ }
509
+ if (!toolPairingBalancedAfterSafe(session, nodes[endIdx])) return null
510
+ if (endIdx < 1) return null
511
+
512
+ return { start: nodes[0], end: nodes[endIdx] }
513
+ }
514
+
515
+ /**
516
+ * Estimate the token count of a single session event's surface content
517
+ * (user/message, assistant/message, tool/result). Log-only events contribute 0.
518
+ * @param {import('@deepseek-ai/dsh-session').Session} session
519
+ * @param {number} seq the event's seq.
520
+ * @returns {number}
521
+ */
522
+ function estimateEventTokens(session, seq) {
523
+ // A missing/malformed event (non-array `session.events`, a non-object row, or
524
+ // missing `data` / `message`) degrades to 0 tokens rather than throwing — this
525
+ // estimator feeds a budget decision, never a correctness path.
526
+ const events = (session && Array.isArray(session.events)) ? session.events : []
527
+ const event = events[seq]
528
+ if (event === undefined || event === null || typeof event !== 'object') return 0
529
+ const data = (event.data && typeof event.data === 'object') ? event.data : {}
530
+ let chars = 0
531
+ const sumBlocks = (blocks) => {
532
+ if (!Array.isArray(blocks)) return
533
+ for (const block of blocks) {
534
+ if (block && typeof block === 'object' && typeof block.text === 'string') chars += block.text.length
535
+ }
536
+ }
537
+ if (event.type === 'user/message') {
538
+ sumBlocks(data.content)
539
+ } else if (event.type === 'assistant/message') {
540
+ const content = (data.message && Array.isArray(data.message.content)) ? data.message.content : undefined
541
+ if (content) sumBlocks(content)
542
+ } else if (event.type === 'tool/result') {
543
+ const content = (data.message && Array.isArray(data.message.content)) ? data.message.content : undefined
544
+ if (content) sumBlocks(content)
545
+ }
546
+ return Math.ceil(chars / 4)
547
+ }
548
+
549
+ /**
550
+ * Estimate the total token count of a session's surface content (user +
551
+ * assistant + tool-result messages), using a 4-chars-per-token heuristic.
552
+ * @param {import('@deepseek-ai/dsh-session').Session} session
553
+ * @returns {number}
554
+ */
555
+ function estimateSurfaceTokens(session) {
556
+ // Malformed shape (missing/non-array `events`, non-object rows, missing
557
+ // `data`/`message`) degrades each row to 0 tokens rather than throwing — this
558
+ // estimator feeds a budget decision, never a correctness path.
559
+ let chars = 0
560
+ const events = (session && Array.isArray(session.events)) ? session.events : []
561
+ for (const event of events) {
562
+ if (event === null || typeof event !== 'object') continue
563
+ const data = (event.data && typeof event.data === 'object') ? event.data : {}
564
+ let content
565
+ if (event.type === 'user/message') content = Array.isArray(data.content) ? data.content : undefined
566
+ else if (event.type === 'assistant/message') content = (data.message && Array.isArray(data.message.content)) ? data.message.content : undefined
567
+ else if (event.type === 'tool/result') content = (data.message && Array.isArray(data.message.content)) ? data.message.content : undefined
568
+ if (content === undefined) continue
569
+ for (const block of content) {
570
+ if (block && typeof block === 'object' && typeof block.text === 'string') chars += block.text.length
571
+ }
572
+ }
573
+ return Math.ceil(chars / 4)
574
+ }
575
+
576
+ /** Public entries — wrapped by the universal crash net. */
577
+ export const selectEarliestByMeasurements = guardFn('region.selectEarliestByMeasurements', __selectEarliestByMeasurementsBody)
578
+ export const selectRetainingLatestTokens = guardFn('region.selectRetainingLatestTokens', __selectRetainingLatestTokensBody)
579
+ export const selectEarliestByTokens = guardFn('region.selectEarliestByTokens', __selectEarliestByTokensBody)