@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,13 @@
1
+ # Opt-in layer: mount the dsh-force-compact function plugin over the
2
+ # current composition without changing shipped defaults.
3
+ #
4
+ # dsh plugin --profile web add github:falling-ts/dsh-force-compact # install
5
+ # dsh web --patch dsh-force-compact/cordis.patch.yml # dev overlay
6
+ #
7
+ # The plugin compacts a session's useful history at every session durability
8
+ # checkpoint (session/flush), using its own region policy + LLM summarizer and
9
+ # delegating the durable mutation to the `compaction` service.
10
+
11
+ - insert:
12
+ - id: force-compact
13
+ name: '@falling-ts/dsh-force-compact'
package/index.js ADDED
@@ -0,0 +1,550 @@
1
+ /**
2
+ * dsh-force-compact — a DSH Cordis function plugin.
3
+ *
4
+ * Hooks the core model-request seam so that, on **every model request**, the
5
+ * "强制压缩配置" (force-compact) settings are read:
6
+ *
7
+ * - **`agent/request`** (a Waterfall around the frozen call configuration) —
8
+ * when the `disableThinking` setting is on, the returned `LlmCallConfig`
9
+ * carries `reasoningEffort: 'off'`, which the LLM adapter maps to
10
+ * `thinking: { type: 'disabled' }`. Every model request is therefore sent
11
+ * with thinking/reasoning disabled.
12
+ * - **`agent/pre-step`** (a Waterfall before each model step) — reads the
13
+ * session's total context tokens; when they reach the `autoThresholdTokens`
14
+ * threshold the proposed step is rejected (the model request is NOT made)
15
+ * and the **latest `retainLatestTokens` of the conversation's tokens** is
16
+ * RETAINED VERBATIM (everything before that cutoff is compacted in one
17
+ * batch into a single summary node)
18
+ * compacted via the `compaction` service's `compactRegion` instead.
19
+ *
20
+ * The plugin also keeps the `session/flush` durability checkpoint: a
21
+ * checkpoint-driven compaction (its own region policy + LLM summarizer,
22
+ * delegated to the `compaction` service's `compactRegion`) so useful history
23
+ * is condensed even between model requests.
24
+ *
25
+ * Layout:
26
+ * - `index.js` — this file; the Cordis plugin entry (listener registrations).
27
+ * - `core/policy.js` — fixed compaction-policy knobs (tunables).
28
+ * - `core/settings.js` — the `falling-ts-force-compact` settings namespace (parameters + schema).
29
+ * - `core/log.js` — the debug-log sink (routes `[force-compact]` lines to `logFile`).
30
+ * - `engine/region.js` — the plugin's own head-anchored region selection.
31
+ * - `engine/summarizer.js` — the plugin's own one-shot LLM summarizer (preview + shrink gate).
32
+ * - `engine/builtin.js` — the self-contained compaction engine (`fc-compact/*` transactions).
33
+ * - `engine/checkpoint.js` — the `session/flush` checkpoint orchestrator: region → delegate to a backend.
34
+ * - `engine/backend.js` — the unified backend facade (official-service-first, builtin-fallback).
35
+ * - `hooks/guard.js` — the per-model-request guard: threshold gate + forced compaction + thinking-off.
36
+ * - `hooks/command.js` — the `/force-compact` slash command (idle → compact now; busy → queue a force flag).
37
+ * - `hooks/idle.js` — the turn-end (agent `idle`) forced compaction.
38
+ * - `web/client.js` — the browser half: the Force-Compact settings.section UI.
39
+ *
40
+ * @module @falling-ts/dsh-force-compact
41
+ */
42
+
43
+ import { compactSession } from './src/engine/checkpoint.js'
44
+ import { registerNamespace, readRawSetting } from './src/core/settings.js'
45
+ import { ensureDebugLogger } from './src/core/log.js'
46
+ import { forceCompactIfNeeded, thinkingDisabled } from './src/hooks/guard.js'
47
+ import { registerCommand } from './src/hooks/command.js'
48
+ import { handleAgentStatus } from './src/hooks/idle.js'
49
+ import { registerLlmStreamHook } from './src/hooks/wire-rewrite.js'
50
+ import { guardFn, installCrashNet } from './src/core/crashnet.js'
51
+
52
+ /** @type {string} the function plugin's display name. */
53
+ export const name = 'force-compact'
54
+
55
+ /**
56
+ * Per-session COMPRESSION SLOT — one in-flight `compactSession` operation per
57
+ * session id (keyed by `session.id`, value the SETTLING PROMISE of that
58
+ * operation). Process-local, bounded naturally by the number of live sessions —
59
+ * no timer, no persistent state.
60
+ *
61
+ * WHY THE LISTENER MUST BE SYNCHRONOUS (the "third send wedges" root cause):
62
+ * `session/flush` is an AWAITED `parallel` checkpoint — the dispatcher runs every
63
+ * listener via `Promise.allSettled` and proceeds once they ALL settle. One of
64
+ * those listeners is the PERSISTENCE COORDINATOR's own handler, which awaits
65
+ * `live.writes.flush()` — a SHARED write-behind barrier for the session id. If
66
+ * OUR listener `await`s `compactSession` INSIDE the checkpoint (as it used to),
67
+ * we participate in the flush's await path: our compaction's durable appends
68
+ * enqueue onto the coordinator's PER-ID serial `chains` bucket, and when two
69
+ * `sessions.flush` callers overlap on the same id within one event-loop tick
70
+ * (observed live: two flush checkpoints a millisecond apart, the inner firing
71
+ * while the outer was mid-`compactRegion`), the barrier and the per-id queue
72
+ * enter a mutual-wait interleave — the barrier waits on a `serialize` op whose
73
+ * `prior` never settles, so the barrier never resolves, so the outer `sessions.
74
+ * flush` never returns, so the caller's pre-step waterfall never resumes, and
75
+ * EVERY later `session.list` / `history` for that id queues behind the poisoned
76
+ * `prior` forever (event loop alive, CPU idle, specific id permanently stalled).
77
+ *
78
+ * FIX — decouple the compaction FROM the checkpoint's await path:
79
+ * 1. The listener STARTS the compaction but does NOT `await` it; it returns
80
+ * immediately (synchronously), so we are never on the flush's await path and
81
+ * cannot hold the barrier open. The `SessionStore.flush` dispatcher sees our
82
+ * listener settle instantly.
83
+ * 2. A PER-ID SLOT serializes compactions: if a compaction for the same id is
84
+ * ALREADY running, a repeat `session/flush` dispatch STARTS NOTHING (skip);
85
+ * the slot clears only when the in-flight op SETTLES, so a following flush
86
+ * after it completes starts a fresh attempt. No concurrent same-id
87
+ * `compactRegion` calls, no re-entrant recursion, no barrier interleave.
88
+ *
89
+ * DURABILITY NOTE: the checkpoint still GUARANTEES the compaction was STARTED by
90
+ * the time it fires (started-before-return, not completed-before-return). Ordering
91
+ * with the next flush is preserved because the slot suppresses overlap until
92
+ * settlement. A crash mid-compaction loses the slot (process-local) and relies on
93
+ * the durable `compaction/*` bracket (an unclosed `compaction/start` surfaces as
94
+ * a `busy` assertion on reload — the expected, safe failure mode).
95
+ * @type {Map<string, Promise<void>>}
96
+ */
97
+ const compactSlot = new Map()
98
+
99
+ /**
100
+ * Register the model-request Waterfalls, the `session/flush` listener, and the
101
+ * `falling-ts-force-compact` settings namespace (the "强制压缩配置" surface).
102
+ *
103
+ * `compaction` is a runtime dependency provided by the preset plane
104
+ * (`include:agent-presets:compaction-basic`, enabled and mounted by default).
105
+ * In modern harness compositions it is mounted **per agent realm** (each preset
106
+ * isolates it), so the plugin-GLOBAL `ctx.get('compaction')` is `undefined`
107
+ * while the listener's OWN context — `this` inside an `agent/*` callback, the
108
+ * agent's scoped context — resolves the instance. Every compaction path therefore
109
+ * captures the LISTENER context (`ctx.on(event, function (p, n) { … })` binds
110
+ * `this` to the dispatch context) and locates the service through it, with a
111
+ * host-global fallback (see `engine/backend.js`). Missing-service cases are
112
+ * still guarded (`undefined` → skip with a log), so a gap never blocks a
113
+ * listener. The plugin therefore declares no `inject` — profile entries activate
114
+ * at process boot, before the preset plane mounts the service, and a boot-time
115
+ * `inject` would fail the boot assertion. `agents`, `settings`, `tokenMeter`, and
116
+ * `commands` are likewise optional: each is read with `ctx.get(...)` and guarded
117
+ * against `undefined` (the plugin falls back to its composition defaults, a
118
+ * coarse estimate, or a skipped registration).
119
+ *
120
+ * @param {import('@deepseek-ai/cordis').Context} ctx
121
+ */
122
+ const __applyInner = (ctx) => {
123
+ ctx.logger.info('[force-compact] apply START; settings=' + (ctx.get('settings') !== undefined ? 'present' : 'ABSENT') + ' compaction=' + (ctx.get('compaction') !== undefined ? 'present' : 'ABSENT'))
124
+ // Register the `falling-ts-force-compact` settings namespace. This is done
125
+ // LAZILY and IDEMPOTENTLY rather than in a boot-time effect because the
126
+ // `settings` service (and the schemastery schema builder it depends on) can
127
+ // arrive well AFTER this plugin's boot-time effects run — the same late-mount
128
+ // ordering that makes a boot-time `ctx.get('fs')` observe `undefined`. A
129
+ // boot-time registration attempt would silently no-op and leave the settings
130
+ // panel permanently stuck on "loading". Instead it is attempted at the top of
131
+ // each guarded listener (where services are guaranteed live) until it settles:
132
+ // `settingsState.attempted` records a settled outcome, `installed` means the
133
+ // namespace is registered and further attempts are a cheap early return.
134
+ const settingsState = { settled: false, installed: false, warnedSchemas: false, scheduled: false }
135
+ // Schedule a bounded retry of the namespace install. Called only while the
136
+ // `settings` service is still absent at the attempt site (boot or a guarded
137
+ // listener that ran before the preset plane mounted it). Each retry re-checks;
138
+ // on success the latch settles and the timer self-clears. Because it settles
139
+ // and cancels itself on completion, it is installation bookkeeping, not a
140
+ // persistent timer or piece of long-lived state.
141
+ const RETRY_DELAY_MS = 750
142
+ const RETRY_MAX_ATTEMPTS = 40
143
+ const retryTimer = { value: undefined }
144
+ const maybeRetryRegister = () => {
145
+ if (settingsState.settled || retryTimer.value !== undefined) return
146
+ let attempts = 0
147
+ const attempt = () => {
148
+ retryTimer.value = undefined
149
+ if (settingsState.settled) return
150
+ attempts += 1
151
+ void (async () => {
152
+ const result = await tryRegisterOnce()
153
+ if (result) {
154
+ // Settled (success, or a terminal "schema build failed" outcome).
155
+ if (retryTimer.value !== undefined) clearTimeout(retryTimer.value)
156
+ retryTimer.value = undefined
157
+ return
158
+ }
159
+ // Still missing; bound the retry count so a genuinely absent service
160
+ // cannot spin forever. After the cap we stop scheduling and leave the
161
+ // guarded listeners (agent/* events) as the final safety net.
162
+ if (attempts >= RETRY_MAX_ATTEMPTS) {
163
+ settingsState.settled = true
164
+ return
165
+ }
166
+ if (settingsState.settled) return
167
+ retryTimer.value = setTimeout(attempt, RETRY_DELAY_MS)
168
+ })().catch(() => {})
169
+ }
170
+ attempt()
171
+ }
172
+ const tryRegisterOnce = async () => {
173
+ if (settingsState.settled) return true
174
+ const settings = ctx.get('settings')
175
+ if (settings === undefined || typeof settings.register !== 'function') {
176
+ // Settings service not mounted yet; keep retrying (do NOT settle).
177
+ return false
178
+ }
179
+ try {
180
+ const ok = await registerNamespace(ctx)
181
+ settingsState.settled = true
182
+ if (ok) {
183
+ settingsState.installed = true
184
+ ctx.logger.info('[force-compact] registered settings namespace "falling-ts-force-compact"')
185
+ } else {
186
+ // `settings` exists but `buildSchema()` failed (typically the
187
+ // schemastery bare-module import could not resolve in this loader).
188
+ // Settle so we stop retrying, but WARN so the silent no-op is
189
+ // diagnosable; warn only once.
190
+ if (!settingsState.warnedSchemas) {
191
+ settingsState.warnedSchemas = true
192
+ ctx.logger.warn(
193
+ '[force-compact] settings present but schema build failed — ' +
194
+ 'namespace "falling-ts-force-compact" NOT registered (check @deepseek-ai/schemastery resolvability)',
195
+ )
196
+ }
197
+ }
198
+ return true
199
+ } catch (error) {
200
+ const message = error instanceof Error ? error.stack || error.message : String(error)
201
+ // Transient failure: keep retrying (do NOT settle), warn once.
202
+ if (!settingsState.warnedSchemas) {
203
+ settingsState.warnedSchemas = true
204
+ ctx.logger.warn(`[force-compact] settings namespace registration threw — ${message}`)
205
+ }
206
+ return false
207
+ }
208
+ }
209
+ // Entry point invoked at boot (eager) and atop each guarded listener. Tries
210
+ // once NOW; if the service is absent it schedules a bounded self-cancelling
211
+ // retry instead of giving up, so a cold-start with no agent traffic still
212
+ // lands the namespace (and therefore un-sticks the settings panel).
213
+ const maybeRegisterSettingsNamespace = () => {
214
+ if (settingsState.settled) return
215
+ void (async () => {
216
+ const ok = await tryRegisterOnce()
217
+ if (!ok) maybeRetryRegister()
218
+ })().catch(() => {})
219
+ }
220
+
221
+ // Route this plugin's own `[force-compact]` log lines to a durable file when
222
+ // debug logging is enabled. Installed lazily: `ensureDebugLogger` is invoked
223
+ // at the top of each guarded listener (the first moment real work runs) and
224
+ // installs at most once (an idempotent latch), so repeated invocations are a
225
+ // cheap boolean check. It writes through native Node `fs` to `logFile`
226
+ // (default `~/.dsh/logs/dsh-force-compact.log`, under the shared user
227
+ // `$DSH_HOME`, kept out of any single checkout) — bypassing the product `fs`
228
+ // service's workspace fence, which refuses an absolute user-home path.
229
+ // Gated by the `debug` setting (default `true`). Observer-only: a failure
230
+ // here never disturbs the request paths.
231
+ const maybeInstallDebugSink = () => {
232
+ void ensureDebugLogger(ctx).catch((error) => {
233
+ const message = error instanceof Error ? error.message : String(error)
234
+ ctx.logger.warn(`[force-compact] debug log sink failed — ${message}`)
235
+ })
236
+ }
237
+
238
+ // Attempt BOTH installations ONCE NOW, at boot (fire-and-forget). When a
239
+ // service is already mounted this completes it immediately; when it is still
240
+ // undefined (the usual case at this early point — the `agent-presets:*` plane
241
+ // mounts shortly after) the settings installer additionally schedules a
242
+ // bounded, self-cancelling retry rather than waiting passively for the first
243
+ // agent/* event. Doing this eagerly matters for the settings namespace
244
+ // specifically: a browser client that opens the settings page BEFORE any
245
+ // agent activity would otherwise wait indefinitely for the namespace to
246
+ // appear. The eager boot call and the guarded-listener calls are safe to
247
+ // overlap (the latches deduplicate), and these are observer-only paths whose
248
+ // failures never disturb requests.
249
+ maybeInstallDebugSink()
250
+ maybeRegisterSettingsNamespace()
251
+
252
+ // Each registration is wrapped in a labeled, logged try/catch so a real-
253
+ // runtime throw is PINPOINTED (name + full stack) and CONTAINED — a bad
254
+ // effect in one registration must not prevent the other listeners from
255
+ // mounting. Keeping these lightweight guards is deliberate: an optional
256
+ // feature (a missing `commands` service, a transient settings glitch)
257
+ // should degrade gracefully rather than abort the whole entry.
258
+ const guard = (label, fn) => {
259
+ try {
260
+ fn()
261
+ } catch (error) {
262
+ const detail = error instanceof Error ? (error.stack || error.message) : String(error)
263
+ ctx.logger.error(`[force-compact][diag] FAILED to register '${label}' — ${detail}`)
264
+ }
265
+ }
266
+
267
+ // Cancel the pending namespace-install retry when this fiber tears down, so
268
+ // no stray timer survives plugin stop/removal. `clearTimeout(undefined)` is a
269
+ // safe no-op when nothing is scheduled.
270
+ guard('settings install retry cleanup', () => {
271
+ ctx.effect(() => () => {
272
+ if (retryTimer.value !== undefined) clearTimeout(retryTimer.value)
273
+ }, 'force-compact: settings install retry cleanup')
274
+ })
275
+
276
+ // Register the `/force-compact` slash command (idle → compact now; busy →
277
+ // queue a force flag the `agent/pre-step` hook consumes). NO-OP at boot when
278
+ // the `commands` service is not mounted YET (typical: the service arrives
279
+ // with the preset plane shortly after this plugin's boot-time effect runs).
280
+ // Mirrors the settings-namespace lazy-install pattern: attempt at boot, then
281
+ // re-attempt at the top of EVERY guarded listener (`agent/request`,
282
+ // `agent/pre-step`, etc.) until it settles. A successful registration is
283
+ // idempotent (registering the same-name twice is a no-op at worst), but we
284
+ // settle on the first success so subsequent listeners pay only a boolean
285
+ // check. A permanent absence (deployment genuinely lacks `commands`) leaves
286
+ // the listeners trying on each event until they give up — that is intentional
287
+ // degradation: the rest of the plugin continues working, the command simply
288
+ // remains unregistered.
289
+ const commandState = { settled: false, warnedAbsent: false }
290
+ const COMMAND_WARN_AFTER_MS = 10 * 60 * 1000
291
+ const commandWarnScheduled = { value: false }
292
+ // Make a PERMANENTLY absent `commands` service diagnosable. While the service
293
+ // is simply still arriving (the normal boot→preset-plane window) nothing is
294
+ // emitted; only if the command has STILL not registered ten minutes after
295
+ // `apply` does a single warn explain the silent symptom (empty slash-command
296
+ // picker). Self-cancelling: nothing left running past the plugin's lifetime.
297
+ const scheduleAbsenceWarning = () => {
298
+ if (commandState.settled || commandWarnScheduled.value) return
299
+ commandWarnScheduled.value = true
300
+ ctx.effect(() => () => {
301
+ if (timerValue !== undefined) clearTimeout(timerValue)
302
+ timerValue = undefined
303
+ }, 'force-compact: command-absence warning cleanup')
304
+ let timerValue
305
+ timerValue = setTimeout(() => {
306
+ timerValue = undefined
307
+ if (!commandState.settled) {
308
+ ctx.logger.warn(
309
+ '[force-compact] /force-compact command still UNREGISTERED 10 min after plugin boot — '
310
+ + 'the `commands` service does not appear to be mounted in this composition.',
311
+ )
312
+ }
313
+ }, COMMAND_WARN_AFTER_MS)
314
+ }
315
+ const maybeRegisterCommand = () => {
316
+ if (commandState.settled) return
317
+ if (typeof registerCommand !== 'function') return
318
+ const ok = (() => {
319
+ try { return registerCommand(ctx) === true }
320
+ catch (error) {
321
+ const message = error instanceof Error ? (error.stack || error.message) : String(error)
322
+ if (!commandState.warnedAbsent) {
323
+ commandState.warnedAbsent = true
324
+ ctx.logger.warn(`[force-compact] /force-compact command registration THREW — ${message}`)
325
+ }
326
+ return false
327
+ }
328
+ })()
329
+ if (ok) {
330
+ commandState.settled = true
331
+ ctx.logger.info('[force-compact] /force-compact command registered (deferred)')
332
+ return
333
+ }
334
+ scheduleAbsenceWarning()
335
+ }
336
+ // NOTE: no boot-time invocation here. At `apply` execution the `commands`
337
+ // service is guaranteed absent (preset plane hasn't mounted yet), so a
338
+ // boot attempt would only emit a misleading "MISSING" diagnostic. Instead,
339
+ // EVERY guarded listener invokes `maybeRegisterCommand()` as its first
340
+ // action; the first successful attempt settles the latch permanently.
341
+
342
+ // The llm/stream wire-rewrite hook (appends `reasoning_effort:"none"` for
343
+ // OpenAI-compatible targets like :8080 llama.cpp when `disableThinking` is
344
+ // on). Registered lazily via `maybeInstallWireRewrite` from each guarded
345
+ // listener below — same defer pattern as the command registration. The
346
+ // hook lives at `src/hooks/wire-rewrite.js`.
347
+ const maybeInstallWireRewrite = () => {
348
+ registerLlmStreamHook(ctx)
349
+ }
350
+
351
+ // Hook the core model request: when "disable thinking" is on, every model
352
+ // request carries reasoningEffort: 'off'. Reading the settings here (per
353
+ // request) means a settings.yaml edit is picked up on the next request.
354
+ // `agent/request` is a Waterfall — `await next()` yields the config the
355
+ // machine would use; returning a replacement switches it.
356
+ guard('agent/request listener', () => ctx.on('agent/request', async (payload, next) => {
357
+ // SAFETY ENVELOPE: this is a PER-MODEL-REQUEST seam — an anomaly (a
358
+ // non-object `config` seed, a rejecting `thinkingDisabled`, a Proxy that
359
+ // traps on spread) must degrade to PASSING THROUGH the original config so
360
+ // the request proceeds normally, never crashing the request chain.
361
+ try {
362
+ maybeInstallDebugSink()
363
+ maybeRegisterSettingsNamespace()
364
+ maybeRegisterCommand()
365
+ maybeInstallWireRewrite()
366
+ return await __agentRequestListenerBody(ctx, payload, next)
367
+ } catch (error) {
368
+ const message = error instanceof Error ? error.message : String(error)
369
+ ctx.logger.warn(`[force-compact] agent/request listener degraded — forwarding config unchanged (swallowed: ${message})`)
370
+ try { return await next() } catch { return undefined }
371
+ }
372
+ }))
373
+
374
+ /** Body of the `agent/request` listener; wrapped by its safe envelope above. */
375
+ async function __agentRequestListenerBody(ctx, payload, next) {
376
+ const config = await next()
377
+ if (!payload || config === undefined || config === null) return config
378
+ if (!(await thinkingDisabled(ctx))) {
379
+ // disableThinking=false (setting off): leave the machine's config untouched.
380
+ ctx.logger.debug('[force-compact] agent/request: disableThinking=false — leaving reasoning effort unchanged')
381
+ return config
382
+ }
383
+ // `config` may be a non-object seed; guard the property reads so a weird shape
384
+ // degrades to returning it untouched rather than throwing on `.reasoningEffort`.
385
+ const isObj = (config !== null && typeof config === 'object')
386
+ const currentEffort = isObj ? config.reasoningEffort : undefined
387
+ if (currentEffort === 'off') {
388
+ // Already off — nothing to switch (still proves the guard is active on this request).
389
+ ctx.logger.debug('[force-compact] agent/request: reasoningEffort already off — no change')
390
+ return config
391
+ }
392
+ if (!isObj) return config
393
+ ctx.logger.debug(`[force-compact] agent/request: applying reasoningEffort=off (disableThinking=true) — original=${currentEffort ?? '(unset)'}`)
394
+ return { ...config, reasoningEffort: 'off' }
395
+ }
396
+
397
+ // Before each model step, run a forced/threshold-triggered compaction as a
398
+ // side effect. `agent/pre-step` is a Waterfall: after the hook processing
399
+ // finishes it MUST route back through the original step decision (`next()`)
400
+ // on every path — the compaction is a side effect; the step decision itself
401
+ // is always `next()`'s (the same pattern as the official `compaction-basic`:
402
+ // compact, then unconditionally `return next()`). Returning a value such as
403
+ // `{ kind: 'reject' }` without ever calling `next()` stalls the request chain.
404
+ guard('agent/pre-step listener', () => ctx.on('agent/pre-step', async (payload, next) => {
405
+ // SAFETY ENVELOPE: the `maybeInstall*` prologue and the terminal `next()`
406
+ // hop sit OUTSIDE the inner compaction try/catch — a throwing install or a
407
+ // rejecting `next()` would otherwise escape the per-step seam. Contain them
408
+ // so the step ALWAYS routes through `next()` (the Waterfall requirement).
409
+ try { maybeInstallDebugSink() } catch { /* non-fatal */ }
410
+ try { maybeRegisterSettingsNamespace() } catch { /* non-fatal */ }
411
+ try { maybeRegisterCommand() } catch { /* non-fatal */ }
412
+ try { maybeInstallWireRewrite() } catch { /* non-fatal */ }
413
+ const agent = payload && payload.agent
414
+ const signal = payload && payload.signal
415
+ if (agent !== undefined && agent !== null && (signal === undefined || !signal.aborted)) {
416
+ try {
417
+ // The resolver locates the per-realm compaction backend through
418
+ // `agent.ctx` (see `engine/backend.js`). We pass the PLUGIN-GLOBAL
419
+ // `ctx` as the fallback context and read the mode once here (raw, cheap)
420
+ // so the hot path never pays a full-settings-parse cost.
421
+ const mode = await readRawSetting(ctx, 'compactionMode')
422
+ await forceCompactIfNeeded(ctx, agent, signal, mode)
423
+ } catch (error) {
424
+ const message = error instanceof Error ? error.message : String(error)
425
+ ctx.logger.warn(`[force-compact] ${(agent && typeof agent.id === 'string') ? agent.id : '?'}: request guard failed — ${message}`)
426
+ }
427
+ }
428
+ try {
429
+ return await next()
430
+ } catch (error) {
431
+ const message = error instanceof Error ? error.message : String(error)
432
+ ctx.logger.warn(`[force-compact] agent/pre-step next() hop degraded (swallowed: ${message})`)
433
+ return undefined
434
+ }
435
+ }))
436
+
437
+ // Turn-end forced compaction: when `turnEndForceCompactionEnabled` is on,
438
+ // compact the earliest `turnEndCompactionRatio` of the conversation's tokens
439
+ // when the agent transitions to `idle` (all turns done, including sub-
440
+ // agents, before the next human turn).
441
+ guard('agent/status listener', () => ctx.on('agent/status', (payload) => {
442
+ // This listener fires FIRST for a fresh session (the agent goes `idle`
443
+ // almost immediately) — often before ANY `agent/request` / `agent/pre-step`
444
+ // event has arrived, i.e. possibly before the preset plane has mounted the
445
+ // `commands` service. It therefore joins the deferred-registration loop too
446
+ // (as the other guarded listeners do) so the first idle transition is what
447
+ // typically settles the `/force-compact` command registration.
448
+ maybeRegisterCommand()
449
+ maybeInstallWireRewrite()
450
+ // Fire-and-forget: trace listener liveness on the idle transition, read the
451
+ // compactionMode raw (cheap), then hand off to the turn-end handler which
452
+ // locates the per-realm compaction backend via `agent.ctx`.
453
+ // SAFETY: this IIFE has NO other error boundary — an anomaly (a missing
454
+ // `payload.agent.session`, a rejecting `readRawSetting`, or a throwing
455
+ // `handleAgentStatus`) would otherwise become an UNHANDLED REJECTION. Attach
456
+ // a `.catch` so every path settles cleanly. `handleAgentStatus` itself is
457
+ // envelope-guarded; this is belt-and-braces for the sid extraction + mode
458
+ // read that precede it.
459
+ void (async () => {
460
+ const st = (payload && typeof payload === 'object') ? payload.status : undefined
461
+ if (st === 'idle') {
462
+ const agentObj = (payload && typeof payload === 'object' && payload.agent && typeof payload.agent === 'object') ? payload.agent : undefined
463
+ const sess = (agentObj && agentObj.session) ? agentObj.session : undefined
464
+ const sid = (sess && typeof sess.id === 'string') ? sess.id : '?'
465
+ ctx.logger.debug(`[force-compact] agent/status fired: idle for ${sid} — evaluating turn-end compaction`)
466
+ }
467
+ const mode = await readRawSetting(ctx, 'compactionMode')
468
+ await handleAgentStatus(ctx, payload, mode)
469
+ })()
470
+ .catch(error => {
471
+ const message = error instanceof Error ? error.message : String(error)
472
+ ctx.logger.warn(`[force-compact] agent/status idle-turn handler degraded (swallowed) — ${message}`)
473
+ })
474
+ }))
475
+
476
+ // Checkpoint-driven compaction: condense useful history at each durability
477
+ // checkpoint (its own region policy + LLM summarizer, delegated to
478
+ // compactRegion), independent of the per-request guard.
479
+ ctx.logger.info('[force-compact][diagnostic] apply END (all registrations attempted)')
480
+
481
+ guard('session/flush listener', () => ctx.on('session/flush', (session) => {
482
+ // SAFETY ENVELOPE: `session/flush` is an AWAITED parallel checkpoint — a
483
+ // throw escaping this listener would break the persistence checkpoint on
484
+ // EVERY flush. The synchronous prologue (service installs, agent lookup,
485
+ // slot check) is NOT covered by the async IIFE's own `.catch`, so the whole
486
+ // callback body is wrapped: any anomaly logs and returns cleanly. The async
487
+ // IIFE keeps its own `.catch`/`.finally` for the background compaction op.
488
+ try {
489
+ maybeInstallDebugSink()
490
+ maybeRegisterSettingsNamespace()
491
+ maybeRegisterCommand()
492
+ const sid = (session && typeof session.id === 'string') ? session.id : '?'
493
+ const agents = ctx.get('agents')
494
+ if (agents === undefined) return
495
+ const agent = agents.get(sid)
496
+ if (agent === undefined || agent === null) {
497
+ ctx.logger.debug(`[force-compact] ${sid}: no live agent — skipping`)
498
+ return
499
+ }
500
+ // COMPRESSION SLOT: if a flush-driven compaction for this session id is
501
+ // ALREADY running, a repeat `session/flush` dispatch starts NOTHING and
502
+ // returns immediately — suppressing the concurrent / re-entrant same-id
503
+ // `compactRegion` call that interleaves with the persistence coordinator's
504
+ // per-id chain (the "third send wedges" deadlock vector). The listener is
505
+ // SYNCHRONOUS: it starts the op and returns at once so it is never on the
506
+ // checkpoint's await path (which is what lets the write-behind barrier
507
+ // settle and `session.list` stay responsive). See `compactSlot` above.
508
+ if (compactSlot.has(sid)) {
509
+ ctx.logger.debug(`[force-compact] ${sid}: session/flush dispatched while a compaction slot is still settling — starting no duplicate (serialized by the slot)`)
510
+ return
511
+ }
512
+ const controller = new AbortController()
513
+ // Start the compaction, attach settlement cleanup, store the settling
514
+ // promise as the id's slot. Never `await`ed here — the listener returns
515
+ // before this op progresses, keeping the checkpoint non-blocking.
516
+ const op = (async () => {
517
+ const mode = await readRawSetting(ctx, 'compactionMode')
518
+ await compactSession(ctx, agent, controller, mode)
519
+ })()
520
+ .catch(error => {
521
+ const message = error instanceof Error ? error.message : String(error)
522
+ ctx.logger.warn(`[force-compact] ${sid}: flush-triggered compaction failed — ${message}`)
523
+ })
524
+ .finally(() => { compactSlot.delete(sid) })
525
+ compactSlot.set(sid, op)
526
+ } catch (error) {
527
+ const message = error instanceof Error ? error.message : String(error)
528
+ const sid = (session && typeof session.id === 'string') ? session.id : '?'
529
+ ctx.logger.warn(`[force-compact] ${sid}: session/flush listener degraded (swallowed) — ${message}`)
530
+ }
531
+ }))
532
+ }
533
+
534
+ /**
535
+ * Plugin entry — the UNIVERSAL-CRASH-NET-covered form of {@link __applyInner}.
536
+ *
537
+ * Every public entry in the plugin is routed through {@link guardFn}: the
538
+ * wrapper catches any throw (sync or promise rejection) crossing this
539
+ * boundary, appends a full diagnostic (function name, thrownAt
540
+ * `file:line:col`, deepest plugin frame, nearest non-plugin frame, full call
541
+ * stack) to the durable crash log, and propagates the original outcome
542
+ * unchanged. `apply` is called ONCE per fiber at boot — the process-wide net
543
+ * ({@link installCrashNet}) is installed from inside the inner body before
544
+ * any listener is registered.
545
+ */
546
+ export const apply = guardFn('index.apply', (ctx) => {
547
+ // Process-wide net — at most one install per process, before anything else.
548
+ installCrashNet()
549
+ return __applyInner(ctx)
550
+ })
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@falling-ts/dsh-force-compact",
3
+ "version": "0.2.0",
4
+ "description": "DSH Cordis plugin: hooks the core model-request seam (agent/pre-step + agent/request) to force-compact a session's context and disable thinking per the \"强制压缩配置\" settings namespace (disableThinking, autoThresholdTokens, retainLatestTokens, turnEndForceCompactionEnabled). When the threshold fires (or /force-compact queues while busy), the latest `retainLatestTokens` of the conversation's surface tokens are KEPT VERBATIM and everything before that cutoff is COMPACTED INTO A SINGLE SUMMARY NODE in one LLM call (original span entries become shadowed/skipped). Also compacts at each turn/end and at each session/flush durability checkpoint. Host half is a pure listener; a web client half registers a settings.section (强制压缩 / Force Compact) that reads and writes the same settings namespace.",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./web/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "index.js",
14
+ "src/",
15
+ "web/",
16
+ "cordis.patch.yml"
17
+ ],
18
+ "license": "MIT",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "dsh": {
23
+ "bundle": {
24
+ "patch": "./cordis.patch.yml"
25
+ },
26
+ "client": {
27
+ "platform": "web",
28
+ "inject": [
29
+ "@deepseek-ai/dsh-client-ui-settings",
30
+ "@deepseek-ai/dsh-client-locale"
31
+ ]
32
+ }
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/falling-ts/dsh-force-compact.git"
37
+ },
38
+ "keywords": [
39
+ "dsh",
40
+ "cordis",
41
+ "compaction",
42
+ "auto-compact",
43
+ "session",
44
+ "settings",
45
+ "force-compact",
46
+ "thinking",
47
+ "reasoningEffort",
48
+ "agent/pre-step",
49
+ "agent/request",
50
+ "slash-command",
51
+ "turn-end",
52
+ "earliest-ratio"
53
+ ]
54
+ }