@falling-ts/dsh-force-compact 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.cn.md +170 -0
- package/README.md +479 -0
- package/cordis.patch.yml +13 -0
- package/index.js +550 -0
- package/package.json +54 -0
- package/src/core/crashnet.js +215 -0
- package/src/core/log.js +346 -0
- package/src/core/pairing.js +188 -0
- package/src/core/policy.js +35 -0
- package/src/core/projected.js +139 -0
- package/src/core/settings.js +475 -0
- package/src/core/ui-signal.js +271 -0
- package/src/engine/backend.js +143 -0
- package/src/engine/builtin.js +1336 -0
- package/src/engine/checkpoint.js +206 -0
- package/src/engine/region.js +579 -0
- package/src/engine/summarizer.js +818 -0
- package/src/hooks/command.js +164 -0
- package/src/hooks/guard.js +706 -0
- package/src/hooks/idle.js +136 -0
- package/src/hooks/wire-rewrite.js +151 -0
- package/web/client.js +807 -0
- package/web/swish.css +188 -0
|
@@ -0,0 +1,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
|
+
|