@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
package/cordis.patch.yml
ADDED
|
@@ -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
|
+
}
|