@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.
- package/LICENSE +21 -0
- package/README.cn.md +170 -0
- package/README.md +479 -0
- package/cordis.patch.yml +13 -0
- package/index.js +550 -0
- package/package.json +54 -0
- package/src/core/crashnet.js +215 -0
- package/src/core/log.js +346 -0
- package/src/core/pairing.js +188 -0
- package/src/core/policy.js +35 -0
- package/src/core/projected.js +139 -0
- package/src/core/settings.js +475 -0
- package/src/core/ui-signal.js +271 -0
- package/src/engine/backend.js +143 -0
- package/src/engine/builtin.js +1336 -0
- package/src/engine/checkpoint.js +206 -0
- package/src/engine/region.js +579 -0
- package/src/engine/summarizer.js +818 -0
- package/src/hooks/command.js +164 -0
- package/src/hooks/guard.js +706 -0
- package/src/hooks/idle.js +136 -0
- package/src/hooks/wire-rewrite.js +151 -0
- package/web/client.js +807 -0
- package/web/swish.css +188 -0
|
@@ -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)
|