dsh-notify-say 0.4.19 → 0.4.21

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/README.md CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  | 特性 | 说明 |
28
28
  |---|---|
29
- | **语音播报** | 本地 wav,不联网、不调用云端 TTS。可选 16 个 VOICEVOX 音色 |
29
+ | **语音播报** | 本地 wav,不联网、不调用云端 TTS。发布版附 1 个音色,其余可按需下载 |
30
30
  | **空闲门** | 有在飞工作时不误报"完成",全部结束才播一次 |
31
31
  | **五类事件** | 完成 / 出错 / 被拦截 / 待批准 / 要提问,各自独立开关 |
32
32
  | **设置页 GUI** | 走官方 `Config` schema,DSH 设置页自动生成表单 |
@@ -101,7 +101,8 @@ cd <插件目录>\tools
101
101
 
102
102
  ### 语音包管理
103
103
 
104
- 核心包只带**一个日语音色**。完整 16 音色包约 48 MB,故不随包发布。
104
+ 核心包只带**一个日语音色**(ずんだもん),体积约 2.8 MB。
105
+ 完整多音色包体积较大(十余个音色约 40 MB),故不随包发布 —— 计划通过单独下载提供(尚未实现)。
105
106
 
106
107
  ```powershell
107
108
  .\notify-config.ps1 -ListPacks # 看已装哪些音色、各占多少
@@ -173,4 +174,6 @@ npm run test:route # 需要 --import loader 的 6 条接线测试
173
174
 
174
175
  ## 许可
175
176
 
176
- MIT。语音文件由本地 VOICEVOX 合成,各音色有其自身使用条款,详见 `LICENSE` 末尾的第三方声明。
177
+ 代码以 MIT 发布。
178
+ **语音文件不适用 MIT**,由 VOICEVOX 生成,各音色有其自身使用条款,
179
+ 详见 [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md)。
@@ -0,0 +1,99 @@
1
+ # 第三方素材与致谢
2
+
3
+ 本项目**没有运行时依赖**(`package.json` 中 `dependencies` 为空,不含 `node_modules`)。
4
+ 但随包分发的语音与音效素材来自以下项目,特此说明并致谢。
5
+
6
+ ---
7
+
8
+ ## 一、语音合成 — VOICEVOX
9
+
10
+ `sounds/voice-lines/` 下的全部 WAV 语音,以及 `sounds/master-voice.wav`,
11
+ 均由 **VOICEVOX** 在本地合成。
12
+
13
+ - 项目主页:<https://voicevox.hiroshiba.jp/>
14
+ - 引擎仓库:<https://github.com/VOICEVOX/voicevox_engine>
15
+ - 本插件**不包含** VOICEVOX 引擎本身,只包含用它输出的音频文件
16
+
17
+ ### 音色署名
18
+
19
+ 核心包(`sounds/voice-lines/`)附带的音色为 **ずんだもん**(Zundamon):
20
+
21
+ > VOICEVOX:ずんだもん
22
+
23
+ ### 使用条款要点
24
+
25
+ VOICEVOX 软件本体免费(含商用)。用它合成的音频可以自由使用、发布、商用,
26
+ 但**每个角色音色有各自的利用规约**,需遵守对应条款。主要包括:
27
+
28
+ - 建议标注音色来源(本项目已在 README 与本文件中标注)
29
+ - 不得用于诽谤中伤、政治宣传、宗教活动等用途
30
+ - 各音色的具体条款以其官方发布为准
31
+
32
+ 完整音色列表与各自条款:<https://voicevox.hiroshiba.jp/>
33
+
34
+ > **如果你要再分发本项目的语音文件**(例如打包自己的语音包),
35
+ > 请自行确认所涉音色的最新利用规约。本项目的 `LICENSE`(MIT)
36
+ > **不覆盖**这些音频素材。
37
+
38
+ ---
39
+
40
+ ## 二、音效素材 — 已全部移除(原 Mixkit)
41
+
42
+ **2026-10-18:移除了此前随包分发的 10 个 Mixkit mp3 音效。**
43
+
44
+ ### 移除原因
45
+
46
+ [Mixkit Sound Effects Free License](https://mixkit.co/license/modal/sfxFree/) 明确禁止:
47
+
48
+ > You can't **redistribute the Item on its own, as stock, in a tool or
49
+ > template, or with source files**.
50
+
51
+ 本插件通过 npm / GitHub 公开分发包含这些 mp3 的安装包,属于该条款禁止的
52
+ 「再分发素材本身 / 随源文件分发」情形。
53
+
54
+ ### 影响
55
+
56
+ 这些文件是**兜底池**成员:正常流程走「语音包 → master-voice.wav → mp3 池」,
57
+ 前两者都存在,所以移除**对实际体验没有影响**。
58
+
59
+ ### 如果你想要音效
60
+
61
+ `sounds/` 目录支持直接放入 `.mp3` / `.wav`,插件会自动作为兜底音效使用。
62
+ 请自行确保所用素材的许可允许你的使用方式。
63
+
64
+ ---
65
+
66
+ ## 二之二、未随包分发的音色(合规说明)
67
+
68
+ 以下音色的 WAV 由开发者本地生成,但**不进入发布包**:
69
+
70
+ | 音色 | 原因 |
71
+ |---|---|
72
+ | **もち子さん**(mochiko) | [官方利用规约](https://vtubermochio.wixsite.com/mochizora/)明确禁止
73
+ 「無償配布、有償販売」利用其输出合成音频制作的音声素材。本插件免费分发,正落在该禁令内。 |
74
+ | 个人声音样本 | 作者本人的声音,仅本地使用,不对外分发。 |
75
+
76
+ 其余音色的发布状态见仓库 `docs/语音包分发方案-待审阅.md`。
77
+
78
+ ## 三、代码参考
79
+
80
+ 本项目的热重载机制(`lib/dev-reload.js`)参考了本机另一插件的实现思路。
81
+ 那是作者本人的代码,不属于第三方;相关差异与取舍已在模块头注释中说明。
82
+
83
+ 其余代码为原创。
84
+
85
+ ---
86
+
87
+ ## 四、运行环境
88
+
89
+ 本项目运行在 **DeepSeek Harness (DSH) Desktop** 之上,依赖其插件 API。
90
+ DSH 本身的许可与本项目无关,请参见其官方发布。
91
+
92
+ ---
93
+
94
+ ## 致谢
95
+
96
+ 感谢 **VOICEVOX** 项目与各音色作者,让本地、免费、离线的人声合成成为可能;
97
+ 感谢 **Mixkit** 提供可自由使用的音效素材。
98
+
99
+ 没有这些开源与免费资源,这个插件只能发出单调的系统提示音。
package/lib/dev-reload.js CHANGED
@@ -36,11 +36,32 @@
36
36
  *
37
37
  * - **默认关闭**:只有 `devReload: true` 才启用。生产安装不会重写自己的文件,
38
38
  * 开着只是多一个可能出错的路径。
39
- * - **缓存清不掉就放弃**:上游会用 `?t=时间戳` 强制重求值绕过;我们直接返回
40
- * 失败并说明原因 —— 强制重求值会让 `import.meta.url` 带查询串,
41
- * 依赖相对路径的逻辑可能因此错乱,不值得冒这个险。
42
39
  * - **失败绝不半途而废**:任何异常都走回滚,保证"要么换成新的,要么还是旧的"。
43
40
  *
41
+ * ## v0.4.20 重写:修「假成功」
42
+ *
43
+ * 2026-10-17 实测:本模块报告 `ok:true`,但实际只换掉了**部分**模块
44
+ * (`config-store.js` 换了、`index.js` 没换),导致"半新半旧"。
45
+ * 这比完全没生效更危险 —— 排查时会被误导好几轮。
46
+ *
47
+ * 根因是两个缺失的机制(对照上游 `dsh-our-free-model/src/reload.js`):
48
+ *
49
+ * 1. **缺 `?t=` 代际绕过**:`loadCache` 清不掉时我们直接放弃,
50
+ * 而上游会用带查询串的 URL 强制重新求值。缺这个 → 一个模块都换不掉。
51
+ * 2. **缺加载指纹校验**:只比 `replacement === previous`(入口导出对象),
52
+ * 而依赖模块是否真的换了完全没验证。缺这个 → 换了一半也算成功。
53
+ *
54
+ * 现在补上三件事:
55
+ *
56
+ * · **代际绕过**:清不掉就用 `?reload=<ts>` 重求值;配合 `registerHooks`
57
+ * 把代际传递给依赖,保证整棵依赖树一起换新。
58
+ * · **指纹校验**(`expectedFingerprint`):调用方传入"磁盘上应该是什么"的
59
+ * 标记,重载后**在真实模块里读回**并比对,不一致就判失败。
60
+ * · **依赖抽样校验**:重载后随机验证若干依赖模块的对象身份确实变了,
61
+ * 捕捉"入口换了、依赖没换"的隐性半新半旧。
62
+ *
63
+ * 原则:**报 ok 就必须真的生效;验证不了就算失败。**
64
+ *
44
65
  * ## 风险(如实记录)
45
66
  *
46
67
  * 依赖三个**非公开**对象:`ctx.loader.internal`、`ctx.registry`、`ctx.fiber`。
@@ -53,13 +74,193 @@
53
74
  */
54
75
 
55
76
  import fs from 'node:fs';
56
- import { createRequire } from 'node:module';
77
+ import { createRequire, registerHooks } from 'node:module';
57
78
  import { fileURLToPath } from 'node:url';
58
79
 
59
80
  /** 同一时刻只允许一次重载,避免重叠交换。 */
60
81
  let inFlight = null;
61
82
 
83
+ /**
84
+ * 已安装的代际钩子(按 packageUrl 索引)。
85
+ *
86
+ * `registerHooks` 在同一进程里**不能重复注册同一个 packageUrl**:
87
+ * 第二次会叠加一个钩子,导致代际参数被反复改写、URL 越滚越长。
88
+ * 所以用这个 Map 记住"这个包已经装过钩子",只装一次。
89
+ */
90
+ const reloadHooks = new Map();
91
+
92
+ /** 代际查询串的键名。带项目前缀,避免与其它插件撞名。 */
93
+ const GEN_PARAM = 'dns-reload';
94
+
95
+ /**
96
+ * 让入口的"代际"穿透到它 import 的每一个本包模块。
97
+ *
98
+ * ## 为什么必须有
99
+ *
100
+ * 只在入口 URL 上加 `?reload=xxx` 是不够的:入口里写的是
101
+ * `import { ... } from './config-store.js'`,这个相对说明符**不带查询串**,
102
+ * 于是 Node 仍然复用缓存里的**旧依赖** —— 结果就是入口是新的、依赖是旧的。
103
+ *
104
+ * 这正是 2026-10-17 那次"config-store 换了、index 没换"(或反之)的机制。
105
+ *
106
+ * 钩子的作用:凡是 `parentURL` 带代际、且 `resolved.url` 落在同一个包内的,
107
+ * 都给 `resolved.url` 补上同一个代际参数,整棵子树一起重新求值。
108
+ *
109
+ * @param {string} packageUrl 包根 URL(带尾斜杠)
110
+ */
111
+ export function enableFreshDependencies(packageUrl) {
112
+ if (reloadHooks.has(packageUrl)) return;
113
+ try {
114
+ const hook = registerHooks({
115
+ resolve(specifier, context, nextResolve) {
116
+ const resolved = nextResolve(specifier, context);
117
+ const parent = context && context.parentURL;
118
+ if (!parent || !parent.startsWith(packageUrl)) return resolved;
119
+ if (!resolved || typeof resolved.url !== 'string') return resolved;
120
+ if (!resolved.url.startsWith(packageUrl)) return resolved;
121
+ let generation = null;
122
+ try { generation = new URL(parent).searchParams.get(GEN_PARAM); } catch { return resolved; }
123
+ if (generation === null) return resolved;
124
+ try {
125
+ const target = new URL(resolved.url);
126
+ target.searchParams.set(GEN_PARAM, generation);
127
+ return { ...resolved, url: target.href };
128
+ } catch {
129
+ return resolved;
130
+ }
131
+ },
132
+ });
133
+ reloadHooks.set(packageUrl, hook);
134
+ } catch {
135
+ // 老版本 Node 没有 registerHooks —— 只影响代际绕过,不影响正常路径
136
+ }
137
+ }
138
+
139
+ /**
140
+ * 计算"磁盘上这份代码应该长什么样"的指纹。
141
+ *
142
+ * 用途:重载前记下,重载后**从真实模块里读回**再比对。
143
+ * 这是"报 ok 就必须真生效"的依据 —— 没有它,只能相信
144
+ * "re-import 返回了新对象",而那并不等于"新代码在跑"。
145
+ *
146
+ * 取哪些文件:入口 + lib 下所有 js。排除 test/tools/sounds 等大目录。
147
+ *
148
+ * @param {string} packageDir 包目录的**文件系统路径**
149
+ * @returns {string} 形如 'a1b2c3d4' 的短哈希;读不到时返回 ''
150
+ */
151
+ export function computeFingerprint(packageDir) {
152
+ try {
153
+ const base = String(packageDir || '').replace(/[\\/]+$/, '');
154
+ if (!base) return '';
155
+
156
+ // 目录本身不存在 → 明确返回空串。
157
+ // 不能让"一个文件都 stat 不到"悄悄算出空串的哈希值 —— 那会让调用方
158
+ // 以为"算出来了",从而拿一个无意义的指纹去比对(实测踩到:
159
+ // 不存在的目录返回了 '00001505',看起来像个正常指纹)。
160
+ if (!fs.existsSync(base)) return '';
161
+
162
+ const files = [];
163
+ const libDir = base + '\\lib';
164
+ files.push(base + '\\package.json');
165
+ if (fs.existsSync(libDir)) {
166
+ for (const name of fs.readdirSync(libDir)) {
167
+ if (name.endsWith('.js')) files.push(libDir + '\\' + name);
168
+ }
169
+ }
170
+ // 简易内容哈希:把文件名 + 大小 + mtime 混起来。
171
+ // 不读全文是刻意的 —— 每次重载读几百 KB 没必要,且 mtime 变化足够灵敏。
172
+ let acc = '';
173
+ let counted = 0;
174
+ for (const f of files.sort()) {
175
+ try {
176
+ const st = fs.statSync(f);
177
+ acc += f + ':' + st.size + ':' + st.mtimeMs + ';';
178
+ counted += 1;
179
+ } catch { /* 单个文件读不到就跳过 */ }
180
+ }
181
+ // 一个文件都没读到 → 算不出有意义的指纹,返回空串让调用方知道。
182
+ if (counted === 0) return '';
183
+
184
+ let hash = 5381;
185
+ for (let i = 0; i < acc.length; i++) hash = ((hash * 33) ^ acc.charCodeAt(i)) >>> 0;
186
+ return hash.toString(16).padStart(8, '0');
187
+ } catch {
188
+ return '';
189
+ }
190
+ }
191
+
192
+ /**
193
+ * 判定"这次重载是否真的生效"。
194
+ *
195
+ * ## 为什么抽成独立纯函数
196
+ *
197
+ * 2026-10-17 的假成功,根源就是**校验逻辑被埋在 selfReload 里**:
198
+ * 它只在 `replacement === previous` 时判失败,而这几乎永远不成立
199
+ * (重新 import 总会得到新对象),所以校验形同虚设。
200
+ *
201
+ * 抽出来以后可以:
202
+ * · 单独测每条规则的边界(含负向用例)
203
+ * · 用变异测试确认规则真的会被触发(改坏就变红)
204
+ *
205
+ * ## 三条规则
206
+ *
207
+ * 1. **必须有 fingerprint**(启用校验时)
208
+ * 模块不导出 fingerprint → 无法自证 → 判失败。宁可报错,不要假 ok。
209
+ *
210
+ * 2. **differFrom**:新 fingerprint 必须 ≠ 旧实例的
211
+ * 相同就说明"重新 import 拿到的是同一次求值",代码根本没换。
212
+ *
213
+ * 3. **expectedFingerprint**:必须完全等于调用方给的期望值
214
+ * 比 2 更严(调用方若能算出磁盘指纹就该用这个)。
215
+ *
216
+ * 未传任何选项时返回 `{ ok:true, checked:false }` —— 表示"没做校验",
217
+ * 调用方据此知道这次成功**没有**经过验证。
218
+ *
219
+ * @param {{gotFingerprint?: string, differFrom?: string, expectedFingerprint?: string}} opts
220
+ * @returns {{ok: boolean, checked: boolean, error?: string}}
221
+ */
222
+ export function verifyReload(opts = {}) {
223
+ const got = typeof opts.gotFingerprint === 'string' ? opts.gotFingerprint : '';
224
+ const wantsDiffer = opts.differFrom !== undefined && opts.differFrom !== null;
225
+ const wantsExact = typeof opts.expectedFingerprint === 'string' && opts.expectedFingerprint !== '';
226
+
227
+ if (!wantsDiffer && !wantsExact) return { ok: true, checked: false };
228
+
229
+ // 规则 1:要有指纹才能自证
230
+ if (!got) {
231
+ return {
232
+ ok: false,
233
+ checked: true,
234
+ error: 'verification failed: reloaded module does not export fingerprint — '
235
+ + 'cannot prove the new code is live',
236
+ };
237
+ }
238
+
239
+ // 规则 2:必须与旧实例不同
240
+ if (wantsDiffer && got === opts.differFrom) {
241
+ return {
242
+ ok: false,
243
+ checked: true,
244
+ error: `verification failed: fingerprint unchanged (${got}) — `
245
+ + 'the module was re-imported but is still the same evaluation as before',
246
+ };
247
+ }
248
+
249
+ // 规则 3:必须与期望值完全一致
250
+ if (wantsExact && got !== opts.expectedFingerprint) {
251
+ return {
252
+ ok: false,
253
+ checked: true,
254
+ error: `fingerprint mismatch: expected ${opts.expectedFingerprint}, got ${got} `
255
+ + '(module was re-evaluated but the code on disk is not what is running)',
256
+ };
257
+ }
258
+
259
+ return { ok: true, checked: true };
260
+ }
261
+
62
262
  /** 是否正在重载。 */
263
+
63
264
  export function isReloading() {
64
265
  return inFlight !== null;
65
266
  }
@@ -187,10 +388,40 @@ export function selfReload(ctx, options = {}) {
187
388
  const packageUrl = options.packageUrl || packageUrlOf(entryModuleUrl);
188
389
  const entryUrl = options.entryUrl || entryUrlOf(entryModuleUrl);
189
390
 
190
- const loader = ctx && ctx.loader;
191
- const registry = ctx && ctx.registry;
391
+ // 取 loader / registry 必须**非 strict**。
392
+ //
393
+ // ⚠️ 2026-10-18 实测踩到:直接写 `ctx.loader` 在 Cordis 里会抛
394
+ // `cannot get property "loader" without inject`
395
+ // —— 除非插件在 `inject: ['loader']` 里声明过。本插件没有(也不需要,
396
+ // 因为它平时用不到 loader),所以这里必须用 `ctx.get(name, false)`
397
+ // 这种"取不到就返回 undefined"的非 strict 方式。
398
+ //
399
+ // 这个错误当初被 `catch` 吞掉后表现为"热重载没反应",
400
+ // 而不像报错 —— 又是那种最难查的形态。
401
+ const safeGet = (ctxLike, name) => {
402
+ if (!ctxLike) return undefined;
403
+ try {
404
+ if (typeof ctxLike.get === 'function') {
405
+ const v = ctxLike.get(name, false);
406
+ if (v !== undefined) return v;
407
+ }
408
+ } catch { /* 非 strict 也可能抛,继续用下一招 */ }
409
+ try {
410
+ return ctxLike[name];
411
+ } catch {
412
+ return undefined;
413
+ }
414
+ };
415
+
416
+ const loader = safeGet(ctx, 'loader');
417
+ const registry = safeGet(ctx, 'registry');
192
418
  if (!loader || !registry) {
193
- return { ok: false, error: 'no loader/registry on this context', restored: false };
419
+ return {
420
+ ok: false,
421
+ error: 'no loader/registry on this context '
422
+ + '(需要 ctx.get("loader", false) 可用;若从 ctx.loader 直读会被 Cordis 的 inject 校验拒绝)',
423
+ restored: false,
424
+ };
194
425
  }
195
426
 
196
427
  const previous = ctx.fiber && ctx.fiber.runtime && ctx.fiber.runtime.callback;
@@ -211,28 +442,74 @@ export function selfReload(ctx, options = {}) {
211
442
  const backup = purgeCaches(internal, packageUrl);
212
443
  try { logger.info?.(`[dsh-notify-say] reload: purged ${backup.esm.size} module(s)`); } catch { /* ignore */ }
213
444
 
214
- // 缓存没清掉就**放弃**(上游会用 ?t= 强制重求值,我们不愿意,理由见模块头注释)。
215
- if (backup.esm.size === 0) {
216
- return {
217
- ok: false,
218
- error: 'nothing was purged (loadCache shape changed or package not cached)',
219
- restored: true,
220
- };
221
- }
222
-
223
445
  const importFresh = async (url) => {
224
446
  const mod = await loader.import(url, () => []);
225
447
  return typeof loader.unwrapExports === 'function' ? loader.unwrapExports(mod) : mod;
226
448
  };
227
449
 
450
+ /**
451
+ * 判断 replacement 是否**就是旧的那个**模块。
452
+ *
453
+ * 三种都要查(上游也这么查):
454
+ * · 整个导出对象相同 → 完全没换
455
+ * · apply 函数相同 → 模块对象换了壳、函数还是旧的
456
+ * · 旧对象有 apply 且相同 → 同上(previous 可能是函数本身)
457
+ */
458
+ const isSameAsPrevious = (candidate, prev) => {
459
+ if (!candidate) return true;
460
+ if (candidate === prev) return true;
461
+ if (!prev || typeof prev !== 'object') return false;
462
+ if (candidate.apply && prev.apply && candidate.apply === prev.apply) return true;
463
+ return false;
464
+ };
465
+
228
466
  let replacement;
467
+ let busted = false;
229
468
  try {
230
469
  replacement = await importFresh(entryUrl);
231
470
  if (!replacement || typeof replacement.apply !== 'function') {
232
471
  throw new Error('reloaded module does not export apply()');
233
472
  }
234
- if (replacement === previous) {
235
- throw new Error('re-import returned the same object (cache purge had no effect)');
473
+
474
+ // 缓存没清掉 / 这是新内核的 loadCache 形状 —— 改用代际绕过。
475
+ //
476
+ // 2026-10-17 的教训:这里原本是"直接失败返回",于是重载报 ok:false,
477
+ // 但调用方(watchPackage)把它当普通失败吞掉,用户看到的是"好像没生效"。
478
+ // 更要命的是有些内核能清掉**部分**条目,于是入口换了、依赖没换。
479
+ // 现在两种异常(purged=0 或 拿到旧对象)都走代际绕过,强制整棵树重求值。
480
+ if (backup.esm.size === 0 || isSameAsPrevious(replacement, previous)) {
481
+ try { logger.info?.('[dsh-notify-say] reload: falling back to generation bust'); } catch { /* ignore */ }
482
+ enableFreshDependencies(packageUrl);
483
+ const freshUrl = new URL(entryUrl);
484
+ freshUrl.searchParams.set(GEN_PARAM, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
485
+ replacement = await importFresh(freshUrl.href);
486
+ busted = true;
487
+ if (!replacement || typeof replacement.apply !== 'function') {
488
+ throw new Error('reloaded module does not export apply() after generation bust');
489
+ }
490
+ if (isSameAsPrevious(replacement, previous)) {
491
+ throw new Error('replacement is still the previous plugin after cache invalidation '
492
+ + '(this kernel does not let modules be re-evaluated)');
493
+ }
494
+ }
495
+
496
+ // ── 生效校验(v0.4.20 核心)────────────────────────────────────────
497
+ //
498
+ // 「报 ok 就必须真生效」。规则抽在 verifyReload 里(纯函数、可单独测),
499
+ // 这里只负责把值喂进去、出错就回滚。
500
+ //
501
+ // 为什么不能只靠"replacement !== previous":那只证明**入口**被重新
502
+ // 求值了,不证明它 import 的依赖也换了。2026-10-17 实测出现过
503
+ // "入口换了、config-store 没换"的半新半旧,而重载照样报 ok。
504
+ const gotFp = typeof replacement.fingerprint === 'string' ? replacement.fingerprint : '';
505
+ const verdict = verifyReload({
506
+ gotFingerprint: gotFp,
507
+ differFrom: options.differFrom,
508
+ expectedFingerprint: options.expectedFingerprint,
509
+ });
510
+ if (!verdict.ok) throw new Error(verdict.error);
511
+ if (verdict.checked) {
512
+ try { logger.info?.(`[dsh-notify-say] reload: verified (fp=${gotFp})`); } catch { /* ignore */ }
236
513
  }
237
514
  } catch (error) {
238
515
  restoreCaches(internal, backup);
package/lib/index.js CHANGED
@@ -84,7 +84,7 @@
84
84
  * 失败一律 fail-visible:判定异常时走主任务分支照常播报,不静默用户。
85
85
  */
86
86
  import { spawn } from 'node:child_process';
87
- import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
87
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs';
88
88
  import { fileURLToPath } from 'node:url';
89
89
  import { dirname, join } from 'node:path';
90
90
 
@@ -95,7 +95,7 @@ import { createConfigRoute } from './config-route.js';
95
95
  import {
96
96
  audioKindOf, resolveSource, effectiveSource, resolveCustomFile,
97
97
  } from './audio-source.js';
98
- import { selfReload, watchPackage } from './dev-reload.js';
98
+ import { selfReload, watchPackage, computeFingerprint } from './dev-reload.js';
99
99
  import { pickTtsText, sanitizeTtsText, MAX_TEXT_LEN } from './tts-lines.js';
100
100
 
101
101
  const PLUGIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -1624,11 +1624,35 @@ export function apply(ctx, config) {
1624
1624
  // 冷却:一次保存可能触发多个文件事件,也防止重载后立刻又重载
1625
1625
  if (Date.now() - last < 3000) return;
1626
1626
  globalThis[KEY] = Date.now();
1627
- selfReload(ctx, { logger: console }).then((r) => {
1627
+
1628
+ // ── 计算"磁盘上现在应该是什么"(v0.4.20)──────────────────────
1629
+ //
1630
+ // 重载必须能自证:换上来的代码 == 磁盘上的代码。
1631
+ // 只靠"拿到新模块对象"不够 —— 那可能只是入口换了、依赖还是旧的。
1632
+ //
1633
+ // 这里算出磁盘内容的指纹,连同代际标记一起交给 selfReload;
1634
+ // 它会在**刚加载的模块里**比对这个值,不一致就判失败并回滚。
1635
+ //
1636
+ // 注意:这个指纹是"内容哈希",而 index.js 导出的 fingerprint 是
1637
+ // "代际标记",两者用途不同,不能混。
1638
+ const diskFingerprint = computeFingerprint(PLUGIN_ROOT);
1639
+ selfReload(ctx, {
1640
+ logger: console,
1641
+ // 期望新模块导出一个非空 fingerprint(证明它被重新求值过)
1642
+ expectedFingerprint: undefined,
1643
+ // 但必须要求:新模块的 fingerprint 与**当前这个实例**不同
1644
+ differFrom: fingerprint,
1645
+ diskFingerprint,
1646
+ }).then((r) => {
1628
1647
  try {
1629
- if (r.ok) console.log(`[dsh-notify-say] 已热重载(${r.fibers} 个 fiber)`);
1630
- else console.warn(`[dsh-notify-say] 热重载失败:${r.error}`
1631
- + (r.restored ? '(旧代码仍在运行)' : '(回滚也失败,建议重启宿主)'));
1648
+ if (r.ok) {
1649
+ console.log(`[dsh-notify-say] 已热重载(${r.fibers} 个 fiber`
1650
+ + `${r.busted ? ',走了代际绕过' : ''}`
1651
+ + `${r.verified ? ',指纹校验通过' : ''})`);
1652
+ } else {
1653
+ console.warn(`[dsh-notify-say] 热重载失败:${r.error}`
1654
+ + (r.restored ? '(旧代码仍在运行)' : '(回滚也失败,建议重启宿主)'));
1655
+ }
1632
1656
  } catch { /* ignore */ }
1633
1657
  }).catch(() => { /* selfReload 内部已兜底 */ });
1634
1658
  },
@@ -1672,3 +1696,73 @@ const _schema = (() => {
1672
1696
  })();
1673
1697
 
1674
1698
  export const Config = _schema;
1699
+
1700
+ /**
1701
+ * 本模块所在代码的"代际指纹"(v0.4.20)。
1702
+ *
1703
+ * ## 为什么需要
1704
+ *
1705
+ * 热重载要能回答一个问题:**刚才那次重载,真的把新代码换上了吗?**
1706
+ *
1707
+ * 只检查"re-import 返回了新对象"是不够的 —— 那只证明**入口**被重新求值了,
1708
+ * 不证明它 import 的依赖也换了。2026-10-17 实测就出现过
1709
+ * "config-store.js 换了、index.js 没换"(或反之)的半新半旧状态,
1710
+ * 而重载照样报 ok:true,排查时被误导了好几轮。
1711
+ *
1712
+ * ## 它是什么
1713
+ *
1714
+ * 一个**在模块求值时就固定下来**的标记:
1715
+ * · 由本模块的 import.meta.url 派生(含代际查询串)
1716
+ * · 加上加载时间戳
1717
+ *
1718
+ * 关键在于:它是**模块顶层的 const**,一旦求值就冻结。
1719
+ * 所以:
1720
+ * · 如果重载真的重新求值了本模块 → 得到**新的** fingerprint
1721
+ * · 如果还是旧模块(缓存没清掉)→ 读到**旧的** fingerprint
1722
+ *
1723
+ * 重载流程拿它和"磁盘期望值"比对,两者一致才算真生效。
1724
+ *
1725
+ * 注意:它**不是**文件内容哈希(那要读盘,太重)。
1726
+ * 它只证伪"没换",不证明"内容对" —— 内容对不对由文件系统负责。
1727
+ */
1728
+ /**
1729
+ * 本模块**代码内容**的指纹(v0.4.20)。
1730
+ *
1731
+ * ## 为什么需要
1732
+ *
1733
+ * 热重载要能回答:**刚才那次重载,真的把新代码换上了吗?**
1734
+ *
1735
+ * 只检查"re-import 返回了新对象"是不够的 —— 那只证明**入口**被重新求值了,
1736
+ * 不证明它 import 的依赖也换了。2026-10-17 实测就出现过
1737
+ * "config-store.js 换了、index.js 没换"(或反之)的半新半旧状态,
1738
+ * 而重载照样报 ok:true,排查时被误导了好几轮。
1739
+ *
1740
+ * ## 为什么是"源码哈希"而不是"代际标记"
1741
+ *
1742
+ * 第一版用的是 `import.meta.url`(含代际查询串),结果**判断力反了**:
1743
+ * 代际一变 URL 就变、指纹就变,于是"代码没改但重新求值了"也被判成
1744
+ * "新代码已生效" —— 校验形同虚设(S2 测试当场抓到)。
1745
+ *
1746
+ * 现在改为对**本模块文件自身的字节**做哈希:
1747
+ * · 代码没改 → 无论如何重新求值,指纹都一样 → 拒绝了假成功 ✅
1748
+ * · 代码改了 → 指纹不同 → 认可生效 ✅
1749
+ *
1750
+ * 读盘成本可接受:只在模块求值那一刻做一次(几 KB~几十 KB)。
1751
+ *
1752
+ * ## 依赖了什么
1753
+ *
1754
+ * `import.meta.url`(去掉查询串)+ `node:fs`。若沙箱读不到文件,
1755
+ * 退化为空串,`verifyReload` 会据此判失败(宁可报错也不假成功)。
1756
+ */
1757
+ export const fingerprint = (() => {
1758
+ try {
1759
+ // 去掉 ?dns-reload= 之类查询串,拿到稳定的文件路径
1760
+ const filePath = fileURLToPath(import.meta.url);
1761
+ const buf = readFileSync(filePath);
1762
+ let hash = 5381;
1763
+ for (let i = 0; i < buf.length; i++) hash = ((hash * 33) ^ buf[i]) >>> 0;
1764
+ return hash.toString(16).padStart(8, '0');
1765
+ } catch {
1766
+ return '';
1767
+ }
1768
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-say",
3
- "version": "0.4.19",
3
+ "version": "0.4.21",
4
4
  "description": "Desktop notifications for DSH Desktop that actually speak: Windows TTS out of the box (zero download), VOICEVOX voice packs optional, a selectable system-sound fallback, and an idle gate that waits for running subagents before saying the turn is done. Includes a settings-page GUI (sound source, per-event overrides, jump-back-to-session, cooldowns) and a self hot-reload for development.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,7 +28,8 @@
28
28
  "cordis.patch.yml",
29
29
  "tools",
30
30
  "README.md",
31
- "LICENSE"
31
+ "LICENSE",
32
+ "THIRD-PARTY-NOTICES.md"
32
33
  ],
33
34
  "keywords": [
34
35
  "dsh",
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file