dsh-theme-endfield 0.0.0-stage → 1.2.2

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/index.js ADDED
@@ -0,0 +1,1403 @@
1
+ 'use strict';
2
+ /**
3
+ * dsh-theme-endfield — installed (bundle) HOST half.
4
+ *
5
+ * This module is the cordis plugin the loader mounts when the package is
6
+ * installed through the official CLI:
7
+ *
8
+ * dsh plugin --profile web add github:ymh0000123/dsh-theme-endfield
9
+ *
10
+ * The `dsh.bundle.patch` layer (cordis.patch.yml) inserts this package's row;
11
+ * the loader requires this main entry and uses its `name` + `apply` exports.
12
+ * The theme itself is pure client-side (browser): token overrides via the
13
+ * `theme` service and a global stylesheet via the `styles` builtin, both
14
+ * registered in the client half (`exports["./client"]` -> client.js).
15
+ *
16
+ * Durable preferences, per DSH generation
17
+ * ---------------------------------------
18
+ * The theme's switches used to live in the browser's `localStorage`, which is
19
+ * scoped to a single origin: DSH Desktop binds a fresh, random loopback port on
20
+ * every launch, so a port change changed the origin and the stored settings
21
+ * silently reset to defaults.
22
+ *
23
+ * DSH 0.1.7-rc.1 replaced that whole persistence layer once more:
24
+ *
25
+ * - `ctx.settings.register(namespace, schema)` is GONE, `@deepseek-ai/
26
+ * dsh-settings-file` is not part of the distribution any more, and
27
+ * `<dshHome>/settings.yaml` is no longer the user-settings carrier (DSH
28
+ * renames an existing file to `settings.yaml.imported` on first boot and
29
+ * only remaps a few section names). `ctx.settings` now *projects plugin
30
+ * Config schemas into forms* (`configure/describe/update/replace/mutate`)
31
+ * and persists user edits into the PROFILE PATCH
32
+ * (`<profile>/cordis.patch.yml`) through `ctx.configEditor`.
33
+ * - The settings namespace of a plugin is therefore the PROFILE ENTRY ID —
34
+ * the `id` of the row this package's `cordis.patch.yml` inserts
35
+ * (`theme-endfield`), NOT the old namespace string. That id is
36
+ * SETTINGS_ENTRY below; the browser half reads/writes it through the
37
+ * `configForms` client service.
38
+ * - Only schema fields marked `.volatile()` are user-editable, which is why
39
+ * every field of the exported `Config` below carries `.volatile()`.
40
+ *
41
+ * The browser half (client.js) reaches the same entry over the
42
+ * `configForms.get('theme-endfield')` mirror and live-reacts to changes with
43
+ * the form's subscription (and still falls back to the pre-0.1.7
44
+ * `ctx.settingsScope` seam on older hosts).
45
+ *
46
+ * The namespace fields mirror exactly the setting keys, defaults and polarity
47
+ * the theme has always shipped (see docs/features.md): default-ON switches
48
+ * default to the string '1' and are read with `!== '0'`, default-OFF switches
49
+ * default to '0' and are read with `=== '1'`. Choosing string-typed schema
50
+ * fields keeps the value model (and the settings panel's own reads/writes)
51
+ * byte-for-byte identical to every previous storage generation, so the client
52
+ * store, its key table and its tests did not have to change with the transport.
53
+ *
54
+ * `schemastery` is deliberately imported lazily and only from the host realm:
55
+ * this package otherwise ships no runtime dependency beyond the optional
56
+ * cordis peer, so the theme degrades to a no-op the same way it always did in
57
+ * any profile that does not supply a settings service.
58
+ *
59
+ * Host-side audio notifications (optional)
60
+ * -------------------------------------------------------------------
61
+ * `lib/audio.js` plays the two (later: four) notification slots. It is wired
62
+ * here because the host, not the page, is what survives a minimized window.
63
+ * `subprocess` is probed at play time rather than declared in `inject`: a
64
+ * profile without that seam must keep the theme fully working and simply stay
65
+ * silent, not fail to load.
66
+ */
67
+
68
+ const { AudioRuntime, PREF, FALLBACK: AUDIO_FALLBACK, LOG_TAG } = require('./lib/audio.js');
69
+ const { SLOT_IDS } = require('./lib/slots.js');
70
+
71
+ const NAME = 'dsh-theme-endfield';
72
+
73
+ /**
74
+ * DSH 0.1.7-rc.1 settings namespace: the profile entry id of this plugin's row
75
+ * (cordis.patch.yml: `id: theme-endfield`). `ctx.settings.describe()` keys every
76
+ * form by `entry.options.id`, and the browser's `configForms.get(ns)` mirrors
77
+ * the same string, so this constant is what ties the two halves together.
78
+ */
79
+ const SETTINGS_ENTRY = 'theme-endfield';
80
+
81
+ /**
82
+ * Pre-0.1.7 settings namespace, registered through `ctx.settings.register` and
83
+ * persisted to `<dshHome>/settings.yaml`. Kept so an older host keeps working
84
+ * (and so the legacy client seam in client.js still has a producer).
85
+ */
86
+ const LEGACY_NAMESPACE = 'dsh-theme-endfield';
87
+
88
+ /** Historical name of the namespace; kept for callers and tests. */
89
+ const NAMESPACE = LEGACY_NAMESPACE;
90
+
91
+ /**
92
+ * Schema defaults for every field. Field names are the short tails of the
93
+ * original localStorage keys (the `dsh-theme-endfield-` prefix is implied by
94
+ * the namespace). Keeping the actual stored values as strings means the value
95
+ * model survives every storage generation unchanged, and an older persisted
96
+ * document still validates without a migration step.
97
+ *
98
+ * Default polarity (same rules as before, now enforced by the schema defaults
99
+ * instead of by an "absent key" check, and documented in docs/features.md):
100
+ * - default-ON switches store '1' and the client reads them as `!== '0'`;
101
+ * - default-OFF switches store '0' and the client reads them as `=== '1'`;
102
+ * - palettes / radii / frame-rate / speed each store exactly one of their
103
+ * documented literals ('valley'/'wuling'; 'square'/'round'; fps in
104
+ * 24/60/120; speed in 1/2/4), with the shipped default filled in here.
105
+ */
106
+ const FIELD_DEFAULTS = {
107
+ enabled: '1', // 终末地主题 —— default on
108
+ palette: 'valley', // 主题配色 —— 谷地黄 (walley default)
109
+ glass: 'off', // optional local frost; original material by default
110
+ radius: 'square', // 主题圆角 —— 直角
111
+ contour: '0', // 等高线背景 —— default off
112
+ contourAnim: '1', // 动态等高线 —— default on
113
+ contourFps: '24', // 动态帧率 —— 24 FPS
114
+ contourSpeed: '2', // 动态速度 —— 标准 2x
115
+ contourRenderer: 'canvas', // opt-in Worker/WebGL; original backend by default
116
+ contourTrail: '0', // optional mouse deformation, default off
117
+ contourScrollPause: '1', // 滚动暂停 —— default on
118
+ // 输出滚动动画 —— default ON. It leaves the app's own scroll bookkeeping
119
+ // untouched (the scrollTop read-back stays exact, so stream following survives)
120
+ // and only compensates the transcript column visually, so an install that
121
+ // upgrades into it gets smoother streaming output with nothing to configure.
122
+ scrollAnim: '1',
123
+ scrollAnimLevel: 'standard', // 动画强度 —— soft / standard / snappy
124
+ watermark: '1', // 背景水印 —— default on
125
+ watermarkPersist: '0', // 水印保持显示 —— default off
126
+ loader: '0', // 启动加载动画 —— default off
127
+ thunder: '0', // 雷霆大字 —— default off
128
+ thunderAnim: '0', // 大字入场动画 —— default off
129
+ balanceCapsule: '0', // 顶部余额胶囊 —— default off
130
+ creditDisplay: 'remaining', // 渠道额度主读数 —— remaining(剩余)| used(已用)
131
+ balanceCapsulePosition: 'center', // 余额胶囊位置 —— center(居中)| left(靠左)| right(靠右)
132
+ balanceCapsuleSidebarHide: '0', // 防遮挡:侧边栏展开自动隐藏 —— default off
133
+ balanceCapsuleHoverHide: '1', // 防遮挡:指针靠近自动隐藏 —— default on
134
+ // --- 音频通知 ---------------------------------------------------------
135
+ // Four live slots: the boot plate, the prompt that starts a turn, the final
136
+ // answer that ends one, and `attention` for the two moments that actually
137
+ // need a human (an approval request and my own question). `turn-fail` ships as
138
+ // a sound and a switch but is wired to nothing on purpose: an error that needs
139
+ // no human decision must stay silent.
140
+ //
141
+ // The MASTER switch ships OFF: sound is opt-in, so an install that upgrades
142
+ // into this feature never starts making noise on its own. The per-slot
143
+ // switches stay ON, which is why flipping the master on is enough to hear the
144
+ // live slots; each one can then be silenced individually.
145
+ audioEnabled: '0', // 音频通知总开关 —— default OFF(默认不出声,需手动开启)
146
+ audioVolume: '100', // 音量 0-100 —— rescaled PCM, not system volume
147
+ audioBoot: '1', // 启动加载动画音 —— 页面加载播放加载板时响一次
148
+ audioTurnStart: '1', // 任务开始音 —— 会话框提交后播放
149
+ audioTurnDone: '1', // 任务结束音 —— 最终结果产出后播放
150
+ audioAttention: '1', // 需要你回应 —— 审批请求 / 我的提问 / 计划求批
151
+ audioTurnFail: '1', // 出错音 —— 无事件接线:不需要人工干预的错误保持静音
152
+ audioDebounceMs: '2500', // 同一槽位最小间隔
153
+ audioSoundDir: '', // 自定义音效目录,留空则用工作区/桌面/内置
154
+ audioHumanOnly: '1', // 开始音只认会话框提交(带 rpcId 的用户消息)
155
+ audioDiag: '0', // 诊断日志 —— 记录事件与判定结果
156
+ };
157
+
158
+ /** The two spellings a reachable Schemastery builder can have on disk. */
159
+ const SCHEMA_SPECS = ['@deepseek-ai/schemastery', 'schemastery'];
160
+
161
+ /* Where a usable builder came from, for the diagnostics apply() logs. Set by
162
+ loadSchemastery; stays null while nothing was found. */
163
+ let SCHEMA_SOURCE = null;
164
+
165
+ /* HOW the chosen builder marks a field editable — also for the diagnostics.
166
+ 'native' the builder carries schemastery's own `.volatile()`;
167
+ 'synthesized' it predates `.volatile()`, so the marker is set through the
168
+ generic `.extra('volatile', true)` that `.volatile()` is
169
+ implemented on top of;
170
+ 'plain' the builder marks nothing; only ever chosen for the pre-0.1.7
171
+ registration, which persisted whole namespaces;
172
+ null no usable builder was found at all, so this entry exports no
173
+ Config and every preference stays page-local. */
174
+ let SCHEMA_MODE = null;
175
+
176
+ /* Resolve a Schemastery namespace builder.
177
+
178
+ This file has no schemastery dependency of its own (it must stay installable
179
+ with nothing but an optional cordis peer), so the builder is RESOLVED at
180
+ module-evaluation time — the loader reads `Config` off this module before any
181
+ plugin body runs, which is why the whole search happens at the top level.
182
+
183
+ 1) Published profile installs put schemastery / @deepseek-ai/schemastery on
184
+ this package's OWN require path (real bundles like dsh-better-sidebar do
185
+ `import z from "schemastery"` and it resolves). Those are covered by the
186
+ first tries below.
187
+ 2) A DEV-LINK bundle (this repo symlinked into the profile's node_modules,
188
+ e.g. `"dsh-theme-endfield": "link:E:/..."`) does NOT: Node resolves the
189
+ symlink to its real path first, so this file's own require chain starts at
190
+ the repo (E:\…), where no schemastery lives — the bare requires throw
191
+ MODULE_NOT_FOUND and the profile's node_modules is never consulted. So
192
+ when they miss, every DSH-rooted directory that could own a copy is asked
193
+ explicitly ({@link resolutionRoots}, resolved through
194
+ `require.resolve(spec, { paths })`, the form that expresses "as if from
195
+ here").
196
+ 3) WHICH builder matters, not just any builder. DSH ships BOTH
197
+ `@deepseek-ai/schemastery` (>= 3.18, has `.volatile()`) and the unscoped
198
+ `schemastery` that some third-party plugins depend on (3.18.0, NO
199
+ `.volatile()` at all). Only a volatile schema produces an editable form in
200
+ DSH 0.1.7, so `loadSchemastery(true)` keeps scanning until it finds a
201
+ builder that really has `.volatile()` and returns undefined otherwise —
202
+ silently claiming a form we cannot build is exactly the "settings won't
203
+ save" class of bug this file keeps guarding against.
204
+ Kept guarded throughout: a profile with no usable schemastery degrades to a
205
+ no-op rather than crashing the host half — but a no-op is no longer silent:
206
+ apply() logs which roots were tried, because "the theme works yet every
207
+ switch resets on reload" is precisely this failure and it used to leave no
208
+ trace at all. */
209
+
210
+ /** Every directory a DSH-owned schemastery install could be resolved from.
211
+ * Order is most-likely first; duplicates are dropped.
212
+ * @returns absolute directory paths to resolve from. */
213
+ function resolutionRoots() {
214
+ const fs = require('fs');
215
+ const path = require('path');
216
+ const out = [];
217
+ const push = (dir) => {
218
+ if (typeof dir !== 'string' || dir === '') return;
219
+ if (out.indexOf(dir) === -1) out.push(dir);
220
+ };
221
+ // This module's own tree: a profile- or app-installed package resolves here.
222
+ push(__dirname);
223
+ // The module the DSH process was started with. Its own graph necessarily
224
+ // reaches schemastery (the host settings service imports it), so this is the
225
+ // one root that exists wherever DSH itself is installed — a global npm
226
+ // install, an app bundle, or the profile.
227
+ try { if (require.main && require.main.filename) push(path.dirname(require.main.filename)); } catch (e) { /* no main module */ }
228
+ try { if (require.main && typeof require.main.path === 'string') push(require.main.path); } catch (e) { /* no main module */ }
229
+ // The process working directory: `dsh web` is normally started from a profile.
230
+ try { push(process.cwd()); } catch (e) { /* no cwd */ }
231
+ // Ancestors of this module, so a checkout nested under a tree that has its own
232
+ // node_modules resolves without knowing anything about DSH's layout.
233
+ let dir = __dirname;
234
+ for (let i = 0; i < 6; i += 1) {
235
+ const parent = path.dirname(dir);
236
+ if (!parent || parent === dir) break;
237
+ dir = parent;
238
+ push(path.join(dir, 'node_modules'));
239
+ push(dir);
240
+ }
241
+ // The DSH home and every profile under it — the profiles root itself, each
242
+ // profile, and each profile's node_modules (where the scoped copy lives).
243
+ for (const home of dshHomeDirs()) pushLayout(home, push);
244
+ // The same layout, discovered STRUCTURALLY rather than from the environment:
245
+ // a DSH home is the directory whose `profiles/<name>/node_modules` holds the
246
+ // install, so walking up from anything the process can name finds it without
247
+ // trusting DSH_HOME, homedir() or the working directory to be any particular
248
+ // value. This is the sweep that survives a launcher which sets none of them.
249
+ for (const seed of processSeeds()) {
250
+ let base = seed;
251
+ for (let i = 0; i < 5; i += 1) {
252
+ const parent = path.dirname(base);
253
+ if (!parent || parent === base) break;
254
+ base = parent;
255
+ push(base);
256
+ push(path.join(base, 'node_modules'));
257
+ pushLayout(base, push);
258
+ }
259
+ }
260
+ // The launched script and the node binary: a global `dsh` install keeps its
261
+ // dependencies beside one of them, which is where a copy would live when DSH
262
+ // itself is not installed into the profile.
263
+ try { if (process.argv && typeof process.argv[1] === 'string') push(path.dirname(process.argv[1])); } catch (e) { /* no argv */ }
264
+ try { push(path.dirname(process.execPath)); } catch (e) { /* no execPath */ }
265
+ try {
266
+ const appData = process.env && (process.env.APPDATA || process.env.LOCALAPPDATA);
267
+ if (typeof appData === 'string' && appData) push(path.join(appData, 'npm', 'node_modules'));
268
+ } catch (e) { /* no app data */ }
269
+ // Every PATH directory (bounded): the parent of a global `dsh` shim, and any
270
+ // other install root an operator put in front of it.
271
+ try {
272
+ const raw = process.env && process.env.PATH;
273
+ if (typeof raw === 'string' && raw) {
274
+ const entries = raw.split(path.delimiter);
275
+ for (let i = 0; i < entries.length && i < 40; i += 1) push(entries[i]);
276
+ }
277
+ } catch (e) { /* no PATH */ }
278
+ return out;
279
+ }
280
+
281
+ /** Every directory this process can name as a starting point for a layout
282
+ * search. Each one is expected to sit INSIDE a DSH tree of some shape.
283
+ * @returns absolute directory paths. */
284
+ function processSeeds() {
285
+ const path = require('path');
286
+ const out = [];
287
+ const push = (dir) => {
288
+ if (typeof dir !== 'string' || dir === '') return;
289
+ if (out.indexOf(dir) === -1) out.push(dir);
290
+ };
291
+ push(__dirname);
292
+ try { push(process.cwd()); } catch (e) { /* no cwd */ }
293
+ try { if (require.main && require.main.filename) push(path.dirname(require.main.filename)); } catch (e) { /* no main module */ }
294
+ try { if (process.argv && typeof process.argv[1] === 'string') push(path.dirname(process.argv[1])); } catch (e) { /* no argv */ }
295
+ try { push(path.dirname(process.execPath)); } catch (e) { /* no execPath */ }
296
+ return out;
297
+ }
298
+
299
+ /** Add one directory's DSH layout: the directory itself, its node_modules, and
300
+ * each of its profiles with that profile's node_modules.
301
+ * @param base - candidate DSH home.
302
+ * @param push - collector for a de-duplicated root. */
303
+ function pushLayout(base, push) {
304
+ if (typeof base !== 'string' || base === '') return;
305
+ const fs = require('fs');
306
+ const path = require('path');
307
+ push(base);
308
+ push(path.join(base, 'node_modules'));
309
+ const profiles = path.join(base, 'profiles');
310
+ push(profiles);
311
+ push(path.join(profiles, 'node_modules'));
312
+ try {
313
+ if (fs.existsSync(profiles)) {
314
+ for (const name of fs.readdirSync(profiles)) {
315
+ const profile = path.join(profiles, name);
316
+ push(profile);
317
+ push(path.join(profile, 'node_modules'));
318
+ }
319
+ }
320
+ } catch (e) { /* an unreadable profiles dir just yields no extra roots */ }
321
+ }
322
+
323
+ /** The DSH home directories to consider, most explicit first.
324
+ * @returns candidate absolute home paths (may be empty). */
325
+ function dshHomeDirs() {
326
+ const out = [];
327
+ const push = (dir) => {
328
+ if (typeof dir !== 'string' || dir === '') return;
329
+ if (out.indexOf(dir) === -1) out.push(dir);
330
+ };
331
+ try { if (process.env && typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME) push(process.env.DSH_HOME); } catch (e) { /* no env */ }
332
+ try { push(require('path').join(require('os').homedir(), '.dsh')); } catch (e) { /* no home dir */ }
333
+ return out;
334
+ }
335
+
336
+ function normalizeSchemastery(found) {
337
+ if (!found) return undefined;
338
+ // Normalize a CJS default-export wrapper to a plain { string, object } API.
339
+ if (found.default && !found.object && found.default.object && found.default.string) {
340
+ return {
341
+ string: (v) => found.default.string(v),
342
+ object: (o) => found.default.object(o),
343
+ union: typeof found.default.union === 'function' ? (...a) => found.default.union(...a) : undefined,
344
+ };
345
+ }
346
+ return found;
347
+ }
348
+
349
+ /** Does this builder have the `.volatile()` modifier (schemastery >= 3.18)? */
350
+ function hasVolatile(z) {
351
+ try { return !!(z && typeof z.string === 'function' && typeof z.string().volatile === 'function'); }
352
+ catch (e) { return false; }
353
+ }
354
+
355
+ /** Does this builder expose the generic `.extra()` metadata setter?
356
+ *
357
+ * `Schema.prototype.volatile` (schemastery >= 3.18.4) is LITERALLY
358
+ * `this.extra('volatile', true)`, and `.extra()` has existed since long before
359
+ * it — including in the unscoped `schemastery` 3.18.0 that ships NO
360
+ * `.volatile()` at all. Marking a field through `.extra()` therefore produces
361
+ * the very schema node DSH's settings service looks for (`meta.volatile`),
362
+ * which is what keeps the form editable on a host whose only reachable *and
363
+ * loadable* builder is one of those older copies. */
364
+ function hasVolatileMarker(z) {
365
+ try { return !!(z && typeof z.string === 'function' && typeof z.string().extra === 'function'); }
366
+ catch (e) { return false; }
367
+ }
368
+
369
+ /** Mark one leaf field editable, by whichever of the two spellings the selected
370
+ * builder supports. The leaf comes back unchanged only when it supports
371
+ * neither, which {@link selectBuilder} never picks on purpose.
372
+ * @param leaf - a schema node produced by the selected builder.
373
+ * @returns the field schema with `meta.volatile === true`. */
374
+ function volatileField(leaf) {
375
+ if (leaf && typeof leaf.volatile === 'function') return leaf.volatile();
376
+ if (leaf && typeof leaf.extra === 'function') return leaf.extra('volatile', true);
377
+ return leaf;
378
+ }
379
+
380
+ /** Every reachable schemastery builder, closest require path first.
381
+ * @returns `{ z, source }` records; `source` names the root that answered, for
382
+ * the diagnostics apply() logs when nothing usable was found. */
383
+ /* WHY a fast path AND a cache: the full scan below (resolutionRoots +
384
+ require.resolve across every root) costs ~80ms of module-evaluation time on a
385
+ real install. That window is not free — the host half must finish evaluating
386
+ before its loader entry activates, and a web page that boots while it runs
387
+ picks up a boot graph without this theme's row ("import failed (see
388
+ console)"). Heavy sibling plugins (e.g. dsh-codearts-auth) widen that window
389
+ further, so every millisecond shaved here narrows the race. The fast path
390
+ answers from the two require chains — a handful of stats — and short-circuits
391
+ ONLY on a native-volatile builder, the top preference of selectBuilder, so it
392
+ can never choose worse than the scan would. The cache key is a registry
393
+ Symbol so re-evaluation of this module in the SAME process (plugin reload,
394
+ dual require/import) reuses the first scan with zero filesystem access.
395
+
396
+ The cache holds SUCCESSFUL scans only (see cacheableScan): a scan that found
397
+ nothing usable is a statement about this millisecond, not about this process,
398
+ and memoizing it is what converts a transient boot race into permanent
399
+ "settings never save". */
400
+ const SCHEMA_SCAN_CACHE = Symbol.for('dsh-theme-endfield.schemastery.scan');
401
+
402
+ /** Whether a scan result is worth memoizing process-wide.
403
+ *
404
+ * "No usable builder right now" and "this entry has no settings form" are not
405
+ * the same claim, and DSH treats the second one as permanent: an entry without
406
+ * a volatile Config gets no settings form at all, so every preference stays
407
+ * page-local and resets on reload. The first one, by contrast, is a property of
408
+ * the moment and heals by itself:
409
+ *
410
+ * - `require(esm)` throws ERR_REQUIRE_ESM_RACE_CONDITION while the ESM
411
+ * dependency of a CJS candidate is still in flight (DSH's own concurrent
412
+ * boot does exactly that to `@deepseek-ai/schemastery`, whose CJS build
413
+ * requires the pure-ESM `@deepseek-ai/cosmokit`), and
414
+ * - the module cache can hold a half-evaluated copy, whose `exports` is still
415
+ * `{}` — truthy, so it would otherwise be recorded as a candidate that
416
+ * fails every builder check in selectBuilder.
417
+ *
418
+ * Both states clear within milliseconds, so a later evaluation (a hot reload, a
419
+ * re-mounted entry) deserves a fresh scan. A successful scan is what the cache
420
+ * is FOR; a failed one costs a filesystem sweep that the fast path usually
421
+ * short-circuits anyway.
422
+ *
423
+ * @param list - a candidate list, possibly one left by an earlier scan.
424
+ * @returns true when at least one candidate can actually build a schema. */
425
+ function cacheableScan(list) {
426
+ if (!Array.isArray(list)) return false;
427
+ return list.some((candidate) => {
428
+ const z = candidate && candidate.z;
429
+ return !!(z && typeof z.object === 'function' && typeof z.string === 'function');
430
+ });
431
+ }
432
+
433
+ function schemasteryCandidates() {
434
+ /* Same-process re-evaluation: the first USABLE scan's answer is still this
435
+ process's answer. */
436
+ try {
437
+ const cached = globalThis[SCHEMA_SCAN_CACHE];
438
+ if (cacheableScan(cached)) return cached;
439
+ } catch (e) { /* fall through to a fresh scan */ }
440
+ const out = [];
441
+ const seenBuilders = [];
442
+ const take = (found, source) => {
443
+ const builder = normalizeSchemastery(found);
444
+ if (!builder) return;
445
+ /* Reject anything that cannot build a schema before it is recorded. A
446
+ half-evaluated module in the require cache exports `{}`, which is truthy
447
+ and would otherwise enter the list as a candidate that then fails every
448
+ check in selectBuilder — indistinguishable, downstream, from "this
449
+ process has no schemastery". */
450
+ if (typeof builder.object !== 'function' || typeof builder.string !== 'function') return;
451
+ if (seenBuilders.indexOf(builder) !== -1) return;
452
+ seenBuilders.push(builder);
453
+ out.push({ z: builder, source });
454
+ };
455
+ /* FAST PATH — scoped spec first, plain require chain then require.main's, the
456
+ two shapes a real install answers through (published profile installs
457
+ resolve from this package's own chain; dev-link installs from the host
458
+ process's). Each miss is a failed resolve against a short ancestor chain,
459
+ i.e. microseconds. A hit is accepted only when it carries schemastery's
460
+ OWN .volatile(): that is selectBuilder's highest preference, so skipping
461
+ the scan cannot demote the choice. A marker-only builder (.extra(), no
462
+ .volatile()) deliberately falls through — the scan's candidate order still
463
+ decides between it and a native copy found elsewhere. */
464
+ for (const spec of SCHEMA_SPECS) {
465
+ const attempts = [[spec, () => require(spec)]];
466
+ try {
467
+ if (require.main && typeof require.main.require === 'function') {
468
+ attempts.push([spec + ' via require.main', () => require.main.require(spec)]);
469
+ }
470
+ } catch (e) { /* no main module to ask */ }
471
+ for (const [source, attempt] of attempts) {
472
+ try {
473
+ const builder = normalizeSchemastery(attempt());
474
+ if (builder && typeof builder.object === 'function' && typeof builder.string === 'function' && hasVolatile(builder)) {
475
+ out.push({ z: builder, source });
476
+ try { if (cacheableScan(out)) globalThis[SCHEMA_SCAN_CACHE] = out; } catch (e) { /* cache is best-effort */ }
477
+ return out;
478
+ }
479
+ } catch (e) { /* keep trying */ }
480
+ }
481
+ }
482
+ // Roots are computed once: the scan touches the filesystem.
483
+ const roots = resolutionRoots();
484
+ /* Spec-major, SCOPED FIRST: `@deepseek-ai/schemastery` is the builder DSH's
485
+ own settings service validates against, so whenever a scoped copy exists
486
+ anywhere it must win over the unscoped one a sibling plugin may have left on
487
+ an earlier path. Within one spec the plain require chain is asked first
488
+ (that is the published-install case), then every explicit root. */
489
+ for (const spec of SCHEMA_SPECS) {
490
+ try { take(require(spec), spec); } catch (e) { /* not on this path */ }
491
+ for (const dir of roots) {
492
+ try {
493
+ take(require(require.resolve(spec, { paths: [dir] })), spec + ' from ' + dir);
494
+ } catch (e) { /* keep scanning the remaining roots */ }
495
+ }
496
+ }
497
+ // Finally, the host process's own main module, asked through ITS require: the
498
+ // last resort for a layout none of the paths above describes.
499
+ try {
500
+ if (require.main && typeof require.main.require === 'function') {
501
+ for (const spec of SCHEMA_SPECS) {
502
+ try { take(require.main.require(spec), spec + ' via require.main'); } catch (e) { /* keep scanning */ }
503
+ }
504
+ }
505
+ } catch (e) { /* no main module to ask */ }
506
+ // Absolute last resort: a copy ALREADY LOADED in this process. DSH's own
507
+ // settings service imports schemastery, so whatever reached the CommonJS
508
+ // module cache is usable by identity even when no path describes this
509
+ // install. Read-only: nothing is executed, only cached exports inspected.
510
+ try {
511
+ const cache = require.cache || {};
512
+ for (const id of Object.keys(cache)) {
513
+ if (id.indexOf('schemastery') === -1) continue;
514
+ let exported = null;
515
+ try { exported = cache[id] && cache[id].exports; } catch (e) { continue; }
516
+ take(exported, id + ' (already loaded in this process)');
517
+ }
518
+ } catch (e) { /* no module cache to read */ }
519
+ /* Successful scans only: see cacheableScan. Leaving the slot empty makes the
520
+ next evaluation rescan, which is exactly what a caller that lost a race
521
+ needs. */
522
+ try { if (cacheableScan(out)) globalThis[SCHEMA_SCAN_CACHE] = out; } catch (e) { /* cache is best-effort */ }
523
+ return out;
524
+ }
525
+
526
+ /**
527
+ * Pick the builder to build `Config` with: the best of the reachable ones.
528
+ *
529
+ * The preference order is the whole point of this function.
530
+ *
531
+ * 1. a builder with schemastery's OWN `.volatile()` — highest fidelity, the
532
+ * exact copy DSH validates against;
533
+ * 2. a builder that only has `.extra()`, with the marker synthesized.
534
+ *
535
+ * Step 2 exists because "no `.volatile()` anywhere" used to mean "no Config",
536
+ * and a missing Config is NOT a degraded theme — it is no persistence at all.
537
+ * DSH projects no settings form for an entry without a volatile Config, so
538
+ * every switch the user flips stays page-local and resets on the next reload:
539
+ * the panel opens, the switches move, and nothing is ever written down. That is
540
+ * exactly what happened on a host whose only *loadable* schemastery was the
541
+ * unscoped 3.18.0 (no `.volatile()`), while the 3.18.4 copy that does have it
542
+ * resolved from the profile but could not be required. Marking through
543
+ * `.extra()` builds the same node shape (`.meta.volatile === true`), which is
544
+ * all DSH's projection reads.
545
+ *
546
+ * @param candidates - `{ z, source }` records, closest require path first.
547
+ * @param requireVolatile - when true only an editable (volatile) schema is
548
+ * acceptable, because DSH drops a non-volatile field and suppresses the whole
549
+ * form; when false any usable builder does (the pre-0.1.7 registration
550
+ * persisted whole namespaces instead of a volatile Config).
551
+ * @returns `{ z, source, mode }`, or undefined when nothing is usable. */
552
+ function selectBuilder(candidates, requireVolatile) {
553
+ const usable = [];
554
+ for (const candidate of candidates) {
555
+ const z = candidate.z;
556
+ if (!z || typeof z.object !== 'function' || typeof z.string !== 'function') continue;
557
+ usable.push(candidate);
558
+ }
559
+ if (!requireVolatile) {
560
+ const first = usable[0];
561
+ if (!first) return undefined;
562
+ /* 'plain' unconditionally: this call site deliberately marks NOTHING, so
563
+ naming the builder's own capability here would misdescribe the schema. */
564
+ return { z: first.z, source: first.source, mode: 'plain' };
565
+ }
566
+ for (const candidate of usable) {
567
+ if (hasVolatile(candidate.z)) return { z: candidate.z, source: candidate.source, mode: 'native' };
568
+ }
569
+ for (const candidate of usable) {
570
+ if (hasVolatileMarker(candidate.z)) return { z: candidate.z, source: candidate.source, mode: 'synthesized' };
571
+ }
572
+ return undefined;
573
+ }
574
+
575
+ /**
576
+ * @param requireVolatile - when true only a builder that can produce an
577
+ * editable (volatile) schema is acceptable (needed for DSH 0.1.7's Config).
578
+ * @returns a usable builder, or undefined when none is reachable. On success
579
+ * SCHEMA_SOURCE records the root it came from and SCHEMA_MODE how it marks a
580
+ * field editable.
581
+ */
582
+ function loadSchemastery(requireVolatile) {
583
+ const chosen = selectBuilder(schemasteryCandidates(), requireVolatile);
584
+ if (!chosen) return undefined;
585
+ SCHEMA_SOURCE = chosen.source;
586
+ SCHEMA_MODE = chosen.mode;
587
+ return chosen.z;
588
+ }
589
+
590
+ /** Build the object schema over FIELD_DEFAULTS with an EXPLICIT builder.
591
+ *
592
+ * Split out of {@link buildSchema} so the field contract — one string field per
593
+ * FIELD_DEFAULTS entry, each carrying its shipped default and, when asked, the
594
+ * volatile marker — can be asserted against a stand-in builder instead of
595
+ * whatever schemastery the test machine happens to have. A Host check that
596
+ * silently skips on a schemastery-free machine is precisely how
597
+ * "settings won't save" shipped green.
598
+ *
599
+ * @param z - a schemastery builder.
600
+ * @param volatileFields - mark every field editable.
601
+ * @returns the object schema. */
602
+ function buildSchemaWith(z, volatileFields) {
603
+ const fields = {};
604
+ for (const [field, fallback] of Object.entries(FIELD_DEFAULTS)) {
605
+ let leaf = z.string().default(fallback);
606
+ if (volatileFields) leaf = volatileField(leaf);
607
+ fields[field] = leaf;
608
+ }
609
+ return z.object(fields);
610
+ }
611
+
612
+ /**
613
+ * Build one schemastery object schema over FIELD_DEFAULTS with the best
614
+ * reachable builder.
615
+ *
616
+ * @param volatileFields - when true every field is marked editable (volatile),
617
+ * which is what makes it user-editable through DSH 0.1.7's settings service
618
+ * (only volatile paths are projected into a form and accepted by a write).
619
+ * The legacy pre-0.1.7 registration used the same schema shape without the
620
+ * flag, because that generation persisted whole namespaces instead of a
621
+ * Config.
622
+ * @returns the schema, or undefined when no usable schemastery builder is
623
+ * reachable (the theme then stays a no-op instead of crashing the host half).
624
+ */
625
+ function buildSchema(volatileFields) {
626
+ try {
627
+ const z = loadSchemastery(volatileFields === true);
628
+ if (z === undefined || typeof z.object !== 'function' || typeof z.string !== 'function') return undefined;
629
+ return buildSchemaWith(z, volatileFields === true);
630
+ } catch (e) {
631
+ // A schema we cannot build must never take the entry (and the theme) down.
632
+ return undefined;
633
+ }
634
+ }
635
+
636
+ /**
637
+ * The Config DSH 0.1.7 projects into a settings form for this entry. It is
638
+ * read off the plugin module namespace (`entry.fiber.runtime.Config`) when the
639
+ * loader mounts the row, so it must exist at module evaluation time.
640
+ *
641
+ * `ctx.settings` refuses a write to any path that is not volatile, and it
642
+ * suppresses the whole form when NO field is volatile — hence `true` here.
643
+ * When no schemastery is reachable the property is omitted entirely, which is
644
+ * exactly "this plugin has nothing to configure" and not an error — but it is
645
+ * also the one failure that silently costs the user every preference on every
646
+ * reload, so apply() reports it instead of staying quiet.
647
+ */
648
+ const Config = buildSchema(true);
649
+
650
+ /** File name of the failure report written next to the profile patch. */
651
+ const DIAGNOSTIC_FILE = 'theme-endfield-diagnostic.json';
652
+
653
+ /** Where the failure report goes: the profile directory when the context names
654
+ * one, else the DSH home. Written only when no Config could be built, and
655
+ * deleted again the moment one can.
656
+ * @returns an absolute path, or null when neither location is derivable. */
657
+ function diagnosticPath(ctx) {
658
+ const path = require('path');
659
+ try {
660
+ const base = ctx && typeof ctx.baseUrl === 'string' ? ctx.baseUrl : null;
661
+ if (base && base.indexOf('file:') === 0) {
662
+ return path.join(require('url').fileURLToPath(base), DIAGNOSTIC_FILE);
663
+ }
664
+ // A few hosts carry the profile as a plain directory rather than a URL.
665
+ if (base && path.isAbsolute(base)) return path.join(base, DIAGNOSTIC_FILE);
666
+ } catch (e) { /* fall through to the DSH home */ }
667
+ try {
668
+ const homes = dshHomeDirs();
669
+ if (homes.length) return path.join(homes[0], DIAGNOSTIC_FILE);
670
+ } catch (e) { /* no home to write into */ }
671
+ return null;
672
+ }
673
+
674
+ /** Per-root resolution outcome, for the failure report.
675
+ * @returns one row per spec/root pair, or a single row describing the throw. */
676
+ function resolutionReport() {
677
+ const rows = [];
678
+ let roots = [];
679
+ try { roots = resolutionRoots(); } catch (e) { return [{ roots: 'threw: ' + String((e && e.message) || e) }]; }
680
+ for (const spec of SCHEMA_SPECS) {
681
+ try { rows.push({ spec, from: 'plain require', resolved: require.resolve(spec) }); } catch (e) { rows.push({ spec, from: 'plain require', error: String((e && e.code) || (e && e.message) || e) }); }
682
+ }
683
+ for (const dir of roots) {
684
+ for (const spec of SCHEMA_SPECS) {
685
+ const row = { spec, from: dir };
686
+ try {
687
+ row.resolved = require.resolve(spec, { paths: [dir] });
688
+ /* Resolving is NOT loading: a copy can resolve perfectly and still throw
689
+ on require (a dependency that will not load, a half-written install).
690
+ The old report stopped at the resolve, so a row reading "resolved"
691
+ next to a Config that was never built looked like a contradiction
692
+ instead of an answer. Record what the require actually did. */
693
+ try {
694
+ const loaded = require(row.resolved);
695
+ row.loaded = true;
696
+ row.volatile = hasVolatile(loaded);
697
+ row.marker = hasVolatileMarker(loaded);
698
+ } catch (e) {
699
+ row.loadError = String((e && e.code) || (e && e.message) || e);
700
+ }
701
+ } catch (e) { row.error = String((e && e.code) || (e && e.message) || e); }
702
+ rows.push(row);
703
+ }
704
+ }
705
+ return rows;
706
+ }
707
+
708
+ /** Everything needed to explain a missing settings form on THIS machine.
709
+ * @returns a JSON-serializable snapshot; every read is individually guarded. */
710
+ function diagnosticSnapshot(ctx) {
711
+ const read = (produce) => { try { return produce(); } catch (e) { return 'threw: ' + String((e && e.message) || e); } };
712
+ let loader = null;
713
+ try { loader = ctx && typeof ctx.get === 'function' ? ctx.get('loader') : null; } catch (e) { loader = null; }
714
+ return {
715
+ at: new Date().toISOString(),
716
+ plugin: read(() => NAME + ' ' + require('./package.json').version),
717
+ configBuilt: Config !== undefined,
718
+ schemaSource: SCHEMA_SOURCE,
719
+ schemaMode: SCHEMA_MODE,
720
+ node: process.version,
721
+ pid: process.pid,
722
+ execPath: process.execPath,
723
+ argv: process.argv.slice(0, 6),
724
+ cwd: read(() => process.cwd()),
725
+ dirname: __dirname,
726
+ filename: __filename,
727
+ mainModule: read(() => (require.main && require.main.filename) || null),
728
+ baseUrl: read(() => (ctx && typeof ctx.baseUrl === 'string' ? ctx.baseUrl : null)),
729
+ dshHomeDirs: read(dshHomeDirs),
730
+ homedir: read(() => require('os').homedir()),
731
+ envDshHome: read(() => (process.env && process.env.DSH_HOME) || null),
732
+ envCordisShared: read(() => (process.env && process.env.CORDIS_SHARED) || null),
733
+ loaderStartedAt: read(() => {
734
+ const start = loader && loader.envData && loader.envData.startTime;
735
+ return typeof start === 'number' ? new Date(start).toISOString() : null;
736
+ }),
737
+ cachedSchemasteryModules: read(() => Object.keys(require.cache || {}).filter((id) => id.indexOf('schemastery') !== -1)),
738
+ resolution: read(resolutionReport),
739
+ };
740
+ }
741
+
742
+ /** Report a missing settings form: a warn line for the log, plus the JSON
743
+ * report above so the exact machine state can be inspected afterwards. */
744
+ function reportMissingConfig(ctx) {
745
+ const note = NAME + ': no schemastery builder that can mark a field volatile was reachable, so this entry'
746
+ + ' exports no Config: DSH projects no settings form for it and every preference stays'
747
+ + ' page-local (it resets on each reload). Tried ' + SCHEMA_SPECS.join(', ') + ' through the'
748
+ + ' plain require chain, this module\'s own tree, the DSH home profiles, the working'
749
+ + ' directory, this module\'s ancestors, require.main and the process module cache.'
750
+ + ' Neither `.volatile()` nor the `.extra()` it is built on was available, so no field'
751
+ + ' could be marked editable.';
752
+ try {
753
+ if (ctx.logger && typeof ctx.logger.warn === 'function') ctx.logger.warn(note);
754
+ else if (typeof console !== 'undefined' && console.warn) console.warn(note);
755
+ } catch (e) { /* a logger that throws must not kill the theme */ }
756
+ try {
757
+ const target = diagnosticPath(ctx);
758
+ if (!target) return;
759
+ require('fs').writeFileSync(target, JSON.stringify(diagnosticSnapshot(ctx), null, 2) + '\n');
760
+ if (ctx.logger && typeof ctx.logger.warn === 'function') {
761
+ ctx.logger.warn(NAME + ': wrote the failure report to ' + target);
762
+ }
763
+ } catch (e) { /* a report that cannot be written must not kill the theme */ }
764
+ }
765
+
766
+ /** Drop a report left by an earlier boot: the form exists now. */
767
+ function clearStaleDiagnostic(ctx) {
768
+ try {
769
+ const target = diagnosticPath(ctx);
770
+ if (!target) return;
771
+ const fs = require('fs');
772
+ if (fs.existsSync(target)) fs.unlinkSync(target);
773
+ } catch (e) { /* nothing to clean up */ }
774
+ }
775
+
776
+ /* ------------------------------------------------------------------ *
777
+ * Audio notification host half
778
+ * ------------------------------------------------------------------ */
779
+
780
+ /** Read a service off the context without letting a missing one throw. */
781
+ function serviceOf(ctx, key) {
782
+ try {
783
+ return ctx.get(key);
784
+ } catch (error) {
785
+ return undefined;
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Whether one inbox message is a prompt the USER typed into the composer.
791
+ *
792
+ * The only positive evidence available at this seam is the prompt-RPC identity
793
+ * that DSH attaches to a browser-submitted prompt: `source = { kind: 'user',
794
+ * rpcId }` (`dsh-api-session-controller`, where `promptRpcId()` reads exactly
795
+ * that field). Everything that injects work programmatically — goal rounds,
796
+ * background-job wakeups, subagent hand-offs, plugin context — produces
797
+ * `kind: 'user'` too, or a `next-step` context message, so "has rpcId" is the
798
+ * discriminator; with `humanOnly` off, the looser `kind: 'user'` test applies.
799
+ *
800
+ * @param message - the claimed inbox message.
801
+ * @param humanOnly - whether to require the composer identity.
802
+ * @returns `{ human, why }` for diagnostics.
803
+ */
804
+ function classifyPrompt(message, humanOnly) {
805
+ const source = message === undefined || message === null ? undefined : message.source;
806
+ if (source === undefined || source === null) return { human: false, why: 'no source' };
807
+ const kind = source.kind;
808
+ const hasRpcId = typeof source.rpcId === 'string' && source.rpcId !== '';
809
+ if (hasRpcId) return { human: true, why: 'composer prompt (rpcId)' };
810
+ if (kind === 'user' && !humanOnly) return { human: true, why: 'user-source message (humanOnly off)' };
811
+ return { human: false, why: `source.kind=${String(kind)} without rpcId` };
812
+ }
813
+
814
+ /** Whether one assistant message carried visible text (as opposed to only calls). */
815
+ function hasVisibleText(message) {
816
+ if (message === undefined || message === null) return false;
817
+ const content = Array.isArray(message.content) ? message.content : [];
818
+ for (const block of content) {
819
+ if (block === null || block === undefined) continue;
820
+ if (block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') return true;
821
+ }
822
+ return false;
823
+ }
824
+
825
+ /**
826
+ * Install the notification feature on one host context.
827
+ *
828
+ * @param ctx - host Cordis context.
829
+ * @param settingsScope - the registered settings scope, when one exists.
830
+ */
831
+ function installAudio(ctx, settingsScope) {
832
+ const audio = new AudioRuntime(ctx);
833
+ const terminals = new Map(); // `${sessionId}:${turn}` -> saw a text answer
834
+ const playedDone = new Set(); // turn keys already reported, so a turn speaks once
835
+ /** How many human-intervention requests actually reached this host half.
836
+ `ui` counts confirmations the PAGE observed and reported — the path that
837
+ actually works in this deployment; `approval`/`question` count the host-side
838
+ seams, which a different composition may drive instead. */
839
+ const attentionSeen = { approval: 0, question: 0, ui: 0, uiLastAt: 0, uiLastKind: '' };
840
+
841
+ if (settingsScope !== undefined) {
842
+ try {
843
+ audio.setValues(settingsScope.get());
844
+ settingsScope.watch(() => {
845
+ audio.setValues(settingsScope.get());
846
+ });
847
+ } catch (error) {
848
+ audio.note('settings-unavailable', String(error && error.message ? error.message : error));
849
+ }
850
+ }
851
+
852
+ /** Whether one agent is a top-level conversation (never a subagent). */
853
+ const isRootAgent = (agent) => {
854
+ if (agent === undefined || agent === null) return false;
855
+ const agents = serviceOf(ctx, 'agents');
856
+ if (agents === undefined || typeof agents.roots !== 'function') return true; // cannot tell: stay permissive
857
+ try {
858
+ return agents.roots().some((candidate) => candidate === agent || candidate?.id === agent.id);
859
+ } catch (error) {
860
+ return true;
861
+ }
862
+ };
863
+
864
+ const turnKey = (agent, turn) => `${agent === undefined ? '?' : agent.id}:${turn}`;
865
+
866
+ // --- event wiring --------------------------------------------------
867
+ ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
868
+ if (!isRootAgent(agent)) return;
869
+ const verdict = classifyPrompt(message, audio.enabled(PREF.humanOnly));
870
+ if (audio.diagnosing) {
871
+ audio.note('inbox/claimed', `turn=${turn} -> ${verdict.why}`);
872
+ }
873
+ if (!verdict.human) return;
874
+ if (!audio.enabled(PREF.start)) return;
875
+ audio.play('turn-start', { reason: 'human prompt' });
876
+ });
877
+
878
+ ctx.on('session/event', (subject, event) => {
879
+ if (event === null || event === undefined) return;
880
+ if (event.type !== 'assistant/message') return;
881
+ const data = event.data;
882
+ if (data === null || data === undefined) return;
883
+ const sessionId = subject !== null && subject !== undefined && subject.id !== undefined ? subject.id : '?';
884
+ const key = `${sessionId}:${data.turn}`;
885
+ if (hasVisibleText(data.message)) terminals.set(key, true);
886
+ else if (!terminals.has(key)) terminals.set(key, false);
887
+ });
888
+
889
+ /* The two "a human has to do something" triggers, and the ONLY things that
890
+ sound besides the boot plate and a finished answer.
891
+
892
+ A turn that fails on its own is deliberately NOT one of them: the user's
893
+ rule is that an error needing no human decision should stay silent, so
894
+ `agent/error` is not wired to any slot. The `turn-fail` sound still ships
895
+ and can still be previewed, but nothing fires it.
896
+
897
+ Each handler keeps a counter so the settings bridge can answer "did the host
898
+ actually receive this request?" — the failure mode where the event exists
899
+ but never reaches a host listener is otherwise invisible from the outside. */
900
+ ctx.on('approval/request', (request, next) => {
901
+ attentionSeen.approval += 1;
902
+ if (audio.diagnosing) audio.note('approval/request', String(request === undefined ? '' : request.kind || ''));
903
+ if (audio.enabled(PREF.attention)) {
904
+ /* This event is a waterfall: play() must never be allowed to throw past
905
+ next(), or the whole approval chain dies exactly the way the
906
+ tools/execute note below describes. play() itself guards its file
907
+ parsing, so this is the belt to that brace. */
908
+ try { audio.play('attention', { reason: 'approval request' }); }
909
+ catch (error) { audio.note('play-threw', `approval/request: ${error && error.message ? error.message : error}`); }
910
+ }
911
+ return typeof next === 'function' ? next() : undefined;
912
+ });
913
+
914
+ ctx.on('user-questions/request', (request, next) => {
915
+ attentionSeen.question += 1;
916
+ if (audio.diagnosing) audio.note('user-questions/request', 'pending question');
917
+ if (audio.enabled(PREF.attention)) {
918
+ // Same waterfall contract as approval/request above.
919
+ try { audio.play('attention', { reason: 'user question' }); }
920
+ catch (error) { audio.note('play-threw', `user-questions/request: ${error && error.message ? error.message : error}`); }
921
+ }
922
+ return typeof next === 'function' ? next() : undefined;
923
+ });
924
+
925
+ /* There is deliberately NO `tools/execute` handler here.
926
+
927
+ An earlier version added one as a redundant second trigger for
928
+ `ask_user_question`. It was wrong and it was destructive: `tools/execute` is
929
+ a WATERFALL (`dsh-tools`: `await this.ctx.waterfall(carrier, 'tools/execute',
930
+ mutableExec, () => this.dispatchToolBody(mutableExec))`), so a listener that
931
+ inspects the call and returns `undefined` without calling `next()` reports
932
+ "no result" for that tool — the tool never runs and the failure surfaces on
933
+ every subsequent call in the session (observed as every tool returning
934
+ `Cannot read properties of undefined (reading 'isError')` while this plugin
935
+ was mounted, and the tool chain recovering the moment it was removed).
936
+
937
+ The attention slot does not need it: `approval/request` and
938
+ `user-questions/request` are the two moments that need a human, and both are
939
+ already wired above to the same slot. A redundant path is not worth a seam
940
+ that can silently break every tool in the profile. */
941
+
942
+ ctx.on('agent/turn-stopping', ({ agent, turn }) => {
943
+ const key = turnKey(agent, turn);
944
+ /* Cleanup runs BEFORE the switches below. Both early returns used to sit in
945
+ front of the delete, which leaked entries two ways: subagent turns fail
946
+ isRootAgent yet are recorded by the session/event handler above, and a
947
+ user who turned the done sound off stopped reaping anything at all. */
948
+ const hadText = terminals.get(key) === true;
949
+ terminals.delete(key);
950
+ if (!isRootAgent(agent)) return;
951
+ if (!audio.enabled(PREF.done)) return;
952
+ // One report per turn, and only for a turn that actually produced a written
953
+ // result: a turn the user interrupted, or one that stopped on an approval
954
+ // prompt, has nothing to announce.
955
+ if (!hadText) {
956
+ if (audio.diagnosing) audio.note('turn-stopping', `turn=${turn} no final text -> silent`);
957
+ return;
958
+ }
959
+ if (playedDone.has(key)) return;
960
+ playedDone.add(key);
961
+ if (playedDone.size > 64) playedDone.delete(playedDone.values().next().value);
962
+ audio.play('turn-done', { reason: 'final answer' });
963
+ });
964
+
965
+ // --- settings-page bridge (preview + diagnostics) -------------------
966
+ const registerBridge = (webServer) => {
967
+ if (webServer === undefined || typeof webServer.register !== 'function') {
968
+ audio.note('bridge-unavailable', 'webServer service absent; preview buttons disabled');
969
+ return;
970
+ }
971
+ const readJson = (req) => new Promise((resolve) => {
972
+ let body = '';
973
+ req.on('data', (chunk) => {
974
+ body += chunk;
975
+ if (body.length > 65536) body = body.slice(0, 65536);
976
+ });
977
+ req.on('end', () => {
978
+ try { resolve(JSON.parse(body === '' ? '{}' : body)); } catch (error) { resolve({}); }
979
+ });
980
+ req.on('error', () => resolve({}));
981
+ });
982
+ const send = (res, code, payload) => {
983
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
984
+ res.end(JSON.stringify(payload));
985
+ };
986
+ try {
987
+ webServer.register({
988
+ kind: 'prefix',
989
+ path: '/theme-endfield/audio',
990
+ handler: async (req, res) => {
991
+ const url = String(req.url || '');
992
+ const pathname = url.split('?')[0];
993
+ const query = url.includes('?') ? new URLSearchParams(url.slice(url.indexOf('?') + 1)) : new URLSearchParams();
994
+ /* Both preview routes force the play (bypassing the debounce window),
995
+ because a preview that silently does nothing is indistinguishable
996
+ from a broken feature. The GET form takes the slot in the query
997
+ string so a non-JS caller can trigger the same play; neither route
998
+ is a new capability — both end in audio.play() under the user's own
999
+ settings, and the Host web server already authenticates the page. */
1000
+ const playForPreview = (slotId, reason) => {
1001
+ const id = String(slotId || '');
1002
+ // The slot's own switch is authoritative even for a preview: playing a
1003
+ // sound the user switched off would make the switch look broken.
1004
+ if (SLOT_IDS.includes(id) && !audio.slotEnabled(id)) {
1005
+ return { slot: id, played: false, why: `slot switch "${id}" is off` };
1006
+ }
1007
+ const result = audio.play(id, { force: true, reason });
1008
+ return Object.assign({ slot: id }, result);
1009
+ };
1010
+ try {
1011
+ if (req.method === 'GET' && pathname === '/theme-endfield/audio/state') {
1012
+ // `attention` rides along so a caller can tell "the host never got
1013
+ // the request" apart from "the host got it and chose to stay
1014
+ // silent" — the two look identical from the page otherwise.
1015
+ return send(res, 200, Object.assign(audio.snapshot(), { attention: Object.assign({}, attentionSeen) }));
1016
+ }
1017
+ if (req.method === 'GET' && pathname === '/theme-endfield/audio/preview') {
1018
+ const result = playForPreview(query.get('slot'), 'preview (GET)');
1019
+ return send(res, result.played ? 200 : 409, result);
1020
+ }
1021
+ if (req.method === 'POST' && pathname === '/theme-endfield/audio/preview') {
1022
+ const body = await readJson(req);
1023
+ const result = playForPreview(body.slot, 'settings preview');
1024
+ return send(res, result.played ? 200 : 409, result);
1025
+ }
1026
+ /* The page reports "a confirmation box is on screen".
1027
+
1028
+ This route exists because the two host-side seams that would
1029
+ normally carry this moment (`approval/request`,
1030
+ `user-questions/request`) do not fire in every composition: in this
1031
+ deployment `ask_user_question` is provided OUTSIDE the profile's
1032
+ plugin stack, so `dsh-tool-ask-user` never runs and the waterfall is
1033
+ never raised — measured directly as a zero counter while a question
1034
+ was on screen. The UI is the one place the moment is always real.
1035
+
1036
+ It deliberately does NOT force: unlike a settings preview, a real
1037
+ notification must respect the switch, the volume and the per-slot
1038
+ debounce, all of which stay owned by the host. `force` is what makes
1039
+ a preview bypass the window, and reusing it here would let two rapid
1040
+ boxes beep twice because the browser holds no shared clock. */
1041
+ if (req.method === 'POST' && pathname === '/theme-endfield/audio/attention') {
1042
+ const body = await readJson(req);
1043
+ attentionSeen.ui += 1;
1044
+ attentionSeen.uiLastAt = Date.now();
1045
+ attentionSeen.uiLastKind = String(body.kind || 'pending');
1046
+ if (audio.diagnosing) audio.note('attention (page)', `saw ${attentionSeen.uiLastKind}`);
1047
+ if (!audio.slotEnabled('attention')) {
1048
+ return send(res, 409, { played: false, why: 'slot switch "attention" is off' });
1049
+ }
1050
+ const result = audio.play('attention', { reason: `page: ${attentionSeen.uiLastKind}` });
1051
+ return send(res, result.played ? 200 : 409, result);
1052
+ }
1053
+ return send(res, 404, { error: 'not found' });
1054
+ } catch (error) {
1055
+ return send(res, 500, { error: String(error && error.message ? error.message : error) });
1056
+ }
1057
+ },
1058
+ });
1059
+ audio.note('bridge', 'settings bridge mounted at /theme-endfield/audio');
1060
+ } catch (error) {
1061
+ audio.note('bridge-failed', String(error && error.message ? error.message : error));
1062
+ }
1063
+ };
1064
+ const webServerNow = serviceOf(ctx, 'webServer');
1065
+ if (webServerNow !== undefined) registerBridge(webServerNow);
1066
+ else if (typeof ctx.inject === 'function') ctx.inject(['webServer'], (scope) => registerBridge(scope.webServer));
1067
+
1068
+ console.log(`${LOG_TAG} ready (host half)`);
1069
+ return audio;
1070
+ }
1071
+
1072
+ /* ---------------------------------------------------------------------------
1073
+ * Balance bridge — GET /theme-endfield/balance
1074
+ *
1075
+ * The browser half cannot reach the account balance itself: only Host
1076
+ * consumers can obtain the request credential the account service needs, so
1077
+ * the capsule in the page asks THIS route instead. The handler mirrors the
1078
+ * account service's own client-metadata shape (version/locale/timezone) and
1079
+ * returns the balance payload verbatim; a failure is reported as
1080
+ * { ok: false } rather than a thrown 500 so the page can keep the last known
1081
+ * numbers on screen instead of flickering an error state.
1082
+ *
1083
+ * TWO BALANCE ROUTES, API KEY FIRST (mirrors dsh-whale-widget v759/v781).
1084
+ * A user who only configured DEEPSEEK_API_KEY and never logged into the
1085
+ * DeepSeek account gets NOTHING from the account service — that route answers
1086
+ * only for the client-login identity. So the handler now tries, in order:
1087
+ * 1. API key route — GET https://api.deepseek.com/user/balance with the
1088
+ * resolved DEEPSEEK_API_KEY credential. Always first when a key exists.
1089
+ * 2. Account route — the DSH `deepseekAccount` service (the original path).
1090
+ * The two never fall back into each other silently in reverse: with a key
1091
+ * configured, the key route's answer is authoritative even when it fails
1092
+ * (the failure reason is reported so the page can keep stale numbers); the
1093
+ * account route is only consulted when no key resolves at all.
1094
+ * ------------------------------------------------------------------------ */
1095
+ const DSH_CLIENT_VERSION_FALLBACK = '0.2.0-rc.2';
1096
+ const BALANCE_API_URL = 'https://api.deepseek.com/user/balance';
1097
+ const BALANCE_API_TIMEOUT_MS = 8000;
1098
+
1099
+ /* Route 1: the API key path. Resolves the DEEPSEEK_API_KEY credential through
1100
+ * the host credentials service and asks the official balance endpoint.
1101
+ * Returns null when there is no key to try (caller falls through to the
1102
+ * account route) or `{ ok:false, why }` on a definitive failure; a success is
1103
+ * the same wallet payload the account route produces, so the page-side
1104
+ * contract never changes. Transient network/5xx failures retry once after a
1105
+ * short backoff before being reported. */
1106
+ async function fetchApiKeyBalance(ctx) {
1107
+ let cred = null;
1108
+ try {
1109
+ const credentials = serviceOf(ctx, 'credentials');
1110
+ if (credentials && typeof credentials.resolve === 'function') {
1111
+ cred = await credentials.resolve('DEEPSEEK_API_KEY');
1112
+ }
1113
+ } catch (error) {
1114
+ return { ok: false, why: 'API key 读取失败: ' + String(error && error.message ? error.message : error) };
1115
+ }
1116
+ if (!cred || !cred.value) return null; // no key configured -> account route
1117
+ let lastError = '';
1118
+ for (let attempt = 0; attempt < 2; attempt++) {
1119
+ let res;
1120
+ try {
1121
+ res = await fetch(BALANCE_API_URL, {
1122
+ headers: { Authorization: 'Bearer ' + cred.value },
1123
+ signal: AbortSignal.timeout(BALANCE_API_TIMEOUT_MS),
1124
+ });
1125
+ } catch (error) {
1126
+ lastError = String(error && error.message ? error.message : error);
1127
+ if (attempt === 0) { await new Promise((resolve) => setTimeout(resolve, 500)); continue; }
1128
+ return { ok: false, why: '余额接口请求失败: ' + lastError };
1129
+ }
1130
+ if (!res.ok) {
1131
+ lastError = 'HTTP ' + res.status;
1132
+ if (res.status < 500) return { ok: false, why: '余额接口返回 ' + lastError }; // 4xx: key bad/unauthorized, no retry
1133
+ if (attempt === 0) { await new Promise((resolve) => setTimeout(resolve, 500)); continue; }
1134
+ return { ok: false, why: '余额接口请求失败: ' + lastError };
1135
+ }
1136
+ let data;
1137
+ try {
1138
+ data = await res.json();
1139
+ } catch (error) {
1140
+ return { ok: false, why: '余额接口返回不是合法 JSON' };
1141
+ }
1142
+ const infos = Array.isArray(data && data.balance_infos) ? data.balance_infos : [];
1143
+ const info = infos.find((x) => x && x.currency === 'CNY') || infos[0];
1144
+ if (!info || info.total_balance === undefined || !Number.isFinite(Number(info.total_balance))) {
1145
+ return { ok: false, why: '余额接口返回结构异常' };
1146
+ }
1147
+ return {
1148
+ ok: true,
1149
+ wallets: [{ currency: String(info.currency || 'CNY'), balance: String(info.total_balance) }],
1150
+ bonusWallets: info.granted_balance !== undefined && Number.isFinite(Number(info.granted_balance))
1151
+ ? [{ currency: String(info.currency || 'CNY'), balance: String(info.granted_balance) }]
1152
+ : [],
1153
+ at: Date.now(),
1154
+ };
1155
+ }
1156
+ return { ok: false, why: '余额接口请求失败: ' + lastError }; // unreachable, keeps lint honest
1157
+ }
1158
+
1159
+ /* Route 2: the DSH account-login path (the original implementation). Returns
1160
+ * the wallet payload on success, `{ ok:false, why }` on any failure — the
1161
+ * why text distinguishes "service absent" (no such host generation) from
1162
+ * "not logged in" so a user can tell the two apart. */
1163
+ async function fetchAccountBalance(ctx) {
1164
+ const account = serviceOf(ctx, 'deepseekAccount');
1165
+ if (!account || typeof account.getBalance !== 'function') {
1166
+ return { ok: false, why: 'account service absent' };
1167
+ }
1168
+ const client = {
1169
+ version: DSH_CLIENT_VERSION_FALLBACK,
1170
+ locale: 'zh-CN',
1171
+ timezoneOffsetSeconds: -(new Date()).getTimezoneOffset() * 60,
1172
+ };
1173
+ const balance = await account.getBalance(client);
1174
+ if (!balance || balance.status !== 'ready') {
1175
+ return { ok: false, why: balance && balance.status ? String(balance.status) : 'null' };
1176
+ }
1177
+ return {
1178
+ ok: true,
1179
+ wallets: balance.value || [],
1180
+ bonusWallets: balance.bonusWallets || [],
1181
+ at: Date.now(),
1182
+ };
1183
+ }
1184
+
1185
+ function registerBalanceBridge(ctx) {
1186
+ const webServer = serviceOf(ctx, 'webServer');
1187
+ const mount = (webServer) => {
1188
+ if (webServer === undefined || typeof webServer.register !== 'function') return;
1189
+ const send = (res, code, payload) => {
1190
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
1191
+ res.end(JSON.stringify(payload));
1192
+ };
1193
+ try {
1194
+ webServer.register({
1195
+ kind: 'prefix',
1196
+ path: '/theme-endfield/balance',
1197
+ handler: async (req, res) => {
1198
+ const pathname = String(req.url || '').split('?')[0];
1199
+ if (req.method !== 'GET' || pathname !== '/theme-endfield/balance') {
1200
+ return send(res, 404, { error: 'not found' });
1201
+ }
1202
+ try {
1203
+ // API key route first; a resolved key makes its answer authoritative
1204
+ // (success or reported failure — no silent fall-through to login).
1205
+ const apiKeyResult = await fetchApiKeyBalance(ctx);
1206
+ if (apiKeyResult !== null) return send(res, 200, apiKeyResult);
1207
+ // No API key configured -> the account-login route.
1208
+ return send(res, 200, await fetchAccountBalance(ctx));
1209
+ } catch (error) {
1210
+ return send(res, 200, { ok: false, why: String(error && error.message ? error.message : error) });
1211
+ }
1212
+ },
1213
+ });
1214
+ } catch (error) {
1215
+ console.error(`${LOG_TAG} balance bridge failed: ${error && error.message ? error.message : error}`);
1216
+ }
1217
+ };
1218
+ if (webServer !== undefined) mount(webServer);
1219
+ else if (typeof ctx.inject === 'function') ctx.inject(['webServer'], (scope) => mount(scope.webServer));
1220
+ }
1221
+
1222
+ /**
1223
+ * A `get()` / `watch()` view over this plugin's OWN resolved Config.
1224
+ *
1225
+ * DSH >= 0.1.7 has no scope to hand a plugin: a preference edit reaches a running
1226
+ * entry as a committed `.volatile()` reference — the loader writes it into the
1227
+ * live config and emits `loader/volatile-update` — so the current values are read
1228
+ * straight off the resolved config `apply()` was started with (every
1229
+ * `.volatile()` leaf is a `{ get() }` reference) and the audio runtime re-reads
1230
+ * them whenever the loader commits a new one.
1231
+ *
1232
+ * Returns `undefined` when there is nothing to read, which leaves the runtime on
1233
+ * its shipped defaults — the same behaviour as a host with no settings service.
1234
+ *
1235
+ * @param ctx - host Cordis context; its event seam is optional.
1236
+ * @param config - the resolved plugin config (volatile leaves or plain values).
1237
+ */
1238
+ function configPrefScope(ctx, config) {
1239
+ if (config === undefined || config === null || typeof config !== 'object') return undefined;
1240
+ const read = () => {
1241
+ const values = {};
1242
+ for (const [key, value] of Object.entries(config)) {
1243
+ if (value === undefined || value === null) continue;
1244
+ values[key] = typeof value.get === 'function' ? value.get() : value;
1245
+ }
1246
+ return values;
1247
+ };
1248
+ return {
1249
+ get: read,
1250
+ watch: (listener) => {
1251
+ if (typeof ctx.on !== 'function') return () => {};
1252
+ try {
1253
+ return ctx.on('loader/volatile-update', () => { listener(); });
1254
+ } catch (error) {
1255
+ return () => {};
1256
+ }
1257
+ },
1258
+ };
1259
+ }
1260
+
1261
+ function apply(ctx, config) {
1262
+ /* Diagnostics, once per mount. Without a volatile Config this entry has no
1263
+ settings form, so the browser half reads and writes its preferences
1264
+ page-locally: every switch still works, and every switch is gone on the next
1265
+ reload. That is indistinguishable from "the theme is broken" unless the host
1266
+ says what happened — so it says it in the log AND leaves a readable report
1267
+ behind, because the reason is a property of this machine's install layout
1268
+ (see resolutionRoots) that no generic message can name. */
1269
+ if (Config === undefined) {
1270
+ reportMissingConfig(ctx);
1271
+ } else {
1272
+ clearStaleDiagnostic(ctx);
1273
+ // Which copy answered, at debug level: the one question that decides whether
1274
+ // a form exists at all, and otherwise invisible.
1275
+ try {
1276
+ if (ctx.logger && typeof ctx.logger.debug === 'function') ctx.logger.debug(NAME + ': Config built from ' + String(SCHEMA_SOURCE) + ' (volatile mode: ' + String(SCHEMA_MODE) + ')');
1277
+ } catch (e) { /* logging is never load-bearing */ }
1278
+ }
1279
+
1280
+ /* The notification feature installs alongside the settings work below: which
1281
+ settings GENERATION answered does not matter to it. A host WITHOUT the
1282
+ inject capability starts the engine right here, on the shipped defaults; a
1283
+ host WITH it starts the engine when the settings service arrives — and if
1284
+ that service never appears, inject keeps waiting, so the engine (and the
1285
+ bridge mounted together with it) never comes up. No known DSH generation
1286
+ behaves that way. The guard keeps the two in-apply paths from installing
1287
+ two engines on one context. */
1288
+ // The balance capsule route mounts unconditionally: the page polls it only
1289
+ // when its own switch is on, so the route itself costs nothing at rest.
1290
+ try { registerBalanceBridge(ctx); } catch (e) { /* never load-bearing */ }
1291
+
1292
+ let audioInstalled = false;
1293
+ const startAudio = (settingsScope) => {
1294
+ if (audioInstalled) return;
1295
+ audioInstalled = true;
1296
+ try {
1297
+ /* The legacy scope wins where a host hands one out; on 0.1.7 the live
1298
+ values are this plugin's own resolved Config (see configPrefScope). */
1299
+ installAudio(ctx, settingsScope === undefined ? configPrefScope(ctx, config) : settingsScope);
1300
+ } catch (error) {
1301
+ // The theme must keep working even if the notification feature cannot
1302
+ // install at all.
1303
+ console.error(`${LOG_TAG} install failed: ${error && error.message ? error.message : error}`);
1304
+ }
1305
+ };
1306
+
1307
+ // Wait for the host settings service. Cordis `ctx.inject(['settings'], ...)`
1308
+ // WAITS for the service (same convention as @deepseek-ai/dsh-client-ui-theme,
1309
+ // dsh-agent-presets, …), so registration is reliable however concurrently
1310
+ // mounted plugins interleave; a synchronous `ctx.get('settings')` probe would
1311
+ // race and could see it absent.
1312
+ //
1313
+ // It is still only an ASK, and asking is what has to be feature-detected — the
1314
+ // webServer lookup above does. A context without `inject` used to throw here
1315
+ // and take the whole mount down; it now ends up on the same defaults-only
1316
+ // engine that a host with no settings service gets.
1317
+ if (typeof ctx.inject !== 'function') {
1318
+ startAudio(undefined);
1319
+ return;
1320
+ }
1321
+ ctx.inject(['settings'], (settingsCtx) => {
1322
+ if (!settingsCtx || !settingsCtx.settings) {
1323
+ startAudio(undefined);
1324
+ return;
1325
+ }
1326
+ const settings = settingsCtx.settings;
1327
+
1328
+ /* DSH >= 0.1.7: page policy only.
1329
+ This plugin renders its own settings page (client.js registers a
1330
+ `settings.section` row with four groups and live previews), so the
1331
+ auto-generated Config page would be a second, poorer copy of it. An
1332
+ absent `autoGenerate:false` policy is what makes DSH add that page.
1333
+ Scoped to this plugin's fiber and disposed with the run. */
1334
+ if (typeof settings.configure === 'function') {
1335
+ try {
1336
+ settingsCtx.effect(() => settings.configure({ auto: false }, ctx.fiber));
1337
+ } catch (e) {
1338
+ // A policy may already be registered for this fiber (double mount);
1339
+ // the theme must still load.
1340
+ }
1341
+ }
1342
+
1343
+ /* DSH <= 0.1.5-rc.2: the legacy namespace registration.
1344
+ Those hosts persisted a plugin-declared namespace to
1345
+ `<dshHome>/settings.yaml` and mirrored it to the browser through
1346
+ `ctx.settingsScope`, which the client half still binds when no
1347
+ `configForms` service exists. Feature-detected: on 0.1.7 `register` is
1348
+ gone and this branch is simply skipped — the Config above is the
1349
+ declaration instead. Its RETURN VALUE is also the only live preference
1350
+ scope those hosts expose, so it is what the audio runtime reads. */
1351
+ let scope;
1352
+ if (typeof settings.register === 'function') {
1353
+ const schema = buildSchema(false);
1354
+ if (schema !== undefined) {
1355
+ try {
1356
+ // Registration is scoped to this plugin's fiber and disposed with the run.
1357
+ scope = settings.register(LEGACY_NAMESPACE, schema, { applies: 'live' });
1358
+ } catch (e) {
1359
+ // A throw here must not kill the whole theme; leaving it unregistered
1360
+ // just means browser prefs stay page-local on that host generation.
1361
+ }
1362
+ }
1363
+ }
1364
+ startAudio(scope);
1365
+ });
1366
+ }
1367
+
1368
+ /* The module export.
1369
+ *
1370
+ * `Config` is written as a STATIC property of the literal on purpose. DSH
1371
+ * imports loader entries through Node's internal ESM loader, which interops a
1372
+ * CommonJS file by building its namespace from cjs-module-lexer's static
1373
+ * analysis of this file. A property assigned afterwards
1374
+ * (`exported.Config = Config`) is invisible to that analysis, and a namespace
1375
+ * that cannot see it leaves DSH's `unwrapExports()` with an object whose
1376
+ * `apply` (a literal property, so detected) mounts the entry while `Config`
1377
+ * stays undefined — the entry runs, `Config.listConfigs` reports `absent`, and
1378
+ * no settings form exists. Hence: literal property, always present, with the
1379
+ * value `undefined` when no schemastery builder was reachable (which is a
1380
+ * legitimate "nothing to configure" and not a broken schema). */
1381
+ module.exports = {
1382
+ name: NAME,
1383
+ apply,
1384
+ Config,
1385
+ // Exposed for tests/documentation.
1386
+ NAMESPACE,
1387
+ SETTINGS_ENTRY,
1388
+ LEGACY_NAMESPACE,
1389
+ FIELD_DEFAULTS,
1390
+ /* Pure helpers, so the settings contracts below can be asserted against a
1391
+ stand-in builder instead of whatever schemastery the test machine happens
1392
+ to have (see test/settings-config-fallback.test.js). */
1393
+ buildSchemaWith,
1394
+ selectBuilder,
1395
+ volatileField,
1396
+ /* Audio-notification surface, asserted by test/audio-notify.test.js. */
1397
+ AUDIO_PREF_DEFAULTS: AUDIO_FALLBACK,
1398
+ classifyPrompt,
1399
+ hasVisibleText,
1400
+ installAudio,
1401
+ registerBalanceBridge,
1402
+ configPrefScope,
1403
+ };