@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,706 @@
1
+ /**
2
+ * dsh-force-compact's per-model-request guard — the "hook the core model
3
+ * request" half of the plugin.
4
+ *
5
+ * Instead of (or in addition to) the `session/flush` checkpoint, this guard
6
+ * runs at the official model-request seam so the decision is made **right
7
+ * before a model request is made**:
8
+ *
9
+ * - **`agent/request`** (a Waterfall around the frozen call configuration) —
10
+ * when the `disableThinking` setting is on, the returned `LlmCallConfig`
11
+ * carries `reasoningEffort: 'off'`, which the LLM adapter maps to
12
+ * `thinking: { type: 'disabled' }`. Every model request in this process is
13
+ * therefore sent with thinking/reasoning disabled.
14
+ * - **`agent/pre-step`** (a Waterfall before each model step) — reads the
15
+ * session's **projected context tokens** through the official
16
+ * `contextPressure` projection (`projectedTokens` — the exact figure the
17
+ * harness renders in the bottom-right corner, provider-anchored). When
18
+ * the reading is **>= `autoThresholdTokens`**, the guard rejects the proposed
19
+ * step (so the model request is NOT made) and instead retains the **latest
20
+ * `retainLatestTokens` of the conversation's tokens verbatim** while sending
21
+ * everything before that cutoff to the `compaction`
22
+ * service's `compactRegion` (read live via `ctx.get('compaction')`), which
23
+ * condenses the head history and lets the loop retry with a smaller context.
24
+ *
25
+ * Both settings are read **per request** through the synchronous
26
+ * `settings.get('falling-ts-force-compact')` so a `settings.yaml` edit is picked up on the
27
+ * next model request without a restart.
28
+ *
29
+ * @module @falling-ts/dsh-force-compact/request-guard
30
+ */
31
+
32
+ import { readSettings, DEFAULTS } from '../core/settings.js'
33
+ import {
34
+ selectEarliestByTokens,
35
+ selectEarliestByMeasurements,
36
+ selectRetainingLatestTokens,
37
+ validateSurfaceRegionSafe,
38
+ } from '../engine/region.js'
39
+ import { resolveCompaction } from '../engine/backend.js'
40
+ import { publishCompressing, publishDone } from '../core/ui-signal.js'
41
+ import { guardFn, renderCrash, captureThrowSite, appendCrashLine as appendDiag } from '../core/crashnet.js'
42
+ import { getProjectedTokens } from '../core/projected.js'
43
+
44
+ /**
45
+ * Process-local "force compact now" records, one per session (keyed by
46
+ * `session.id`). Set by the `/force-compact` command handler when the agent is
47
+ * busy, and consumed (and cleared) by the `agent/pre-step` hook at the next
48
+ * model step. Each entry is a `{commandId}` object (P1 — carries the
49
+ * originating slash-command id so the pre-step consumer can thread it into the
50
+ * `compaction/*` bracket's `sourceCommandId` field). Survives across the
51
+ * agent's steps within the process without any durable state or timer.
52
+ * @type {Map<string, {commandId: string|undefined}>}
53
+ */
54
+ const pendingForce = new Map()
55
+
56
+ /**
57
+ * Queue a forced compaction for one session (the `/force-compact` command).
58
+ * When the agent is idle the command compacts directly; when it is busy it sets
59
+ * this flag so the next model step force-compacts instead of requesting the
60
+ * model.
61
+ * @param {string} sessionId
62
+ * @param {string|undefined} [commandId] the originating slash-command id (P1).
63
+ */
64
+ /** Top-level entry — wrapped by the universal crash net. */
65
+ export const queueForceCompact = guardFn('guard.queueForceCompact', (sessionId, commandId) => {
66
+ if (sessionId !== undefined && sessionId !== null) {
67
+ pendingForce.set(sessionId, { commandId })
68
+ }
69
+ })
70
+
71
+ /**
72
+ * Consume (and clear) any pending forced-compaction record for one session.
73
+ * @param {string} sessionId
74
+ * @returns {boolean} whether a force was pending and is now cleared.
75
+ */
76
+ export const takeForceCompact = guardFn('guard.takeForceCompact', (sessionId) => {
77
+ const pending = pendingForce.get(sessionId)
78
+ if (pending) pendingForce.delete(sessionId)
79
+ return Boolean(pending)
80
+ })
81
+
82
+ /**
83
+ * Read the pending forced-compaction record WITHOUT consuming it (peek). Used
84
+ * by the pre-step consumer to retrieve the `commandId` before clearing.
85
+ * @param {string} sessionId
86
+ * @returns {{commandId: string|undefined}|undefined}
87
+ */
88
+ export const peekForceCompact = guardFn('guard.peekForceCompact', (sessionId) => {
89
+ return pendingForce.get(sessionId)
90
+ })
91
+
92
+
93
+
94
+ /**
95
+ * Estimate the total token count of the messages contained in a region span,
96
+ * using the `tokenMeter.estimateMessage` service when available and falling
97
+ * back to a 4-chars-per-token character heuristic. Mirrors the projection the
98
+ * builtin engine performs (`projectRegion`), but only to SUM sizes for the
99
+ * threshold-aware shrink gate — it builds no durable artifacts.
100
+ * @param {object|undefined} meter the `tokenMeter` service (may be undefined).
101
+ * @param {import('@deepseek-ai/dsh-session').Session} session
102
+ * @param {{start: number, end: number}} region the head-anchored span (inclusive seqs).
103
+ * @returns {number} the summed token estimate (0 when nothing measurable).
104
+ */
105
+ /**
106
+ * Sum the meter's per-node prices for the surface nodes whose seq falls within
107
+ * the selected region's [start..end] seq window. Reusing the measurement's own
108
+ * node prices keeps the shrink gate's region figure on the SAME caliber as both
109
+ * the region selector and the gate's `totalTokens` (all fed by the one
110
+ * `measure()` snapshot). Falls back to re-pricing flat surface content via
111
+ * `meter.estimateMessage` / char-heuristic when no measurement is supplied.
112
+ */
113
+ function estimateRegionTokens(meter, session, region, measurement) {
114
+ // PREFERRED: when a `measure()` snapshot is available, sum the node prices for
115
+ // the seq window directly — same pricer, same total caliber as the selector.
116
+ if (measurement !== undefined && Array.isArray(measurement.nodes)) {
117
+ const lo = Math.min(region.start, region.end)
118
+ const hi = Math.max(region.start, region.end)
119
+ let tokens = 0
120
+ for (const node of measurement.nodes) {
121
+ const n = Number(node.seq)
122
+ if (Number.isFinite(n) && n >= lo && n <= hi) {
123
+ const t = Number(node.tokens)
124
+ if (Number.isFinite(t) && t > 0) tokens += t
125
+ }
126
+ }
127
+ return tokens
128
+ }
129
+ // LEGACY: no measurement — price the region's surface seqs manually. Malformed
130
+ // shapes degrade to whatever IS measurable (often 0) rather than throwing —
131
+ // feeds a shrink gate, never a correctness path.
132
+ const surfaceNodes = (session && session.surface && Array.isArray(session.surface.nodes)) ? session.surface.nodes : []
133
+ const nodes = [...surfaceNodes]
134
+ const firstIdx = nodes.indexOf(region.start)
135
+ const lastIdx = nodes.lastIndexOf(region.end)
136
+ const segment = (firstIdx >= 0 && lastIdx >= firstIdx)
137
+ ? nodes.slice(firstIdx, lastIdx + 1)
138
+ : []
139
+ const events = (session && Array.isArray(session.events)) ? session.events : []
140
+ let tokens = 0
141
+ const useMeter = meter !== undefined && typeof meter.estimateMessage === 'function'
142
+ for (const seq of segment) {
143
+ const event = events[seq]
144
+ if (event === undefined || event === null || typeof event !== 'object') continue
145
+ const data = (event.data && typeof event.data === 'object') ? event.data : {}
146
+ let content
147
+ if (event.type === 'user/message') content = data.content
148
+ else if (event.type === 'assistant/message') content = (data.message && data.message.content !== undefined) ? data.message.content : undefined
149
+ else if (event.type === 'tool/result') content = (data.message && data.message.content !== undefined) ? data.message.content : undefined
150
+ if (content === undefined || content === null) continue
151
+ if (useMeter) {
152
+ try {
153
+ tokens += meter.estimateMessage({ role: 'user', content })
154
+ } catch {
155
+ /* estimator hiccup — ignore this block */
156
+ }
157
+ } else {
158
+ let chars = 0
159
+ const blocks = Array.isArray(content) ? content : []
160
+ for (const block of blocks) if (block && typeof block === 'object' && typeof block.text === 'string') chars += block.text.length
161
+ tokens += Math.ceil(chars / 4)
162
+ }
163
+ }
164
+ return tokens
165
+ }
166
+
167
+ /**
168
+ * Compact a session's head so that the latest `retainLatestTokens` of the
169
+ * surface remains verbatim, sending the remainder to a single summarizer call
170
+ * via the `compaction` service's `compactRegion(start, end, agent, signal)`.
171
+ * Measures the session's total context tokens (via `tokenMeter` or a
172
+ * character-based fallback), then delegates the durable mutation.
173
+ *
174
+ * Selection prefers the meter's own per-node prices (when a `measure()` snapshot
175
+ * is available): starting FROM THE LATEST surface node, ACCUMULATE node tokens
176
+ * BACKWARD until the sum REACHES OR EXCEEDS `retainLatestTokens`; the cutoff
177
+ * splits the surface into the head SPAN TO COMPACT and the RETAINED TAIL
178
+ * (verbatim). Everything before the cut (plus the snap-to-nearest-preceding-
179
+ * tool-pairing-balanced-boundary adjustment — the official pairing ledger,
180
+ * a strict superset of `user/message` boundaries) is sent to the summarizer
181
+ * AS ONE BATCH — the original span's entries become shadowed/skipped in
182
+ * derived history. Before spending the summarization round-trip, the selected
183
+ * span passes TWO official safety gates (both ported from `compaction-basic`):
184
+ * a SURFACE-CONSISTENCY cross-check (the meter's priced snapshot must align
185
+ * node-for-node with the CURRENT `session.surface.nodes`; a concurrent
186
+ * modification between measure and selection aborts this attempt) and the
187
+ * `validateSurfaceRegion` DOUBLE-BALANCE gate (both bounds must sit on
188
+ * tool-pairing balanced cuts — a candidate that would split a step's
189
+ * tool-call/result pair is refused here, logged, and skipped).
190
+ *
191
+ * Legacy `selectEarliestByTokens` is used only when no measurement snapshot is
192
+ * available (tokenMeter absent): it estimates total tokens from char-count and
193
+ * picks a head-aligned prefix under the same `retainLatestTokens` budget (with
194
+ * an implicit total assumption that fits the legacy behavior).
195
+ *
196
+ * Never throws: all failures resolve `false` so the caller's model request
197
+ * proceeds unimpeded.
198
+ * @param {import('@deepseek-ai/cordis').Context} ctx
199
+ * @param {import('@deepseek-ai/dsh-agent').Agent} agent
200
+ * @param {AbortSignal|undefined} signal the current turn's signal (forwarded to compaction).
201
+ * @param {string|undefined} mode the `compactionMode` setting (passed by the caller); undefined re-reads live.
202
+ * @param {string|undefined} [sourceCommandId] P1 — the originating slash-command id threaded into the `compaction/*` bracket.
203
+ * @returns {Promise<boolean>} whether a compaction was committed.
204
+ */
205
+ async function compactRetainingLatest(ctx, agent, signal, mode, sourceCommandId) {
206
+ // SAFETY ENVELOPE: this function's CONTRACT is to resolve `false` (let the
207
+ // request proceed) on ANY failure — a missing service, a missing span, a
208
+ // throwing `tokenMeter.measure`, a rejecting backend call, or even a
209
+ // malformed `agent` shape. None of those may propagate into the `agent/pre-step`
210
+ // waterfall, where an uncaught throw would surface as a stalled/aborted step
211
+ // (the "every request pauses" symptom). The actual logic lives in the inner
212
+ // closure; any exception anywhere inside resolves `false`.
213
+ try {
214
+ return await __compactRetainingLatestBody(ctx, agent, signal, mode, sourceCommandId)
215
+ } catch (error) {
216
+ const message = error instanceof Error ? (error.stack || error.message) : String(error)
217
+ const sid = (agent && agent.session && agent.session.id) ? agent.session.id : '?'
218
+ ctx.logger.warn(`[force-compact] ${sid}: compactRetainingLatest degraded to false (letting the request proceed) — ${message}`)
219
+ return false
220
+ }
221
+ }
222
+
223
+ /** Body of {@link compactRetainingLatest}; wrapped by its safe envelope. */
224
+ async function __compactRetainingLatestBody(ctx, agent, signal, mode, sourceCommandId) {
225
+ const settings = (await readSettings(ctx)) ?? DEFAULTS
226
+ const session = agent.session
227
+ if (session === undefined || session === null) return false
228
+ // Locate a usable compaction backend: the OFFICIAL `compaction` service
229
+ // (preferred when reachable) OR this plugin's OWN builtin engine (the
230
+ // fallback when the service is realm-isolated away — e.g. standard preset).
231
+ // Both backends expose the SAME `{ compactNow, compactRegion, kind }` shape
232
+ // so the call site below is agnostic to which one served the request.
233
+ const backend = await resolveCompaction(ctx, agent, mode)
234
+ if (backend === undefined || typeof backend.compactRegion !== 'function') {
235
+ const effMode = (mode !== undefined ? mode : settings.compactionMode)
236
+ ctx.logger.warn(
237
+ `[force-compact] ${session.id}: NO compaction backend available (official service unreachable AND builtin engine ` +
238
+ `either disabled via \`builtinEnabled=false\` or lacking prerequisites: llm.service/stream or agent.session). ` +
239
+ `No compaction performed. If you want the builtin fallback, ensure \`builtinEnabled=true\` in the ` +
240
+ `\`falling-ts-force-compact\` namespace and that the \`llm\` service is mounted.`
241
+ )
242
+ return false
243
+ }
244
+ // Pressure basis — PROJECTED TOKENS (single definition with the harness UI).
245
+ // Reads the SAME `projectedTokens` the harness renders in the bottom-right
246
+ // corner (provider-anchored sample + surface movement since the sample), so
247
+ // the plugin's arithmetic never drifts from what the user sees.
248
+ //
249
+ // Historical note: this path previously keyed off
250
+ // `tokenMeter.measure().surfaceTokens` — the UNANCHORED meter-node sum —
251
+ // rejecting `totalTokens` because its usage baseline inflates and resets
252
+ // after each compaction. `projectedTokens` keeps a provider anchor while
253
+ // staying responsive to surface churn (compactions drop the figure the
254
+ // moment a span is shadowed), so the original objection no longer applies.
255
+ // When the reading is unavailable (fresh session with no usage sample yet,
256
+ // or a trimmed composition without the projection registry) we fall back to
257
+ // `surfaceTokens` — the closest same-caliber substitute — before giving up
258
+ // to the char estimator.
259
+ let totalTokens = getProjectedTokens(ctx, session)
260
+ // Measurement snapshot used for NODE-BY-NODE region selection: prefer the
261
+ // same-caliber `measure()` snapshot when a projection registry is not
262
+ // driving the basis (or is unavailable), otherwise reuse the meter
263
+ // snapshot for a consistent caliber across the selection path.
264
+ const meter = ctx.get('tokenMeter')
265
+ let measurement
266
+ if (meter !== undefined && typeof meter.measure === 'function') {
267
+ try {
268
+ const measured = meter.measure(session)
269
+ if (measured !== undefined && measured !== null) {
270
+ measurement = measured
271
+ if (totalTokens === undefined) {
272
+ totalTokens = (Number.isFinite(measured.surfaceTokens) && measured.surfaceTokens > 0)
273
+ ? measured.surfaceTokens
274
+ : undefined
275
+ }
276
+ }
277
+ } catch {
278
+ // leave `totalTokens` as-is (possibly already set from the projection)
279
+ }
280
+ }
281
+ // Region selection: PREFER the same-caliber meter-node selector (prices each
282
+ // candidate from the very `measure()` snapshot (when that snapshot backs
283
+ // `totalTokens`) — so the budget is always reachable and the boundary
284
+ // well-defined). When the basis is the projection-derived `projectedTokens`,
285
+ // the snapshot is used purely for node-by-node pricing, decoupled from the
286
+ // scalar basis. The
287
+ // `maxRegionNodes` cap CLAMPS an oversized 0.ratio head-span down to the
288
+ // largest serviceable head-aligned prefix so the builtin engine's replay cap
289
+ // is never tripped and a region is ALWAYS committable on a threshold trip.
290
+ // Fall back to the legacy char-heuristic variant only when no measurement
291
+ // snapshot is available (tokenMeter absent). See the selectors' docs.
292
+ const maxRegionNodes = (settings.maxRegionNodes !== undefined && Number.isFinite(Number(settings.maxRegionNodes)))
293
+ ? Number(settings.maxRegionNodes)
294
+ : undefined
295
+ // Prefer the tail-retaining selector when a measurement snapshot exists: it
296
+ // walks the node prices backward from the newest entry accumulating tokens
297
+ // until `>= retainLatestTokens`, snapping the cutoff to a preceding
298
+ // `user/message` boundary. This is exactly the "keep latest N tokens
299
+ // verbatim, send everything older in one batch to the LLM" semantic the
300
+ // user-facing `retainLatestTokens` knob promises.
301
+ //
302
+ // FALLBACK (legacy `selectEarliestByTokens`): when no measurement snapshot
303
+ // is available (tokenMeter absent), use the char-heuristic variant — it
304
+ // prices from `estimateSessionTokens` and applies the same `maxRegionNodes`
305
+ // clamp for the same bounded-region guarantee.
306
+ const region = (measurement !== undefined)
307
+ ? selectRetainingLatestTokens(session, settings.retainLatestTokens, measurement)
308
+ : (() => {
309
+ // DEGENERATE FALLBACK (tokenMeter absent): express the retention
310
+ // semantic via the char-estimated total. The head to compact is at
311
+ // most (totalEstimated − retainLatestTokens); pass THAT absolute
312
+ // head-budget to the legacy selector, which walks from the head
313
+ // accumulating until the budget is consumed. When the retention
314
+ // budget exceeds the estimated total (tiny session), the head budget
315
+ // clamps to 0 and the selector trivially returns null — no compaction.
316
+ if (typeof totalTokens !== 'number' || !Number.isFinite(totalTokens) || totalTokens <= 0) return null
317
+ const headBudget = Math.max(0, Math.round(totalTokens - settings.retainLatestTokens))
318
+ if (headBudget <= 0) return null
319
+ return selectEarliestByTokens(session, headBudget, maxRegionNodes)
320
+ })()
321
+ if (region === null) {
322
+ ctx.logger.debug(`[force-compact] ${session.id}: no region to compact retaining ~${settings.retainLatestTokens} latest tokens (basis=${totalTokens == null ? 'unknown(fallback est)' : totalTokens}${measurement !== undefined ? `, surface nodes=${measurement.nodes?.length}, surfaceTokens=${typeof measurement.surfaceTokens === 'number' ? measurement.surfaceTokens : '?'}` : ''})`)
323
+ return false
324
+ }
325
+
326
+ // ---- Surface-consistency CROSS-CHECK (ported from the official
327
+ // `compaction-basic` `prepareCompaction`) -------------------------------
328
+ // The meter's priced snapshot MUST align position-for-position with the
329
+ // session's current surface nodes. When a concurrent modification landed a
330
+ // node between the `measure()` above and selection completion, the two
331
+ // disagree; proceeding would price a STALE span. Refuse the compaction
332
+ // attempt entirely (next step retries on a fresh snapshot) rather than pay
333
+ // for a summarization of the wrong bytes.
334
+ // NOTE: `measurement.nodes` entries are OBJECTS shaped `{ seq, tokens }`
335
+ // (per-node pricing), whereas `session.surface.nodes` is the bare SEQUENCE
336
+ // of surface-node seqs. Compare BY EXTRACTED SEQ, element for element — the
337
+ // meter snapshot is taken microseconds earlier, so a concurrent append or
338
+ // replace landing in between shows up here as either a length difference or
339
+ // a seq divergence at some position. (A naive `element !== surfaceNodes[i]`
340
+ // comparison is WRONG here: it compares an object to a number and ALWAYS
341
+ // diverges, refusing every single compaction — the live-observed symptom
342
+ // "priced=49 vs current=49 nodes" yet REFUSED.)
343
+ const surfaceNodes = (session.surface && Array.isArray(session.surface.nodes)) ? session.surface.nodes : []
344
+ const pricedNodes = (measurement !== undefined && Array.isArray(measurement.nodes)) ? measurement.nodes : null
345
+ const misaligned = pricedNodes.length !== surfaceNodes.length
346
+ || pricedNodes.some((node, index) => node === null || typeof node !== 'object'
347
+ || typeof node.seq !== 'number' || node.seq !== surfaceNodes[index])
348
+ if (pricedNodes !== null && misaligned) {
349
+ ctx.logger.debug(
350
+ `[force-compact] ${session.id}: token-meter surface does not match the current session surface ` +
351
+ `(priced=${pricedNodes.length} vs current=${surfaceNodes.length} nodes) — REFUSING this compaction ` +
352
+ `attempt rather than summarize a stale span; retrying on the next step.`
353
+ )
354
+ return false
355
+ }
356
+
357
+ // ---- Official PAIRING BOUNDARY GATE (ported from the official
358
+ // `validateSurfaceRegion`) ----------------------------------------------
359
+ // Before spending a summarization round-trip, verify BOTH bounds are
360
+ // tool-pairing balanced on the CURRENT surface (the precise per-event
361
+ // ledger, not an assumption about the selection having done its job). A
362
+ // candidate that would split a step's tool-call/result pair is refused
363
+ // HERE (fail-loud, logged) — the session core's own replace validation
364
+ // remains the last line of defense behind this gate.
365
+ const validated = validateSurfaceRegionSafe(session, region.start, region.end)
366
+ if (validated === null) {
367
+ ctx.logger.debug(
368
+ `[force-compact] ${session.id}: selected span seq ${region.start}..${region.end} FAILED the official ` +
369
+ `surface/balance validation (unknown bound, inverted index, or an unbalanced tool-pairing cut) — ` +
370
+ `REFUSING this compaction attempt; the session core's own replace validation remains the safety net.`
371
+ )
372
+ return false
373
+ }
374
+
375
+ // THRESHOLD-AWARE SHRINK GATE (root fix for the low-threshold dead loop).
376
+ // Predict whether compacting this region can ACTUALLY pull the session below
377
+ // `autoThresholdTokens` before paying for a summarization LLM call. When the
378
+ // chosen region is too small relative to the total — i.e. even removing it
379
+ // WHOLE would leave total >= threshold — this compaction cannot achieve the
380
+ // goal, so attempting it just burns an LLM call and (because total hardly
381
+ // drops) re-arms the same gate on the next step: the "send 3 times, third
382
+ // wedges" storm. Skip early and let the request proceed.
383
+ //
384
+ // Only applied when we KNOW the total (tokenMeter available). With an unknown
385
+ // total there is no threshold comparison to make, so we proceed normally.
386
+ // This gate intentionally serves BOTH the auto-threshold path AND the
387
+ // explicit `/force-compact` path (both funnel here), so a command that
388
+ // cannot shrink below the threshold is likewise deferred rather than spammed.
389
+ if (totalTokens !== undefined && totalTokens >= settings.autoThresholdTokens) {
390
+ let regionTokens
391
+ try {
392
+ regionTokens = estimateRegionTokens(meter, session, region, measurement)
393
+ } catch {
394
+ regionTokens = 0 // a measurement failure means "skip the shrink gate"; the inner try/catch handles the eventual compaction.
395
+ }
396
+ // NOTE ON CAP-CLAMPING WITH TAIL RETENTION: unlike the legacy
397
+ // ratio-of-total selector (where a capped head-span had to be deliberately
398
+ // bypassed because committing it was the ONLY way to make headway), the
399
+ // new tail-retention semantic ALREADY bounds the retained side. When a
400
+ // measurement snapshot is present, `selectRetainingLatestTokens` returns a
401
+ // region whose head-span is AT MOST (windowSum − retainLatestTokens)
402
+ // tokens wide — inherently a bounded head. If THAT bound is still too big
403
+ // to cross the threshold, skipping is CORRECT here: retrying the SAME
404
+ // region next step changes nothing (nothing shrunk), so deferring avoids
405
+ // burning repeated summarization calls. We therefore DO NOT special-case
406
+ // a "capped head-span" branch — the math is simpler and correct.
407
+ if (typeof regionTokens === 'number' && regionTokens > 0 && totalTokens - regionTokens >= settings.autoThresholdTokens) {
408
+ ctx.logger.debug(
409
+ `[force-compact] ${session.id}: threshold-aware gate — retained-tail region (~${regionTokens} tokens; retains ~${settings.retainLatestTokens} latest tokens) `
410
+ + `cannot pull total ~${totalTokens} below threshold ${settings.autoThresholdTokens} `
411
+ + `(would still be ~${totalTokens - regionTokens}); SKIPPING compaction, letting the request proceed`
412
+ )
413
+ return false
414
+ }
415
+ }
416
+
417
+ ctx.logger.debug(
418
+ `[force-compact] ${session.id}: compacting head spanning seqs ${region?.start}..${region?.end} `
419
+ + `while retaining the latest ~${settings.retainLatestTokens} tokens, via ${backend?.kind} backend (totalTokens=${totalTokens})`
420
+ + ` | REGION-PICK budget=${settings.retainLatestTokens} `
421
+ + `crossingAccBefore=${region.crossingAccBefore} `
422
+ + `crossingNodeSize=${region.crossingNodeSize} `
423
+ + `crossingAccAfter=${region.crossingAccAfter} `
424
+ + `boundaryKind=${region.boundaryKind ?? 'unknown'} `
425
+ + `retainedTokens(after-boundary-snap)=${region.retainedTokens}`
426
+ )
427
+ try {
428
+ // LIVE UI SIGNAL — PIN RED "compressing" BEFORE the region compaction
429
+ // commits. This single site covers BOTH pre-step trigger paths (queued
430
+ // `/force-compact` flag and the auto token-threshold gate), since both
431
+ // funnel through `compactRetainingLatest`. Publishers swallow their own
432
+ // failures — the messenger can never affect whether the compaction itself
433
+ // commits.
434
+ await publishCompressing(ctx)
435
+ // P1 — forward `sourceCommandId` as the 5th positional arg (official
436
+ // `compactRegion(start, end, agent, signal, sourceCommandId)` and the
437
+ // builtin equivalent both absorb it).
438
+ const result = await backend.compactRegion(region.start, region.end, agent, signal, sourceCommandId)
439
+ if (result === undefined || result === null) {
440
+ ctx.logger.debug(`[force-compact] ${session.id}: retained-tail compaction committed nothing via ${backend?.kind}`)
441
+ return false
442
+ }
443
+ // COMMITTED — range shadowed + summary added.
444
+ // Pin GREEN "done"; the next model step's `llm/stream` watermark replaces
445
+ // it with a fresh random working pair shortly after (cadence < 3 s, no timer).
446
+ await publishDone(ctx)
447
+ ctx.logger.info(
448
+ `[force-compact] ${session.id}: retained-latest-${settings.retainLatestTokens}-tokens compaction (${backend?.kind}) `
449
+ + `shadowed ${result.shadowedSeqs?.length ?? '?'} nodes (~${result.shadowedTokenCount ?? '?'} tokens) `
450
+ + `spanning seqs ${region?.start}..${region?.end}`,
451
+ )
452
+ return true
453
+ } catch (error) {
454
+ const message = error instanceof Error ? error.message : String(error)
455
+ ctx.logger.warn(`[force-compact] ${session.id}: retained-tail compaction via ${backend?.kind} FAILED — ${message}`)
456
+ return false
457
+ }
458
+ }
459
+
460
+ /**
461
+ * The `agent/pre-step` guard. A `/force-compact` command queued a force flag for
462
+ * this agent (`takeForceCompact`) → compact immediately, bypassing the token
463
+ * threshold. Otherwise, measure the session's total context tokens and, when they
464
+ * reach `autoThresholdTokens`, compact instead. A failed or no-safe-range
465
+ * compaction resolves to `false` (let the request proceed) rather than throwing.
466
+ * @param {import('@deepseek-ai/cordis').Context} ctx
467
+ * @param {import('@deepseek-ai/dsh-agent').Agent} agent
468
+ * @param {AbortSignal|undefined} signal the current turn's signal.
469
+ * @param {string|undefined} mode the `compactionMode` setting (passed by the caller); undefined re-reads live.
470
+ * @returns {Promise<boolean>} `true` when the caller should return `{ kind: 'reject' }`.
471
+ */
472
+ // SAFETY ENVELOPE (pre-step gate): the CONTRACT is to resolve `false` (let
473
+ // the model request proceed) whenever ANYTHING goes wrong — missing/malformed
474
+ // `agent.session`, a rejecting `readSettings`, a throwing `tokenMeter.measure`,
475
+ // or a failing compaction. An uncaught throw HERE would surface as a broken
476
+ // `agent/pre-step` step (a stall), which is precisely the "every request
477
+ // pauses" symptom we are eliminating. So the entire body is contained; any
478
+ // anomaly logs and lets the request through.
479
+ // Additionally, the wrapper appends a UNIVERSAL-CRASH-NET diagnostic
480
+ // (message, thrownAt file:line:col, deepest plugin frame, nearest
481
+ // non-plugin frame, full call stack) to the durable crash log — belt-
482
+ // and-braces beyond the ctx.logger line above.
483
+ async function __forceCompactIfNeededEnvelope(ctx, agent, signal, mode) {
484
+ try {
485
+ return await __forceCompactIfNeededBody(ctx, agent, signal, mode)
486
+ } catch (error) {
487
+ const message = error instanceof Error ? (error.stack || error.message) : String(error)
488
+ ctx.logger.warn(`[force-compact] forceCompactIfNeeded degraded to false (letting the request proceed) — ${message}`)
489
+ // Crash-net side-channel: always-visible file entry even if ctx.logger
490
+ // is miswired. Swallows its own errors (never disturbs the degradation).
491
+ try {
492
+ const lines = renderCrash('guard.forceCompactIfNeeded', error, captureThrowSite())
493
+ for (const line of lines) appendDiag(line)
494
+ } catch (_netFailure) { /* never affect the request path */ }
495
+ return false
496
+ }
497
+ }
498
+
499
+ export const forceCompactIfNeeded = guardFn('guard.forceCompactIfNeeded', __forceCompactIfNeededEnvelope)
500
+
501
+ /** Body of {@link forceCompactIfNeeded}; wrapped by its safe envelope. */
502
+ async function __forceCompactIfNeededBody(ctx, agent, signal, mode) {
503
+ const settings = (await readSettings(ctx)) ?? DEFAULTS
504
+ const session = (agent && typeof agent === 'object') ? agent.session : undefined
505
+ // No usable session object → nothing to gate; let the request proceed.
506
+ if (session === undefined || session === null || typeof session.id !== 'string') {
507
+ ctx.logger.debug(`[force-compact] forceCompactIfNeeded: agent.session unusable — threshold gate skipped, letting the request proceed`)
508
+ return false
509
+ }
510
+
511
+ // A `/force-compact` command was issued for this agent while it was busy:
512
+ // compact now, regardless of the token threshold — retain the latest
513
+ // `retainLatestTokens` of the surface, compress the head in one batch.
514
+ // P1 — peek the pending record to recover the originating `commandId`
515
+ // BEFORE `takeForceCompact` clears it, so the `compaction/*` bracket can
516
+ // echo the same `sourceCommandId` the idle-manual path used.
517
+ const pendingRecord = peekForceCompact(session.id)
518
+ if (takeForceCompact(session.id)) {
519
+ const queuedCommandId = (pendingRecord && typeof pendingRecord.commandId === 'string' && pendingRecord.commandId.length > 0)
520
+ ? pendingRecord.commandId
521
+ : undefined
522
+ ctx.logger.info(`[force-compact] ${session.id}: /force-compact queued; force-compacting the head (keeping the latest ~${settings.retainLatestTokens} tokens) immediately${queuedCommandId ? ` (commandId=${queuedCommandId})` : ''}`)
523
+ const committed = await compactRetainingLatest(ctx, agent, signal, mode, queuedCommandId)
524
+ ctx.logger.debug(`[force-compact] ${session.id}: /force-compact forced compaction ${committed ? 'COMMITTED' : 'did not commit'} — letting the request proceed`)
525
+ return committed
526
+ }
527
+
528
+ // PROJECTED TOKENS — the authoritative pressure basis. Single definition
529
+ // with the harness UI: reads the SAME `projectedTokens` the harness renders
530
+ // in the bottom-right corner, so the plugin's threshold arithmetic never
531
+ // drifts from what the user sees.
532
+ //
533
+ // Caliber note (vs the previous `surfaceTokens` basis): the delta term in
534
+ // `projectedTokens` is estimated at the meter's fixed CHARS_PER_TOKEN=4
535
+ // density, so heavy-CJK / tool-JSON content is systematically undercounted
536
+ // compared to the unanchored node sum — a deliberate provider-anchor trade
537
+ // the meter prefers. Gate thresholds calibrated on the old basis may fire
538
+ // slightly less often; that is intended, not a regression.
539
+ const projectedTotal = getProjectedTokens(ctx, session)
540
+ const total = (typeof projectedTotal === 'number' && Number.isFinite(projectedTotal) && projectedTotal > 0)
541
+ ? projectedTotal
542
+ : estimateSessionTokens(session)
543
+ // Meter snapshot retained for TWO purposes on the threshold branch: (a) the
544
+ // node-priced region selection fed to `compactRetainingLatest` below — keeps
545
+ // the official tool-pairing ledger alignment even when the THRESHOLD BASIS
546
+ // comes from the projection (scalar basis ≠ node-pricing source, and that
547
+ // split is deliberate); (b) the diagnostic facets logged on the rare
548
+ // threshold-hit branch. Defensive:
549
+ // `measure` might return undefined/a non-object or throw on a transient
550
+ // glitch — neither may propagate through a gate that decides whether to run
551
+ // a model step.
552
+ const meter = ctx.get('tokenMeter')
553
+ let measurement
554
+ if (meter !== undefined && typeof meter.measure === 'function') {
555
+ try {
556
+ const maybeMeasurement = meter.measure(session)
557
+ measurement = (maybeMeasurement !== undefined && maybeMeasurement !== null) ? maybeMeasurement : undefined
558
+ } catch {
559
+ measurement = undefined
560
+ }
561
+ }
562
+ // DIAGNOSTIC: log every facet on the threshold branch so a divergent total
563
+ // can be attributed (projection basis vs baseline kind/tokens vs surface sum
564
+ // vs nodes-window sum). Threshold hits are rare events, so unconditional
565
+ // DEBUG here is cheap.
566
+ const diagNodes = (measurement && Array.isArray(measurement.nodes)) ? measurement.nodes : []
567
+ const diagWindowSum = diagNodes.reduce((acc, n) => acc + (Number(n && n.tokens) > 0 ? Number(n.tokens) : 0), 0)
568
+ if (total >= settings.autoThresholdTokens) {
569
+ const baseline = (measurement && measurement.baseline) || undefined
570
+ const estFallback = estimateSessionTokens(session)
571
+ ctx.logger.debug(
572
+ `[force-compact] ${session.id}: MEASURE-DIAG basis=projectedTokens total=${total} `
573
+ + `baseline=${baseline ? `${baseline?.kind}:${baseline?.tokens}` : 'none'} `
574
+ + `delta=${measurement && typeof measurement.surfaceDeltaTokens === 'number' ? measurement.surfaceDeltaTokens : '?'} `
575
+ + `surfaceTokens=${measurement && typeof measurement.surfaceTokens === 'number' ? measurement.surfaceTokens : '?'} `
576
+ + `nodes=${diagNodes.length} windowSum=${diagWindowSum} charEst4=${estFallback}`
577
+ )
578
+ }
579
+ if (total < settings.autoThresholdTokens) {
580
+ ctx.logger.debug(`[force-compact] ${session.id}: total ~${total} tokens < threshold ${settings.autoThresholdTokens} — below gate, letting the request proceed`)
581
+ return false
582
+ }
583
+
584
+ // PRE-FLIGHT DIAGNOSTIC — SURFACE-BASED FLOOR OBSERVATION (informational
585
+ // only; NEVER aborts the compaction). Rationale for dropping the early-return
586
+ // this block USED TO perform: `totalTokens` mixes a provider-reported USAGE
587
+ // baseline (which inflates on sessions that have already consumed context)
588
+ // with the live SURFACE DELTA. Shaving the whole surface window lowers the
589
+ // NEXT request's usage baseline dramatically (the provider re-baselines on
590
+ // the compacted surface), so projecting `total − maxRemovableHead` onto the
591
+ // CURRENT measurement is UNSOUND whenever the baseline is usage-flavored:
592
+ // it predicts "cannot cross" precisely in the regime where compaction helps
593
+ // most. Instead we LOG the floor arithmetic (useful attribution data — how
594
+ // much of `total` is baseline vs window vs delta) and ALWAYS fall through to
595
+ // the attempted compaction. The guard's `total` basis is now the projection's `projectedTokens` (provider-anchored, reacts to surface churn); the meter snapshot beside it is diagnostic-only, so the
596
+ // SURFACE-TOKENS ONLY (no usage-baseline water), so this naive projection is
597
+ // meaningful again; it nonetheless stays INFORMATIONAL — the per-region
598
+ // SHRINK GATE downstream makes the actual decision, and no separate
599
+ // blank-result cooldown exists anymore ("先压缩再说": every threshold-hit
600
+ // step attempts a fresh compaction).
601
+ const floorWindow = (measurement && Array.isArray(measurement.nodes) ? measurement.nodes : [])
602
+ .filter(n => n !== null && typeof n === 'object' && Number.isFinite(Number(n.tokens)))
603
+ const windowSumObserved = floorWindow.reduce((acc, n) => acc + Number(n.tokens), 0)
604
+ const maxRemovableObserved = Math.max(0, windowSumObserved - settings.retainLatestTokens)
605
+ const projectedAfterObserved = total - maxRemovableObserved
606
+ if (floorWindow.length > 0 && projectedAfterObserved >= settings.autoThresholdTokens) {
607
+ ctx.logger.debug(
608
+ `[force-compact] ${session.id}: PRE-FLIGHT OBSERVATION — total ${total} `
609
+ + `(diagnostic baseline ${measurement && measurement.baseline ? `${measurement.baseline?.kind}:${measurement.baseline?.tokens}` : 'n/a'} `
610
+ + `+ diagnostic surfaceDelta ${(measurement && measurement.surfaceDeltaTokens != null) ? measurement.surfaceDeltaTokens : '?'}); `
611
+ + `surfaces window = ${windowSumObserved} tokens across ${floorWindow.length} nodes, `
612
+ + `retains ~${settings.retainLatestTokens} → max removable head = ${maxRemovableObserved} tokens; `
613
+ + `naive projected-after ${projectedAfterObserved} is >= threshold ${settings.autoThresholdTokens} `
614
+ + `BUT the baseline is provider-reported usage (resets post-compaction), `
615
+ + `so we PROCEED with the compaction attempt regardless. The downstream `
616
+ + `shrink-gate protects against repeat no-ops (no BLANK cooldown anymore).`
617
+ )
618
+ }
619
+
620
+ // At or above the threshold: do NOT request the model. Retain the latest
621
+ // `retainLatestTokens` of the surface VERBATIM, and send everything before
622
+ // that cutoff (the head) as ONE BATCH to the LLM summarizer. The loop retries
623
+ // the step against the shrunken context (retained tail unchanged).
624
+ ctx.logger.info(
625
+ `[force-compact] ${session.id}: context ~${total} tokens >= threshold ${settings.autoThresholdTokens}; `
626
+ + `rejecting the model request and compacting the head while retaining the latest ~${settings.retainLatestTokens} tokens`,
627
+ )
628
+ const committed = await compactRetainingLatest(ctx, agent, signal, mode, measurement)
629
+ if (!committed) {
630
+ // BLANK OUTCOME — nothing shrank, so the NEXT step re-attempts at the same
631
+ // total. That is intentional ("先压缩再说"): a blank result never wedges the
632
+ // gate behind a high-water mark; the shrink-gate inside
633
+ // `compactRetainingLatest` plus the engine-side replay/failure caps absorb
634
+ // any repeat no-ops. Letting the request proceed.
635
+ ctx.logger.debug(`[force-compact] ${session.id}: threshold-gate compaction came back BLANK — letting the request proceed (will re-attempt on the next step).`)
636
+ } else {
637
+ ctx.logger.debug(`[force-compact] ${session.id}: threshold-gate compaction COMMITTED — letting the request proceed`)
638
+ }
639
+ return committed
640
+ }
641
+
642
+ /**
643
+ * Whether a model request should be sent with thinking/reasoning disabled.
644
+ *
645
+ * Called from the `agent/request` Waterfall. When the `disableThinking`
646
+ * setting is on (default), the caller sets `reasoningEffort: 'off'` on the
647
+ * returned `LlmCallConfig`.
648
+ *
649
+ * @param {import('@deepseek-ai/cordis').Context} ctx
650
+ * @returns {Promise<boolean>}
651
+ */
652
+ // `agent/request` entry — a throw here would corrupt EVERY outgoing model
653
+ // request. Contain it: any settings anomaly resolves `false` (thinking left
654
+ // at its provider default) rather than breaking the request path.
655
+ // Also append a UNIVERSAL-CRASH-NET diagnostic on any degradation.
656
+ async function __thinkingDisabledBody(ctx) {
657
+ try {
658
+ const settings = (await readSettings(ctx)) ?? DEFAULTS
659
+ return settings.disableThinking === true
660
+ } catch (error) {
661
+ const message = error instanceof Error ? error.message : String(error)
662
+ ctx.logger.warn(`[force-compact] thinkingDisabled degraded to false — ${message}`)
663
+ try {
664
+ const lines = renderCrash('guard.thinkingDisabled', error, captureThrowSite())
665
+ for (const line of lines) appendDiag(line)
666
+ } catch (_netFailure) { /* swallow */ }
667
+ return false
668
+ }
669
+ }
670
+
671
+ export const thinkingDisabled = guardFn('guard.thinkingDisabled', __thinkingDisabledBody)
672
+
673
+ /**
674
+ * Coarse token estimate for a session's whole surface content, used only when
675
+ * the `tokenMeter` service is not mounted. Mirrors the character-based
676
+ * heuristic of `engine/checkpoint.js`.
677
+ * @param {import('@deepseek-ai/dsh-session').Session} session
678
+ * @returns {number}
679
+ */
680
+ function estimateSessionTokens(session) {
681
+ // Coarse char-based estimator used ONLY when `tokenMeter` is absent. Must
682
+ // survive ANY receiver/event shape: every dereference is guarded so a
683
+ // malformed session (no `events`, missing `data`, non-object blocks) degrades
684
+ // to 0 rather than throwing — this feeds a fallback measurement, not a
685
+ // correctness path.
686
+ const CHARS_PER_TOKEN = 4
687
+ let chars = 0
688
+ const events = (session && Array.isArray(session.events)) ? session.events : []
689
+ for (const event of events) {
690
+ if (event === null || typeof event !== 'object') continue
691
+ let content
692
+ const data = (event.data && typeof event.data === 'object') ? event.data : undefined
693
+ if (event.type === 'user/message') content = data.content
694
+ else if (event.type === 'assistant/message') {
695
+ content = (data.message && typeof data.message === 'object') ? data.message.content : undefined
696
+ } else if (event.type === 'tool/result') {
697
+ const msg = (data.message && typeof data.message === 'object') ? data.message : undefined
698
+ content = msg !== undefined ? msg.content : undefined
699
+ }
700
+ if (!Array.isArray(content)) continue
701
+ for (const block of content) {
702
+ if (block && typeof block === 'object' && typeof block.text === 'string') chars += block.text.length
703
+ }
704
+ }
705
+ return Math.ceil(chars / CHARS_PER_TOKEN)
706
+ }