dsh-chubby-whale 1.1.57 → 1.1.58

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/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@
4
4
 
5
5
  ## [未发布]
6
6
 
7
+ ## [1.1.58] - 2026-11-07
8
+
9
+ ### 修复(B74 配置/备份/迁移分身)
10
+ - **坏 `config.json` 被静默吞掉**:用户只看到「Key 没了、语音没反应」;现在按「未配置」降级并**只告警一次**(坑 35:没反应比报错更糟)
11
+ - **`sanitizePluginConfig` 可被原型链注入**:`{"__proto__":{...}}` 会改结果对象原型(实测 `out.dataSource` 能从原型读到);已拒收 `__proto__`/`constructor`/`prototype`
12
+ - **`prefs.json` 是数组时缓存开关永远写不进去**:`load` 原样返回数组/标量,属性写入被 `JSON.stringify` 丢弃;已归一 + 单次提示
13
+ - **迁移半途失败会留下「假已迁移」**:直写目标,`copyFileSync` 失败留半截文件,下次 `existsSync` 为真就永远跳过;改为「临时名 + link 优先/rename 兜底」原子落地
14
+ - **自动备份快照非原子写 + 时间口径双源**:崩溃会留半截 JSON 被当成一份备份;裁剪按文件名、`lastAt` 按 mtime,目录被拷贝后时间漂移。已统一(文件名时间优先、mtime 兜底)+ 原子写 + 定时器按运行目录去重
15
+
16
+ ### 修复(B75 主代理:备份定时器停不下来)
17
+ - **`ctx.on('dispose')` 在 cordis 里永不触发**:卸载只派发 `internal/plugin`,全仓没有 dispose 事件——热重载/禁用后旧备份定时器会一直写到进程退出。改用 `ctx.effect` 把「停备份」登记成 fiber 清理器
18
+
19
+ ### 修复(B76 媒体/提醒分身)
20
+ - **提醒轮询收尾不彻底**:`start()` 只返回 `stopPoll`,全仓没有 `dispose()` 调用 → 卸载后待播队列与 gap/retry 定时器继续冒泡/朗读(基线实测还播了第 2 条);收尾改为撤定时器→清队列→停轮询
21
+ - **收尾后迟到的轮询响应仍入队**:补 `alive` 守卫(并发纪律「每个 await 之后校验我还是当前那次」)
22
+ - **空正文提醒被静默丢掉**:`if (it && it.text)` 让「时间到了」兜底成死代码;现在空正文走兜底
23
+ - **顺延跨零点提示错误**:23:55 顺延到 00:05 只说 `00:05`,会被读成今晨;现在显示「明天 00:05」,多天显示日期
24
+ - **`image.ts` 抛错漏 revoke**:`createObjectURL` 之后 `new Image()`/赋值抛错时只 `resolve(null)`,泄漏一份 Blob;已覆盖
25
+ - **TTS 抛错把「看图成功」改判成失败**:朗读回调裸在 try 里;已包 try/catch
26
+
27
+ ### 修复(B77 语音桥/唤醒分身)
28
+ - **打断词漏「别」「不」**:桥里的整句正则只有「停/停下/…/够了」,而宿主专门放行五个打断词、队列测试也断言「别」「不」必须放行 → 朗读中说「别」「不」被当正文送模型,停不下来;已统一走 `shared/noise.ts` 的 `INTERRUPT_WORDS`(并认「大肥鱼,停」),叙述锁分支同口径
29
+ - **`beforeunload` 匿名监听每次重建都多挂一个**:dispose 摘不掉(旧监听攥着废弃实例);改为单例 + dispose 摘除
30
+ - **`wakeInfo` 交出内部词表数组**:调用方可直接改唤醒门词表;改为返回副本
31
+ - **自定义唤醒词后旧词谐音仍生效**:换词后仍能被「大肥鱼/大粉鱼」叫醒并剥正文,与「只用这一个词」的契约冲突
32
+ - **单字唤醒词被误剥**:「好的」会剥成「的」送模型;单字只认整句
33
+ - **浏览器朗读兜底首句被吞**:上层每次兜底前都 `cancel()`,而模块只在 `busy` 时让 80ms → 立刻 speak 被吞;改为 cancel 后统一隔 80ms
34
+ - **看门狗判死不开扬声器回收**:只 resolve 不 cancel,下一段(可能是 edge-tts 音频)会叠着播;现在判死即 cancel
35
+
36
+ ### 测试
37
+ - 新增 `scripts/test-prefs-config.mjs`(84)、`test-media-reminders.mjs`(171)、`test-voice-bridge.mjs`(59),均接入 `npm test`
38
+ - 全链 **2723 项 0 失败**(2409 + 84 + 171 + 59)
39
+
7
40
  ## [1.1.57] - 2026-11-07
8
41
 
9
42
  ### 修复(B73 主代理:统计与备份)
package/dist/client.js CHANGED
@@ -14,7 +14,7 @@
14
14
  }
15
15
 
16
16
  // src/client/version.ts
17
- var VERSION = "1.1.57";
17
+ var VERSION = "1.1.58";
18
18
 
19
19
  // src/client/styles.ts
20
20
  var W = 240;
@@ -787,6 +787,7 @@
787
787
  const ms2 = opts && typeof opts.watchdog === "number" && opts.watchdog > 0 ? opts.watchdog : watchdogMs(src);
788
788
  return new Promise((resolve) => {
789
789
  let done = false;
790
+ let timedOut = false;
790
791
  let startTimer = null;
791
792
  let voiceTimer = null;
792
793
  let onVoices = null;
@@ -815,9 +816,18 @@
815
816
  } catch (e) {
816
817
  }
817
818
  onVoices = null;
819
+ if (timedOut) {
820
+ try {
821
+ if (synthRef && typeof synthRef.cancel === "function") synthRef.cancel();
822
+ } catch (e) {
823
+ }
824
+ }
818
825
  resolve();
819
826
  };
