@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,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
|
+
}
|