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