820
- const watch = setTimeout(finish, ms2);
827
+ const watch = setTimeout(() => {
828
+ timedOut = true;
829
+ finish();
830
+ }, ms2);
821
831
  const realFinish = () => {
822
832
  clearT(watch);
823
833
  finish();
@@ -884,8 +894,8 @@
884
894
  if (typeof Synth.cancel === "function") Synth.cancel();
885
895
  } catch (e) {
886
896
  }
887
- startTimer = setTimeout(speak, 80);
888
- } else speak();
897
+ }
898
+ startTimer = setTimeout(speak, 80);
889
899
  };
890
900
  let waiting = false;
891
901
  try {
@@ -2859,18 +2869,24 @@
2859
2869
  resolve(null);
2860
2870
  return;
2861
2871
  }
2862
- const url = URLRef.createObjectURL(file);
2863
- const img = new ImageRef();
2872
+ let url = "";
2864
2873
  const done = (v) => {
2865
2874
  try {
2866
- URLRef.revokeObjectURL(url);
2875
+ if (url) URLRef.revokeObjectURL(url);
2867
2876
  } catch (e) {
2868
2877
  }
2878
+ url = "";
2869
2879
  resolve(v);
2870
2880
  };
2871
- img.onload = () => done(img);
2872
- img.onerror = () => done(null);
2873
- img.src = url;
2881
+ try {
2882
+ url = URLRef.createObjectURL(file);
2883
+ const img = new ImageRef();
2884
+ img.onload = () => done(img);
2885
+ img.onerror = () => done(null);
2886
+ img.src = url;
2887
+ } catch (e) {
2888
+ done(null);
2889
+ }
2874
2890
  } catch (e) {
2875
2891
  resolve(null);
2876
2892
  }
@@ -3045,7 +3061,12 @@
3045
3061
  const reply0 = await post({ message: VISION_PROMPT, image: b64, imageMime: mime });
3046
3062
  const reply = String(reply0 && reply0.reply || "").trim() || "这条鱼看不懂这张图……";
3047
3063
  bubble("(看图)" + reply, 1e4);
3048
- if (speak) speak(reply);
3064
+ if (speak) {
3065
+ try {
3066
+ speak(reply);
3067
+ } catch (e2) {
3068
+ }
3069
+ }
3049
3070
  return { ok: true, kind: "image", message: VISION_PROMPT, reply };
