@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,206 @@
1
+ /**
2
+ * dsh-force-compact's session-flush compaction orchestrator.
3
+ *
4
+ * On the awaited `session/flush` checkpoint: gate on the session's total
5
+ * estimated context reaching `autoThresholdTokens`; select the compactable
6
+ * region with the plugin's own head-anchored policy (`selectRegion`);
7
+ * then DELEGATE THE DURABLE MUTATION TO WHICHEVER BACKEND IS AVAILABLE —
8
+ * the official `compaction` service when reachable (preferred) or this
9
+ * plugin's OWN builtin engine when it isn't (fallback). Both backends
10
+ * perform their OWN preview summarization + shrink gate internally, so this
11
+ * caller no longer re-implements a redundant preview.
12
+ *
13
+ * @module @falling-ts/dsh-force-compact/compact
14
+ */
15
+
16
+ import { resolveConfig } from '../core/policy.js'
17
+ import { selectRegion } from './region.js'
18
+ import { readSettings, DEFAULTS } from '../core/settings.js'
19
+ import { resolveCompaction } from './backend.js'
20
+ import { getProjectedTokens, diagnoseProjectedTokensAbsence } from '../core/projected.js'
21
+ import { guardFn, renderCrash, captureThrowSite, appendCrashLine as appendDiag } from '../core/crashnet.js'
22
+
23
+ /** Characters per token, mirroring the token meter's coarse estimate. */
24
+ const CHARS_PER_TOKEN = 4
25
+
26
+ /**
27
+ * Compact a session's useful history at a durability checkpoint.
28
+ *
29
+ * Flow: gate on threshold → select the region with the plugin's own policy →
30
+ * locate the available backend (official-preferred, builtin-fallback) →
31
+ * delegate the durable `compactRegion` call (each backend performs its own
32
+ * summarization and shrink gate internally).
33
+ *
34
+ * @param {import('@deepseek-ai/cordis').Context} ctx
35
+ * @param {import('@deepseek-ai/dsh-agent').Agent} agent
36
+ * @param {AbortController} controller
37
+ * @param {string|undefined} mode the `compactionMode` setting (passed by the caller); undefined re-reads live.
38
+ * @returns {Promise<object | null>} the compaction result (shape depends on the backend;
39
+ * the builtin engine returns `{ kind:'builtin', compactionId, startSeq, summarySeq,
40
+ * endSeq, summary, shadowedRange, shadowedSeqs, shadowedTokenCount }`), or `null`
41
+ * when nothing was worth compacting or no backend was available.
42
+ */
43
+ // Internal body of `compactSession` — routed through the crash-net wrapper.
44
+ // The existing try/catch around `backend.compactRegion` handles the
45
+ // expected-backend-failure path; the crash-net layer adds observability
46
+ // for ANOMALOUS throws escaping that catch (malformed `selectRegion` inputs,
47
+ // an unexpected throw out of the backend facade, etc.).
48
+ async function __compactSessionBody(ctx, agent, controller, mode) {
49
+ // SAFETY GUARD: a missing/unusable `agent.session` means there is nothing to
50
+ // compact — degrade to `null` (skip) rather than a downstream `session.id` /
51
+ // `session.events` dereference throwing out of the flush-checkpoint path.
52
+ const agentObj = (agent && typeof agent === 'object') ? agent : undefined
53
+ const session = (agentObj && agentObj.session) ? agentObj.session : undefined
54
+ if (session === undefined || session === null || typeof session.id !== 'string') {
55
+ if (controller && typeof controller.abort === 'function' && controller.signal.aborted === false) {
56
+ // Nothing actionable; leave the slot open for the caller's finally cleanup.
57
+ }
58
+ const sid = (session && typeof session.id === 'string') ? session.id : '?'
59
+ ctx.logger.debug(`[force-compact] ${sid}: checkpoint skipped — no usable agent session`)
60
+ return null
61
+ }
62
+ const config = resolveConfig()
63
+
64
+ // The "强制压缩配置" (force-compact configuration) settings: the automatic
65
+ // compaction trigger threshold and whether to disable thinking for the
66
+ // summarization call. Falls back to composition defaults when the `settings`
67
+ // service is not mounted.
68
+ const settings = (await readSettings(ctx)) ?? DEFAULTS
69
+
70
+ // Automatic compaction trigger gate: only compact when the session's context
71
+ // occupancy reaches the configured threshold. Below it, the checkpoint is
72
+ // skipped so short sessions are never force-compacted.
73
+ //
74
+ // BASIS (same caliber as the harness bottom-right occupancy display): the
75
+ // primary reading is the official `projectedTokens` — the EXACT figure the
76
+ // GUI renders (provider-anchored, reacts instantly when a compaction shadows
77
+ // a span). Keying the gate off it means "what the user SEEs in the corner is
78
+ // what decides whether we compact", eliminating the historic cognitive gap
79
+ // where this gate ran a raw `estimateSessionTokens` sweep of `session.events`
80
+ // (a "gross weight" that re-counts already-shadowed spans and climbs to
81
+ // several multiples of the honest net) and therefore misfired far below the
82
+ // displayed occupancy. The raw char-based estimate is kept ONLY as a degraded
83
+ // fallback for sessions that have no usage sample yet (no `projectedTokens`),
84
+ // mirroring the pre-step gate in `hooks/guard.js`.
85
+ const projected = getProjectedTokens(ctx, session)
86
+ const grossEstimate = estimateSessionTokens(session)
87
+ const gateUsesProjectedList = (typeof projected === 'number' && Number.isFinite(projected) && projected >= 0)
88
+ const sessionTokens = gateUsesProjectedList ? projected : grossEstimate
89
+ // RECONCILIATION FACET (diagnostic only): when the official meter is
90
+ // mounted, take ONE snapshot and record BOTH sides of the caliber split —
91
+ // the projection-derived gate basis next to the meter's own figures — so a
92
+ // future operator can eyeball "corner figure vs meter total vs raw sweep"
93
+ // on a single line. Defensive: a `measure` fault degrades the facet to a
94
+ // bare basis label, never the gate decision.
95
+ const meter = ctx.get('tokenMeter')
96
+ let reconciliationFacet = ''
97
+ if (typeof meter?.measure === 'function') {
98
+ try {
99
+ const m = meter.measure(session)
100
+ if (m && typeof m === 'object') {
101
+ const projectedDisplay = gateUsesProjectedList ? String(projected) : 'absent'
102
+ reconciliationFacet = ` [facet: corner=${projectedDisplay} meter.total=${m.totalTokens} meter.surface=${m.surfaceTokens} rawSweep=${grossEstimate}]`
103
+ }
104
+ } catch { /* facet is decorative; a meter fault must not degrade the gate */ }
105
+ }
106
+ // On the degraded path, append the CLASSIFIED reason `projectedTokens` was
107
+ // absent, so a future log can distinguish "service unreachable from this
108
+ // listener context" (needs a different access path) from "reachable but no
109
+ // usage sample yet" (expected, benign).
110
+ const basisLabel = gateUsesProjectedList
111
+ ? 'projectedTokens (corner-identical)'
112
+ : `char-estimate (projectedTokens absent: ${diagnoseProjectedTokensAbsence(ctx, session)})`
113
+ ctx.logger.debug(`[force-compact] ${session.id}: session/flush checkpoint fired — session ~${sessionTokens} tokens via ${basisLabel}${reconciliationFacet} (threshold ${settings.autoThresholdTokens})`)
114
+ if (sessionTokens < settings.autoThresholdTokens) {
115
+ ctx.logger.debug(`[force-compact] ${session.id}: context ~${sessionTokens} tokens below threshold ${settings.autoThresholdTokens}; skipping`)
116
+ return null
117
+ }
118
+
119
+ const region = selectRegion(session, config)
120
+ if (region === null) {
121
+ ctx.logger.debug(`[force-compact] ${session.id}: no compactable region; skipping`)
122
+ return null
123
+ }
124
+
125
+ // Locate a usable compaction backend: the OFFICIAL `compaction` service
126
+ // (preferred) OR this plugin's OWN builtin engine (fallback). Each performs
127
+ // its own summarization + shrink gate internally, so no redundant preview
128
+ // is needed here.
129
+ const backend = await resolveCompaction(ctx, agent, mode)
130
+ if (backend === undefined || typeof backend.compactRegion !== 'function') {
131
+ const effMode = (mode !== undefined ? mode : settings.compactionMode)
132
+ ctx.logger.warn(
133
+ `[force-compact] ${session.id}: NO compaction backend available at checkpoint (mode=${effMode}). ` +
134
+ `Enable \`builtinEnabled=true\` in the \`falling-ts-force-compact\` namespace to activate the ` +
135
+ `plugin's own engine as a fallback (it needs the \`llm\` service + \`agent.session\` present).`
136
+ )
137
+ return null
138
+ }
139
+
140
+ ctx.logger.debug(`[force-compact] ${session.id}: checkpoint compaction via ${backend?.kind} over seq ${region?.start}..${region?.end}`)
141
+ let result
142
+ try {
143
+ result = await backend.compactRegion(region.start, region.end, agent, controller.signal)
144
+ } catch (error) {
145
+ const message = error instanceof Error ? error.message : String(error)
146
+ ctx.logger.warn(`[force-compact] ${session.id}: checkpoint compaction via ${backend?.kind} FAILED — ${message}`)
147
+ // UNIVERSAL-CRASH-NET diagnostic — a durable trail for every anomalous
148
+ // backend failure, independent of logger wiring.
149
+ try {
150
+ const lines = renderCrash('checkpoint.compactSession.backend-call', error, captureThrowSite())
151
+ for (const line of lines) appendDiag(line)
152
+ } catch (_netFailure) { /* swallow */ }
153
+ return null
154
+ }
155
+ if (result === undefined || result === null) {
156
+ ctx.logger.debug(`[force-compact] ${session.id}: checkpoint compaction via ${backend?.kind} committed nothing`)
157
+ return null
158
+ }
159
+ ctx.logger.info(
160
+ `[force-compact] ${session.id}: checkpoint compaction (${backend?.kind}) shadowed ` +
161
+ `${(result.shadowedSeqs && result.shadowedSeqs.length) ?? '?'} nodes (~${result.shadowedTokenCount ?? '?'} tokens)`,
162
+ )
163
+ return result
164
+ }
165
+
166
+ /** Public entry — wrapped by the universal crash net. */
167
+ export const compactSession = guardFn('checkpoint.compactSession', __compactSessionBody)
168
+
169
+ /**
170
+ * DEGRADED FALLBACK — coarse raw-char token estimate for a session's WHOLE
171
+ * `session.events` content (user + assistant + tool-result message text, ÷4).
172
+ *
173
+ * Role change (2026-08-25): this sweep is NO LONGER the automatic compaction
174
+ * trigger GATE's primary basis. That basis is now the official `projectedTokens`
175
+ * (the exact figure the harness renders in the bottom-right corner, read
176
+ * through {@link getProjectedTokens}), so the gate no longer misfires on the
177
+ * "gross weight" artifact of this function — it sums the RAW durable log rows
178
+ * and RE-COUNTS spans already shadowed by a compaction, so it monotonically
179
+ * climbs to several multiples of the honest net occupancy. This function is
180
+ * retained SOLELY as the degraded fallback for sessions that have not yet
181
+ * reported a usage sample (where `projectedTokens` is absent), and for the
182
+ * backend's `compactRegion` authoritative accounting which prices from its own
183
+ * meter snapshot.
184
+ * @param {import('@deepseek-ai/dsh-session').Session} session
185
+ * @returns {number}
186
+ */
187
+ function estimateSessionTokens(session) {
188
+ // Feeds only the threshold GATE: a malformed session (missing/non-array
189
+ // `events`, non-object rows, missing `data`/`message`) degrades each row to
190
+ // 0 rather than throwing. Every deep deref is individually guarded.
191
+ let chars = 0
192
+ const events = (session && Array.isArray(session.events)) ? session.events : []
193
+ for (const event of events) {
194
+ if (event === null || typeof event !== 'object') continue
195
+ const data = (event.data && typeof event.data === 'object') ? event.data : {}
196
+ let content
197
+ if (event.type === 'user/message') content = Array.isArray(data.content) ? data.content : undefined
198
+ else if (event.type === 'assistant/message') content = (data.message && Array.isArray(data.message.content)) ? data.message.content : undefined
199
+ else if (event.type === 'tool/result') content = (data.message && Array.isArray(data.message.content)) ? data.message.content : undefined
200
+ if (content === undefined) continue
201
+ for (const block of content) {
202
+ if (block && typeof block === 'object' && typeof block.text === 'string') chars += block.text.length
203
+ }
204
+ }
205
+ return Math.ceil(chars / CHARS_PER_TOKEN)
206
+ }