@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,1336 @@
1
+ /**
2
+ * dsh-force-compact's BUILTIN compaction engine.
3
+ *
4
+ * A SELF-CONTAINED, durable compaction that does NOT depend on the `compaction`
5
+ * service (which the standard preset realm-isolates away from this plugin). It
6
+ * performs the full persistent effect — a summary node that shadows a head-anchored
7
+ * conversation span — by appending log-only bracket events and a single
8
+ * `user/message` whose `surfaceOp:{op:'replace'}` shadows the range.
9
+ *
10
+ * Naming: the brackets reuse the OFFICIAL `compaction/start|summary|end`
11
+ * vocabulary (already in `KNOWN_SESSION_EVENT_TYPES`) rather than a
12
+ * plugin-private event-type prefix. Rationale: `Session.append` offers no
13
+ * channel to persist
14
+ * the `ignorable` marker, so a CUSTOM event type written through `append` lands
15
+ * WITHOUT it — which would brick the log on a future harness rebuild that does
16
+ * not recognize the type (the persistence load gate refuses an unknown, non-
17
+ * ignorable event). The official `compaction/*` types are already in the catalog,
18
+ * so no marker is needed and the transaction stays durable across rebuilds.
19
+ * The global `compaction/invariant` listener validates these brackets; the
20
+ * bracket payloads are shaped to satisfy it (matching ids/owners/turns, a
21
+ * non-empty `shadowedSeqs` aligned with `shadowedRange`, and — for a successful
22
+ * `compaction/end` — a preceding `compaction/summary`).
23
+ *
24
+ * The transaction mirrors the official backend's structure:
25
+ * compaction/start → (LLM summarize) → compaction/summary →
26
+ * user/message{surfaceOp:replace} → compaction/end
27
+ *
28
+ * `seq` is auto-assigned by the session (`log.length`); the `replace` bounds
29
+ * and provenance (`sourceEventSeqs`) are enforced by the session core at append
30
+ * time. A stability re-check AFTER the async LLM call aborts the transaction if
31
+ * the surface moved, keeping the log consistent.
32
+ *
33
+ * @module @falling-ts/dsh-force-compact/builtin-engine
34
+ */
35
+
36
+ import { summarize, headerPrefix, frameSummary } from './summarizer.js'
37
+ import { selectEarliestByTokens, selectRetainingLatestTokens } from './region.js'
38
+ import { readSettings, DEFAULTS } from '../core/settings.js'
39
+ import { guardFn } from '../core/crashnet.js'
40
+ import { publishDone } from '../core/ui-signal.js'
41
+
42
+ /**
43
+ * ONE-SHOT LOAD MARKER — proves WHICH built engine is actually loaded on a
44
+ * booted instance (so a stale-process or wrong-source scenario is instantly
45
+ * visible in the dev-server log rather than guessed at). Emitted once per
46
+ * process on first `runTransaction` entry (before any other work), guarded so
47
+ * a console failure can never disturb a compaction. Remove freely once the
48
+ * `reading 'kind'` investigation closes.
49
+ */
50
+ let loadMarkerEmitted = false
51
+ function emitLoadMarker() {
52
+ if (loadMarkerEmitted) return
53
+ loadMarkerEmitted = true
54
+ try {
55
+ console.log(`[force-compact] BUILTIN ENGINE LOADED — marker v2026-08-25-p0-p1-port `
56
+ + `(official port active: tool-pairing-ledger boundary selection, validateSurfaceRegion double gate, `
57
+ + `surface-consistency cross-check, official busy-lock semantics, tools prefix RESTORED, `
58
+ + `instruction aligned to official COMPACTION_INSTRUCTION)`)
59
+ } catch {
60
+ /* a load marker must never throw out of a compaction path */
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Per-session SUMMARIZATION FAILURE cooldown (process-local, no timers, no
66
+ * persistence) — the storm-suppression layer for the IDLE/`compactNow` path,
67
+ * which (unlike the `agent/pre-step` threshold gate) never consulted the
68
+ * existing `guard.js` blank-result cooldown.
69
+ *
70
+ * Why this exists: `compactNow` runs its OWN head-anchored region selection on
71
+ * EVERY `agent/status: idle` transition. If that region's summarization FAILS
72
+ * (provider error, truncated-empty, non-iterable stream — anything that reaches
73
+ * `closeWithError`), NOTHING commits, so the surface is UNCHANGED. The next idle
74
+ * transition selects the IDENTICAL region and repeats the doomed, expensive LLM
75
+ * round-trip. At the observed cadence (one idle tick roughly every ~5s on a
76
+ * busy session) this becomes a livelock: the SAME giant span re-summarized and
77
+ * re-failed on every tick — the concrete "stutters every request" symptom.
78
+ *
79
+ * Mechanism (token-high-water-mark PLUS wall-clock aging):
80
+ * when a transaction fails we remember the session's CURRENT authoritative
81
+ * total-token count as a "do not retry until it grows past THIS" mark, plus a
82
+ * wall-clock timestamp so a pure stall (no new tokens ever) also cools off after
83
+ * a fixed grace period. Both conditions being satisfied clears the mark and the
84
+ * NEXT attempt proceeds. Wall-clock use here is a process-local monotonic-ish
85
+ * read at DECISION time (Date.now()); it stores no timers and starts no
86
+ * intervals — consistent with the "no timer/memory-state beyond Maps" rule.
87
+ * Capped at MAX_FAILURE_COOLDOWN_ENTRIES to stay bounded under many sessions.
88
+ */
89
+ const failureCooldown = new Map()
90
+ const MAX_FAILURE_COOLDOWN_ENTRIES = 32
91
+ /** Absolute token-growth needed past the recorded mark before a failed span may retry. */
92
+ const FAILURE_RETRY_GROWTH_TOL = 500
93
+ /** Grace period (ms) after a failure before a retry is permitted EVEN IF the
94
+ * token count has not grown (guards the "identical doomed span" livelock where
95
+ * the surface never changes, so the growth test alone would never clear).
96
+ * 180s (raised from 60s, 2026-08-25): a DETERMINISTIC upstream failure — e.g.
97
+ * a streaming pipeline defect that always truncates the same head span — will
98
+ * burn an entire ~40s summarization round-trip on EVERY retry, so a shorter
99
+ * grace window merely re-hammers the identical doomed span at higher
100
+ * frequency. Three minutes gives the underlying condition time to change
101
+ * (server restart, transient overload clearing, …) before the next attempt. */
102
+ const FAILURE_RETRY_GRACE_MS = 180_000
103
+ /** How often (ms) a still-cooled session re-evaluates, bounding how long a
104
+ * genuinely-stuck span suppresses further attempts; also caps map retention. */
105
+ const FAILURE_REEVAL_INTERVAL_MS = 15_000
106
+
107
+ /** Characters per token — mirrors the token meter's coarse estimator. */
108
+ const CHARS_PER_TOKEN = 4
109
+
110
+ /**
111
+ * Hard CAP on the NUMBER of messages replayed into a single summarization call.
112
+ * A head-anchored region spanning thousands of surface nodes (observed live: a
113
+ * session whose whole 3600-node history kept getting projected into one prompt)
114
+ * produces a multi-hundred-KB prompt that a LOCAL GGUF endpoint
115
+ * (llama.cpp :8080, Qwen3.8-27B) routinely rejects or times out on — guaranteeing
116
+ * a FAILED, never-committing compaction every idle tick (the "stuck, stutters
117
+ * every ~5s" symptom). Refusing such a replay outright (return `null`, skip) is
118
+ * strictly better than paying a doomed round-trip repeatedly: it stops the
119
+ * livelock at its source. The cap is generous (1024 messages ≈ a substantial but
120
+ * bounded window) so legitimate mid-size compactions still proceed; only
121
+ * pathological whole-history replays are refused.
122
+ */
123
+ const MAX_REPLAY_MESSAGES = 1024
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Official-estimator parity helpers (byte-mirrors of
127
+ // `deepseek-harness/packages/llm/token-meter/src/estimate.ts`).
128
+ //
129
+ // WHY THESE EXIST — the shadow-price protocol:
130
+ // `compaction/summary` records `shadowedTokenCount` as the HEURISTIC PRICE OF
131
+ // THE EXACT SURFACE RANGE IT SHADOWS. That figure rides the token-meter
132
+ // surface fold's "shadow-price claim" mechanism: the fold arms a pending claim
133
+ // from the summary event and settles it against the IMMEDIATELY FOLLOWING
134
+ // surface `replace` (delta = checkpoint estimate − claim tokens). Producers
135
+ // are required to price the claimed range under the SAME fixed estimator the
136
+ // fold prices appends with ("The counts are exact by construction"), so a
137
+ // claim priced under a divergent heuristic makes the fold OVER-subtract
138
+ // (undercount) or UNDER-subtract (overcount) the settled total. This port
139
+ // exists precisely so the shadow bill we write equals what the fold expects.
140
+ //
141
+ // Each helper below mirrors the official source line-for-line in plain JS:
142
+ // • CHAR/BLOCK/ROLE overhead constants and `estimateContent` recursion
143
+ // (text/reasoning ceil(len/4)+BLOCK; tool-call name+args+BLOCK;
144
+ // tool-result recurse+BLOCK; unknown block JSON-stringified);
145
+ // • `estimateHeaderParts` ≙ official `estimateHeader` (system ceil(len/4)+ROLE
146
+ // when present; tools ceil(JSON len/4)+BLOCK when non-empty);
147
+ // • `priceSurfaceNode` ≙ official `foldSurface` pricing
148
+ // (user/user-message → message content; assistant/message → its content;
149
+ // tool/result → content + ROLE overhead when content present; otherwise 0);
150
+ // • `priceRegionFromMeasurement` ≙ official `prepareCompaction`'s
151
+ // `selectedNodes.reduce((total, node) => total + node.tokens, 0)`.
152
+ // ---------------------------------------------------------------------------
153
+
154
+ const ESTIMATE_BLOCK_OVERHEAD = 4
155
+ const ESTIMATE_ROLE_OVERHEAD = 4
156
+
157
+ /** Port of official `estimateContent`: recursive block pricing under the fixed density heuristic. */
158
+ function estimateContentBlocks(blocks) {
159
+ let tokens = 0
160
+ if (!Array.isArray(blocks)) return tokens
161
+ for (const block of blocks) {
162
+ if (block === null || typeof block !== 'object') continue
163
+ switch (block.type) {
164
+ case 'text':
165
+ case 'reasoning':
166
+ tokens += Math.ceil(String(block.text === undefined || block.text === null ? '' : block.text).length / CHARS_PER_TOKEN) + ESTIMATE_BLOCK_OVERHEAD
167
+ break
168
+ case 'tool-call':
169
+ tokens += Math.ceil(String(block.name === undefined || block.name === null ? '' : block.name).length / CHARS_PER_TOKEN)
170
+ + Math.ceil(String(block.arguments === undefined || block.arguments === null ? '' : block.arguments).length / CHARS_PER_TOKEN)
171
+ + ESTIMATE_BLOCK_OVERHEAD
172
+ break
173
+ case 'tool-result':
174
+ tokens += estimateContentBlocks(Array.isArray(block.content) ? block.content : []) + ESTIMATE_BLOCK_OVERHEAD
175
+ break
176
+ default: {
177
+ // Merge-extensible union: unknown block types retain a conservative
178
+ // structural JSON price (official `default` arm).
179
+ let json
180
+ try {
181
+ json = JSON.stringify(block)
182
+ } catch {
183
+ json = ''
184
+ }
185
+ tokens += ESTIMATE_BLOCK_OVERHEAD + Math.ceil(json.length / CHARS_PER_TOKEN)
186
+ break
187
+ }
188
+ }
189
+ }
190
+ return tokens
191
+ }
192
+
193
+ /** Port of official `estimateHeader` (system + tools parts), plain-JS tolerant. */
194
+ function estimateHeaderTokens(header) {
195
+ let total = 0
196
+ if (header === null || typeof header !== 'object') return total
197
+ const system = header.system
198
+ if (typeof system === 'string' && system.length > 0) {
199
+ total += Math.ceil(system.length / CHARS_PER_TOKEN) + ESTIMATE_ROLE_OVERHEAD
200
+ }
201
+ const tools = header.tools
202
+ if (Array.isArray(tools) && tools.length > 0) {
203
+ let json
204
+ try {
205
+ json = JSON.stringify(tools)
206
+ } catch {
207
+ json = ''
208
+ }
209
+ total += Math.ceil(json.length / CHARS_PER_TOKEN) + ESTIMATE_BLOCK_OVERHEAD
210
+ }
211
+ return total
212
+ }
213
+
214
+ /**
215
+ * Port of the official surface-fold per-node pricing (`foldSurface`):
216
+ * • `user/message` / `user` → content blocks, NO role framing;
217
+ * • `assistant/message` → `data.message.content`, NO role framing;
218
+ * • `tool/result` → `data.message.content` + ROLE OVERHEAD when content present;
219
+ * • anything else → 0 (and it is not a priced surface node anyway).
220
+ * All dereferences guarded so a malformed node degrades to 0 rather than throw.
221
+ */
222
+ function priceSurfaceNode(event) {
223
+ if (event === null || typeof event !== 'object') return 0
224
+ const data = (event.data && typeof event.data === 'object') ? event.data : {}
225
+ const type = event.type
226
+ if (type === 'user/message' || type === 'user') return estimateContentBlocks(data.content)
227
+ if (type === 'assistant/message') {
228
+ const message = (data.message && typeof data.message === 'object') ? data.message : {}
229
+ return estimateContentBlocks(message.content)
230
+ }
231
+ if (type === 'tool/result') {
232
+ const message = (data.message && typeof data.message === 'object') ? data.message : {}
233
+ if (message.content === undefined || message.content === null) return 0
234
+ return estimateContentBlocks(message.content) + ESTIMATE_ROLE_OVERHEAD
235
+ }
236
+ return 0
237
+ }
238
+
239
+ /**
240
+ * Port of official `prepareCompaction`'s shadow bill — sum the METER-PRICED
241
+ * nodes covering exactly the requested seq range. Prefer the live meter
242
+ * snapshot's per-node prices (`measurement.nodes`, each `{seq, tokens}`,
243
+ * priced by the SAME estimator family the fold uses); fall back to pricing
244
+ * the session log directly when the snapshot is unusable or does not cover
245
+ * the range. Returns `null` when neither source can price the range, so the
246
+ * CALLER decides whether to degrade (pre-check only) or fail-loud
247
+ * (transaction commit — a summary with no priced claim poisons the fold).
248
+ *
249
+ * @param {object} session the durable session (for the direct-log fallback).
250
+ * @param {object} region `{start, end}` inclusive SURFACE-NODE seq bounds.
251
+ * @param {object|undefined} measurement the `tokenMeter.measure` snapshot.
252
+ * @returns {number|null}
253
+ */
254
+ function priceRegionFromMeasurement(session, region, measurement) {
255
+ const events = (session && Array.isArray(session.events)) ? session.events : []
256
+ const surfaceNodes = (session && session.surface && Array.isArray(session.surface.nodes)) ? session.surface.nodes : []
257
+ const firstIdx = surfaceNodes.indexOf(region.start)
258
+ const lastIdx = surfaceNodes.lastIndexOf(region.end)
259
+ if (firstIdx < 0 || lastIdx < firstIdx) return null
260
+ const covered = surfaceNodes.slice(firstIdx, lastIdx + 1)
261
+ const meterNodes = (measurement && Array.isArray(measurement.nodes)) ? measurement.nodes : null
262
+ if (meterNodes !== null) {
263
+ let total = 0
264
+ let complete = true
265
+ for (const seq of covered) {
266
+ const node = meterNodes.find(n => n && typeof n === 'object' && n.seq === seq)
267
+ if (node === undefined || node === null || typeof node.tokens !== 'number' || !Number.isFinite(node.tokens)) {
268
+ complete = false
269
+ break
270
+ }
271
+ total += node.tokens
272
+ }
273
+ if (complete) return total
274
+ }
275
+ let total = 0
276
+ for (const seq of covered) {
277
+ const event = events[seq]
278
+ if (event === undefined || event === null || typeof event !== 'object') return null
279
+ total += priceSurfaceNode(event)
280
+ }
281
+ return total
282
+ }
283
+
284
+ /**
285
+ * Minimum shadowed-span size (in estimated tokens) below which a summarization
286
+ * is skipped WITHOUT opening the lock or calling the LLM (the small-span
287
+ * pre-check in `runTransaction`). Rationale: for spans this small, the
288
+ * summarizer's verbosity floor means the output very often EXCEEDS the input
289
+ * (observed live 2026-08-25: a ~3175-token span produced a ~3789-token
290
+ * summary → the post-summary shrink gate vetoes it), so attempting such a
291
+ * span deterministically wastes a ~40s local round-trip and burns the
292
+ * transaction bracket for a guaranteed `summary-not-smaller` outcome. Spans
293
+ * grow naturally as head accumulates, so the FIRST worthwhile compression
294
+ * happens automatically once enough older content gathers. The post-summary
295
+ * SHRINK GATE remains authoritative for everything above this floor.
296
+ */
297
+ const MIN_USEFUL_SPAN_TOKENS = 8000
298
+
299
+ /**
300
+ * Consult a session's summarization-failure cooldown. Returns a human-readable
301
+ * SKIP NOTE (suppress this attempt) or `undefined` (proceed normally). Clears
302
+ * the mark when EITHER condition holds, so a recovered span retries promptly:
303
+ * • the session's total tokens have grown past `mark + tolerance` (new content
304
+ * arrived → a different, larger span is now worth trying), OR
305
+ * • `FAILURE_RETRY_GRACE_MS` have elapsed since the last failure (pure stall
306
+ * guard against the identical-doomed-span livelock).
307
+ * @param {string} sessionId
308
+ * @param {number|undefined} totalTokens current authoritative total (best-effort).
309
+ * @returns {string|undefined} skip-note, or `undefined` to proceed.
310
+ */
311
+ function consultFailureCooldown(sessionId, totalTokens) {
312
+ const entry = failureCooldown.get(sessionId)
313
+ if (entry === undefined) return undefined
314
+ const grew = Number.isFinite(totalTokens)
315
+ && totalTokens > entry.tokens + FAILURE_RETRY_GROWTH_TOL
316
+ const agedOut = (Date.now() - entry.at) >= FAILURE_RETRY_GRACE_MS
317
+ && (Date.now() - entry.lastReeval) >= FAILURE_REEVAL_INTERVAL_MS
318
+ if (grew || agedOut) {
319
+ failureCooldown.delete(sessionId)
320
+ return undefined
321
+ }
322
+ // Still cooling: refresh the re-eval watermark (throttled bookkeeping only —
323
+ // no timers spawned) and explain the suppression.
324
+ if (agedOut === false && (Date.now() - entry.lastReeval) >= FAILURE_REEVAL_INTERVAL_MS) {
325
+ entry.lastReeval = Date.now()
326
+ }
327
+ return `last builtin summarization failed (at ~${entry?.tokens} total tokens); backing off ${Math.max(1, Math.round((FAILURE_RETRY_GRACE_MS - (Date.now() - (entry?.at ?? Date.now()))) / 1000))}s`
328
+ }
329
+
330
+ /**
331
+ * Record a summarization failure for a session so subsequent idle ticks back
332
+ * off (see the `failureCooldown` doc-block for rationale). Bounded & evicted
333
+ * oldest-first like `guard.js`'s cooldown.
334
+ * @param {string} sessionId
335
+ * @param {number|undefined} totalTokens best-effort current total (marks the growth baseline).
336
+ */
337
+ function markFailureCooldown(sessionId, totalTokens) {
338
+ while (failureCooldown.size >= MAX_FAILURE_COOLDOWN_ENTRIES) {
339
+ const oldest = failureCooldown.keys().next().value
340
+ if (oldest === undefined) break
341
+ failureCooldown.delete(oldest)
342
+ }
343
+ const now = Date.now()
344
+ const base = Number.isFinite(totalTokens) ? totalTokens : 0
345
+ failureCooldown.delete(sessionId) // move-to-tail semantics
346
+ failureCooldown.set(sessionId, { tokens: base, at: now, lastReeval: now })
347
+ }
348
+
349
+ /** Drop a session's failure cooldown (called when a compaction SUCCEEDS). */
350
+ function clearFailureCooldown(sessionId) {
351
+ failureCooldown.delete(sessionId)
352
+ }
353
+
354
+ /**
355
+ * Checkpoint provenance carried on the replacement `user/message`'s `source`.
356
+ * Uses the CANONICAL compaction-checkpoint marker (`{kind:'plugin',
357
+ * plugin:'compact'}`) that `isCompactCheckpointSource` recognizes — so the
358
+ * official `compaction/invariant` validator treats our replacement as a real
359
+ * compaction checkpoint and enforces its correlation with the open
360
+ * `compaction/start`. The plugin-specific identity rides on `compactionId`
361
+ * (see `mintCompactionId`) and the bracket events, not on `source.plugin`,
362
+ * keeping the checkpoint universally recognizable across all backends.
363
+ * `CHECKPOINT_SOURCE_BASE` is spread together with `compactionId` per
364
+ * transaction (below).
365
+ */
366
+ const CHECKPOINT_SOURCE_BASE = Object.freeze({ kind: 'plugin', plugin: 'compact' })
367
+
368
+ /** Mint a stable transaction identity (opaque string; branded conceptually). */
369
+ function mintCompactionId() {
370
+ // Node crypto is reachable in the host process; fall back to a composite id.
371
+ try {
372
+ const crypto = globalThis.crypto
373
+ if (crypto && typeof crypto.randomUUID === 'function') return 'fc-' + crypto.randomUUID()
374
+ } catch { /* fall through */ }
375
+ return 'fc-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
376
+ }
377
+
378
+ /**
379
+ * Run a standalone manual compaction over the agent's session (the `compactNow`
380
+ * analogue): select a compactable region with the plugin's own policy, then run
381
+ * the full transaction. Safe to call only when the agent is idle; a busy agent
382
+ * or an already-active transaction is detected and returns `null`.
383
+ *
384
+ * MANUAL SELECTION SEMANTICS (OFFICIAL PARITY, P1): like the official
385
+ * `selectCompactableRange(session, measure, 0)`, a command-driven manual entry
386
+ * selects with `retainTokens=0` — a FULL-SURFACE head-anchored region — rather
387
+ * than retaining the latest `settings.retainLatestTokens` tail. Only callers
388
+ * that pass an explicit `opts` (or use the region-carrying `compactRegion`
389
+ * path) keep the `retainLatestTokens` behavior; the default `compactNow`
390
+ * without opts is treated as a manual command-driven entry.
391
+ *
392
+ * @param {import('@deepseek-ai/cordis').Context} ctx
393
+ * @param {import('@deepseek-ai/dsh-agent').Agent} agent
394
+ * @param {AbortSignal} [signal]
395
+ * @param {string} [sourceCommandId] the originating `/compact`/`/force-compact`
396
+ * command id, threaded into the lifecycle
397
+ * events and the checkpoint source (P1).
398
+ * @param {object} [opts] optional overrides:
399
+ * - `retainTokens?: number` — explicit retention budget (default: 0 =
400
+ * full-surface manual selection). Pass `settings.retainLatestTokens` from
401
+ * the auto/pre-step paths to preserve the legacy "retain the latest N
402
+ * tokens" behavior.
403
+ * @returns {Promise<object|null>} the compaction result, or `null` when skipped.
404
+ */
405
+ // Internal body of `compactNowBuiltin` — routed through the crash-net wrapper
406
+ // so an unexpected throw escaping the internal guards becomes a durable,
407
+ // parseable diagnostic rather than a silent propagation up the call stack.
408
+ async function __compactNowBuiltinBody(ctx, agent, signal, sourceCommandId, opts) {
409
+ const session = agent.session
410
+ if (session === undefined || typeof session.append !== 'function') return null
411
+ const settings = (await readSettings(ctx)) ?? DEFAULTS
412
+ if (!(settings.builtinEnabled !== false)) return null
413
+
414
+ // Guard: refuse while a prior compaction transaction is still open (durable lock).
415
+ if (hasOpenFctLock(session)) {
416
+ warn(ctx, `${session.id}: builtin compaction skipped — a prior compaction transaction is still open`)
417
+ return null
418
+ }
419
+
420
+ // ---- AUTHORITATIVE METER SNAPSHOT (SAME CALIBER AS THE AUTO PATH) -------
421
+ // The manual/self-selecting path prices its region from the OFFICIAL
422
+ // `tokenMeter.measure` snapshot — the exact same measurement the
423
+ // `agent/pre-step` auto path uses (see `hooks/guard.js`): the meter's own
424
+ // per-node prices, so the `retainLatestTokens` budget is expressed in the
425
+ // SAME token caliber that the threshold gate measures, not in a divergent
426
+ // char/4 estimate of flat text (which systematically undercounts nested
427
+ // tool blocks / JSON framing and starves the head budget on short-ish
428
+ // sessions — the observed "/force-compact → no compactable range" cause).
429
+ // Missing/malformed snapshot → `undefined` → the legacy char-heuristic
430
+ // fallback below keeps working (degradation, never a hard failure).
431
+ const meter = ctx.get('tokenMeter')
432
+ let measurement
433
+ if (meter !== undefined && typeof meter.measure === 'function') {
434
+ try {
435
+ const measured = meter.measure(session)
436
+ if (measured !== undefined && measured !== null) measurement = measured
437
+ } catch {
438
+ measurement = undefined
439
+ }
440
+ }
441
+
442
+ // MANUAL SELECTION (official `selectCompactableRange(…, 0)` parity): when no
443
+ // explicit `opts.retainTokens` is supplied, treat this as a command-driven
444
+ // manual entry and select the FULL surface (retain 0). Auto/pre-step/idle
445
+ // callers MUST pass `opts: { retainTokens: settings.retainLatestTokens }`
446
+ // (see hooks/guard.js + hooks/idle.js) to preserve the legacy "retain the
447
+ // latest N tokens" behavior they historically relied on.
448
+ const retainTokens = (opts !== undefined && typeof opts === 'object' && Number.isFinite(opts.retainTokens))
449
+ ? Math.max(0, Math.round(opts.retainTokens))
450
+ : 0
451
+ const region = selectHeadAnchoredRegion(settings, session, measurement, retainTokens)
452
+ if (region === null) {
453
+ // Diagnose WHY: report the surface-node count and how much of it is already
454
+ // checkpoint material. The typical "nothing worth compacting" case is a
455
+ // session whose head IS a previously-generated checkpoint (small, not
456
+ // worth re-summarizing) — re-running /force-compact right after a
457
+ // successful compaction is the classic trigger.
458
+ const surfNodes = (session.surface && Array.isArray(session.surface.nodes)) ? session.surface.nodes : []
459
+ let headIsCheckpoint = false
460
+ if (surfNodes.length > 0 && Array.isArray(session.events)) {
461
+ const headEvent = session.events[surfNodes[0]]
462
+ const headSource = headEvent && headEvent.data && typeof headEvent.data === 'object' ? headEvent.data.source : undefined
463
+ headIsCheckpoint = !!(headSource && typeof headSource === 'object' && headSource.plugin === 'force-compact-builtin')
464
+ }
465
+ info(ctx,
466
+ `${session.id}: builtin compaction — no compactable region; skipping `
467
+ + `(${surfNodes.length} surface nodes, head=${headIsCheckpoint ? 'previous checkpoint' : 'ordinary history'}, `
468
+ + `estimated ~${estimateSurfaceTokens(session)} surface tokens, retainTokens=${retainTokens})`,
469
+ )
470
+ return null
471
+ }
472
+
473
+ return runTransaction(ctx, agent, session, region, signal, settings, sourceCommandId, measurement)
474
+ }
475
+
476
+ /**
477
+ * Compactor for a SPECIFIC region (the `compactRegion` analogue): run the full
478
+ * transaction over EXACTLY `start..end`. Callers (the `agent/pre-step` guard,
479
+ * `/force-compact`, …) choose the span themselves — typically via
480
+ * `selectRetainingLatestTokens` priced from a live `tokenMeter.measure`
481
+ * snapshot. This backend RESPECTS that choice verbatim: it does NOT re-derive
482
+ * a region internally, mirroring the official `compaction` service contract
483
+ * where the caller owns region selection. Re-deriving here with a coarser
484
+ * estimator would silently downgrade the caller's precise region to a
485
+ * narrower char-heuristic one (observed live 2026-08-25: a meter-priced
486
+ * head-span shrinking to only the smallest user-boundary slice, shadowing a
487
+ * few thousand tokens instead of the intended tens of thousands).
488
+ *
489
+ * @param {import('@deepseek-ai/cordis').Context} ctx
490
+ * @param {number} start first surface-node seq, inclusive.
491
+ * @param {number} end last surface-node seq, inclusive.
492
+ * @param {import('@deepseek-ai/dsh-agent').Agent} agent
493
+ * @param {AbortSignal} [signal]
494
+ * @param {string} [sourceCommandId] optional originating-command id, threaded
495
+ * into the lifecycle events + checkpoint
496
+ * source (mirrors official `compactRegion`
497
+ * taking a 5th positional arg).
498
+ * @returns {Promise<object|null>} the compaction result, or `null` when aborted/skippedped.
499
+ */
500
+ // Internal body of `compactRegionBuiltin` — routed through the crash-net wrapper.
501
+ async function __compactRegionBuiltinBody(ctx, start, end, agent, signal, sourceCommandId) {
502
+ const session = agent.session
503
+ if (session === undefined || typeof session.append !== 'function') return null
504
+ const settings = (await readSettings(ctx)) ?? DEFAULTS
505
+ if (!(settings.builtinEnabled !== false)) return null
506
+ if (start > end) return null
507
+ return runTransaction(ctx, agent, session, { start, end }, signal, settings, sourceCommandId)
508
+ }
509
+
510
+ /** Public entries — wrapped by the universal crash net. */
511
+ export const compactNowBuiltin = guardFn('builtin.compactNowBuiltin', __compactNowBuiltinBody)
512
+ export const compactRegionBuiltin = guardFn('builtin.compactRegionBuiltin', __compactRegionBuiltinBody)
513
+
514
+ /**
515
+ * The core transaction: append the durable bracket + a replace node shadowing
516
+ * the region. Every step is guarded; any failure appends `compaction/end` with
517
+ * an `error` and returns `null` (leaving exactly one closed-or-orphaned marker
518
+ * pair so the log stays interpretable on reload).
519
+ *
520
+ * SOURCE COMMAND ID (OFFICIAL PARITY, P1): an optional originating-command id
521
+ * (from `/compact` / `/force-compact`) is threaded into ALL THREE lifecycle
522
+ * events (`compaction/start` / `compaction/summary` / `compaction/end`) AND
523
+ * the hand-built checkpoint `source` object. The official `invariant`
524
+ * listener validates that all three bracket events carry IDENTICAL
525
+ * `sourceCommandId` values — so threading one value everywhere is mandatory,
526
+ * not optional. When `undefined`, the field is omitted from all payloads and
527
+ * the source carries only `{kind,plugin,compactionId}` (backward compatible).
528
+ *
529
+ * FLUSH (OFFICIAL PARITY, P0): after a SUCCESSFUL `compaction/end` close, the
530
+ * transaction invokes `sessions.flush(session)` when available (best-effort —
531
+ * a missing/unusable service degrades to a deferred-flush-risk note rather
532
+ * than a fatal error). This mirrors the official `compactSurfaceRegion`
533
+ * behavior (`if (closed && options.flush !== undefined) await options.flush()`
534
+ * inside a try/catch that surfaces failures as `CompactionError({cause})`).
535
+ * The awaited `session/flush` checkpoint in `index.js` ALREADY covers the
536
+ * lifetime of a successful transaction for most call sites — an explicit flush
537
+ * here is belt-and-braces for the command-driven entry (where the caller
538
+ * expects durability BEFORE returning) and the busy-pre-step consume path.
539
+ */
540
+ async function runTransaction(ctx, agent, session, region, signal, settings, sourceCommandId, measurementArg) {
541
+ emitLoadMarker()
542
+ const llm = ctx.get('llm')
543
+ if (llm === undefined || typeof llm.stream !== 'function') {
544
+ warn(ctx, `${session.id}: builtin compaction unavailable — no LLM service`)
545
+ return null
546
+ }
547
+
548
+ // ---- Busy-lock REFUSAL (ported from the official `assertNoActiveCompaction`)
549
+ // An UNMATCHED `compaction/start` with no later `session/end-seed` proves a
550
+ // transaction is in flight (typically a crashed predecessor that opened its
551
+ // bracket but died before closing). Refuse THIS entry rather than nest a
552
+ // second bracket on top (nested brackets violate the invariant listener's
553
+ // single-inflight-trace contract). Inherited orphans (preceded by a later
554
+ // end-seed) are IGNORED per official semantics — see the helper's doc.
555
+ const busyNote = assertNoActiveCompaction(session, 'builtin.runTransaction')
556
+ if (busyNote !== null) {
557
+ info(ctx, `${session.id}: builtin compaction SKIPPED (${busyNote})`)
558
+ return null
559
+ }
560
+ const meter = ctx.get('tokenMeter')
561
+
562
+ // ---- Failure cooldown ---------------------------------------------------
563
+ // Suppress a retry of a recently FAILED summarization on this session (see
564
+ // the `failureCooldown` doc-block). Best-effort read of the authoritative
565
+ // total; a missing meter simply passes `undefined` and relies on the grace
566
+ // period alone. This is what breaks the idle livelock: a failing span backs
567
+ // off for a bounded interval instead of re-hammering every tick.
568
+ let currentTotalTokens
569
+ if (meter !== undefined && typeof meter.measure === 'function') {
570
+ try { currentTotalTokens = meter.measure(session)?.totalTokens } catch { /* best effort */ }
571
+ }
572
+ const cooledNote = consultFailureCooldown(session.id, currentTotalTokens)
573
+ if (cooledNote !== undefined) {
574
+ info(ctx, `${session.id}: builtin compaction SKIPPED (cooldown) — ${cooledNote}`)
575
+ return null
576
+ }
577
+
578
+ // ---- Small-span PRE-CHECK (doom avoidance, mirrors official O26) --------
579
+ // A summarization of a TINY head span is statistically likely to produce
580
+ // MORE text than the span itself (an abstract cannot beat the verbosity
581
+ // floor of a few thousand tokens), so the shrink gate further down WOULD
582
+ // reject it — yet we would have burned the lock + a full LLM round-trip
583
+ // (≈40s on a local endpoint) to learn that. Skip such regions BEFORE the
584
+ // lock opens and the call fires: the caller's selection is honored on the
585
+ // NEXT attempt, and once enough head accumulates the span naturally grows
586
+ // past MIN_USEFUL_SPAN_TOKENS and proceeds. This replaces the wasteful
587
+ // cycle "attempt giant prompt → shrink-gate veto → blanket → re-attempt the
588
+ // identical doomed span every step".
589
+ // NOTE: the post-summary SHRINK GATE still applies to everything that
590
+ // passes this pre-check — it remains the authoritative defense.
591
+ const projected = projectRegion(session, region)
592
+ const messages = projected.messages
593
+ const shadowedSeqs = projected.shadowedSeqs
594
+ if (messages.length === 0) {
595
+ info(ctx, `${session.id}: builtin compaction — region has no surface messages; skipping`)
596
+ return null
597
+ }
598
+
599
+ // ---- Shadow bill — OFFICIAL shadow-price protocol parity -----------------
600
+ // Price the EXACT surface range being shadowed under the meter's estimator
601
+ // family (per-node prices from the same snapshot the selection was cut
602
+ // from, falling back to a direct price of the log), so the claim the
603
+ // token-meter fold settles against our `compaction/summary` event
604
+ // subtracts the TRUE cost of the replaced span. See the estimator-parity
605
+ // block above for the mirrored official sources.
606
+ const measurement = (measurementArg && typeof measurementArg === 'object' && Array.isArray(measurementArg.nodes))
607
+ ? measurementArg
608
+ : (typeof meter.measure === 'function' ? (() => { try { return meter.measure(session) } catch { return undefined } })() : undefined)
609
+ let regionPrice
610
+ try {
611
+ regionPrice = priceRegionFromMeasurement(session, region, measurement)
612
+ } catch (error) {
613
+ // Unresolvable surface state — degrade gracefully (refuse to commit, see
614
+ // the null branch) rather than propagate out of the transaction.
615
+ warn(ctx, `${session.id}: builtin compaction — region pricing threw (${messageOf(error)}); refusing to commit`)
616
+ regionPrice = null
617
+ }
618
+ if (regionPrice === null) {
619
+ // Cannot price the claimed range under any caliber (snapshot unusable AND
620
+ // the log cannot be resolved): writing a summary WITHOUT a correct shadow
621
+ // bill would make the fold settle a bogus claim, so refuse to commit
622
+ // rather than poison the persisted projection. Best-effort degradation:
623
+ // the next attempt with a fresh snapshot re-prices cleanly.
624
+ warn(ctx, `${session.id}: builtin compaction ABORTED — could not price region seq[${region.start}..${region.end}] `
625
+ + `against the shadow-price protocol (meter snapshot unusable and direct log pricing failed); refusing to commit`)
626
+ return null
627
+ }
628
+ const shadowedTokenCount = regionPrice
629
+
630
+ // ---- Threshold-aware SHADOW-SPAN FLOOR ----------------------------------
631
+ // Mirror of `guard.js`'s threshold-aware shrink gate, applied to the
632
+ // SELF-SELECTING path (`compactNow` — idle turn-end hook, `/force-compact`
633
+ // when idle) which otherwise has no equivalent pre-LLM viability check.
634
+ //
635
+ // PREDICATE: when a reliable meter-priced total is known (same condition
636
+ // `guard.js` uses) AND the selected head span is TOO SMALL to drag the total
637
+ // below `autoThresholdTokens` even when removed wholesale
638
+ // (`total − span ≥ threshold`), the compaction CANNOT achieve its purpose.
639
+ // Paying for a summarization call to learn that just re-arms the identical
640
+ // doomed attempt on the next tick (the idle-path twin of the "threshold-
641
+ // aware gate — cannot pull total below threshold; SKIPPING" storm the
642
+ // auto path already suppresses). Skipping here costs nothing — the head
643
+ // grows naturally and becomes compactable on its own once large enough.
644
+ //
645
+ // CALIBER NOTE: `shadowedTokenCount` is now priced from the SAME meter
646
+ // caliber the fold itself uses (per-node prices from the shared snapshot);
647
+ // the threshold arithmetic below compares like-for-like figures.
648
+ // The auto path retains its strict meter pricing unchanged; this floor
649
+ // merely PREVENTS the idle path from re-entering the same doom.
650
+ if (currentTotalTokens !== undefined && Number.isFinite(currentTotalTokens)
651
+ && currentTotalTokens >= settings.autoThresholdTokens
652
+ && (currentTotalTokens - shadowedTokenCount) >= settings.autoThresholdTokens) {
653
+ info(ctx,
654
+ `${session.id}: builtin compaction — threshold-aware floor: shadowed span (~${shadowedTokenCount} est-tokens, char heuristic) `
655
+ + `cannot pull the total (~${currentTotalTokens} meter-priced) below ${settings.autoThresholdTokens} `
656
+ + `(removing it wholesale would still leave ~${currentTotalTokens - shadowedTokenCount}). `
657
+ + `Skipping (no lock opened, no LLM call made) — the head will qualify naturally as it grows. `
658
+ + `Raise \`retainLatestTokens\` so less of the tail is retained and more of the head becomes compactable.`,
659
+ )
660
+ return null
661
+ }
662
+
663
+ // ---- Small-span SKIP (above) now applied to the measured span ------------
664
+ if (shadowedTokenCount < MIN_USEFUL_SPAN_TOKENS) {
665
+ info(
666
+ ctx,
667
+ `${session.id}: builtin compaction — head span (~${shadowedTokenCount} tokens, seq ${region?.start}..${region?.end}) `
668
+ + `is below the ${MIN_USEFUL_SPAN_TOKENS}-token usefulness floor; a summary of this much content almost surely `
669
+ + `cannot shrink it, so NO lock is opened and NO LLM call is made (the next attempt will see a larger span as `
670
+ + `more head accumulates). Retrying on a bigger head is cheaper than burning a doomed ~40s round-trip.`
671
+ )
672
+ return null
673
+ }
674
+
675
+ // ---- Replay-size CAP ----------------------------------------------------
676
+ // Refuse a replay whose message count exceeds MAX_REPLAY_MESSAGES. Such a
677
+ // region (typically a head-anchored selection that grabbed nearly the WHOLE
678
+ // session) sends a gigantic prompt the local model endpoint cannot serve, so
679
+ // attempting it guarantees a failed, non-committing transaction repeated on
680
+ // every idle tick — the livelock behind "stuck + stutters each request".
681
+ // Skipping here (before opening the lock or calling the LLM) costs nothing.
682
+ if (messages.length > MAX_REPLAY_MESSAGES) {
683
+ warn(
684
+ ctx,
685
+ `${session.id}: builtin compaction REFUSED — region projects ${messages.length} messages `
686
+ + `(span seq ${region?.start}..${region?.end}), exceeding the ${MAX_REPLAY_MESSAGES}-message replay cap; `
687
+ + `a summarization of that size is unserviceable on a local model endpoint. Skipping (no lock opened, `
688
+ + `no LLM call made). Raise \`retainLatestTokens\` so less of the head is compacted, `
689
+ + `or increase \`maxRegionNodes\` / the built-in engine's replay cap.`,
690
+ )
691
+ return null
692
+ }
693
+
694
+ // ---- Open the durable lock ---------------------------------------------
695
+ // `sourceCommandId` (P1): the originating `/compact`/`/force-compact` command
696
+ // id, threaded conditionally — OMITTED when `undefined` so the event stays
697
+ // backward-compatible with readers that predate the field (exact mirror of
698
+ // the official `region.ts` conditional spread). All three bracket events
699
+ // (start / summary / end) MUST agree on this value — the official invariant
700
+ // listener enforces it.
701
+ const compactionId = mintCompactionId()
702
+ let startEvent
703
+ try {
704
+ startEvent = session.append('compaction/start', {
705
+ compactionId,
706
+ turn: currentOpenTurn(session),
707
+ ...(sourceCommandId === undefined ? {} : { sourceCommandId }),
708
+ })
709
+ } catch (error) {
710
+ warn(ctx, `${session.id}: builtin compaction — failed to append compaction/start: ${messageOf(error)}`)
711
+ return null
712
+ }
713
+ if (signal !== undefined) signal.throwIfAborted()
714
+
715
+ // ---- Summarize ---------------------------------------------------------
716
+ // Feed the session's latest request-header prefix (system prompt + tool
717
+ // schemas) verbatim into the summarization call so the provider's warm KV
718
+ // cache for the last routed request is REUSED rather than invalidated (the
719
+ // official `compaction-basic` prefix-cache-alignment strategy). When the
720
+ // header carries neither, the call degrades to the legacy messages-only
721
+ // shape. The summarizer's three-tier target resolution (configured →
722
+ // latest-routed-header → agent.options) picks the right provider/model.
723
+ let summaryBlocks
724
+ let summarizationUsage
725
+ let summarizationProvider
726
+ let summarizationModel
727
+ let summarizationMaxTokens
728
+ try {
729
+ const extra = { reasoningEffort: settings.disableThinking ? 'off' : undefined }
730
+ if (Number.isFinite(settings.maxSummaryTokens) && settings.maxSummaryTokens > 0) {
731
+ extra.maxTokens = settings.maxSummaryTokens
732
+ }
733
+ const prefix = headerPrefix(agent && agent.session)
734
+ const input = {
735
+ messages,
736
+ ...(prefix.system !== undefined ? { system: prefix.system } : {}),
737
+ // FULL OFFICIAL PREFIX-CACHE ALIGNMENT: feed the session's latest
738
+ // request-header SYSTEM PROMPT AND TOOL SCHEMAS verbatim into the
739
+ // auxiliary call (mirrors `summarizeWithLlm`'s `input.tools` pass-through
740
+ // — the auxiliary call becomes a genuine prefix of the last routed
741
+ // request and the provider's warm KV cache is reused instead of
742
+ // invalidated). The earlier temporary omission was a bisection probe
743
+ // against the vendor-side replay `reading 'kind'` crash, which has since
744
+ // been proven unrelated to the `tools` option (replays of ROUTED requests
745
+ // crash identically with or without this field) — so restore full parity.
746
+ ...(prefix.tools !== undefined ? { tools: prefix.tools } : {}),
747
+ }
748
+ // `summarize` NEVER throws and resolves to a discriminated `{ status, ... }`
749
+ // object (plus `reason` on non-ok outcomes). Branch defensively:
750
+ // • status 'ok' → commit-ready: read summary/envelope.
751
+ // • 'no-target'/'no-llm' → call never made (nothing to cool). Silently
752
+ // close the bracket with a neutral note and stop.
753
+ // • anything else → the call was made but produced no usable summary
754
+ // (provider-error / aborted / truncated-empty /
755
+ // image-content / empty-text / no-finish /
756
+ // not-iterable). ARM the per-session failure
757
+ // cooldown (so the idle path backs off instead of
758
+ // re-running the same doomed span every tick) and
759
+ // close the bracket carrying the descriptive error.
760
+ // Belt-and-braces: `summarize` is total, but we STILL guard the result shape
761
+ // here so a hypothetical non-object result cannot throw downstream either.
762
+ const preview = await summarize(ctx, settings, agent, input, signal, extra)
763
+ const ok = preview !== null && typeof preview === 'object' && preview.status === 'ok'
764
+ if (ok) {
765
+ const s = Array.isArray(preview.summary) ? preview.summary : []
766
+ if (s.length === 0) {
767
+ // Defensive: a declared-'ok' result with an empty summary is anomalous;
768
+ // treat it as a failure rather than committing an empty checkpoint.
769
+ markFailureCooldown(session.id, currentTotalTokens)
770
+ closeWithError(session, startEvent, compactionId, new Error('summarizer returned ok but no summary blocks'), ctx)
771
+ return null
772
+ }
773
+ summaryBlocks = s
774
+ summarizationUsage = (preview.usage !== undefined && preview.usage !== null) ? preview.usage : undefined
775
+ summarizationProvider = typeof preview.provider === 'string' ? preview.provider : ''
776
+ summarizationModel = typeof preview.model === 'string' ? preview.model : ''
777
+ summarizationMaxTokens = Number.isFinite(preview.maxTokens) ? preview.maxTokens : undefined
778
+ } else if (preview !== null && typeof preview === 'object' && (preview.status === 'no-target' || preview.status === 'no-llm')) {
779
+ // The summarization call was NEVER made (no resolvable target, or no `llm`
780
+ // service). There is nothing that "failed", so do NOT arm the cooldown —
781
+ // the next attempt should try again immediately. Close the bracket with a
782
+ // neutral, non-error note and stop (no doomed round-trip occurred).
783
+ const why = (typeof preview.reason === 'string' && preview.reason.length > 0) ? preview.reason : preview.status
784
+ info(ctx, `${session.id}: builtin compaction skipped (no summarization call made — ${why})`)
785
+ try {
786
+ session.append('compaction/end', { compactionId, turn: currentOpenTurn(session), note: why })
787
+ } catch { /* best effort */ }
788
+ return null
789
+ } else {
790
+ // A call was made but yielded no usable summary — OR (defensively) the
791
+ // result was a completely unexpected shape. Arm the cooldown and close with
792
+ // a descriptive error so the operator sees WHY it skipped.
793
+ const label = (preview && typeof preview.status === 'string') ? preview.status : 'unexpected-result-shape'
794
+ const reason = (preview && typeof preview.reason === 'string' && preview.reason.length > 0) ? preview.reason : 'no usable summary'
795
+ markFailureCooldown(session.id, currentTotalTokens)
796
+ warn(ctx, `${session.id}: builtin compaction summarized-but-unusable (${label}): ${reason}`)
797
+ closeWithError(session, startEvent, compactionId, new Error(`summarization ${label}: ${reason}`), ctx)
798
+ return null
799
+ }
800
+ } catch (error) {
801
+ // Last-resort safety net: `summarize` is designed to never reject, but if an
802
+ // unexpected error ever escapes (bug, or a non-guarded read), it lands HERE
803
+ // rather than propagating into the event dispatcher. Same treatment as a
804
+ // labeled failure: arm the cooldown, close the bracket, stop. No throw.
805
+ markFailureCooldown(session.id, currentTotalTokens)
806
+ closeWithError(session, startEvent, compactionId, error instanceof Error ? error : new Error(messageOf(error)), ctx)
807
+ return null
808
+ }
809
+
810
+ // ---- Shrink gate -------------------------------------------------------
811
+ const summaryTextLen = estimateBlocks(summaryBlocks)
812
+ if (meter !== undefined && typeof meter.estimateMessage === 'function') {
813
+ try {
814
+ const framedEstimate = meter.estimateMessage({ role: 'user', content: summaryBlocks })
815
+ if (framedEstimate >= shadowedTokenCount) {
816
+ warn(ctx, `${session.id}: builtin compaction — summary (~${framedEstimate} tokens) is not smaller than the shadowed span (~${shadowedTokenCount}); aborting to avoid bloat`)
817
+ closeWithError(session, startEvent, compactionId, new Error('summary-not-smaller'), ctx)
818
+ return null
819
+ }
820
+ } catch { /* estimator unavailable — proceed best-effort */ }
821
+ } else if (summaryTextLen >= shadowedTokenCount * CHARS_PER_TOKEN) {
822
+ warn(ctx, `${session.id}: builtin compaction — summary characters (${summaryTextLen}) not clearly smaller than the shadowed span (${shadowedTokenCount} est-tokens ≈ ${shadowedTokenCount * CHARS_PER_TOKEN} chars); aborting`)
823
+ closeWithError(session, startEvent, compactionId, new Error('summary-not-smaller'), ctx)
824
+ return null
825
+ }
826
+
827
+ // ---- Commit: summary marker + replace node ----------------------------
828
+ if (signal !== undefined) signal.throwIfAborted()
829
+ const targetRange = validateReplacementBounds(session, region)
830
+ if (targetRange === null) {
831
+ closeWithError(session, startEvent, compactionId, new Error('range-moved-under-us'), ctx)
832
+ return null
833
+ }
834
+
835
+ let summaryEvent
836
+ const summaryData = {
837
+ compactionId,
838
+ summary: summaryBlocks,
839
+ shadowedRange: targetRange,
840
+ shadowedSeqs,
841
+ shadowedTokenCount,
842
+ ...(sourceCommandId === undefined ? {} : { sourceCommandId }),
843
+ }
844
+ // Record the ACTUAL LLM call envelope observed from the summarization
845
+ // invocation (ground-truth provider/model/maxTokens/usage) — this is the
846
+ // authoritative source, replacing the previous pre-call header/options
847
+ // label heuristic. We only reach this point after a successful summarization,
848
+ // so the observed fields are defined whenever the provider carried them.
849
+ // DEFENSE-IN-DEPTH: the official `compaction/summary` validator marks
850
+ // `provider` and `model` REQUIRED. If the summarization envelope somehow
851
+ // lacked either (degraded provider metadata), fall back to a non-empty
852
+ // placeholder rather than emitting `undefined`, which the invariant listener
853
+ // would reject. Live runs carry the real ids; this only guards edge cases.
854
+ summaryData.provider = (typeof summarizationProvider === 'string' && summarizationProvider.length > 0)
855
+ ? summarizationProvider
856
+ : 'unknown'
857
+ summaryData.model = (typeof summarizationModel === 'string' && summarizationModel.length > 0)
858
+ ? summarizationModel
859
+ : 'unknown'
860
+ if (Number.isFinite(summarizationMaxTokens) && summarizationMaxTokens > 0) {
861
+ summaryData.maxTokens = summarizationMaxTokens
862
+ }
863
+ if (summarizationUsage !== undefined) summaryData.usage = summarizationUsage
864
+
865
+ try {
866
+ summaryEvent = session.append('compaction/summary', summaryData)
867
+ } catch (error) {
868
+ closeWithError(session, startEvent, compactionId, error, ctx)
869
+ return null
870
+ }
871
+
872
+ // P0 — OFFICIAL FRAMING: wrap the summary BLOCKS (not a joined blob) in
873
+ // `CHECKPOINT_PREAMBLE` + `<compacted-summary>` tags block-wise, byte-mirror
874
+ // of the official `frameSummary` — so a future prior-checkpoint merge finds
875
+ // the structured region the instruction tells the LLM about ("keep the parts
876
+ // still true, drop the expired, fold in the newer") instead of an untagged
877
+ // free-form paragraph.
878
+ const checkpointContent = frameSummary(summaryBlocks)
879
+ const checkpointSourceData = {
880
+ ...CHECKPOINT_SOURCE_BASE,
881
+ compactionId,
882
+ ...(sourceCommandId === undefined ? {} : { sourceCommandId }),
883
+ }
884
+ let replaceEvent
885
+ try {
886
+ replaceEvent = session.append('user/message', {
887
+ content: checkpointContent,
888
+ source: Object.freeze(checkpointSourceData),
889
+ }, {
890
+ surfaceOp: { op: 'replace', start: targetRange.start, end: targetRange.end },
891
+ sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
892
+ })
893
+ } catch (error) {
894
+ closeWithError(session, startEvent, compactionId, error, ctx)
895
+ return null
896
+ }
897
+
898
+ // ---- Close the lock ----------------------------------------------------
899
+ let endSeq
900
+ try {
901
+ const endEvent = session.append('compaction/end', {
902
+ compactionId,
903
+ turn: currentOpenTurn(session),
904
+ // P1 — same conditional spread as start/summary: the third bracket must
905
+ // agree with the first two (`compaction/invariant` enforces it).
906
+ ...(sourceCommandId === undefined ? {} : { sourceCommandId }),
907
+ })
908
+ endSeq = endEvent.seq
909
+ } catch {
910
+ // Non-fatal: the summary already landed durably; the missing end marker is
911
+ // tolerated as a (rarely orphaned) lock on next reload.
912
+ info(ctx, `${session.id}: builtin compaction — warning: could not append compaction/end (lock may appear open on next reload)`)
913
+ }
914
+
915
+ // P0 — DURABILITY FLUSH AFTER `compaction/end` (mirror of the official
916
+ // `compactSurfaceRegion` `if (closed && flush) await flush()` tail). The
917
+ // transaction's four appends are durable by construction, but a command-
918
+ // driven caller (`/force-compact`) expects the on-disk state to be settled
919
+ // BEFORE it returns "Compacted N…". The awaiting `session/flush` checkpoint
920
+ // in `index.js` already covers the idle/auto call sites; this is belt-and-
921
+ // braces for the others. BEST-EFFORT by design: unlike the official engine
922
+ // (which has a typed `CompactionError` to escalate a flush rejection into —
923
+ // deliberately NOT ported, out of scope) we degrade to a WARN and ignore,
924
+ // so a flaky or missing `sessions` service can never turn a SUCCESSFUL
925
+ // committed compaction into a visible failure.
926
+ const sessions = ctx.get('sessions')
927
+ if (sessions !== undefined && typeof sessions.flush === 'function') {
928
+ try {
929
+ await sessions.flush(session)
930
+ } catch (flushFailure) {
931
+ warn(ctx, `${session.id}: builtin compaction — best-effort session/flush after compaction/end failed (ignored): ${messageOf(flushFailure)}`)
932
+ }
933
+ }
934
+
935
+ // A successful compaction clears ANY residual failure cooldown for this
936
+ // session so the NEXT idle tick isn't suppressed by a stale mark.
937
+ clearFailureCooldown(session.id)
938
+ info(ctx, `${session.id}: builtin compaction OK — replaced span seq[${targetRange?.start}..${targetRange?.end}] (${shadowedSeqs?.length} nodes, ~${shadowedTokenCount} tokens) with a ${summaryTextLen}-char checkpoint`)
939
+ // LIVE UI SIGNAL — pin GREEN "[压缩完成!]" NOW that the compaction RESULT is
940
+ // durable: the four-bracket transaction has committed (span shadowed + checkpoint
941
+ // appended), the durability flush above has settled, and the failure cooldown is
942
+ // cleared. This is the ONE authoritative "compaction result landed in the session"
943
+ // boundary, so we emit DONE HERE regardless of WHICH path initiated the compaction
944
+ // (idle / checkpoint / manual / threshold). `publishDone` swallows its own failures
945
+ // (see ui-signal.js) so a messenger hiccup can never disturb the committed outcome
946
+ // returned below. Callers' own `publishDone` sites remain as harmless duplicates
947
+ // (idempotent — republishing the same pinned green payload is a no-op visually).
948
+ try {
949
+ await publishDone(ctx)
950
+ } catch { /* publisher is self-contained; a throw here would corrupt a committed tx */ }
951
+ return {
952
+ kind: 'builtin',
953
+ compactionId,
954
+ startSeq: startEvent.seq,
955
+ summarySeq: summaryEvent.seq,
956
+ endSeq,
957
+ summary: summaryBlocks,
958
+ shadowedRange: targetRange,
959
+ shadowedSeqs,
960
+ shadowedTokenCount,
961
+ // P1 — echo the originating command id for observability (conditional
962
+ // spread keeps the key ABSENT for non-command-driven transactions so the
963
+ // result shape matches pre-port behavior for those paths).
964
+ ...(sourceCommandId === undefined ? {} : { sourceCommandId }),
965
+ }
966
+ }
967
+
968
+ /** Append `compaction/end` carrying the error so the lock is released explicitly. */
969
+ function closeWithError(session, startEvent, compactionId, error, ctx) {
970
+ try {
971
+ session.append('compaction/end', { compactionId, turn: currentOpenTurn(session), error: messageOf(error) })
972
+ } catch { /* best effort */ }
973
+ warn(ctx, `builtin compaction transaction ended in error: ${messageOf(error)}`)
974
+ }
975
+
976
+ /** Total surface-content token estimate for diagnostics (4 chars/token). */
977
+ function estimateSurfaceTokens(session) {
978
+ // Coarse char-based fallback used only when `tokenMeter` is absent. Every
979
+ // dereference is guarded so a malformed session (missing `events`, `data`,
980
+ // or `message`) degrades to 0 instead of throwing — this feeds a
981
+ // diagnostics/cooldown decision, never a correctness path.
982
+ let chars = 0
983
+ const events = (session && Array.isArray(session.events)) ? session.events : []
984
+ for (const event of events) {
985
+ if (event === null || typeof event !== 'object') continue
986
+ const data = (event.data && typeof event.data === 'object') ? event.data : {}
987
+ let content
988
+ if (event.type === 'user/message') content = data.content
989
+ else if (event.type === 'assistant/message') content = (data.message && data.message.content !== undefined) ? data.message.content : undefined
990
+ else if (event.type === 'tool/result') content = (data.message && data.message.content !== undefined) ? data.message.content : undefined
991
+ if (content === undefined) continue
992
+ for (const block of Array.isArray(content) ? content : []) {
993
+ if (block && typeof block === 'object' && typeof block.text === 'string') chars += block.text.length
994
+ }
995
+ }
996
+ return Math.ceil(chars / 4)
997
+ }
998
+
999
+ /**
1000
+ * Select a head-anchored compactable region — the SINGLE SELF-SELECTOR for
1001
+ * `compactNow` (manual command entry AND idle/auto entry alike). Called from
1002
+ * {@link __compactNowBuiltinBody} ONLY — region-carrying callers (the
1003
+ * `/force-compact` busy-queue consumption and the `session/flush`
1004
+ * checkpoint path) always route their OWN span through `compactRegion` and
1005
+ * never enter this helper.
1006
+ *
1007
+ * Manual-vs-auto DISTINCTION (via the OPTIONAL 4th argument, mirroring the
1008
+ * official engine's "explicit opts ⇒ caller supplies its own selection"):
1009
+ * • MANUAL entry (`/force-compact`, owner `null`) leaves `opts` undefined →
1010
+ * `__compactNowBuiltinBody` computes `retainTokens = 0` → this helper
1011
+ * receives `retainOverride = 0` → the meter branch delegates to
1012
+ * `selectRetainingLatestTokens(session, 0, measurement)`, whose internal
1013
+ * `budget = max(1, …)` clamp means the tail walk retains effectively
1014
+ * NOTHING and the compactable span becomes the ENTIRE head up to the
1015
+ * pairing-balanced boundary NEAR THE TAIL — the official "full-head"
1016
+ * manual-compaction behavior.
1017
+ * • AUTO / IDLE entry (`agent/status` idle transition, `session/flush`)
1018
+ * OPTS INTO legacy semantics by passing
1019
+ * `opts: { retainTokens: settings.retainLatestTokens }` → the helper
1020
+ * receives that finite override → retain-the-latest-N-tokens behavior
1021
+ * unchanged from pre-port.
1022
+ * • `retainOverride` left `undefined` WITHOUT the caller specifying it (a
1023
+ * future caller that wants the configured default) falls back to
1024
+ * `settings.retainLatestTokens` — preserving the historical default.
1025
+ *
1026
+ * Pricing fidelity (two branches converging on the same rule): when a
1027
+ * reliable `meter.measure` snapshot IS available (nodes array present with ≥
1028
+ * 2 entries), both the compactable prefix AND the retained tail are priced
1029
+ * from that single authoritative snapshot via
1030
+ * {@link selectRetainingLatestTokens} — one `measure()` call, consistent
1031
+ * calibration end-to-end (NOT a divergent char/4 estimate of flat text,
1032
+ * which systematically UNDERCOUNTS nested tool blocks / JSON framing and
1033
+ * starved the head budget — the observed "/force-compact → no compactable
1034
+ * range" cause). WITHOUT such a snapshot, fall back to a char heuristic:
1035
+ * price the surface sum (4 chars/token),
1036
+ * `headBudget = max(0, surfaceSum − retain)`, and hand that head budget to
1037
+ * {@link selectEarliestByTokens} (it walks from the head until the running
1038
+ * sum reaches the budget).
1039
+ *
1040
+ * Boundary snapping: whichever branch selects the span ENDS it at a
1041
+ * TOOL-PAIRING BALANCED position (official pairing-ledger criterion via
1042
+ * `core/pairing.js` — any cut-after with zero unanswered tool calls, a
1043
+ * strict superset of the historical `user/message` boundary) so the
1044
+ * compacted span never splits a tool call/result pair.
1045
+ *
1046
+ * Edge cases: with a meter snapshot, `selectRetainingLatestTokens` itself
1047
+ * reports `null` when the retained tail consumes the whole window (fewer
1048
+ * than 2 nodes, or `tailStartIdx <= 0`); with the legacy fallback, if the
1049
+ * char-estimated surface sum is smaller than the retention budget (very
1050
+ * short session), the head budget clamps to zero and no region results.
1051
+ *
1052
+ * @param {object} settings resolved plugin settings (used ONLY when `retainOverride` is undefined).
1053
+ * @param {object} session live session handle.
1054
+ * @param {object|undefined} measurement fresh `tokenMeter.measure(session)` snapshot.
1055
+ * @param {number|undefined} [retainOverride] ABSOLUTE tokens to retain at the
1056
+ * tail. `0` (the manual-command default) ⇒ full-head region. Finite
1057
+ * positive values ⇒ legacy retain-latest behavior. `undefined` ⇒ fall
1058
+ * back to `settings.retainLatestTokens` (historical default for any
1059
+ * caller that doesn't specify).
1060
+ * @returns {{start:number,end:number}|null} head-anchored span, or `null`.
1061
+ */
1062
+ function selectHeadAnchoredRegion(settings, session, measurement, retainOverride) {
1063
+ const retain = retainOverride !== undefined && Number.isFinite(retainOverride)
1064
+ ? Math.max(0, Math.round(retainOverride))
1065
+ : (Number.isFinite(settings.retainLatestTokens)
1066
+ ? Math.max(0, Math.round(settings.retainLatestTokens))
1067
+ : 0)
1068
+ // PRIMARY (mirror of the auto path): price from the meter's own per-node
1069
+ // snapshot — the node-pricing source shared with the threshold gate's
1070
+ // retained-tail selector (whose SCALAR pressure basis is now the
1071
+ // projection's `projectedTokens`, the exact figure the harness renders in
1072
+ // the bottom-right corner).
1073
+ if (measurement !== undefined && Array.isArray(measurement.nodes) && measurement.nodes.length > 1) {
1074
+ return selectRetainingLatestTokens(session, retain, measurement)
1075
+ }
1076
+ // LEGACY FALLBACK: no meter snapshot — char-estimate the surface sum and
1077
+ // route the residual head budget through the legacy selector.
1078
+ const surfaceSum = estimateSurfaceTokensLocal(session)
1079
+ const headBudget = Math.max(0, surfaceSum - retain)
1080
+ if (headBudget <= 0) return null
1081
+ return selectEarliestByTokens(session, headBudget, undefined)
1082
+ }
1083
+
1084
+ /** Local surface-sum estimator (module-private copy of the char heuristic). */
1085
+ function estimateSurfaceTokensLocal(session) {
1086
+ const events = (session && Array.isArray(session.events)) ? session.events : []
1087
+ let chars = 0
1088
+ for (const event of events) {
1089
+ if (event === null || typeof event !== 'object') continue
1090
+ const data = (event.data && typeof event.data === 'object') ? event.data : {}
1091
+ let content
1092
+ if (event.type === 'user/message') content = data.content
1093
+ else if (event.type === 'assistant/message') content = (data.message && data.message.content !== undefined) ? data.message.content : undefined
1094
+ else if (event.type === 'tool/result') content = (data.message && data.message.content !== undefined) ? data.message.content : undefined
1095
+ if (content === undefined) continue
1096
+ for (const block of Array.isArray(content) ? content : []) {
1097
+ if (block && typeof block === 'object' && typeof block.text === 'string') chars += block.text.length
1098
+ }
1099
+ }
1100
+ return Math.ceil(chars / 4)
1101
+ }
1102
+
1103
+ /**
1104
+ * Project a region's surface nodes into LLM messages and collect their seqs.
1105
+ *
1106
+ * A replace op's bounds are interpreted by the session core over the CURRENT
1107
+ * SURFACE PROJECTION as an INCLUSIVE INDEX SEGMENT: everything the projection
1108
+ * holds between `nodes.indexOf(start)` and `nodes.indexOf(end)` is shadowed
1109
+ * (`surface.replacementRange` slices by index, not by seq value). Because a
1110
+ * previously-generated checkpoint REPLACES earlier nodes but APPENDS at its
1111
+ * own (later) log position, the surviving early survivors keep LOWER seqs yet
1112
+ * HIGHER indices than the checkpoint node. An inclusive SEQ-value-range filter
1113
+ * (`seq >= start && seq <= end`) therefore MISSES those surviving mid-span
1114
+ * nodes whenever a prior checkpoint sits at the head — exactly the second
1115
+ * compaction round — and the session core rejects the replace for incomplete
1116
+ * provenance ("sourceEventSeqs must include every shadowed surface node").
1117
+ *
1118
+ * So we compute the shadowed set by the SAME rule the core applies: the index
1119
+ * segment from `start` to `end` in the live projection. Log-only events
1120
+ * contribute nothing; a projected node that yields no message still counts as
1121
+ * shadowed.
1122
+ */
1123
+ function projectRegion(session, region) {
1124
+ // Tolerate a malformed surface: a missing `session.surface` / non-array
1125
+ // `nodes` yields an EMPTY projection (zero shadowed, zero messages) rather
1126
+ // than a throw, so the caller simply finds nothing to compact instead of
1127
+ // crashing the transaction.
1128
+ const surfaceNodes = (session && session.surface && Array.isArray(session.surface.nodes)) ? session.surface.nodes : []
1129
+ const nodes = [...surfaceNodes]
1130
+ const firstIdx = nodes.indexOf(region.start)
1131
+ const lastIdx = nodes.lastIndexOf(region.end)
1132
+ const segment = (firstIdx >= 0 && lastIdx >= firstIdx)
1133
+ ? nodes.slice(firstIdx, lastIdx + 1)
1134
+ : []
1135
+ const events = (session && Array.isArray(session.events)) ? session.events : []
1136
+ const messages = []
1137
+ const shadowedSeqs = []
1138
+ for (const seq of segment) {
1139
+ const event = events[seq]
1140
+ if (event === undefined || event === null || typeof event !== 'object') continue
1141
+ const data = (event.data && typeof event.data === 'object') ? event.data : {}
1142
+ if (event.type === 'user/message') {
1143
+ shadowedSeqs.push(seq)
1144
+ messages.push({ role: 'user', content: data.content })
1145
+ } else if (event.type === 'assistant/message') {
1146
+ shadowedSeqs.push(seq)
1147
+ const content = (data.message && data.message.content !== undefined) ? data.message.content : undefined
1148
+ const source = (data.message && typeof data.message.source === 'object' && data.message.source !== null) ? data.message.source : { kind: 'model' }
1149
+ if (content) messages.push({ role: 'assistant', content, source })
1150
+ } else if (event.type === 'tool/result') {
1151
+ const msg = (data.message && typeof data.message === 'object') ? data.message : undefined
1152
+ if (msg && msg.content) {
1153
+ shadowedSeqs.push(seq)
1154
+ messages.push({ role: 'user', content: msg.content, tool_call_id: msg.toolCallId })
1155
+ }
1156
+ } else {
1157
+ // Still a surface node that yields no message (empty assistant usage
1158
+ // host); it is shadowed by the replace even though it contributes no
1159
+ // message.
1160
+ shadowedSeqs.push(seq)
1161
+ }
1162
+ }
1163
+ return { shadowedSeqs, messages }
1164
+ }
1165
+
1166
+ /**
1167
+ * Validate that the replace bounds STILL land on current surface nodes (guarding
1168
+ * against a surface that changed under us since preparation). Returns the bounds
1169
+ * or `null` when invalid.
1170
+ */
1171
+ function validateReplacementBounds(session, region) {
1172
+ // A malformed surface (missing `session.surface` / non-array `nodes`) means
1173
+ // we cannot validate the bounds — return null (refuse the replace) rather
1174
+ // than throw. Reading `.nodes` off a null surface would otherwise crash the
1175
+ // whole transaction.
1176
+ const surfaceNodes = (session && session.surface && Array.isArray(session.surface.nodes)) ? session.surface.nodes : []
1177
+ const firstIdx = surfaceNodes.indexOf(region.start)
1178
+ const lastIdx = surfaceNodes.lastIndexOf(region.end)
1179
+ // Same validity predicate the session core applies: both bounds exist in the
1180
+ // projection and start precedes end BY INDEX (not by seq value — see
1181
+ // projectRegion).
1182
+ if (firstIdx < 0 || lastIdx < 0 || firstIdx > lastIdx) return null
1183
+ return { start: region.start, end: region.end }
1184
+ }
1185
+
1186
+ /**
1187
+ * Inspect open-turn, unmatched-compaction, and latest seed-boundary state
1188
+ * independently — ported from the official `inspectCompactionEntryState`.
1189
+ * Scans the durable log BACKWARD once, collecting:
1190
+ * • `openTurn` — the turn number of the currently-open turn, or
1191
+ * `null` when no turn is open (or the state is
1192
+ * simply absent);
1193
+ * • `unmatchedCompactionStart` — the LATEST `compaction/start` without a
1194
+ * following `compaction/end` (the in-flight
1195
+ * transaction lock), `undefined` when none;
1196
+ * • `latestEndSeedSeq` — the newest `session/end-seed` marker,
1197
+ * `undefined` when absent.
1198
+ * A backward scan means each field is found in O(1)-amortised passes: we stop
1199
+ * as soon as ALL THREE are known.
1200
+ * @param {readonly object[]} events the durable session log.
1201
+ * @returns {{openTurn: number|null, unmatchedCompactionStart: object|undefined, latestEndSeedSeq: number|undefined}}
1202
+ */
1203
+ function inspectCompactionEntryState(events) {
1204
+ const rows = (Array.isArray(events)) ? events : []
1205
+ let openTurn = null
1206
+ let openTurnStateKnown = false
1207
+ let unmatchedCompactionStart
1208
+ let compactionEntryStateKnown = false
1209
+ let latestEndSeedSeq
1210
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
1211
+ const event = rows[index]
1212
+ if (event === null || typeof event !== 'object') continue
1213
+ const type = event.type
1214
+ if (latestEndSeedSeq === undefined && type === 'session/end-seed' && typeof event.seq === 'number') {
1215
+ latestEndSeedSeq = event.seq
1216
+ }
1217
+ if (!compactionEntryStateKnown) {
1218
+ if (type === 'compaction/start') {
1219
+ unmatchedCompactionStart = event
1220
+ compactionEntryStateKnown = true
1221
+ } else if (type === 'compaction/end') {
1222
+ compactionEntryStateKnown = true
1223
+ }
1224
+ }
1225
+ if (!openTurnStateKnown) {
1226
+ if (type === 'turn/start') {
1227
+ const data = (event.data && typeof event.data === 'object') ? event.data : undefined
1228
+ openTurn = (data && data.turn !== undefined) ? data.turn : null
1229
+ openTurnStateKnown = true
1230
+ } else if (type === 'turn/end') {
1231
+ openTurnStateKnown = true
1232
+ }
1233
+ }
1234
+ if (openTurnStateKnown && compactionEntryStateKnown && latestEndSeedSeq !== undefined) break
1235
+ }
1236
+ return { openTurn, unmatchedCompactionStart, latestEndSeedSeq }
1237
+ }
1238
+
1239
+ /**
1240
+ * Refuse to enter a compaction while one is already active — ported from the
1241
+ * official `assertCompactionInactive` + `assertNoActiveCompaction`.
1242
+ *
1243
+ * SEMANTICS (official):
1244
+ * • An UNMATCHED `compaction/start` with NO later `session/end-seed` proves
1245
+ * a transaction is genuinely in flight → throw `ManualCompactionError`-style
1246
+ * rejection (here: return a descriptive string the caller logs and skips on).
1247
+ * • An unmatched `compaction/start` PRECEDED by a LATER `session/end-seed` is
1248
+ * a CONSTRUCTOR-INHERITED ORPHAN (persisted across a session resume whose
1249
+ * reload reseeded the surface from a checkpoint). The official code IGNORES
1250
+ * such a stale marker — it belongs to an earlier session lifecycle and must
1251
+ * NOT wedge subsequent compactions.
1252
+ * • `null` → no refusal (proceed).
1253
+ *
1254
+ * Our builtin transaction closes its bracket SYNCHRONOUSLY (four appends in a
1255
+ * row, no yielding), so a LIVE process can never observe its own in-flight
1256
+ * marker from another entry — the refusal only matters for the rare
1257
+ * corrupted-orphan case and for defensive double-entry suppression.
1258
+ * @param {import('@deepseek-ai/dsh-session').Session} session
1259
+ * @param {string} stage operation label for the diagnostic (e.g. `'runTransaction'`).
1260
+ * @returns {string|null} a human-readable BUSY NOTE when refused, `null` to proceed.
1261
+ */
1262
+ function assertNoActiveCompaction(session, stage) {
1263
+ const state = inspectCompactionEntryState((session && Array.isArray(session.events)) ? session.events : [])
1264
+ const { unmatchedCompactionStart, latestEndSeedSeq } = state
1265
+ if (unmatchedCompactionStart === undefined) return null
1266
+ if (latestEndSeedSeq !== undefined && latestEndSeedSeq > (unmatchedCompactionStart.seq ?? -1)) {
1267
+ // Inherited orphan cleared by a later end-seed boundary (constructor
1268
+ // reseed) — the official semantics explicitly ignore it.
1269
+ return null
1270
+ }
1271
+ return `${stage}: compaction already in progress; the session compaction lock is already active`
1272
+ }
1273
+
1274
+ /** Whether an open compaction transaction is present (busy-lock check — kept for
1275
+ * compatibility; now backed by the official entry-state inspection). */
1276
+ function hasOpenFctLock(session) {
1277
+ return assertNoActiveCompaction(session, 'lockCheck') !== null
1278
+ }
1279
+
1280
+ /** The turn number of the currently-open turn, or `null` (standalone/idle). */
1281
+ function currentOpenTurn(session) {
1282
+ // Reads the latest turn bracket from the durable log to stamp `compaction/*`
1283
+ // events' `turn` field. Must never throw (it runs on every append/close): a
1284
+ // missing/non-array `events` or a non-object row degrades to `null` (no open
1285
+ // turn), matching the standalone/idle case.
1286
+ const events = (session && Array.isArray(session.events)) ? session.events : []
1287
+ for (let i = events.length - 1; i >= 0; i--) {
1288
+ const ev = events[i]
1289
+ if (ev === null || typeof ev !== 'object') continue
1290
+ if (ev.type === 'turn/start') {
1291
+ const data = (ev.data && typeof ev.data === 'object') ? ev.data : undefined
1292
+ return (data && data.turn !== undefined) ? data.turn : null
1293
+ }
1294
+ if (ev.type === 'turn/end') return null
1295
+ }
1296
+ return null
1297
+ }
1298
+
1299
+ /** Coarse token count for a set of messages (4 chars/token). */
1300
+ function estimateTokens(messages) {
1301
+ let chars = 0
1302
+ for (const m of messages) for (const b of m.content || []) {
1303
+ if (b && typeof b.text === 'string') chars += b.text.length
1304
+ }
1305
+ return Math.ceil(chars / CHARS_PER_TOKEN)
1306
+ }
1307
+
1308
+ /** Character length across a block array's text fields. */
1309
+ function estimateBlocks(blocks) {
1310
+ let n = 0
1311
+ for (const b of blocks || []) if (b && typeof b.text === 'string') n += b.text.length
1312
+ return n
1313
+ }
1314
+
1315
+ /** Concatenate a block array's text fields. */
1316
+ function joinBlocks(blocks) {
1317
+ return (blocks || []).filter(b => b && typeof b.text === 'string').map(b => b.text).join('\n')
1318
+ }
1319
+
1320
+ /** Clamp a value to (0,1]; defaults to `fallback` when not a finite positive. */
1321
+ function clamp01(value, fallback) {
1322
+ const v = Number(value)
1323
+ if (!Number.isFinite(v) || v <= 0 || v > 1) return (typeof fallback === 'number' ? fallback : 0.5)
1324
+ return v
1325
+ }
1326
+
1327
+ /** A cheap human-readable error string. */
1328
+ function messageOf(error) {
1329
+ if (error === undefined || error === null) return 'unknown'
1330
+ return (typeof error === 'string') ? error : (error.message || String(error))
1331
+ }
1332
+
1333
+ /** Info/warn shims routed through the logger (never throws). */
1334
+ function info(ctx, msg) { try { ctx.logger.debug('[force-compact] ' + msg) } catch {} }
1335
+ function warn(ctx, msg) { try { ctx.logger.warn('[force-compact] ' + msg) } catch {} }
1336
+