3050
3071
  } catch (e) {
3051
3072
  return fail(e, "图片处理失败");
@@ -3063,7 +3084,12 @@
3063
3084
  const reply0 = await post({ message });
3064
3085
  const reply = String(reply0 && reply0.reply || "").trim() || "这份文档本鲸没看懂……";
3065
3086
  bubble("(文档)" + reply, 12e3);
3066
- if (speak) speak(reply);
3087
+ if (speak) {
3088
+ try {
3089
+ speak(reply);
3090
+ } catch (e2) {
3091
+ }
3092
+ }
3067
3093
  return { ok: true, kind: "text", message, reply };
3068
3094
  } catch (e) {
3069
3095
  return fail(e, "文档处理失败");
@@ -3573,6 +3599,11 @@
3573
3599
  function escapeRe(s) {
3574
3600
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3575
3601
  }
3602
+ function isDefaultWords(words) {
3603
+ if (!words || words.length !== WAKE_WORDS.length) return false;
3604
+ for (let i = 0; i < WAKE_WORDS.length; i++) if (words[i] !== WAKE_WORDS[i]) return false;
3605
+ return true;
3606
+ }
3576
3607
  function stripWakeWords(input, words = WAKE_WORDS) {
3577
3608
  const raw = str(input);
3578
3609
  const norm = normalizeWakeText(raw);
@@ -3581,11 +3612,12 @@
3581
3612
  const hits = sorted.filter((w) => {
3582
3613
  const nw = normalizeWakeText(w);
3583
3614
  if (!nw) return false;
3615
+ if (nw.length === 1) return norm === nw;
3584
3616
  if (nw.length >= 3) return norm.indexOf(nw) >= 0;
3585
3617
  return norm.startsWith(nw) || norm.length <= 4 && norm.indexOf(nw) >= 0;
3586
3618
  });
3587
3619
  if (!hits.length) {
3588
- if (norm.length <= 12) {
3620
+ if (isDefaultWords(words) && norm.length <= 12) {
3589
3621
  for (const re of WAKE_FUZZY) {
3590
3622
  const m = norm.match(re);
3591
3623
  if (m) {
@@ -3642,6 +3674,14 @@
3642
3674
  }
3643
3675
 
3644
3676
  // src/client/voice-bridge.ts
3677
+ var STOP_PHRASES = ["停下", "停一下", "别说了", "别讲了", "别念了", "别吵", "安静", "够了"];
3678
+ var NARR_STOP_RE = /停|别念|不念|够了|算了|打住/;
3679
+ var HAS_CONTENT_RE = /[\u4e00-\u9fa5a-z0-9]/i;
3680
+ function isStopCommand(norm) {
3681
+ if (!norm) return false;
3682
+ if (isInterruptWord(norm)) return true;
3683
+ return STOP_PHRASES.indexOf(norm) >= 0;
3684
+ }
3645
3685
  function bump(key) {
3646
3686
  try {
3647
3687
  const w = typeof window !== "undefined" ? window : null;
@@ -3675,6 +3715,7 @@
3675
3715
  }
3676
3716
  const wakeStats = { passed: 0, dropped: 0, hits: 0, lastHitAt: 0, dropNotifiedAt: 0 };
3677
3717
  let asrNoKeyNotified = false;
3718
+ let unloadHandler = null;
3678
3719
  function wakeInfo() {
3679
3720
  syncWakeWords();
3680
3721
  const now = Date.now();
@@ -3683,7 +3724,8 @@
3683
3724
  awake: remainingMs > 0,
3684
3725
  remainingMs,
3685
3726
  windowMs: gate.windowMs,
3686
- words: gate.words,
3727
+ // 给出去的是副本:调用方(菜单/诊断)拿到的是可写数组,不能顺着它改门里的词表
3728
+ words: gate.words.slice(),
3687
3729
  hits: wakeStats.hits,
3688
3730
  passed: wakeStats.passed,
3689
3731
  dropped: wakeStats.dropped,
@@ -3696,6 +3738,47 @@
3696
3738
  } catch (e) {
3697
3739
  }
3698
3740
  }
3741
+ function stopNarration(notice) {
3742
+ try {
3743
+ host.stopSpeaking();
3744
+ } catch (e) {
3745
+ }
3746
+ try {
3747
+ host.narration.clear();
3748
+ } catch (e) {
3749
+ }
3750
+ try {
3751
+ host.bubble(notice, 2500);
3752
+ } catch (e) {
3753
+ }
3754
+ }
3755
+ function tryInterrupt(text) {
3756
+ let spoken = "";
3757
+ try {
3758
+ const probe = stripWakeWords(text, gate.words);
3759
+ spoken = normalizeWakeText(probe.hit ? probe.text : text);
3760
+ } catch (e) {
3761
+ return false;
3762
+ }
3763
+ if (!isStopCommand(spoken)) return false;
3764
+ let speaking = false;
3765
+ try {
3766
+ speaking = host.speaking ? !!host.speaking() : true;
3767
+ } catch (e) {
3768
+ speaking = false;
3769
+ }
3770
+ if (!speaking) return false;
3771
+ try {
3772
+ host.stopSpeaking();
3773
+ } catch (e) {
3774
+ }
3775
+ try {
3776
+ host.bubble("好,不说了", 2e3);
3777
+ } catch (e) {
3778
+ }
3779
+ bump("wakeBreak");
3780
+ return true;
3781
+ }
3699
3782
  function build() {
3700
3783
  const make = host.makeVoice || ((o) => createVoice(o));
3701
3784
  const v = make({
@@ -3708,38 +3791,18 @@
3708
3791
  }
3709
3792
  try {
3710
3793
  if (host.narration.active()) {
3711
- if (/停|别念|不念|够了|算了|打住/.test(String(text || ""))) {
3712
- try {
3713
- host.stopSpeaking();
3714
- } catch (e) {
3715
- }
3716
- host.narration.clear();
3717
- host.bubble("好,不念了", 2500);
3718
- } else {
3719
- bump("narrDrop");
3794
+ let narrStop = false;
3795
+ try {
3796
+ narrStop = NARR_STOP_RE.test(String(text || "")) || isStopCommand(normalizeWakeText(text));
3797
+ } catch (e) {
3720
3798
  }
3799
+ if (narrStop) stopNarration("好,不念了");
3800
+ else bump("narrDrop");
3721
3801
  return;
3722
3802
  }
3723
3803
  } catch (e) {
3724
3804
  }
3725
- try {
3726
- if (/^(停|停下|停一下|别说了|别讲了|别念了|别吵|闭嘴|安静|打住|够了)$/.test(normalizeWakeText(text))) {
3727
- const speaking = host.speaking ? !!host.speaking() : true;
3728
- if (speaking) {
3729
- try {
3730
- host.stopSpeaking();
3731
- } catch (e) {
3732
- }
3733
- try {
3734
- host.bubble("好,不说了", 2e3);
3735
- } catch (e) {
3736
- }
3737
- bump("wakeBreak");
3738
- return;
3739
- }
3740
- }
3741
- } catch (e) {
3742
- }
3805
+ if (tryInterrupt(text)) return;
3743
3806
  try {
3744
3807
  if (host.voiceChat && !host.voiceChat()) {
3745
3808
  wakeStats.dropped++;
@@ -3756,14 +3819,16 @@
3756
3819
  }
3757
3820
  const said = r.hit ? r.text : String(text || "").trim();
3758
3821
  wakeStats.passed++;
3759
- if (!said) {
3760
- try {
3761
- if (host.say) host.say("本鲸在呢~", 3200);
3762
- else host.bubble("本鲸在呢~", 3200);
3763
- } catch (e) {
3822
+ if (!HAS_CONTENT_RE.test(said)) {
3823
+ if (r.hit) {
3764
3824
  try {
3765
- host.bubble("本鲸在呢~", 3200);
3766
- } catch (e2) {
3825
+ if (host.say) host.say("本鲸在呢~", 3200);
3826
+ else host.bubble("本鲸在呢~", 3200);
3827
+ } catch (e) {
3828
+ try {
3829
+ host.bubble("本鲸在呢~", 3200);
3830
+ } catch (e2) {
3831
+ }
3767
3832
  }
3768
3833
  }
3769
3834
  pushWake();
@@ -3815,16 +3880,30 @@
3815
3880
  host.diag({ voice: v, wake: wakeInfo() });
3816
3881
  } catch (e) {
3817
3882
  }
3883
+ bindUnload();
3884
+ return v;
3885
+ }
3886
+ function bindUnload() {
3887
+ if (unloadHandler) return;
3818
3888
  try {
3819
- if (typeof window !== "undefined") window.addEventListener("beforeunload", () => {
3889
+ if (typeof window === "undefined" || !window.addEventListener) return;
3890
+ unloadHandler = () => {
3820
3891
  try {
3821
- v.stop();
3892
+ if (voice) voice.stop();
3822
3893
  } catch (e) {
3823
3894
  }
3824
- });
3895
+ };
3896
+ window.addEventListener("beforeunload", unloadHandler);
3825
3897
  } catch (e) {
3898
+ unloadHandler = null;
3826
3899
  }
3827
- return v;
3900
+ }
3901
+ function unbindUnload() {
3902
+ try {
3903
+ if (unloadHandler && typeof window !== "undefined" && window.removeEventListener) window.removeEventListener("beforeunload", unloadHandler);
3904
+ } catch (e) {
3905
+ }
3906
+ unloadHandler = null;
3828
3907
  }
3829
3908
  function ensure() {
3830
3909
  return voice || build();
@@ -3847,6 +3926,10 @@
3847
3926
  } catch (e) {
3848
3927
  }
3849
3928
  }
3929
+ try {
3930
+ gate.reset();
3931
+ } catch (e) {
3932
+ }
3850
3933
  },
3851
3934
  dispose() {
3852
3935
  if (voice) {
@@ -3856,6 +3939,7 @@
3856
3939
  }
3857
3940
  }
3858
3941
  voice = null;
3942
+ unbindUnload();
3859
3943
  try {
3860
3944
  gate.reset();
3861
3945
  } catch (e) {
@@ -7270,6 +7354,7 @@
7270
7354
  const queue = [];
7271
7355
  let dueTimer = null;
7272
7356
  let pollTimer = null;
7357
+ let alive = false;
7273
7358
  function pump() {
7274
7359
  if (dueTimer) return;
7275
7360
  if (queue.length === 0) return;
@@ -7302,19 +7387,24 @@
7302
7387
  }
7303
7388
  function start() {
7304
7389
  stopPoll();
7390
+ alive = true;
7305
7391
  pollTimer = setInterval(() => {
7306
7392
  fetchJson(DUE_PATH).then((d) => {
7393
+ if (!alive) return;
7307
7394
  const due = d && d.due || [];
7308
7395
  for (const it of due) {
7309
- if (it && it.text) queue.push(it);
7396
+ if (it && typeof it === "object") queue.push(it);
7310
7397
  }
7311
7398
  pump();
7312
7399
  }).catch(() => {
7313
7400
  });
7314
7401
  }, intervalMs);
7315
- return stopPoll;
7402
+ return () => {
7403
+ dispose();
7404
+ };
7316
7405
  }
7317
7406
  function dispose() {
7407
+ alive = false;
7318
7408
  if (dueTimer !== null) {
7319
7409
  clearT(dueTimer);
7320
7410
  dueTimer = null;
@@ -7346,6 +7436,20 @@
7346
7436
  const mm = String(d.getMinutes()).padStart(2, "0");
7347
7437
  return hh + ":" + mm;
7348
7438
  }
7439
+ function formatClockWithDay(at, nowMs) {
7440
+ const d = new Date(at);
7441
+ if (isNaN(d.getTime())) return "--:--";
7442
+ const clock = formatClock(d);
7443
+ const baseMs = typeof nowMs === "number" && isFinite(nowMs) ? nowMs : Date.now();
7444
+ const today = new Date(baseMs);
7445
+ const startToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
7446
+ const startTarget = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
7447
+ const days = Math.round((startTarget - startToday) / (24 * 60 * 60 * 1e3));
7448
+ if (days === 0) return clock;
7449
+ if (days === 1) return "明天 " + clock;
7450
+ if (days === -1) return "昨天 " + clock;
7451
+ return d.getMonth() + 1 + "月" + d.getDate() + "日 " + clock;
7452
+ }
7349
7453
  function stampOf(item2) {
7350
7454
  const raw = item2.nextAt || item2.at;
7351
7455
  const d = new Date(raw);
@@ -7489,7 +7593,7 @@
7489
7593
  const dueMs = new Date(pick.nextAt || pick.at).getTime();
7490
7594
  const base = Number.isFinite(dueMs) ? Math.max(clock(), dueMs) : clock();
7491
7595
  const at = new Date(base + POSTPONE_MS);
7492
- const when = formatClock(at);
7596
+ const when = formatClockWithDay(at, clock());
7493
7597
  const body = {
7494
7598
  text: pick.text,
7495
7599
  at: at.toISOString(),
@@ -8,12 +8,21 @@
8
8
  * · 只读运行数据文件、写到 RUNTIME_DIR/backup/ 目录(不进插件包、不碰 data/);
9
9
  * · 一份备份 = 一个 JSON({ at, files: { 文件名: 内容 } }),失败静默(绝不因备份失败影响插件);
10
10
  * · 保留策略按文件名(auto-时间戳)排序删旧,最多 keep 份;
11
+ * 诊断口径(autoBackupInfo.lastAt)也从文件名取时间——两处同一真源(坑 28),
12
+ * 备份目录被整体拷贝/恢复过(mtime 变成恢复时间)时不会与裁剪口径打架;
13
+ * · 快照走 atomic.ts 的原子写:进程被杀不会留半截 JSON 假装是“一份备份”,
14
+ * 再借裁剪把好的旧份挤掉;
11
15
  * · setInterval 加 unref(不拖住进程退出);启动时先备一份,之后每小时一次。
16
+ * · 同一定时器只允许一份(2026-11-07 B74):index.ts 用 `ctx.on('dispose')` 兜清理,
17
+ * 而 cordis 没有该事件(详见 docs/坑.md 坑 11,cordis 卸载只发 internal/plugin),
18
+ * 热重载/禁用再启用时旧定时器不会被停掉;这里按运行目录去重,重复 start 先停旧的,
19
+ * 至少保证不会一次比一次多出定时器、也不会在插件停用后继续叠写备份。
12
20
  *
13
21
  * 兼容:宿主侧改动要重启 dsh web 才生效;备份目录只增不减(除旧份)。
14
22
  */
15
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
- import { join } from 'node:path';
23
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
24
+ import { join, resolve } from 'node:path';
25
+ import { writeFileAtomic } from './atomic.js';
17
26
  /** 需要备份的运行数据文件(都在 RUNTIME_DIR 根下) */
18
27
  export const AUTO_BACKUP_FILES = [
19
28
  'memory.json',
@@ -28,12 +37,22 @@ export const AUTO_BACKUP_FILES = [
28
37
  ];
29
38
  /** 备份目录名 */
30
39
  export const AUTO_BACKUP_DIR = 'backup';
40
+ /** setInterval 允许的最大延时;超过会被 Node 折成 1ms(TimeoutOverflowWarning),变成疯狂写盘 */
41
+ const MAX_TIMER_MS = 2147483647;
31
42
  /** 时间戳 → auto-YYYYMMDD-HHmmss(文件名安全,无冒号) */
32
43
  function stamp(ms) {
33
44
  const d = new Date(ms);
34
45
  const p = (n) => (n < 10 ? '0' + n : String(n));
35
46
  return 'auto-' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
36
47
  }
48
+ /** 从备份文件名反解时间(与裁剪同一口径);解析不出返回 0 */
49
+ function stampMs(name) {
50
+ const m = /^auto-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})\.json$/.exec(name);
51
+ if (!m)
52
+ return 0;
53
+ const t = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])).getTime();
54
+ return Number.isFinite(t) ? t : 0;
55
+ }
37
56
  /** 立即备份一次,返回是否写成功(失败静默,不抛) */
38
57
  export function backupOnce(opts) {
39
58
  try {
@@ -59,7 +78,7 @@ export function backupOnce(opts) {
59
78
  const nowMs = (opts.now || (() => Date.now()))();
60
79
  const out = join(dir, stamp(nowMs) + '.json');
61
80
  // 同一秒内重复备份:覆盖同名文件即可(幂等)
62
- writeFileSync(out, JSON.stringify({ at: nowMs, files }), 'utf8');
81
+ writeFileAtomic(out, JSON.stringify({ at: nowMs, files }));
63
82
  // 保留最近 keep 份(按文件名排序 = 时间顺序)
64
83
  const keep = Math.max(1, Number(opts.keep) || 7);
65
84
  let list = [];
@@ -90,27 +109,45 @@ export function autoBackupInfo(runtimeDir) {
90
109
  return { lastAt: 0, count: 0 };
91
110
  const list = readdirSync(dir).filter((f) => /^auto-.*\.json$/.test(f));
92
111
  let lastAt = 0;
112
+ let lastMtime = 0;
93
113
  for (const f of list) {
114
+ // 口径与裁剪一致:时间优先取文件名里的时间戳;文件名不合规(手工放进去的)才退回 mtime
115
+ const named = stampMs(f);
116
+ if (named > lastAt)
117
+ lastAt = named;
118
+ if (named > 0)
119
+ continue;
94
120
  try {
95
121
  const st = statSync(join(dir, f));
96
- if (st.mtimeMs > lastAt)
97
- lastAt = st.mtimeMs;
122
+ if (st.mtimeMs > lastMtime)
123
+ lastMtime = st.mtimeMs;
98
124
  }
99
125
  catch { }
100
126
  }
101
- return { lastAt, count: list.length };
127
+ return { lastAt: lastAt || lastMtime, count: list.length };
102
128
  }
103
129
  catch {
104
130
  return { lastAt: 0, count: 0 };
105
131
  }
106
132
  }
133
+ /** 运行目录 → 停止函数:同一定时器只留一份(见文件头 2026-11-07 说明) */
134
+ const activeBackupTimers = new Map();
107
135
  /**
108
136
  * 启动自动备份:立即备一份,之后每 intervalMs 一份。
109
137
  * 返回停止函数(插件 dispose 时调用,别让定时器挂着)。
138
+ * 同一运行目录重复调用会先停掉上一份定时器,不会叠加;停止函数可安全重复调用。
110
139
  * 定时器 unref:不拖住宿主进程退出。
111
140
  */
112
141
  export function startAutoBackup(opts) {
113
- const intervalMs = Math.max(60_000, Number(opts.intervalMs) || 3600_000);
142
+ const key = resolve(opts.runtimeDir);
143
+ const prev = activeBackupTimers.get(key);
144
+ if (prev) {
145
+ try {
146
+ prev();
147
+ }
148
+ catch { }
149
+ }
150
+ const intervalMs = Math.min(MAX_TIMER_MS, Math.max(60_000, Number(opts.intervalMs) || 3600_000));
114
151
  try {
115
152
  backupOnce(opts);
116
153
  }
@@ -124,8 +161,14 @@ export function startAutoBackup(opts) {
124
161
  timer.unref();
125
162
  }
126
163
  catch { }
127
- return () => { try {
128
- clearInterval(timer);
129
- }
130
- catch { } };
164
+ const stop = () => {
165
+ try {
166
+ clearInterval(timer);
167
+ }
168
+ catch { }
169
+ if (activeBackupTimers.get(key) === stop)
170
+ activeBackupTimers.delete(key);
171
+ };
172
+ activeBackupTimers.set(key, stop);
173
+ return stop;
131
174
  }
@@ -11,13 +11,41 @@
11
11
  */
12
12
  import { readFileSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
+ import { isMissingFileError, stripBom } from './atomic.js';
15
+ /**
16
+ * 读本插件 config.json。
17
+ *
18
+ * 降级口径(2026-11-07 B74 加固):
19
+ * · 没有文件(ENOENT/ENOTDIR)是常态(Key 全走环境变量),静默按“未配置”处理;
20
+ * · 坏 JSON / 数组 / 标量 / null 一律按“未配置”处理,且**只提示一次**(readApiKey 在每次聊天都会被调用,
21
+ * 不能让坏文件把日志刷满);旧实现是全静默,用户只会看到“Key 没了、语音没反应”,
22
+ * 照坑 35 的教训,“没反应”比“报错”更糟;
23
+ * · 配置对象必须是普通对象:数组/字符串没有对应字段,照原样返回只会让 cfg.deepseekKey 取到原型链上的怪值。
24
+ */
14
25
  function readConfigJson(pluginDir) {
26
+ const file = join(pluginDir, 'config.json');
27
+ let text;
15
28
  try {
16
- return JSON.parse(String(readFileSync(join(pluginDir, 'config.json'), 'utf8')).replace(/^\uFEFF/, ''));
29
+ text = String(readFileSync(file, 'utf8'));
17
30
  }
18
- catch {
31
+ catch (err) {
32
+ if (!isMissingFileError(err))
33
+ warnConfig('config.json 读取失败(' + file + '):' + String((err && err.message) || err));
34
+ return null;
35
+ }
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(stripBom(text));
39
+ }
40
+ catch (err) {
41
+ warnConfig('config.json 不是合法 JSON(' + file + '),已按未配置处理:' + String((err && err.message) || err));
42
+ return null;
43
+ }
44
+ if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
45
+ warnConfig('config.json 应为对象(实际 ' + shortValue(parsed) + '),已按未配置处理');
19
46
  return null;
20
47
  }
48
+ return parsed;
21
49
  }
22
50
  function fromEnv(own, legacy) {
23
51
  try {
@@ -220,6 +248,13 @@ export function sanitizePluginConfig(raw) {
220
248
  }
221
249
  const out = {};
222
250
  for (const [key, value] of Object.entries(raw)) {
251
+ // 原型链保留字一律拒收(2026-11-07 B74):`out[key] = value` 对 `__proto__` 会**改掉 out 的原型**,
252
+ // 于是 {"__proto__":{"dataSource":"cloud"}} 这种“未知键”会通过原型链让 config.dataSource 读到 cloud,
253
+ // 甚至让 config.enabled 读到 false —— 配置笔误/恶意键都能把插件静默改状态,与导入侧拒收 __proto__ 同一口径。
254
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
255
+ warnConfig('配置键 ' + key + ' 已忽略(原型链保留字,不允许写入配置对象)');
256
+ continue;
257
+ }
223
258
  if (key === 'enabled') {
224
259
  if (typeof value === 'boolean')
225
260
  out.enabled = value;
@@ -3,37 +3,99 @@
3
3
  *
4
4
  * 只在目标不存在时拷贝,绝不覆盖用户现有数据;失败静默(环境差异)。
5
5
  * 从 index.ts 抽出:架构宪章要求 index 只做组装。
6
+ *
7
+ * 2026-11-07(B74)加固,两处都是“半途失败留下坏状态”的坑:
8
+ * · 先拷到同目录唯一临时名,再原子改名到位:copyFileSync 写到一半失败(磁盘满 / 进程被杀)
9
+ * 不会在目标位置留半截文件。旧实现直接写目标,半截文件还能被解析成坏 JSON;
10
+ * 更糟的是下次启动会因「目标已存在」把它永久当成“已迁移”,再也不会重试。
11
+ * · 落地用 link 优先、rename 兜底:link 天然 EEXIST,两个实例(主实例与临时 3099 实例)
12
+ * 共用同一个运行目录时,不会把对方刚写出的新数据用插件包里的旧副本盖掉。
13
+ * link 不可用(跨卷 / FAT / exFAT)时退回“先查存在再改名”,只保留极小的竞态窗口。
14
+ * · 缓存目录整目录迁移同样走临时目录 + 改名。
6
15
  */
7
- import { existsSync, mkdirSync, copyFileSync, cpSync } from 'node:fs';
16
+ import { existsSync, mkdirSync, copyFileSync, cpSync, linkSync, renameSync, rmSync } from 'node:fs';
8
17
  import { join } from 'node:path';
9
- export function migrateRuntimeData(dataDir, runtimeDir) {
10
- const files = [
11
- 'memory.json', 'state.json', 'growth.json', 'fishfood.json', 'reminders.json',
12
- 'story_progress.json', 'culture-seen.json', 'poem-seen.json', 'pet_state.json',
13
- ];
18
+ /** 需要迁移的运行数据文件(都在插件包 data/ 根下) */
19
+ const MIGRATED_FILES = [
20
+ 'memory.json', 'state.json', 'growth.json', 'fishfood.json', 'reminders.json',
21
+ 'story_progress.json', 'culture-seen.json', 'poem-seen.json', 'pet_state.json',
22
+ ];
23
+ /** 临时名:与目标同目录(保证 rename 不跨卷),进程 + 随机串保证并发实例不撞名 */
24
+ function tempNameFor(dst) {
25
+ return dst + '.migrating-' + process.pid + '-' + Math.random().toString(36).slice(2, 8);
26
+ }
27
+ function discard(path) {
14
28
  try {
15
- mkdirSync(runtimeDir, { recursive: true });
29
+ rmSync(path, { recursive: true, force: true });
16
30
  }
17
31
  catch { }
18
- for (const f of files) {
19
- const src = join(dataDir, f);
20
- const dst = join(runtimeDir, f);
32
+ }
33
+ /**
34
+ * 把临时副本原子落到目标位置;目标已存在时返回 false(绝不覆盖用户数据)。
35
+ * 成功/失败都不会留下临时名:link 成功后临时名只是第二个硬链接,删掉目录项即可。
36
+ */
37
+ function placeIfAbsent(tmp, dst) {
38
+ try {
39
+ linkSync(tmp, dst);
40
+ discard(tmp);
41
+ return true;
42
+ }
43
+ catch (err) {
44
+ if (err && err.code === 'EEXIST') {
45
+ discard(tmp);
46
+ return false;
47
+ }
48
+ }
49
+ // 目录不支持 link,部分文件系统(FAT/exFAT/跨卷)也不支持:退回“先查存在再改名”
50
+ if (existsSync(dst)) {
51
+ discard(tmp);
52
+ return false;
53
+ }
54
+ renameSync(tmp, dst);
55
+ return true;
56
+ }
57
+ /** 单个文件迁移:源存在且目标不存在才动手;任何失败都只清临时文件,不抛给插件加载 */
58
+ function migrateFile(src, dst, copyFile, label) {
59
+ try {
60
+ if (!existsSync(src) || existsSync(dst))
61
+ return;
62
+ const tmp = tempNameFor(dst);
21
63
  try {
22
- if (existsSync(src) && !existsSync(dst)) {
23
- copyFileSync(src, dst);
24
- console.log('[chubby-whale] 迁移运行数据 ' + f + ' → ' + runtimeDir);
25
- }
64
+ copyFile(src, tmp);
65
+ if (placeIfAbsent(tmp, dst))
66
+ console.log('[chubby-whale] 迁移运行数据 ' + label + ' → ' + dst);
67
+ }
68
+ catch {
69
+ discard(tmp);
26
70
  }
27
- catch { }
28
71
  }
29
- // 缓存目录整目录搬迁(只在目标不存在时)
72
+ catch { }
73
+ }
74
+ /** 缓存目录迁移:与文件同一套“临时 + 原子落地”,部分拷贝不会留在 cache/ 里 */
75
+ function migrateDir(src, dst, copyDir, label) {
30
76
  try {
31
- const src = join(dataDir, 'cache');
32
- const dst = join(runtimeDir, 'cache');
33
- if (existsSync(src) && !existsSync(dst)) {
34
- cpSync(src, dst, { recursive: true });
35
- console.log('[chubby-whale] 迁移缓存 → ' + dst);
77
+ if (!existsSync(src) || existsSync(dst))
78
+ return;
79
+ const tmp = tempNameFor(dst);
80
+ try {
81
+ copyDir(src, tmp);
82
+ if (placeIfAbsent(tmp, dst))
83
+ console.log('[chubby-whale] ' + label + ' → ' + dst);
84
+ }
85
+ catch {
86
+ discard(tmp);
36
87
  }
37
88
  }
38
89
  catch { }
39
90
  }
91
+ export function migrateRuntimeData(dataDir, runtimeDir, io) {
92
+ const copyFile = (io && io.copyFile) || copyFileSync;
93
+ const copyDir = (io && io.copyDir) || ((src, dst) => cpSync(src, dst, { recursive: true }));
94
+ try {
95
+ mkdirSync(runtimeDir, { recursive: true });
96
+ }
97
+ catch { }
98
+ for (const f of MIGRATED_FILES)
99
+ migrateFile(join(dataDir, f), join(runtimeDir, f), copyFile, f);
100
+ migrateDir(join(dataDir, 'cache'), join(runtimeDir, 'cache'), copyDir, '迁移缓存');
101
+ }
@@ -7,20 +7,53 @@
7
7
  */
8
8
  import { readFileSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
- import { writeJsonAtomic } from './atomic.js';
10
+ import { stripBom, writeJsonAtomic } from './atomic.js';
11
11
  import { sendJson } from './respond.js';
12
12
  import { isBodyTooLarge, readBody, sendTooLarge } from './http.js';
13
13
  /** /cache-toggle 请求体上限:只需一个 {"enabled":true},16KB 足够,超限回 413 */
14
14
  const CACHE_TOGGLE_MAX_BODY = 16 * 1024;
15
15
  export function createPrefs(runtimeDir) {
16
16
  const PREFS_FILE = join(runtimeDir, 'prefs.json');
17
+ /** 坏文件只提示一次:chatCacheEnabled 每次聊天都会被调用,不能让坏文件把日志刷满 */
18
+ let warnedBadFile = false;
19
+ function warnBadFile(msg) {
20
+ if (warnedBadFile)
21
+ return;
22
+ warnedBadFile = true;
23
+ console.error('[prefs] ' + msg);
24
+ }
25
+ /**
26
+ * 读偏好。降级口径(2026-11-07 B74 加固):
27
+ * · 没有文件是常态(首次运行),静默返回 {}(chatCache 缺省 true);
28
+ * · 坏 JSON / 数组 / 标量一律返回 {},并提示一次;
29
+ * 旧实现把数组原样返回,setChatCache 往数组上挂 chatCache 属性后 JSON.stringify 不保留它,
30
+ * 于是“关了缓存”永远写不进去、每次读回来还是 true——用户看到的开关像坏了一样。
31
+ */
17
32
  function load() {
33
+ let text;
18
34
  try {
19
- return JSON.parse(String(readFileSync(PREFS_FILE, 'utf8')).replace(/^\uFEFF/, ''));
35
+ text = String(readFileSync(PREFS_FILE, 'utf8'));
20
36
  }
21
- catch {
37
+ catch (err) {
38
+ const code = err && err.code;
39
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') {
40
+ warnBadFile('READ-FAIL ' + String(PREFS_FILE) + ' err=' + String((err && err.message) || err));
41
+ }
42
+ return {};
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(stripBom(text));
47
+ }
48
+ catch (err) {
49
+ warnBadFile('READ-FAIL ' + String(PREFS_FILE) + ' 坏 JSON,已按默认值处理 err=' + String((err && err.message) || err));
50
+ return {};
51
+ }
52
+ if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
53
+ warnBadFile('READ-FAIL ' + String(PREFS_FILE) + ' 形状不是对象,已按默认值处理');
22
54
  return {};
23
55
  }
56
+ return parsed;
24
57
  }
25
58
  function save(p) {
26
59
  // 原子写:写坏一次就整份偏好丢失(与 store 同一套工具)
package/dist/index.js CHANGED
@@ -318,15 +318,19 @@ export function apply(ctx, rawConfig) {
318
318
  // 导入记忆/人格后作废旧聊天缓存(服务端保证,不依赖客户端补刀)
319
319
  clearChatCache: () => chatCache.clear(),
320
320
  });
321
- // 可视化汇总:全部读现有 store / cache / 人格,不新增持久化
322
- // 运行数据自动定时备份(每小时一份、保留 7 份;失败静默;插件 dispose 时停)
321
+ // 运行数据自动定时备份(每小时一份、保留 7 份;失败静默;卸载时停)
322
+ //
323
+ // 这里**不能**用 ctx.on('dispose'):cordis 卸载只派发 internal/plugin,全仓没有 dispose 事件,
324
+ // 订阅了也永远不触发,旧定时器会一直写到进程退出(2026-11-07 分身取证:cordis/lib/index.js:969、1079)。
325
+ // 改用 ctx.effect 把「停备份」登记成当前 fiber 的清理器,卸载/热重载时由框架调用。
323
326
  try {
324
327
  const stopAutoBackup = startAutoBackup({ runtimeDir: RUNTIME_DIR });
325
- if (ctx && typeof ctx.on === 'function')
326
- ctx.on('dispose', () => { try {
328
+ if (ctx && typeof ctx.effect === 'function') {
329
+ ctx.effect(() => () => { try {
327
330
  stopAutoBackup();
328
331
  }
329
- catch { } });
332
+ catch { } }, 'chubby-whale: auto-backup');
333
+ }
330
334
  }
331
335
  catch { }
332
336
  // 只读配置视图:主人能在菜单里看到当前生效的设置(绝不回 Key 内容,也不写盘)
@@ -116,3 +116,7 @@
116
116
  | B71 | 2026-11-07 | 主代理:唤醒词立即生效——`voice-bridge` 判定前同步 `loadCustomWakeWords()`(原地换数组、保留 30s 有效窗),诊断与提示同源;版本 1.1.56 | 双 tsc 0 / build 0 / 2307 项 0 失败 | 见 git |
117
117
  | B72 | 2026-11-07 | 客户端聊天分身:`reactTo` 移到流式之前(修「人设情绪在默认路径从不触发」)/ 流式回退前补纪元守卫 / P0 打断条件补 `storyBusy` / 占线阈值改用 `BUSY_STALE_MS`;新增 `test-chat-client.mjs`(96);版本 1.1.56 | 双 tsc 0 / build 0(+85B) / 2403 项 0 失败 | 见 git |
118
118
  | B73 | 2026-11-07 | 主代理:`/stats` 四个自检字段接线(deps 支持 getter,TTS 形状显式映射,`cooling` 不臆造)/ `BackupDeps.clearChatCache`——导入记忆·人格后服务端清新聊天缓存(不再只靠客户端补刀);`test.mjs` 补 2 条、`test-misc-routes` 补 4 条;版本 1.1.57 | 双 tsc 0 / build 0 / **2409 项 0 失败** / 119 模块 717 导出 | 见 git |
119
+ | B74 | 2026-11-07 | 配置/备份/迁移分身:坏 config 单次告警 + 拒收 `__proto__`/`constructor`/`prototype` / prefs 形状归一 / migrate 临时名 + link·rename 原子落地 / auto-backup 定时器去重 + 原子快照 + 文件名单一时间源;新增 `test-prefs-config.mjs`(84);版本 1.1.58 | 双 tsc 0 / build 0 / 2723 项 0 失败 | 见 git |
120
+ | B75 | 2026-11-07 | 主代理:`ctx.on('dispose')` 永不触发(cordis 无该事件)→ 备份定时器卸载后不停;改用 `ctx.effect` 登记清理器;版本 1.1.58 | 双 tsc 0 / build 0 / 2723 项 0 失败 | 见 git |
121
+ | B76 | 2026-11-07 | 媒体/提醒分身:`start()` 收尾完整 + `alive` 守卫 / 空正文提醒走兜底 / 顺延跨零点提示 / `image` 抛错也 revoke objectURL / 朗读失败不改判看图失败;新增 `test-media-reminders.mjs`(171);版本 1.1.58 | 双 tsc 0 / build 0(+1165B) / 2723 项 0 失败 | 见 git |
122
+ | B77 | 2026-11-07 | 语音桥/唤醒分身:打断词统一 `shared/noise` 五词(补别·不)/ `beforeunload` 单例 + dispose 摘 / `wakeInfo` 返回副本 / 自定义词不套旧谐音 / 单字只认整句 / 兜底 cancel 后统一 80ms / 看门狗判死即 cancel;新增 `test-voice-bridge.mjs`(59);版本 1.1.58 | 双 tsc 0 / build 0(+2019B) / 2723 项 0 失败 | 见 git |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-chubby-whale",
3
- "version": "1.1.57",
3
+ "version": "1.1.58",
4
4
  "description": "DSH 网页版桌面宠物「肥鲸」——蓝发鲸鱼娘,固定在 harness 页面右下角:会眨眼、能拖拽、点击可聊天,支持语音对话、念诗说书、饭点提醒。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -74,7 +74,7 @@
74
74
  "scripts": {
75
75
  "build": "node scripts/gen-version.mjs && node scripts/check-bom.mjs && tsc -p tsconfig.json && tsc -p tsconfig.client.json && esbuild src/client.ts --bundle --format=iife --external:react --charset=utf8 --outfile=dist/client.js",
76
76
  "prepublishOnly": "npm run build",
77
- "test": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && node scripts/check-imports.mjs && node scripts/gen-module-index.mjs --check && node scripts/check-boundaries.mjs && node scripts/check-diag-keys.mjs && node scripts/check-text.mjs && node scripts/test.mjs && node scripts/test-chat-limits.mjs && node scripts/test-stream-ledger.mjs && node scripts/test-story-security.mjs && node scripts/test-behavior-regressions.mjs && node scripts/verify-voice.mjs && node scripts/smoke-client.mjs && node scripts/ape-bench.mjs && node scripts/smoke-host.mjs && node scripts/test-voice-queue.mjs && node scripts/test-tts-engine.mjs && node scripts/test-data-layer.mjs && node scripts/test-repos.mjs && node scripts/test-personality.mjs && node scripts/test-story-player.mjs && node scripts/test-chat-routes.mjs && node scripts/test-menu-actions.mjs && node scripts/test-story-client.mjs && node scripts/test-story-auto.mjs && node scripts/test-content-routes.mjs && node scripts/test-render-layer.mjs && node scripts/test-store-cache.mjs && node scripts/test-actions-rest.mjs && node scripts/test-misc-routes.mjs && node scripts/test-chat-client.mjs && node scripts/platform-check.mjs",
77
+ "test": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && node scripts/check-imports.mjs && node scripts/gen-module-index.mjs --check && node scripts/check-boundaries.mjs && node scripts/check-diag-keys.mjs && node scripts/check-text.mjs && node scripts/test.mjs && node scripts/test-chat-limits.mjs && node scripts/test-stream-ledger.mjs && node scripts/test-story-security.mjs && node scripts/test-behavior-regressions.mjs && node scripts/verify-voice.mjs && node scripts/smoke-client.mjs && node scripts/ape-bench.mjs && node scripts/smoke-host.mjs && node scripts/test-voice-queue.mjs && node scripts/test-tts-engine.mjs && node scripts/test-data-layer.mjs && node scripts/test-repos.mjs && node scripts/test-personality.mjs && node scripts/test-story-player.mjs && node scripts/test-chat-routes.mjs && node scripts/test-menu-actions.mjs && node scripts/test-story-client.mjs && node scripts/test-story-auto.mjs && node scripts/test-content-routes.mjs && node scripts/test-render-layer.mjs && node scripts/test-store-cache.mjs && node scripts/test-actions-rest.mjs && node scripts/test-misc-routes.mjs && node scripts/test-chat-client.mjs && node scripts/test-prefs-config.mjs && node scripts/test-media-reminders.mjs && node scripts/test-voice-bridge.mjs && node scripts/platform-check.mjs",
78
78
  "version:check": "node tools/version-check.mjs",
79
79
  "typecheck": "node scripts/gen-version.mjs && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
80
80
  "smoke:client": "node scripts/smoke-client.mjs",