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/LICENSE +21 -0
- package/README.md +117 -2
- package/client.js +10152 -0
- package/cordis.patch.yml +17 -0
- package/docs/PR-description.md +141 -0
- package/docs/audio-notifications.md +133 -0
- package/docs/contour-scan-bounds.md +24 -0
- package/docs/contour-trail.md +61 -0
- package/docs/contour-worker.md +38 -0
- package/docs/design-language.md +212 -0
- package/docs/engineering-notes.md +1147 -0
- package/docs/features.md +295 -0
- package/docs/glass.md +32 -0
- package/docs/testing.md +427 -0
- package/index.js +1403 -0
- package/lib/audio.js +426 -0
- package/lib/slots.js +97 -0
- package/lib/tone.js +216 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +111 -4
- package/sounds/attention.wav +0 -0
- package/sounds/boot.wav +0 -0
- package/sounds/turn-done.wav +0 -0
- package/sounds/turn-fail.wav +0 -0
- package/sounds/turn-start.wav +0 -0
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
|
+
};
|