@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,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-force-compact settings — the "强制压缩配置" (Force-Compact Configuration)
|
|
3
|
+
* surface.
|
|
4
|
+
*
|
|
5
|
+
* User-tunable parameters are registered under the `falling-ts-force-compact`
|
|
6
|
+
* settings namespace so the harness settings panel can expose and persist them
|
|
7
|
+
* (the `falling-ts-` prefix prevents collisions with other plugins' keys):
|
|
8
|
+
*
|
|
9
|
+
* - `disableThinking` (boolean, default `true`): when true, the plugin's
|
|
10
|
+
* compaction summarization request carries `reasoningEffort: 'off'`, which
|
|
11
|
+
* the LLM adapter maps to `thinking: { type: 'disabled' }` — i.e. the
|
|
12
|
+
* provider's thinking/reasoning is switched off for the summarization call.
|
|
13
|
+
* - `autoThresholdTokens` (positive integer, default `32000`, floor `32000`):
|
|
14
|
+
* the automatic compaction trigger threshold in tokens. Compaction runs only
|
|
15
|
+
* when the session's estimated total context is at least this many tokens;
|
|
16
|
+
* below it, the checkpoint is skipped. Stored values BELOW the floor are
|
|
17
|
+
* coerced UP to it at read time (and the schema rejects sub-floor drafts).
|
|
18
|
+
* - `retainLatestTokens` (positive integer, default `8000`, floor `8000`): the
|
|
19
|
+
* ABSOLUTE TOKEN COUNT retained at the LATEST end of the session's surface
|
|
20
|
+
* when an auto or forced compaction fires. Starting from the newest surface
|
|
21
|
+
* node and walking BACKWARD (latest → oldest) using the official
|
|
22
|
+
* `tokenMeter`'s per-node token prices, node tokens accumulate until the
|
|
23
|
+
* running sum
|
|
24
|
+
* REACHES OR EXCEEDS this budget; everything before that cutoff forms the
|
|
25
|
+
* head-anchored region compacted into a single summary node in one LLM call
|
|
26
|
+
* (the original entries of the compacted span become shadowed / skipped in
|
|
27
|
+
* derived history). Replaces the former `autoEarliestRatio` /
|
|
28
|
+
* `forceEarliestRatio` percentage knobs with a fixed retention target
|
|
29
|
+
* independent of the (possibly usage-inflated) `totalTokens` denominator.
|
|
30
|
+
* - `turnEndForceCompactionEnabled` (boolean, default `true`): whether a turn-end
|
|
31
|
+
* forced compaction runs when the agent transitions to `idle` — compaction
|
|
32
|
+
* goes through the engine's idle manual entry (`compactNow`), which uses its
|
|
33
|
+
* own range selection (the idle path cannot select a custom token fraction,
|
|
34
|
+
* so there is no turn-end ratio parameter).
|
|
35
|
+
* - `debug` (boolean, default `true`): the gate for the debug log
|
|
36
|
+
* (`core/log.js`). **On by default** so the plugin's `[force-compact]`
|
|
37
|
+
* diagnostics always land in the debug file; set `false` for a production
|
|
38
|
+
* deployment to suppress the file. There is no environment auto-detection —
|
|
39
|
+
* the operator declares intent directly via this flag.
|
|
40
|
+
* - `logFile` (string, default `~/.dsh/logs/dsh-force-compact.log`): the
|
|
41
|
+
* debug-log target path. Leading `~` expands to the OS user home, so by
|
|
42
|
+
* default the log sits under the shared user `$DSH_HOME` (`~/.dsh/logs/`),
|
|
43
|
+
* independent of any single session's workspace — keeping the log out of the
|
|
44
|
+
* official `deepseek-harness` checkout. Any absolute path may override it.
|
|
45
|
+
* - `compactionMode` (`'realm'` | `'global'`, default `'realm'`): how the
|
|
46
|
+
* plugin locates the official `compaction` service (realm-scoped per-agent
|
|
47
|
+
* vs host-global). See `COMPACT_MODE_*` constants below.
|
|
48
|
+
* - `builtinEnabled` (boolean, default `true`): the gate for this plugin's
|
|
49
|
+
* own self-contained compaction engine (see `engine/builtin.js`). When
|
|
50
|
+
* the official `compaction` service is reachable (host-global mount), it is
|
|
51
|
+
* always preferred; when unreachable (the standard preset realm-isolates it)
|
|
52
|
+
* the plugin falls back to the builtin engine — which runs the full durable
|
|
53
|
+
* transaction itself (reusing the OFFICIAL `compaction/*` event vocabulary, own checkpoint
|
|
54
|
+
* `user/message` shadowing a head-anchored span, own shrink gate). Setting
|
|
55
|
+
* this to `false` disables that fallback so ONLY the official backend is
|
|
56
|
+
* attempted.
|
|
57
|
+
* - `maxSummaryTokens` (positive integer, default `1024`, floor `1024`): the
|
|
58
|
+
* `maxTokens` bound applied to the plugin's OWN summarization LLM call — a
|
|
59
|
+
* cap on the summary length. Combined with the shrink gate (the summary must
|
|
60
|
+
* be strictly smaller than the span it replaces), this prevents runaway
|
|
61
|
+
* summarizer outputs from ballooning past the region being condensed.
|
|
62
|
+
* Stored values below the floor are coerced up to it at read time.
|
|
63
|
+
*
|
|
64
|
+
* The namespace is registered against the `settings` service when one is
|
|
65
|
+
* mounted. The schema is BUILT BEST-EFFORT through `@deepseek-ai/schemastery`:
|
|
66
|
+
* when that bare module cannot be resolved from the plugin's install location
|
|
67
|
+
* (common when the plugin is developed outside a node_modules root, where Node
|
|
68
|
+
* cannot walk up to find it), {@linkcode buildSchema} returns `null` and
|
|
69
|
+
* {@linkcode registerNamespace} falls back to registering the namespace with
|
|
70
|
+
* **defaults only** (editable fields, no validation metadata) rather than
|
|
71
|
+
* skipping registration entirely — so the settings panel still loads and the
|
|
72
|
+
* values remain readable/writable. A `settings` service absence still results
|
|
73
|
+
* in a no-op, so it is never a hard dependency.
|
|
74
|
+
*
|
|
75
|
+
* @module @falling-ts/dsh-force-compact/settings
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The settings namespace id for the force-compact configuration.
|
|
80
|
+
*
|
|
81
|
+
* Prefixed `falling-ts-` so it cannot collide with another plugin's
|
|
82
|
+
* `force-compact` namespace — the `falling-ts` vendor prefix is shared by
|
|
83
|
+
* every setting key this project owns. It is the top-level key in
|
|
84
|
+
* `$DSH_HOME/settings.yaml`.
|
|
85
|
+
*/
|
|
86
|
+
export const NS = 'falling-ts-force-compact'
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Accepted values for the `compactionMode` setting.
|
|
90
|
+
*
|
|
91
|
+
* The `compaction` backend is mounted in two fundamentally different shapes
|
|
92
|
+
* depending on the composition:
|
|
93
|
+
*
|
|
94
|
+
* - **modern presets**: the compaction backend (`compaction-basic`) is isolated
|
|
95
|
+
* into each agent preset's **realm** (see the `standard` preset, which puts
|
|
96
|
+
* `compaction` inside `- isolate:`), so the HOST-GLOBAL `ctx.get('compaction')`
|
|
97
|
+
* is `undefined` while each live agent's OWN context resolves the instance.
|
|
98
|
+
* - **base/global bundles**: `compaction-basic` is a top-level host row and the
|
|
99
|
+
* service is visible at global scope.
|
|
100
|
+
*
|
|
101
|
+
* `COMPACT_MODE_REALM` (default) tries the agent's realm-scoped context first,
|
|
102
|
+
* then falls back to the host-global `ctx.get`, then the injected
|
|
103
|
+
* `ctx.compaction` property — so it covers every layout. `COMPACT_MODE_GLOBAL`
|
|
104
|
+
* restricts resolution to the host-global lookup only (for a deployment known to
|
|
105
|
+
* mount the backend globally).
|
|
106
|
+
*/
|
|
107
|
+
export const COMPACT_MODE_REALM = 'realm'
|
|
108
|
+
export const COMPACT_MODE_GLOBAL = 'global'
|
|
109
|
+
export const COMPACT_MODES = [COMPACT_MODE_REALM, COMPACT_MODE_GLOBAL]
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Composition defaults for the parameters. These are the `base` layer the
|
|
113
|
+
* settings namespace resolves over, so a field the user has not overridden
|
|
114
|
+
* resolves to these values.
|
|
115
|
+
* @type {Readonly<{
|
|
116
|
+
* disableThinking: boolean,
|
|
117
|
+
* autoThresholdTokens: number,
|
|
118
|
+
* retainLatestTokens: number,
|
|
119
|
+
* turnEndForceCompactionEnabled: boolean,
|
|
120
|
+
* debug: boolean,
|
|
121
|
+
* logFile: string,
|
|
122
|
+
* compactionMode: string,
|
|
123
|
+
* builtinEnabled: boolean,
|
|
124
|
+
* maxSummaryTokens: number,
|
|
125
|
+
* }>}
|
|
126
|
+
*/
|
|
127
|
+
/** Default debug-log destination: the shared user `$DSH_HOME/logs/` dir. */
|
|
128
|
+
export const DEFAULT_LOG_FILE = '~/\.dsh/logs/dsh-force-compact.log'.replace('\\', '/')
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Floor applied to the three token-scale parameters on EVERY read. Stored values
|
|
132
|
+
* BELOW the floor are coerced UP to it (rather than rejected), so a hand-edited
|
|
133
|
+
* settings.yaml with an out-of-band value still yields a legal runtime setting.
|
|
134
|
+
* The web form mirrors these floors as input constraints; the server-side clamp
|
|
135
|
+
* is authoritative in any race (e.g. a stale draft written by a different client
|
|
136
|
+
* while the form was open).
|
|
137
|
+
* @type {Readonly<{
|
|
138
|
+
* autoThresholdTokens: number,
|
|
139
|
+
* retainLatestTokens: number,
|
|
140
|
+
* maxSummaryTokens: number,
|
|
141
|
+
* }>}
|
|
142
|
+
*/
|
|
143
|
+
export const MIN_TOKEN_SCALES = Object.freeze({
|
|
144
|
+
autoThresholdTokens: 32000,
|
|
145
|
+
retainLatestTokens: 8000,
|
|
146
|
+
maxSummaryTokens: 1024,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
export const DEFAULTS = Object.freeze({
|
|
150
|
+
disableThinking: true,
|
|
151
|
+
autoThresholdTokens: 32000,
|
|
152
|
+
// Absolute TOKEN COUNT retained at the LATEST end of the surface when an
|
|
153
|
+
// auto / forced compaction fires. Starting from the newest surface node and
|
|
154
|
+
// walking backward, node tokens (from the official `tokenMeter` per-node
|
|
155
|
+
// prices) accumulate until the running sum REACHES OR EXCEEDS this budget;
|
|
156
|
+
// everything BEFORE that cutoff forms the head-anchored region compacted
|
|
157
|
+
// into a single summary node in one summarizer call. Replaces the former
|
|
158
|
+
// `autoEarliestRatio` / `forceEarliestRatio` percentage knobs with a fixed,
|
|
159
|
+
// predictable retention target independent of the (potentially
|
|
160
|
+
// usage-inflated) `totalTokens` denominator.
|
|
161
|
+
retainLatestTokens: 8000,
|
|
162
|
+
turnEndForceCompactionEnabled: true,
|
|
163
|
+
debug: true,
|
|
164
|
+
logFile: DEFAULT_LOG_FILE,
|
|
165
|
+
compactionMode: COMPACT_MODE_REALM,
|
|
166
|
+
// Built-in compaction engine — ON by default so the plugin's own engine is
|
|
167
|
+
// always available as the fallback whenever the official `compaction` service
|
|
168
|
+
// is unreachable from this context (the common standard-preset layout). Set
|
|
169
|
+
// `false` to strictly use the official backend only.
|
|
170
|
+
builtinEnabled: true,
|
|
171
|
+
// Hard ceiling on the summarizer's output tokens (applied as `maxTokens` on
|
|
172
|
+
// the plugin's own summarization LLM call). Prevents runaway summaries when
|
|
173
|
+
// the shadowed span is large; the shrink gate independently ensures the
|
|
174
|
+
// committed summary is smaller than the span it replaces.
|
|
175
|
+
maxSummaryTokens: 1024,
|
|
176
|
+
// Ceiling on the NUMBER OF SURFACE NODES one compaction region may span
|
|
177
|
+
// (positional, counted from the head of the ordered surface). When the
|
|
178
|
+
// token-budget-driven cutoff point lands beyond this many nodes —
|
|
179
|
+
// normal for a large `autoEarliestRatio` such as 0.7 on a long tool-heavy
|
|
180
|
+
// conversation — the region is CLAMPED DOWN to the largest head-aligned
|
|
181
|
+
// prefix under this cap that ends on a `user/message` boundary. Sized
|
|
182
|
+
// safely under the builtin engine's 128-message replay cap (a region of N
|
|
183
|
+
// surface nodes projects at most N messages, so N < 128 keeps the projected
|
|
184
|
+
// message count within the cap), guaranteeing a COMMISIBLE region on every
|
|
185
|
+
// threshold trip so the auto-gate never livelocks. Successive gates chip the
|
|
186
|
+
// head away until the session settles below the threshold.
|
|
187
|
+
maxRegionNodes: 96,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Read the resolved force-compact settings from the `settings` service, applying
|
|
192
|
+
* the `DEFAULTS` fallback for any field the user has not overridden.
|
|
193
|
+
*
|
|
194
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
195
|
+
* @returns {Promise<{
|
|
196
|
+
* disableThinking: boolean,
|
|
197
|
+
* autoThresholdTokens: number,
|
|
198
|
+
* retainLatestTokens: number,
|
|
199
|
+
* turnEndForceCompactionEnabled: boolean,
|
|
200
|
+
* debug: boolean,
|
|
201
|
+
* logFile: string,
|
|
202
|
+
* compactionMode: string,
|
|
203
|
+
* builtinEnabled: boolean,
|
|
204
|
+
* maxSummaryTokens: number,
|
|
205
|
+
* } | null>}
|
|
206
|
+
* the resolved settings, or `null` when the `settings` service is not mounted
|
|
207
|
+
* (callers should fall back to their composition entry).
|
|
208
|
+
*/
|
|
209
|
+
export async function readSettings(ctx) {
|
|
210
|
+
// SAFETY ENVELOPE: every model request and compaction path reads settings —
|
|
211
|
+
// a throw escaping here would take down the model-request seam. Wrap the whole
|
|
212
|
+
// read so ANY anomaly (a rejecting `settings.get`, a non-object stored value)
|
|
213
|
+
// degrades to `null` (= "use the caller's composition defaults"), never throw.
|
|
214
|
+
try {
|
|
215
|
+
return await __readSettingsBody(ctx)
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const logger = (typeof ctx?.logger?.warn === 'function') ? ctx.logger.warn.bind(ctx.logger) : () => {}
|
|
218
|
+
logger(`[force-compact] readSettings degraded to defaults — ${error instanceof Error ? error.message : String(error)}`)
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function __readSettingsBody(ctx) {
|
|
224
|
+
const settings = ctx.get('settings')
|
|
225
|
+
if (settings === undefined || typeof settings.get !== 'function') return null
|
|
226
|
+
const rawSection = settings.get(NS)
|
|
227
|
+
if (rawSection === undefined) return null
|
|
228
|
+
// A non-object stored value (corrupt/legacy yaml edge) degrades to an empty
|
|
229
|
+
// section so every field resolves to its DEFAULT below — never a throw.
|
|
230
|
+
const section = (rawSection && typeof rawSection === 'object') ? rawSection : {}
|
|
231
|
+
const asBool = (field, fallback) =>
|
|
232
|
+
(typeof section[field] === 'boolean' ? section[field] : fallback)
|
|
233
|
+
const asPositiveInt = (field, fallback) =>
|
|
234
|
+
(Number.isFinite(section[field]) && section[field] > 0 ? section[field] : fallback)
|
|
235
|
+
// Token-scale parameter: parse + clamp up to the published floor (below-floor
|
|
236
|
+
// values RESOLVE to the floor rather than being rejected).
|
|
237
|
+
const asScaled = (field, floor) => {
|
|
238
|
+
const v = asPositiveInt(field, DEFAULTS[field])
|
|
239
|
+
return Number.isFinite(v) && v < floor ? floor : v
|
|
240
|
+
}
|
|
241
|
+
const disableThinking = asBool('disableThinking', DEFAULTS.disableThinking)
|
|
242
|
+
const autoThresholdTokens = asScaled('autoThresholdTokens', MIN_TOKEN_SCALES.autoThresholdTokens)
|
|
243
|
+
const retainLatestTokens = asScaled('retainLatestTokens', MIN_TOKEN_SCALES.retainLatestTokens)
|
|
244
|
+
const turnEndForceCompactionEnabled = asBool('turnEndForceCompactionEnabled', DEFAULTS.turnEndForceCompactionEnabled)
|
|
245
|
+
const debug = asBool('debug', DEFAULTS.debug)
|
|
246
|
+
const logFile = (typeof section.logFile === 'string' ? section.logFile : DEFAULTS.logFile)
|
|
247
|
+
const rawMode = (typeof section.compactionMode === 'string' ? section.compactionMode.toLowerCase()
|
|
248
|
+
: DEFAULTS.compactionMode)
|
|
249
|
+
const compactionMode = COMPACT_MODES.includes(rawMode) ? rawMode : DEFAULTS.compactionMode
|
|
250
|
+
// Builtin-engine gate: absent / non-boolean stored values treat the field as
|
|
251
|
+
// UNSET (rather than false), preserving the "default on" semantics even
|
|
252
|
+
// when a legacy settings.yaml predates the field.
|
|
253
|
+
const builtinEnabled = (typeof section.builtinEnabled === 'boolean'
|
|
254
|
+
? section.builtinEnabled
|
|
255
|
+
: DEFAULTS.builtinEnabled)
|
|
256
|
+
const maxSummaryTokens = asScaled('maxSummaryTokens', MIN_TOKEN_SCALES.maxSummaryTokens)
|
|
257
|
+
return {
|
|
258
|
+
disableThinking,
|
|
259
|
+
autoThresholdTokens,
|
|
260
|
+
retainLatestTokens,
|
|
261
|
+
turnEndForceCompactionEnabled,
|
|
262
|
+
debug,
|
|
263
|
+
logFile,
|
|
264
|
+
compactionMode,
|
|
265
|
+
builtinEnabled,
|
|
266
|
+
maxSummaryTokens,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Build ONE schema field for an enumerated setting (`compactionMode`).
|
|
272
|
+
*
|
|
273
|
+
* Probes the resolved `z` for a usable enum constructor (`z.enum`, else
|
|
274
|
+
* `z.nativeEnum`). When it exists AND returns a schema, that is used (proper
|
|
275
|
+
* constraint + UI affordance). Otherwise — a reduced/partial schema surface —
|
|
276
|
+
* it degrades to a plain `z.string().default(fallback)` so the field STILL
|
|
277
|
+
* exists and is writable; the value is validated at read time in `readSettings`
|
|
278
|
+
* (invalid strings coerce to the default). Crucially this NEVER throws, so the
|
|
279
|
+
* field's construction can never take down the entire `buildSchema` call.
|
|
280
|
+
*
|
|
281
|
+
* @param {any} z the resolved schemastery `z` (or a partial surface).
|
|
282
|
+
* @param {string} fallback the default value (also the fallback-mode default).
|
|
283
|
+
* @returns {unknown} the constructed field schema.
|
|
284
|
+
*/
|
|
285
|
+
function buildEnumField(z, fallback) {
|
|
286
|
+
const enumFn = (typeof z.enum === 'function' ? z.enum
|
|
287
|
+
: (typeof z.nativeEnum === 'function' ? z.nativeEnum : undefined))
|
|
288
|
+
if (enumFn) {
|
|
289
|
+
try {
|
|
290
|
+
const built = (enumFn.length > 0 ? enumFn(COMPACT_MODES) : enumFn)
|
|
291
|
+
if (built !== undefined && built !== null) return built
|
|
292
|
+
} catch {
|
|
293
|
+
// Unsupported/throwing enum constructor — fall through to the plain field.
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// Plain-string fallback: always constructible, writable, value-checked later.
|
|
297
|
+
return typeof z.string === 'function' ? z.string().default(fallback) : { default: fallback }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Read ONE RAW field of the `falling-ts-force-compact` namespace, WITHOUT the
|
|
302
|
+
* per-request `readSettings` full-parse overhead. Cached-friendly (re-reads only
|
|
303
|
+
* the single field) so it is safe to call from service-resolution paths.
|
|
304
|
+
*
|
|
305
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
306
|
+
* @param {string} field
|
|
307
|
+
* @returns {Promise<unknown>} the raw stored value, or `undefined` when the
|
|
308
|
+
* settings service is not mounted or the field is unset.
|
|
309
|
+
*/
|
|
310
|
+
export async function readRawSetting(ctx, field) {
|
|
311
|
+
// SAFETY ENVELOPE: called from service-resolution and command paths; a
|
|
312
|
+
// rejecting `settings.get` or a non-section shape degrades to `undefined`
|
|
313
|
+
// (= "unset") rather than throwing.
|
|
314
|
+
try {
|
|
315
|
+
const settings = ctx.get('settings')
|
|
316
|
+
if (settings === undefined || typeof settings.get !== 'function') return undefined
|
|
317
|
+
const value = settings.get(NS)
|
|
318
|
+
if (value === undefined || value === null) return undefined
|
|
319
|
+
if (value !== null && typeof value !== 'object') return undefined
|
|
320
|
+
return value[field]
|
|
321
|
+
} catch {
|
|
322
|
+
return undefined
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Build the `falling-ts-force-compact` settings schema through `@deepseek-ai/schemastery`.
|
|
328
|
+
*
|
|
329
|
+
* @returns {Promise<((section: unknown) => unknown) & { toJSON: () => unknown } | null>}
|
|
330
|
+
* the schemastery schema (a callable validator with a `toJSON`), or `null`
|
|
331
|
+
* when the schemastery module cannot be resolved at runtime.
|
|
332
|
+
*/
|
|
333
|
+
/**
|
|
334
|
+
* Resolve the `z` schema constructor, tolerating BOTH layouts the plugin ships
|
|
335
|
+
* in:
|
|
336
|
+
* - a monorepo/dev layout where `@deepseek-ai/schemastery` is a resolvable
|
|
337
|
+
* bare specifier (other workspace packages depend on it and Node walks up
|
|
338
|
+
* their `node_modules`);
|
|
339
|
+
* - this plugin as a STANDALONE repo (its own `node_modules` lacks
|
|
340
|
+
* schemastery, which lives in the sibling `deepseek-harness/vendor/` copy).
|
|
341
|
+
* In that case the bare import fails, so we additionally attempt the known
|
|
342
|
+
* vendored build by ABSOLUTE path (relative to this file, walking upward to
|
|
343
|
+
* the workspace root), which is portable across machines/users because it is
|
|
344
|
+
* resolved at runtime from this very file's location. Returns the resolved
|
|
345
|
+
* `z`, or `undefined` when NO candidate yields a usable `z.object`.
|
|
346
|
+
*/
|
|
347
|
+
async function resolveZ() {
|
|
348
|
+
// Candidate 1: bare specifier (works when installed inside the monorepo).
|
|
349
|
+
try {
|
|
350
|
+
const mod = await import('@deepseek-ai/schemastery')
|
|
351
|
+
const z = mod.default ?? mod
|
|
352
|
+
if (typeof z.object === 'function') return z
|
|
353
|
+
} catch { /* fall through to candidate 2 */ }
|
|
354
|
+
// Candidate 2: the vendored build sitting beside the checkout. Walk up from
|
|
355
|
+
// THIS file (dsh-force-compact/src/core/) looking for a
|
|
356
|
+
// `deepseek-harness/vendor/schemastery/lib/index.mjs` alongside the checkout
|
|
357
|
+
// root. Portable: computed from this file's own path, never hardcoded.
|
|
358
|
+
try {
|
|
359
|
+
const { fileURLToPath, pathToFileURL } = await import('node:url')
|
|
360
|
+
const { dirname, join } = await import('node:path')
|
|
361
|
+
const { existsSync } = await import('node:fs')
|
|
362
|
+
let dir = dirname(fileURLToPath(import.meta.url))
|
|
363
|
+
for (let hop = 0; hop < 8; hop += 1) {
|
|
364
|
+
const cand = join(dir, 'deepseek-harness/vendor/schemastery/lib/index.mjs')
|
|
365
|
+
if (existsSync(cand)) {
|
|
366
|
+
// Dynamic import() on Windows REQUIRES a file:// URL (a bare drive-letter
|
|
367
|
+
// absolute path throws ERR_UNSUPPORTED_ESM_URL_SCHEME). Convert the found
|
|
368
|
+
// path so the vendored build loads reliably on both POSIX and Windows.
|
|
369
|
+
const mod = await import(pathToFileURL(cand).href)
|
|
370
|
+
const z = mod.default ?? mod
|
|
371
|
+
if (typeof z.object === 'function') return z
|
|
372
|
+
}
|
|
373
|
+
const parent = dirname(dir)
|
|
374
|
+
if (parent === dir) break
|
|
375
|
+
dir = parent
|
|
376
|
+
}
|
|
377
|
+
} catch { /* candidate 2 unavailable — proceed unresolved */ }
|
|
378
|
+
return undefined
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function buildSchema() {
|
|
382
|
+
try {
|
|
383
|
+
const z = await resolveZ()
|
|
384
|
+
if (z === undefined) return null
|
|
385
|
+
const schema = z.object({
|
|
386
|
+
disableThinking: z.boolean().default(DEFAULTS.disableThinking),
|
|
387
|
+
// Minimal chain: `.step()` and `.min()` were ADDED this pass and are
|
|
388
|
+
// exactly what broke the host's vendored schemastery surface (the
|
|
389
|
+
// standalone-node build resolves these fine but the host's z doesn't
|
|
390
|
+
// expose them as chainable). Fall back to a bare `z.number().default(…)`
|
|
391
|
+
// — the FLOOR IS STILL ENFORCED IN `readSettings` (asScaled clamps
|
|
392
|
+
// stored values BELOW the floor UP TO the floor before they're surfaced),
|
|
393
|
+
// and the web FORM enforces its own minimum at the input level
|
|
394
|
+
// (`useDraftNumberClamped`). So even though the schema carries no
|
|
395
|
+
// machine-checked lower bound, a hand-edited settings.yaml holding a
|
|
396
|
+
// sub-floor value still RESOLVES to the legal floor at read time, and
|
|
397
|
+
// the form refuses to persist a sub-floor draft. Documented trade-off:
|
|
398
|
+
// the schema is descriptive here; the floor is behavioral.
|
|
399
|
+
autoThresholdTokens: z.number().default(DEFAULTS.autoThresholdTokens),
|
|
400
|
+
// ABSOLUTE TOKEN COUNT retained at the latest end of the surface when an
|
|
401
|
+
// auto / forced compaction fires (see the `DEFAULTS` comment for the full
|
|
402
|
+
// semantics). `step(1)` constrains to whole tokens (schemastery has no
|
|
403
|
+
// `.int()`); `min(1)` guards the degenerate 0 case (which clamps to 1
|
|
404
|
+
// node minimum retained anyway).
|
|
405
|
+
retainLatestTokens: z.number().default(DEFAULTS.retainLatestTokens),
|
|
406
|
+
turnEndForceCompactionEnabled: z.boolean().default(DEFAULTS.turnEndForceCompactionEnabled),
|
|
407
|
+
// Debug-log gate, on by default; set false for production deployments.
|
|
408
|
+
debug: z.boolean().default(DEFAULTS.debug),
|
|
409
|
+
// Debug-log target; leading ~ expands to the OS user home. Default sits
|
|
410
|
+
// under the shared user $DSH_HOME so it is never dropped into a checkout.
|
|
411
|
+
logFile: z.string().default(DEFAULTS.logFile),
|
|
412
|
+
// How the plugin locates the compaction backend (realm-scoped per-agent
|
|
413
|
+
// vs host-global). Built best-effort: prefer a proper enum when the
|
|
414
|
+
// schemastery schema supports it; otherwise fall back to a plain
|
|
415
|
+
// `.default()` field so the field ALWAYS exists and stays writable (the
|
|
416
|
+
// value is still validated in `readSettings`, which coerces any invalid
|
|
417
|
+
// string to the default). NEVER let an unsupported enum construct throw —
|
|
418
|
+
// that would escape `buildSchema`'s try/catch and break the WHOLE
|
|
419
|
+
// namespace registration (regressing the settings panel to "loading").
|
|
420
|
+
compactionMode: buildEnumField(z, DEFAULTS.compactionMode),
|
|
421
|
+
// The builtin engine fallback (see `engine/backend.js`): on by default
|
|
422
|
+
// so the plugin's own engine takes over whenever the official
|
|
423
|
+
// `compaction` service is unreachable (standard-preset realm isolation).
|
|
424
|
+
// Value is coerced at read-time in `readSettings`, so the schema field is
|
|
425
|
+
// purely UI affordance.
|
|
426
|
+
builtinEnabled: z.boolean().default(DEFAULTS.builtinEnabled),
|
|
427
|
+
// Token ceiling applied to the plugin's own summarizer LLM call. Bounds
|
|
428
|
+
// runaway summaries; combined with the shrink gate (the summary must be
|
|
429
|
+
// strictly smaller than the span it replaces) this keeps transactions
|
|
430
|
+
// bounded while ensuring compression is always net-negative.
|
|
431
|
+
maxSummaryTokens: z.number().default(DEFAULTS.maxSummaryTokens),
|
|
432
|
+
liveUi: z.any(), // TRANSIENT UI MESSENGER (core/ui-signal.js): host-written { phase,text,color }. z.any() used because the vendored schemastery exposes object/any/string/number/boolean/array only (no record/unknown/chained .optional()); z.record(z.unknown()).optional() throws there and aborts the whole z.object(...), stranding the settings panel on "loading". Absence-by-default is inherent (no .default). readSettings ignores it — not a user preference.
|
|
433
|
+
})
|
|
434
|
+
return schema
|
|
435
|
+
} catch {
|
|
436
|
+
return null
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Register the `falling-ts-force-compact` settings namespace when a `settings` service is
|
|
442
|
+
* mounted. Idempotent for the calling fiber; safe to call once in `apply`.
|
|
443
|
+
*
|
|
444
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
445
|
+
* @returns {Promise<boolean>} whether the namespace was registered.
|
|
446
|
+
*/
|
|
447
|
+
export async function registerNamespace(ctx) {
|
|
448
|
+
const settings = ctx.get('settings')
|
|
449
|
+
if (settings === undefined || typeof settings.register !== 'function') return false
|
|
450
|
+
|
|
451
|
+
const schema = await buildSchema()
|
|
452
|
+
// Prefer the full validation schema. When it could not be built (the
|
|
453
|
+
// schemastery bare module is unresolvable from this install location — a
|
|
454
|
+
// common development layout with no ancestor `node_modules`), register a
|
|
455
|
+
// minimal placeholder schema object instead of skipping: the namespace still
|
|
456
|
+
// gets exposed (so the settings panel loads and values stay
|
|
457
|
+
// readable/writable) but without field-level validation metadata. Both layers
|
|
458
|
+
// carry the same `base` defaults, so either way the effective values resolve
|
|
459
|
+
// identically.
|
|
460
|
+
const thirdArg = { base: { ...DEFAULTS } }
|
|
461
|
+
// Placeholder MUST be callable (callable-validator contract of
|
|
462
|
+
// `settings.register`): identity passthrough that accepts any section shape
|
|
463
|
+
// so the namespace stays exposed even when schemastery is unresolvable.
|
|
464
|
+
const placeholderSchema = (section) => section
|
|
465
|
+
placeholderSchema.toJSON = () => ({})
|
|
466
|
+
// Contain the actual `settings.register` call: a hostile/partial `settings`
|
|
467
|
+
// implementation that throws on register must NOT break the plugin's `apply`
|
|
468
|
+
// (which registers all listeners). Degrade to "not registered" (false).
|
|
469
|
+
try {
|
|
470
|
+
settings.register(NS, schema !== null ? schema : placeholderSchema, thirdArg)
|
|
471
|
+
return true
|
|
472
|
+
} catch {
|
|
473
|
+
return false
|
|
474
|
+
}
|
|
475
|
+
}
|