dsh-chubby-whale 1.1.60 → 1.1.62

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,60 @@
4
4
 
5
5
  ## [未发布]
6
6
 
7
+ ## [1.1.62] - 2026-11-07
8
+
9
+ ### 修复(B90 人格与云端语音分身)
10
+ - **阶段语气查表走原型链**:`STAGE_TONE[命名]` 用普通对象查表,`constructor`/`__proto__`/`toString` 都会命中——`buildSystemPrompt('constructor')` 会把 `function Object() { [native code] }` 拼进系统提示词。改为 `hasOwnProperty` 判定
11
+ - **云端 TTS 响应无体积上限**:11MB 假响应会被完整收下并解码(错误分支同样无上限),设计稿要求「每段带大小上限」。现在 16MB 上限,声明超限不读流、流式超限提前 cancel
12
+ - **云端成本熔断 fail-open**:`saveUsage` 的 `catch {}` 静默吞掉写盘失败,且预算只读磁盘 → 用量文件坏掉后每天字数限制彻底失效(实测连跑 3 次全部放行、`usage()` 恒 0)。现在写失败留 `CLOUD-USAGE-FAIL`,`usedChars = max(磁盘, 进程内)`
13
+
14
+ ### 修复(B90 客户端粘合层分身)
15
+ - **点播路由劫持追问**:「换个说法 / 换个故事 / 换个声音 / 再来个人」被正则当成念诗,还会 abort 在途聊天;而纯函数层测试明确断言「换个故事」应交模型——两层口径冲突
16
+ - **带句末标点的整句指令失效**:「接着说。」「报书名!」「继续。」不再被识别
17
+ - **典故/成语播报与主人原话相反**:数据池叫 `chengyu` 就念「讲个成语」,讲典故反而说成语
18
+ - **APE 判停两类输入崩坏**:`NaN` 进噪声底后段落永不 commit;`-Infinity`/首帧 `-160` 哨兵让噪声底永远回不来(每段等满 6s);`steadyWindowFrames<8` 时永远判不出平稳
19
+ - **气泡状态字收不起来**:`dismissStatus` 用未 trim 的文本比对(「在想… 」带尾随空白就永远在)
20
+ - **`useLazyRef` 对假值反复重建**:工厂返回 `0/''/false/null` 时每次都重建(违反「只建一次」契约)
21
+
22
+ ### 修复(主代理)
23
+ - **动作描写超过 60 字会被念出来**:`stripStageDirections` 的 `{1,60}` 上限去掉(否定字符类无回溯风险)
24
+ - **超长无标点一次念几百字**:`createSentenceBuffer` 的 `maxLen` 从「只做阈值」改为「也做硬上限」,按阈值切段且不劈代理对(`test-utils-pure` 契约同步)
25
+ - **语速读侧不 trim**:写侧有 trim、读侧没有,手改的 ` -10% ` 静默回默认
26
+ - **客户端情绪定时器不清理**:`setMood` 的回落定时器无 dispose、`holdMs` 为 NaN/负数会被 `setTimeout` 当 0;`bowls` 负数原样保留(成长档位按负经验算);`pet.setMood` 无白名单(任意字符串写进 `pet_state.json`)
27
+
28
+ ### 测试
29
+ - 新增 `test-persona-cloud.mjs`(139+4 变异体)、`test-client-glue.mjs`(113+6 变异体),均接入 `npm test`
30
+ - 全链 **3400 项 0 失败**(`node tools/test-count.mjs`:47 个套件,失败 0,未识别 0)
31
+
32
+ ## [1.1.61] - 2026-11-07
33
+
34
+ ### 修复(B89 宿主 HTTP 层分身)
35
+ - **413 永远送不到客户端**:`readBody` 发现超限先 `abortRequest()`→`destroy()` 再抛错,连接被主动断掉,`sendTooLarge` 写的 413 根本没机会发出(真实 socket 探针:声明超限与 chunked 超限**全部** `ECONNRESET`、0 个 413)。现在区分两种语义:声明超限=不读流不断连,流式超限=1MB 预算内 drain 后再判
36
+ - **UTF-8 BOM 让合法 JSON 变成空请求**:`readJsonBody` 未剥 BOM,`JSON.parse` 必抛 → `body={}`;带 BOM 的备份包原本被判 400 `BAD_BODY`
37
+ - **二次写响应抛 `ERR_HTTP_HEADERS_SENT`**:宿主 webserver 在 `headersSent` 后只 `destroy()` → 客户端丢掉已写好的响应;四个 writer 统一加短路
38
+
39
+ ### 修复(B89 客户端网络层分身)
40
+ - **非对象 SSE 帧进 events**:`data: null` 会让 `chat.ts` 读 `obj.error` 抛 TypeError → 白回退一轮 `/chat`(白烧一次模型);现在只收对象帧,其余计 `invalid`
41
+ - **恢复快捷键抢别的键**:`keyCode=87` 兜底不看 `key`,`Ctrl+Shift+Z` 会被当成恢复快捷键
42
+
43
+ ### 修复(B89 纯函数工具层分身)
44
+ - **切句器卡死**:增量以句末标点开头时(`。你好。世界。`)既不吐也不前进,后面整段攒到 `maxLen` 才出
45
+ - **书名劫持普通陈述**:「我在看三国」「三国好看」被判成说书点播
46
+ - **否定句反着做**:「不要讲故事」→ 真的讲起来了;「讲个故事吗。」不认提问
47
+ - **脏 fallback 透传 TTS**:`poemRate('胡说')` 原样进 TTS rate
48
+ - **`lock(0)` 闭麦一分钟**:空文本估算 0 被 `|| 60000` 兜成 60s;且 `expire()` 与 `active()` 差 1ms 界线(`t===until` 时不释放)
49
+ - **对仗分类静默错档**:`pickCouplet(null, ' 春联')` 会落「书院」
50
+
51
+ ### 修复(主代理)
52
+ - **`/chat` 超时被报成普通失败**:只认 `AbortError`/20/2,`AbortSignal.timeout()` 的 `TimeoutError`(code 23)漏判(`story.ts` 早已补)
53
+ - **endpoint 切换重复写盘**:先自己写一次再让实例 setter 写一次,setter 抛错时落盘值与实例值分叉;改为有实例时只走 setter
54
+ - **多文件拖入静默丢弃**:只取第一张,其余文件没有任何提示;现在先提示「一次一张(共 N 个)」再处理第一张
55
+ - 文档:坑 49(读体超限的两种语义、新路由必须用 `sendTooLarge` 收尾);`story.ts` 的超限注释改为新语义
56
+
57
+ ### 测试
58
+ - 新增 `test-host-http.mjs`(32)、`test-net-client.mjs`(122+4 变异体)、`test-utils-pure.mjs`(153)、`test-multidrop.mjs`(10),均接入 `npm test`
59
+ - 全链 **3258 项 0 失败**(`node tools/test-count.mjs`:45 个套件,失败 0,未识别 0)
60
+
7
61
  ## [1.1.60] - 2026-11-07
8
62
 
9
63
  ### 修复(B87 数据端口收口:路由不再直读 data/)
package/dist/client.js CHANGED
@@ -14,7 +14,7 @@
14
14
  }
15
15
 
16
16
  // src/client/version.ts
17
- var VERSION = "1.1.60";
17
+ var VERSION = "1.1.62";
18
18
 
19
19
  // src/client/styles.ts
20
20
  var W = 240;
@@ -213,12 +213,17 @@
213
213
  */
214
214
  steadyMaxAboveFloorDb: 20,
215
215
  maxSegmentMs: 6e3,
216
+ /**
217
+ * 平稳性判别窗口(帧)。10 帧 × 60ms = 600ms。
218
+ * 注意:起判前至少采 8 帧再算起伏(见 step 里的 minFrames,窗口配得更小时按窗口本身算);
219
+ * 这里原来写「25 帧 = 1.5s」是旧稿注释,与取值不符,2026-11-07 按实际取值改正。
220
+ */
216
221
  steadyWindowFrames: 10,
217
- // 25 帧 × 60ms = 1.5s
218
222
  steadySpreadDb: 3,
219
223
  steadyMs: 400
220
224
  };
221
225
  deepFreeze(APE_DEFAULTS);
226
+ var SENTINEL_DB = -160;
222
227
  var Ape = class {
223
228
  p;
224
229
  floorDb = null;
@@ -254,20 +259,29 @@
254
259
  floor() {
255
260
  return this.floorDb === null ? -70 : this.floorDb;
256
261
  }
257
- step(db) {
262
+ step(rawDb) {
258
263
  const p = this.p;
259
- if (this.floorDb === null) this.floorDb = db;
260
- if (db > this.peakDb) this.peakDb = db;
261
- else this.peakDb = Math.max(-160, this.peakDb - p.peakDecayDb);
262
- const range = this.peakDb - this.floorDb;
264
+ const valid = Number.isFinite(rawDb) && rawDb > SENTINEL_DB;
265
+ const db = valid ? rawDb : this.floor();
266
+ if (valid) {
267
+ if (this.floorDb === null) this.floorDb = db;
268
+ if (db > this.peakDb) this.peakDb = db;
269
+ else this.peakDb = Math.max(-160, this.peakDb - p.peakDecayDb);
270
+ }
271
+ const floorNow = this.floor();
272
+ const range = this.peakDb - floorNow;
263
273
  const margin = Math.max(p.marginMinDb, Math.min(p.marginMaxDb, range * p.marginRatio));
264
- const onsetTh = this.floorDb + margin;
265
- const offsetTh = this.floorDb + p.offsetMarginDb;
274
+ const onsetTh = floorNow + margin;
275
+ const offsetTh = floorNow + p.offsetMarginDb;
266
276
  this.elapsedMs += p.frameMs;
267
- if (db > onsetTh) {
277
+ if (!valid) {
278
+ this.quietMs += p.frameMs;
279
+ this.bandMs = 0;
280
+ } else if (db > onsetTh) {
268
281
  this.steadyHist.push(db);
269
282
  while (this.steadyHist.length > p.steadyWindowFrames) this.steadyHist.shift();
270
- const spread = this.steadyHist.length >= 8 ? Math.max(...this.steadyHist) - Math.min(...this.steadyHist) : 99;
283
+ const minFrames = Math.max(2, Math.min(8, p.steadyWindowFrames));
284
+ const spread = this.steadyHist.length >= minFrames ? Math.max(...this.steadyHist) - Math.min(...this.steadyHist) : 99;
271
285
  const nearFloor = db - this.floorDb <= p.steadyMaxAboveFloorDb;
272
286
  const steadyNow = spread < p.steadySpreadDb && nearFloor;
273
287
  if (steadyNow) {
@@ -329,7 +343,7 @@
329
343
  }
330
344
  return {
331
345
  db,
332
- floorDb: this.floorDb,
346
+ floorDb: this.floor(),
333
347
  onsetTh,
334
348
  offsetTh,
335
349
  speechMs: this.speechMs,
@@ -352,10 +366,14 @@
352
366
  var DEFAULT_CHAT_RATE = "-20%";
353
367
  var DEFAULT_STORY_RATE = "-35%";
354
368
  var DEFAULT_POEM_RATE = "-25%";
369
+ function validPercent(v) {
370
+ const s = String(v == null ? "" : v).trim();
371
+ return /^-(?:[1-9]\d?)%$/.test(s) ? s : "";
372
+ }
355
373
  function readPercentRate(key, fallback) {
356
374
  try {
357
- const s = String(localStorage.getItem(key) || "").trim();
358
- if (/^-(?:[1-9]\d?)%$/.test(s)) return s;
375
+ const s = validPercent(localStorage.getItem(key));
376
+ if (s) return s;
359
377
  } catch (e) {
360
378
  }
361
379
  return fallback;
@@ -364,11 +382,11 @@
364
382
  return readPercentRate("chubby-story-rate", DEFAULT_STORY_RATE);
365
383
  }
366
384
  function poemRate(fallback = DEFAULT_POEM_RATE) {
367
- return readPercentRate("chubby-poem-rate", fallback);
385
+ return readPercentRate("chubby-poem-rate", validPercent(fallback) || DEFAULT_POEM_RATE);
368
386
  }
369
387
  function chatRate() {
370
388
  try {
371
- const s = localStorage.getItem("chubby-chat-rate");
389
+ const s = String(localStorage.getItem("chubby-chat-rate") || "").trim();
372
390
  if (s && CHAT_RATES.indexOf(s) >= 0) return s;
373
391
  } catch (e) {
374
392
  }
@@ -386,7 +404,7 @@
386
404
  }
387
405
  function narrationRate() {
388
406
  try {
389
- const s = localStorage.getItem("chubby-narration-rate");
407
+ const s = String(localStorage.getItem("chubby-narration-rate") || "").trim();
390
408
  if (s && NARRATION_RATES.indexOf(s) >= 0) return s;
391
409
  } catch (e) {
392
410
  }
@@ -570,7 +588,7 @@
570
588
  }
571
589
  function stripStageDirections(raw) {
572
590
  let t = str(raw);
573
- t = t.replace(/[\**][^**\n]{1,60}[\**]/g, " ");
591
+ t = t.replace(/[\**][^**\n]*[\**]/g, " ");
574
592
  t = t.replace(/[\**]/g, " ");
575
593
  t = t.replace(/([,。!?;:、])\s+/g, "$1");
576
594
  return t.replace(/\s{2,}/g, " ").trim();
@@ -603,6 +621,7 @@
603
621
  function createSentenceBuffer(maxLen = 40) {
604
622
  const limit = Math.max(2, Number(maxLen) || 40);
605
623
  let tail = "";
624
+ let lead = "";
606
625
  function take(force) {
607
626
  const out = [];
608
627
  for (; ; ) {
@@ -611,18 +630,35 @@
611
630
  if (!tail) break;
612
631
  const m = tail.match(/[\s\S]*?[。!?!?;;\n]/);
613
632
  if (m && m[0].length >= 2) {
614
- const seg = m[0].trim();
633
+ const seg = (lead + m[0]).trim();
634
+ lead = "";
615
635
  tail = tail.slice(m[0].length);
616
636
  if (seg) out.push(seg);
617
637
  continue;
618
638
  }
619
- if (force || tail.length >= limit) {
620
- const seg = tail.trim();
621
- tail = "";
622
- if (seg) out.push(seg);
639
+ if (m) {
640
+ lead += m[0];
641
+ tail = tail.slice(m[0].length);
642
+ continue;
623
643
  }
624
644
  break;
625
645
  }
646
+ if (force || tail.length + lead.length >= limit) {
647
+ const buf = lead + tail;
648
+ lead = "";
649
+ tail = "";
650
+ let rest = buf;
651
+ while (rest.length > limit) {
652
+ let cut = limit;
653
+ const code = rest.charCodeAt(cut - 1);
654
+ if (code >= 55296 && code <= 56319) cut += 1;
655
+ const seg2 = rest.slice(0, cut).trim();
656
+ if (seg2) out.push(seg2);
657
+ rest = rest.slice(cut);
658
+ }
659
+ const seg = rest.trim();
660
+ if (seg) out.push(seg);
661
+ }
626
662
  return out;
627
663
  }
628
664
  return {
@@ -4423,7 +4459,8 @@
4423
4459
  var COUPLET_CATS = ["书院", "格言", "谐趣", "名胜", "书房", "春联", "寿联", "景观", "中药", "叠字", "数字"];
4424
4460
  deepFreeze(COUPLET_CATS);
4425
4461
  function pickCouplet(excludeUpper, cat) {
4426
- const byCat = cat ? COUPLETS.filter((c) => c.cat === cat) : COUPLETS;
4462
+ const key = cat == null ? "" : String(cat).trim();
4463
+ const byCat = key ? COUPLETS.filter((c) => c.cat === key) : COUPLETS;
4427
4464
  const base = byCat.length > 0 ? byCat : COUPLETS;
4428
4465
  const pool = base.length > 1 && excludeUpper ? base.filter((c) => c.upper !== excludeUpper) : base;
4429
4466
  const src = pool.length > 0 ? pool : base;
@@ -5565,8 +5602,8 @@
5565
5602
  const v = host.voice();
5566
5603
  const cur = v && v.endpointInfo ? v.endpointInfo().mode : readEndpointMode();
5567
5604
  const next = cur === "ape" ? "classic" : "ape";
5568
- writeEndpointMode(next);
5569
5605
  if (v && v.setEndpointMode) v.setEndpointMode(next);
5606
+ else writeEndpointMode(next);
5570
5607
  const real = v && v.endpointInfo ? v.endpointInfo().mode : next;
5571
5608
  host.bubble(real === "ape" ? "端点检测:自适应(试用)" : "端点检测:经典(稳定)", 4e3);
5572
5609
  } catch (e) {
@@ -5915,7 +5952,8 @@
5915
5952
  const s = JSON.parse(raw);
5916
5953
  return {
5917
5954
  ...base,
5918
- bowls: Number(s.bowls) || 0,
5955
+ // 负数/NaN/小数一律归成非负整数:原来负数会原样保留,成长档位会按负经验算
5956
+ bowls: Math.max(0, Math.floor(Number(s.bowls) || 0)),
5919
5957
  stageIndex: Number(s.stageIndex) || 0,
5920
5958
  lastFeedAt: Number(s.lastFeedAt) || 0,
5921
5959
  lastInteractAt: Number(s.lastInteractAt) || 0,
@@ -5980,9 +6018,12 @@
5980
6018
  return state.mood;
5981
6019
  }
5982
6020
  let moodTimer = null;
6021
+ const MOOD_HOLD_MAX_MS = 60 * 1e3;
5983
6022
  function setMood(m, holdMs = 5e3) {
5984
6023
  state.mood = m;
5985
6024
  clearT(moodTimer);
6025
+ const n = Number(holdMs);
6026
+ const ms2 = Number.isFinite(n) && n > 0 ? Math.min(n, MOOD_HOLD_MAX_MS) : 5e3;
5986
6027
  moodTimer = setTimeout(() => {
5987
6028
  moodTimer = null;
5988
6029
  try {
@@ -5990,7 +6031,11 @@
5990
6031
  refreshMood();
5991
6032
  } catch (e) {
5992
6033
  }
5993
- }, holdMs);
6034
+ }, ms2);
6035
+ }
6036
+ function dispose() {
6037
+ clearT(moodTimer);
6038
+ moodTimer = null;
5994
6039
  }
5995
6040
  activeState = state;
5996
6041
  activeMoodSource = () => refreshMood();
@@ -6061,6 +6106,7 @@
6061
6106
  state,
6062
6107
  refreshMood,
6063
6108
  setMood,
6109
+ dispose,
6064
6110
  line,
6065
6111
  greeting,
6066
6112
  feed,
@@ -6158,7 +6204,7 @@
6158
6204
  if (!el) return;
6159
6205
  const te = opts.textEl();
6160
6206
  const txt = te ? String(te.textContent || "") : "";
6161
- if (STATUS_TEXTS.indexOf(txt) >= 0) {
6207
+ if (STATUS_TEXTS.indexOf(txt.trim()) >= 0) {
6162
6208
  clearTimer(el);
6163
6209
  el.classList.remove("show");
6164
6210
  }
@@ -6175,7 +6221,8 @@
6175
6221
  let until = 0;
6176
6222
  return {
6177
6223
  lock(ms2) {
6178
- const dur = Math.max(NARRATE_MIN_MS, Math.min(Number(ms2) || 6e4, NARRATE_MAX_MS));
6224
+ const n = ms2 == null ? NaN : Number(ms2);
6225
+ const dur = Math.max(NARRATE_MIN_MS, Math.min(Number.isNaN(n) ? 6e4 : n, NARRATE_MAX_MS));
6179
6226
  until = Date.now() + dur;
6180
6227
  },
6181
6228
  clear() {
@@ -6191,7 +6238,7 @@
6191
6238
  },
6192
6239
  expire(now) {
6193
6240
  const t = typeof now === "number" ? now : Date.now();
6194
- if (!until || t <= until) return false;
6241
+ if (!until || t < until) return false;
6195
6242
  until = 0;
6196
6243
  return true;
6197
6244
  }
@@ -6871,6 +6918,16 @@
6871
6918
  ];
6872
6919
  deepFreeze(BOOK_ALIAS);
6873
6920
  var BARE_STRIP = /[\s。,!?、,.!?…~~]/g;
6921
+ var BARE_TAIL_PARTICLES = "吧啊呀呢么嘛哈哦嗯啦";
6922
+ function isBareBookName(bare, book, alias) {
6923
+ if (!bare) return false;
6924
+ if (bare === book) return true;
6925
+ const core = BARE_TAIL_PARTICLES.indexOf(bare.slice(-1)) >= 0 ? bare.slice(0, -1) : bare;
6926
+ if (core === book) return true;
6927
+ const m = alias.exec(core);
6928
+ return !!m && m.index === 0 && core.length <= m[0].length + 1;
6929
+ }
6930
+ var NEGATED_ASK = /(不|别|甭|勿|无需)(要|用|想|再|许|必)?\s*(讲|说|念|背|来|放|听|唱)/;
6874
6931
  function matchPoemTitle(text) {
6875
6932
  const t = String(text || "");
6876
6933
  if (!t) return "";
@@ -6880,12 +6937,13 @@
6880
6937
  return "";
6881
6938
  }
6882
6939
  function isQuestion(text) {
6883
- return /[??]|为什么|怎么|如何|什么|谁|哪[儿里]?|多少|几点|几号|是不是|能不能|要不要|吗\s*$|呢\s*$/.test(String(text || ""));
6940
+ return /[??]|为什么|怎么|如何|什么|谁|哪[儿里]?|多少|几点|几号|是不是|能不能|要不要|吗[。..!!…~~]*\s*$|呢[。..!!…~~]*\s*$/.test(String(text || ""));
6884
6941
  }
6885
6942
  function detectIntent(rawText) {
6886
6943
  const t = String(rawText || "").trim();
6887
6944
  if (!t || t.length > 30) return null;
6888
6945
  if (isQuestion(t)) return null;
6946
+ if (NEGATED_ASK.test(t)) return null;
6889
6947
  if (poemTitles().length === 0) ensurePoemTitles();
6890
6948
  const byTitle = matchPoemTitle(t);
6891
6949
  if (byTitle) {
@@ -6898,17 +6956,17 @@
6898
6956
  const asksCulture = /讲|说|来|听|教|学|点|考/.test(t);
6899
6957
  if (asksCulture) return { kind: "culture", sub: /成语/.test(t) ? "idiom" : "chengyu" };
6900
6958
  }
6901
- let book = "";
6902
- for (const [re, name] of BOOK_ALIAS) {
6903
- if (re.test(t)) {
6904
- book = name;
6959
+ let hit = null;
6960
+ for (const pair of BOOK_ALIAS) {
6961
+ if (pair[0].test(t)) {
6962
+ hit = pair;
6905
6963
  break;
6906
6964
  }
6907
6965
  }
6908
- if (book) {
6966
+ if (hit) {
6909
6967
  const bare = t.replace(BARE_STRIP, "");
6910
- const asksBook = /讲|说书|听|来(一)?段|来(一)?回/.test(t) || bare.length <= book.length + 1;
6911
- if (asksBook) return { kind: "story", book };
6968
+ const asksBook = /讲|说书|听|来(一)?段|来(一)?回/.test(t) || isBareBookName(bare, hit[1], hit[0]);
6969
+ if (asksBook) return { kind: "story", book: hit[1] };
6912
6970
  }
6913
6971
  if (/讲故事|讲个故事|说书|来段|讲一段/.test(t)) return { kind: "story", book: "" };
6914
6972
  return null;
@@ -6935,13 +6993,16 @@
6935
6993
  }
6936
6994
  function route(rawText) {
6937
6995
  const t0 = String(rawText || "").trim();
6938
- if (/^(再来|再念|换)(一)?(首|个)/.test(t0)) {
6996
+ const cmd = t0.replace(/[。..!!~~…]+$/, "");
6997
+ const again = /^(再来|再念|换)(一)?(首|个)([\s\S]*)$/.exec(cmd);
6998
+ const againTail = again ? again[4] : "";
6999
+ if (again && (againTail === "" || /^[吧啊呀哦嘛哈啦呗吖诗]+/.test(againTail))) {
6939
7000
  takeOver();
6940
7001
  host.bubble("(点播)好,再念一首", 3e3);
6941
7002
  fetchPoem("", (p) => recitePoem(host, p));
6942
7003
  return true;
6943
7004
  }
6944
- if (/^(继续|接着说|接着讲|往下讲)$/.test(t0)) {
7005
+ if (/^(继续|接着说|接着讲|往下讲)$/.test(cmd)) {
6945
7006
  takeOver();
6946
7007
  host.bubble("(点播)接着说", 3e3);
6947
7008
  try {
@@ -6952,7 +7013,7 @@
6952
7013
  host.story.start(host.storyState.book.current || "");
6953
7014
  return true;
6954
7015
  }
6955
- if (/^(报|说|列)(一下|下)?(书名|书单)$/.test(t0)) {
7016
+ if (/^(报|说|列)(一下|下)?(书名|书单)$/.test(cmd)) {
6956
7017
  takeOver();
6957
7018
  host.bubble("(点播)本鲸数数书单…", 3e3);
6958
7019
  getJson("/story/books", { cache: "no-store" }).then((d) => {
@@ -6979,7 +7040,8 @@
6979
7040
  host.bubble("正在讲呢,稍等~", 2e3);
6980
7041
  return true;
6981
7042
  }
6982
- host.bubble(type === "chengyu" ? "(点播)讲个成语" : "(点播)讲个典故", 3e3);
7043
+ const saidIdiom = /成语/.test(t0);
7044
+ host.bubble(saidIdiom ? "(点播)讲个成语" : "(点播)讲个典故", 3e3);
6983
7045
  getJson("/culture?type=" + type).then((d) => {
6984
7046
  const it = d && d.ok && d.item ? d.item : null;
6985
7047
  if (!it) {
@@ -6987,8 +7049,8 @@
6987
7049
  return;
6988
7050
  }
6989
7051
  const body = collapseText(String(it.text || ""));
6990
- host.bubble((type === "chengyu" ? "成语·" : "典故·") + String(it.title || ""), 15e3);
6991
- host.speak((type === "chengyu" ? "讲个成语——" : "讲个典故——") + String(it.title || "") + (it.src ? ",出自" + String(it.src) : "") + "。" + body, { rate: host.narrationRate() });
7052
+ host.bubble((saidIdiom ? "成语·" : "典故·") + String(it.title || ""), 15e3);
7053
+ host.speak((saidIdiom ? "讲个成语——" : "讲个典故——") + String(it.title || "") + (it.src ? ",出自" + String(it.src) : "") + "。" + body, { rate: host.narrationRate() });
6992
7054
  }).catch(() => host.bubble("这个没读到", 4e3));
6993
7055
  return true;
6994
7056
  }
@@ -7016,7 +7078,9 @@
7016
7078
  const payload = line.slice(5).trim();
7017
7079
  if (!payload || payload === "[DONE]") continue;
7018
7080
  try {
7019
- events.push(JSON.parse(payload));
7081
+ const v = JSON.parse(payload);
7082
+ if (v && typeof v === "object") events.push(v);
7083
+ else invalid++;
7020
7084
  } catch (e) {
7021
7085
  invalid++;
7022
7086
  }
@@ -7388,7 +7452,7 @@
7388
7452
  if (data && data.upgraded) host.bubble("本鲸又长大一点啦!", 4e3);
7389
7453
  } catch (err) {
7390
7454
  if (!host.epoch.isCurrent(myEpoch)) return;
7391
- const timedOut = !!err && (err.name === "AbortError" || err.code === 20 || err.code === 2);
7455
+ const timedOut = !!err && (err.name === "AbortError" || err.name === "TimeoutError" || err.code === 20 || err.code === 2 || err.code === 23);
7392
7456
  const tip = timedOut ? "主人,本鲸等太久啦(网络慢),再问一次?" : host.persona.line("error");
7393
7457
  if (d1) d1.lastChat = { stage: timedOut ? "timeout" : "error", error: String(err && err.message || err).slice(0, 60), at: Date.now() };
7394
7458
  fishTip(host, tip, 5e3);
@@ -7722,6 +7786,7 @@
7722
7786
  if (!ctrl || !shift) return false;
7723
7787
  const key = str(e.key).toLowerCase();
7724
7788
  if (key === "w") return true;
7789
+ if (key && key !== "unidentified") return false;
7725
7790
  const code = Number(e.keyCode != null ? e.keyCode : e.which);
7726
7791
  return code === 87;
7727
7792
  }
@@ -7788,8 +7853,9 @@
7788
7853
  }
7789
7854
  function createHooks(R) {
7790
7855
  function useLazyRef(factory) {
7791
- const ref = R.useRef(null);
7792
- if (!ref.current) ref.current = factory();
7856
+ const UNSET = {};
7857
+ const ref = R.useRef(UNSET);
7858
+ if (ref.current === UNSET) ref.current = factory();
7793
7859
  return ref;
7794
7860
  }
7795
7861
  function usePersistentBool(key, fallback = true) {
@@ -7825,7 +7891,12 @@
7825
7891
  e.preventDefault();
7826
7892
  e.stopPropagation();
7827
7893
  const dt = e.dataTransfer;
7828
- const file = dt && dt.files && dt.files[0];
7894
+ const files = dt && dt.files ? dt.files : null;
7895
+ const file = files && files[0];
7896
+ try {
7897
+ if (files && files.length > 1 && typeof opts.onMany === "function") opts.onMany(files.length);
7898
+ } catch (e2) {
7899
+ }
7829
7900
  try {
7830
7901
  opts.onFile(file);
7831
7902
  } catch (e2) {
@@ -8696,6 +8767,12 @@
8696
8767
  });
8697
8768
  } catch (e2) {
8698
8769
  }
8770
+ },
8771
+ onMany: (n) => {
8772
+ try {
8773
+ showBubble("一次先看一张哦~先看第一张(共 " + n + " 个)", 3200);
8774
+ } catch (e2) {
8775
+ }
8699
8776
  }
8700
8777
  });
8701
8778
  }, []);
package/dist/host/body.js CHANGED
@@ -6,6 +6,15 @@
6
6
  * 这里只统一「读 + 判 + 解析」;日志标签、413 响应由调用方决定(保持原有各路由的日志文案)。
7
7
  */
8
8
  import { isBodyTooLarge, readBody } from './http.js';
9
+ /**
10
+ * 去掉文本开头的 UTF-8 BOM(U+FEFF)。
11
+ * Node 的 Buffer.toString('utf8') 不会再吞 BOM,而 JSON.parse 不容忍它(坑 15 / 坑 37 的同一个坑):
12
+ * 带 BOM 的 `{"a":1}` 会落到「解析失败 → body={}」,客户端拿到的是「空请求」而不是自己的数据。
13
+ * 这里只在解析前剥一次,`raw` 仍是原始字节,调用方要原样留证不受影响。
14
+ */
15
+ function stripBom(text) {
16
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
17
+ }
9
18
  /**
10
19
  * 带上限读请求体并解析 JSON。
11
20
  * - 超过 maxBytes:返回 {ok:false,tooLarge:true}(不抛),调用方回 413;
@@ -22,7 +31,7 @@ export async function readJsonBody(req, maxBytes) {
22
31
  return { ok: false, tooLarge: true };
23
32
  throw err;
24
33
  }
25
- const text = raw.toString('utf8');
34
+ const text = stripBom(raw.toString('utf8'));
26
35
  let body = {};
27
36
  try {
28
37
  body = text ? JSON.parse(text) : {};
package/dist/host/http.js CHANGED
@@ -4,6 +4,14 @@
4
4
  */
5
5
  /** 请求体超限的错误码(readBody 抛出,路由据此回 413) */
6
6
  const BODY_TOO_LARGE = 'BODY_TOO_LARGE';
7
+ /**
8
+ * 超限后还愿意「读掉并丢弃」的字节数上限。
9
+ * 为什么需要:readBody 抛错后路由要写 413,但如果先 destroy 了连接,413 永远送不到客户端
10
+ * (客户端只看得到 ECONNRESET,实测见 scripts/test-host-http.mjs)。超一点点是常态
11
+ * (16KB 上限的请求多发了 1KB、2MB 的备份包多打了 200KB),把这点尾巴读空再抛,
12
+ * 413 才真的能回出去;超过预算(对方还在猛发)仍然立刻断连,不拿内存和带宽陪跑。
13
+ */
14
+ const DRAIN_GRACE = 1024 * 1024;
7
15
  function bodyTooLarge() {
8
16
  const e = new Error('body too large');
9
17
  e.code = BODY_TOO_LARGE;
@@ -54,10 +62,12 @@ export function sendTooLarge(res) {
54
62
  catch { }
55
63
  }
56
64
  /**
57
- * 请求体超限时把连接断掉。
58
- * 为什么必须断:只从 for-await 里 throw 出去只是「不再消费」,客户端还在发的 body 会继续占着
65
+ * 超限且「丢弃预算」也用完时把连接断掉。
66
+ * 为什么此时必须断:只从 for-await 里 throw 出去只是「不再消费」,客户端还在发的 body 会继续占着
59
67
  * socket,HTTP 服务也走不到 keep-alive 复用,等于每个超大请求漏一条连接。
60
68
  * destroy 优先(客户端立刻收到 RST,剩余数据丢弃);没有 destroy 就 resume 把剩下的读空丢掉。
69
+ * 注意:这条路径客户端收不到 413(连接先断了),所以只在明显超大时才用——
70
+ * 小额超限走 readBody 里的有限 drain,让 413 能送达。
61
71
  */
62
72
  function abortRequest(req) {
63
73
  // destroy/半截读取都可能触发 error(EPIPE/ECONNRESET),先挂空监听,避免冒成未捕获异常
@@ -80,26 +90,42 @@ function abortRequest(req) {
80
90
  catch { }
81
91
  }
82
92
  /**
83
- * 读请求体,超过 maxBytes 立刻中止并抛 BODY_TOO_LARGE。
84
- * 先看 content-length(有就直接拒,连读都不读),读的过程中累计超限也当场抛,不把整份大包收进内存。
85
- * 抛错前先 abortRequest 断连,否则超限后客户端仍在传的 body 会一直占着连接。
93
+ * 读请求体,超过 maxBytes 判超限并抛 BODY_TOO_LARGE。
94
+ * 两道保险:
95
+ * · 先看 content-length:声明值已超限就**连读都不读**直接拒(既有语义,调用方据此“不读流、不落盘”);
96
+ * · 读的过程中累计超限(无 content-length / 声明偏小)也当场判超限,不把整份大包收进内存。
97
+ *
98
+ * 超限后不立刻 destroy:路由要写 413,先断连接的话客户端只看到 ECONNRESET(413 永远送不到)。
99
+ * 声明超限时函数直接返回、一个字节都不读,连接由 sendTooLarge 的 `connection: close` 收掉;
100
+ * 流式超限时先在 maxBytes + DRAIN_GRACE 的预算内把剩余数据读掉并丢弃,再把错误抛给路由;
101
+ * 预算用尽还不结束才 abortRequest 断连,避免超大/慢速的体占住连接与内存。
86
102
  */
87
103
  export async function readBody(req, maxBytes) {
88
104
  const declared = Number(req && req.headers && req.headers['content-length']);
89
105
  if (Number.isFinite(declared) && declared > maxBytes) {
90
- abortRequest(req);
91
- throw bodyTooLarge();
106
+ throw bodyTooLarge(); // 不读、不断连:让路由的 413 送得出去
92
107
  }
93
108
  const chunks = [];
94
109
  let total = 0;
110
+ let overflow = false;
95
111
  for await (const chunk of req) {
96
112
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
97
113
  total += buf.length;
98
- if (total > maxBytes) {
99
- abortRequest(req); // 先把连接断掉,再抛给路由回 413
114
+ if (!overflow) {
115
+ if (total <= maxBytes) {
116
+ chunks.push(buf);
117
+ continue;
118
+ }
119
+ // 刚越线:半份数据对调用方没意义,先放掉,别继续涨内存
120
+ overflow = true;
121
+ chunks.length = 0;
122
+ }
123
+ if (total > maxBytes + DRAIN_GRACE) {
124
+ abortRequest(req); // 丢弃预算也用完:不陪跑,直接断连
100
125
  throw bodyTooLarge();
101
126
  }
102
- chunks.push(buf);
103
127
  }
128
+ if (overflow)
129
+ throw bodyTooLarge(); // 已经读空,路由这时写 413 能送到客户端
104
130
  return Buffer.concat(chunks);
105
131
  }
@@ -26,5 +26,12 @@ const STAGE_TONE = {
26
26
  };
27
27
  /** 组装系统提示词:基础人设 + 当前成长阶段语气(未知阶段回落空串) */
28
28
  export function buildSystemPrompt(stageName) {
29
- return SYSTEM_PROMPT + (STAGE_TONE[String(stageName || '')] || '');
29
+ const key = String(stageName || '');
30
+ // 只认自有键:STAGE_TONE 是普通对象字面量,`STAGE_TONE['constructor']` / `'__proto__'` /
31
+ // `'toString'` 会命中原型链,把函数源码或 "[object Object]" 拼进系统提示词
32
+ // (实测 buildSystemPrompt('constructor') 末尾多出 "function Object() { [native code] }",
33
+ // '__proto__' 多出 "[object Object]")。未知阶段仍回落空串,已知阶段名行为不变。
34
+ // 与 host/repos/growth.ts 里 EXP_SOURCE_CAP 的 hasOwnProperty 口径一致。
35
+ const tone = key && Object.prototype.hasOwnProperty.call(STAGE_TONE, key) ? STAGE_TONE[key] : '';
36
+ return SYSTEM_PROMPT + tone;
30
37
  }
@@ -16,6 +16,11 @@ import { writeJsonAtomic, isMissingFileError, quarantineFile, readTextNoBom } fr
16
16
  const HUNGER_PER_HOUR = 3;
17
17
  /** 饥饿到 70 算「饿了」,由 /pet 下发 hungry(客户端只认 happy/hungry/normal 三态) */
18
18
  const HUNGRY_AT = 70;
19
+ /**
20
+ * 宠物情绪白名单:只接受这些值,其余按 idle。
21
+ * 为什么:`setMood` 原来把任意字符串写进 pet_state.json,客户端表情渲染落空、备份也会带走脏值。
22
+ */
23
+ const PET_MOODS = ['idle', 'happy', 'hungry', 'sleepy', 'sulk', 'shy', 'excited'];
19
24
  /** 喂食后心情保持 happy 的时长,与客户端 6 小时「该喂了」的阈值同口径 */
20
25
  const HAPPY_WINDOW_MS = 6 * 3600 * 1000;
21
26
  /** 饥饿统一夹到 [0, 100];非数字按 0 */
@@ -103,7 +108,9 @@ export function createPetRepo(dataDir) {
103
108
  }
104
109
  function setMood(mood) {
105
110
  const p = loadPet();
106
- p.mood = mood;
111
+ // 只接受已知情绪:任意字符串写进 pet.json 会让客户端表情渲染落空(还会被备份带走)
112
+ const m = String(mood || '').trim();
113
+ p.mood = PET_MOODS.indexOf(m) >= 0 ? m : 'idle';
107
114
  savePet(p);
108
115
  }
109
116
  return { loadPet, savePet, tickPet, feedPet, setMood };
@@ -7,8 +7,20 @@
7
7
  * 注意:本插件大量使用「HTTP 200 承载业务失败(ok:false)」的既有约定,sendError 默认沿用;
8
8
  * 需要真状态码(400/405/500)时显式传 status。
9
9
  */
10
+ /**
11
+ * 响应是否已经写过:已发头或已 end 过就别再写。
12
+ * Node 的 ServerResponse 二次 writeHead 会抛 ERR_HTTP_HEADERS_SENT;而本插件大量
13
+ * 「catch 里无条件 sendError」的写法(backup / culture / misc / tts 等)一旦在回包之后再抛,
14
+ * 这个异常会从 catch 里冒出去,宿主 webserver 只能把连接 destroy 掉——客户端反而丢掉已经
15
+ * 写好的那份响应。这里统一改成「已经回过就不动」:保留第一份响应,也不再往外抛。
16
+ */
17
+ function alreadyResponded(res) {
18
+ return Boolean(res && (res.headersSent || res.writableEnded));
19
+ }
10
20
  /** 返回 JSON 响应;status 缺省 200(与既有路由一致) */
11
21
  export function sendJson(res, payload, status = 200) {
22
+ if (alreadyResponded(res))
23
+ return;
12
24
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
13
25
  res.end(JSON.stringify(payload));
14
26
  }
@@ -19,16 +31,22 @@ export function sendError(res, error, extra, status = 200) {
19
31
  }
20
32
  /** 纯文本响应(图片缺失等非 JSON 场景) */
21
33
  export function sendText(res, status, text, contentType = 'text/plain; charset=utf-8') {
34
+ if (alreadyResponded(res))
35
+ return;
22
36
  res.writeHead(status, { 'content-type': contentType });
23
37
  res.end(text);
24
38
  }
25
39
  /** 二进制响应(音频等);额外响应头由调用方给,保持各接口原有缓存策略 */
26
40
  export function sendBuffer(res, buffer, contentType, headers = {}) {
41
+ if (alreadyResponded(res))
42
+ return;
27
43
  res.writeHead(200, Object.assign({ 'content-type': contentType }, headers));
28
44
  res.end(buffer);
29
45
  }
30
46
  /** SSE 响应头(流式聊天);不再被缓冲的 x-accel-buffering 是给反向代理看的 */
31
47
  export function sendSseHeaders(res) {
48
+ if (alreadyResponded(res))
49
+ return;
32
50
  res.writeHead(200, {
33
51
  'content-type': 'text/event-stream; charset=utf-8',
34
52
  'cache-control': 'no-store',
@@ -142,8 +142,9 @@ export function createStoryHandlers(opts) {
142
142
  }
143
143
  try {
144
144
  const url = new URL(req.url, 'http://x');
145
- // 先按 content-length 直接拒:readJsonBody 超限时为保证不把大包收进内存会先断连接,
146
- // 断早了客户端就只能看到连接重置;这里先回一个能收到的 413,再交给下面的流式读取兜底。
145
+ // 先按 content-length 直接拒:能省掉白读一个大包(也避免慢客户端长时间占连接)。
146
+ // 2026-11-07 起 readBody 对「声明超限」不再断连(不读流、直接抛,由 sendTooLarge 的
147
+ // connection: close 收尾),所以下面那层流式读取是兜底而不是唯一出路。
147
148
  const declared = Number(req?.headers?.['content-length']);
148
149
  if (Number.isFinite(declared) && declared > RESET_MAX_BODY) {
149
150
  sendTooLarge(res);
@@ -12,6 +12,11 @@
12
12
  *
13
13
  * 成本控制:按「日合成字数」熔断(默认 5000 字/天,可在 config.cloudVoice.dailyChars 改);
14
14
  * 超限直接抛错 -> 路由回退 edge-tts(免费)-> 客户端始终有声。
15
+ * 日用量落盘 ~/.dsh-chubby-whale/cloud-usage.json(重启/跨进程接着算);写盘失败时进程内
16
+ * 计数兜底并留 CLOUD-USAGE-FAIL,熔断不会因为一次 IO 失败就失效。
17
+ *
18
+ * 健壮性:单次响应体积上限 16MB(声明超限不读流、流式超限提前 cancel),坏响应吃不掉宿主内存;
19
+ * 超时(默认 15s,可注入)到点 abort,失败一律抛错交给路由回退,不记账。
15
20
  *
16
21
  * 安全:API Key 只从本地 config.json 读,不进包、不进备份导出。
17
22
  */
@@ -26,6 +31,19 @@ export const CLOUD_TTS_RESOURCE_ID = 'seed-tts-2.0';
26
31
  export const CLOUD_TTS_DEFAULT_SPEAKER = 'zh_female_vv_uranus_bigtts';
27
32
  /** 合成请求超时(云端 HTTP 单次,通常 0.2–0.5s) */
28
33
  const CLOUD_TTS_TIMEOUT_MS = 15000;
34
+ /**
35
+ * 单次响应体积上限(字节,按 JSON 文本算:base64 比音频大约 4/3)。
36
+ *
37
+ * 为什么必须有:原来直接 `await res.text()`,chunked 流有多大就收多大——服务端 bug、
38
+ * 中间层塞回一个错误页/HTML、或响应被劫持时,宿主进程的内存没有任何护栏
39
+ * (实测 8MB 的假响应照收不误,再大也一样)。
40
+ *
41
+ * 上限怎么定:/tts 路由侧单段文本最多 6000 字(host/routes/tts.ts 的 MAX_TEXT),
42
+ * 按 24kHz mp3 估算约 5–10MB base64;正常请求(客户端按 ≤80 字分段)只有几十~一百多 KB。
43
+ * 取 16MB 既装得下最长的合法请求,又给坏响应一个硬天花板;超限一律抛错让路由回退
44
+ * edge-tts(免费兜底,客户端仍有声),不拿宿主内存赌对方一定守规矩。
45
+ */
46
+ const CLOUD_TTS_MAX_BYTES = 16 * 1024 * 1024;
29
47
  /** 从 pluginDir/config.json 读 cloudVoice 段(缺省/无 Key = 不启用) */
30
48
  export function readCloudVoice(pluginDir) {
31
49
  const empty = {
@@ -76,11 +94,73 @@ function readUsage(runtimeDir) {
76
94
  return { day: todayKey(), chars: 0 };
77
95
  }
78
96
  }
97
+ /**
98
+ * 写日用量;失败返回 false(调用方记一行日志,绝不把异常抛进聊天链路)。
99
+ * 返回布尔而不是吞掉:熔断的全部依据就是这份计数,写不进去必须留痕。
100
+ */
79
101
  function saveUsage(runtimeDir, u) {
80
102
  try {
81
103
  writeJsonAtomic(join(runtimeDir, 'cloud-usage.json'), u);
104
+ return true;
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ /**
111
+ * 带体积上限地读完响应文本(成功与失败两条路都走这里)。
112
+ * · 声明了 content-length 且已超限:一个字节都不读,直接判超限;
113
+ * · 没声明或声明偏小:边读边累计,超限立刻 cancel 掉流再抛(不把整份坏响应收进内存);
114
+ * · 没有 body 流(离线测试里的假响应)时退回 res.text(),按 UTF-8 字节数补判上限。
115
+ */
116
+ async function readResponseText(res, maxBytes) {
117
+ const declared = Number(res && res.headers && typeof res.headers.get === 'function'
118
+ ? res.headers.get('content-length') : NaN);
119
+ if (Number.isFinite(declared) && declared > maxBytes) {
120
+ throw new Error('cloud-tts 响应超限 ' + declared + ' > ' + maxBytes + ' 字节');
121
+ }
122
+ const body = res && res.body;
123
+ if (!body || typeof body.getReader !== 'function') {
124
+ const text = String(await res.text());
125
+ const bytes = Buffer.byteLength(text, 'utf8');
126
+ if (bytes > maxBytes)
127
+ throw new Error('cloud-tts 响应超限 ' + bytes + ' > ' + maxBytes + ' 字节');
128
+ return text;
129
+ }
130
+ const reader = body.getReader();
131
+ const chunks = [];
132
+ let total = 0;
133
+ try {
134
+ for (;;) {
135
+ const step = await reader.read();
136
+ if (!step || step.done)
137
+ break;
138
+ if (!step.value)
139
+ continue;
140
+ const buf = Buffer.from(step.value);
141
+ total += buf.length;
142
+ if (total > maxBytes)
143
+ throw new Error('cloud-tts 响应超限 >' + maxBytes + ' 字节');
144
+ chunks.push(buf);
145
+ }
146
+ }
147
+ catch (err) {
148
+ // 读取中途出错(含超时 abort / 超限):把流放掉,别让连接挂着
149
+ try {
150
+ if (typeof reader.cancel === 'function')
151
+ await reader.cancel();
152
+ }
153
+ catch { }
154
+ throw err;
155
+ }
156
+ finally {
157
+ try {
158
+ if (typeof reader.releaseLock === 'function')
159
+ reader.releaseLock();
160
+ }
161
+ catch { }
82
162
  }
83
- catch { }
163
+ return Buffer.concat(chunks).toString('utf8');
84
164
  }
85
165
  /**
86
166
  * 创建云端 TTS 实例。synthesize 失败会抛错(路由据此回退 edge-tts);
@@ -89,10 +169,27 @@ function saveUsage(runtimeDir, u) {
89
169
  export function createCloudTts(deps) {
90
170
  const log = deps.log || (() => { });
91
171
  const clock = deps.now || (() => new Date());
92
- function budgetLeft(cfg) {
172
+ const timeoutRaw = Number(deps.timeoutMs);
173
+ const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : CLOUD_TTS_TIMEOUT_MS;
174
+ /**
175
+ * 进程内兜底计数。
176
+ *
177
+ * 为什么需要:日用量原本只认磁盘上的 cloud-usage.json,而写盘失败(只读目录、磁盘满、
178
+ * 杀软占着句柄)被静默吞掉——实测把 cloud-usage.json 换成目录后,dailyChars=150 的额度
179
+ * 连跑 3 次 100 字的合成全部放行、usage() 还是 0,熔断彻底失效(花的是真金白银)。
180
+ * 现在成功一次就同时记内存与磁盘,取两者的最大值:磁盘写失败时进程内仍能挡住超额,
181
+ * 重启后才有丢计数的风险(写盘成功时磁盘自然追平,不会重复计数)。
182
+ */
183
+ let memUsage = null;
184
+ /** 今天已用字数:磁盘与内存取大(磁盘可能是别的实例写的,内存是本进程的最低保障) */
185
+ function usedChars(today) {
93
186
  const u = readUsage(deps.runtimeDir);
94
- const today = todayKey(clock());
95
- return u.day === today ? cfg.dailyChars - u.chars : cfg.dailyChars;
187
+ const fromDisk = u.day === today ? u.chars : 0;
188
+ const fromMem = memUsage && memUsage.day === today ? memUsage.chars : 0;
189
+ return Math.max(fromDisk, fromMem);
190
+ }
191
+ function budgetLeft(cfg, today) {
192
+ return cfg.dailyChars - usedChars(today);
96
193
  }
97
194
  return {
98
195
  enabled() {
@@ -101,8 +198,8 @@ export function createCloudTts(deps) {
101
198
  },
102
199
  usage() {
103
200
  const cfg = deps.config();
104
- const u = readUsage(deps.runtimeDir);
105
- return { day: u.day, chars: u.chars, limit: cfg.dailyChars };
201
+ const today = todayKey(clock());
202
+ return { day: today, chars: usedChars(today), limit: cfg.dailyChars };
106
203
  },
107
204
  async synthesize(text) {
108
205
  const cfg = deps.config();
@@ -111,8 +208,9 @@ export function createCloudTts(deps) {
111
208
  const body = String(text || '').trim();
112
209
  if (!body)
113
210
  throw new Error('cloud-tts 空文本');
211
+ const today = todayKey(clock());
114
212
  // 日字数熔断:超了直接抛,路由回退 edge-tts
115
- if (budgetLeft(cfg) < body.length) {
213
+ if (budgetLeft(cfg, today) < body.length) {
116
214
  log('[tts] CLOUD-BUDGET 日字数超限(' + cfg.dailyChars + '),回退 edge-tts');
117
215
  throw new Error('cloud-tts 日预算用尽');
118
216
  }
@@ -120,7 +218,7 @@ export function createCloudTts(deps) {
120
218
  const timer = setTimeout(() => { try {
121
219
  ctl && ctl.abort();
122
220
  }
123
- catch { } }, CLOUD_TTS_TIMEOUT_MS);
221
+ catch { } }, timeoutMs);
124
222
  try {
125
223
  const res = await fetch(CLOUD_TTS_URL, {
126
224
  method: 'POST',
@@ -140,11 +238,12 @@ export function createCloudTts(deps) {
140
238
  signal: ctl ? ctl.signal : undefined,
141
239
  });
142
240
  if (!res.ok) {
143
- const t = await res.text().catch(() => '');
241
+ // 错误页也走体积上限:有的网关会回一整页 HTML,读满内存才知道是 500
242
+ const t = await readResponseText(res, CLOUD_TTS_MAX_BYTES).catch(() => '');
144
243
  throw new Error('cloud-tts HTTP ' + res.status + ' ' + t.slice(0, 200));
145
244
  }
146
- // 响应是 chunked 行流;text() 会全部拼好后按行解析
147
- const raw = await res.text();
245
+ // 响应是 chunked 行流;整段读完后按行解析(半行不会跨包被判成坏帧)
246
+ const raw = await readResponseText(res, CLOUD_TTS_MAX_BYTES);
148
247
  const parts = [];
149
248
  for (const line of raw.split('\n')) {
150
249
  const s = line.trim();
@@ -168,10 +267,12 @@ export function createCloudTts(deps) {
168
267
  const buf = Buffer.concat(parts);
169
268
  if (!buf.length)
170
269
  throw new Error('cloud-tts 没有返回音频');
171
- // 记账(成功才记)
172
- const u = readUsage(deps.runtimeDir);
173
- const today = todayKey(clock());
174
- saveUsage(deps.runtimeDir, { day: today, chars: (u.day === today ? u.chars : 0) + body.length });
270
+ // 记账(成功才记):内存与磁盘同时记,磁盘写失败也不能让熔断瞎掉
271
+ const used = usedChars(today) + body.length;
272
+ memUsage = { day: today, chars: used };
273
+ if (!saveUsage(deps.runtimeDir, memUsage)) {
274
+ log('[tts] CLOUD-USAGE-FAIL 日用量写盘失败(进程内计数仍生效,重启后会丢):day=' + today + ' chars=' + used);
275
+ }
175
276
  return buf;
176
277
  }
177
278
  finally {
@@ -7,9 +7,9 @@
7
7
  *
8
8
  * 约定:调用方把已收到的字符串拼进 buffer,交给 parseSseBuffer,
9
9
  * 返回:
10
- * events —— 这一批完整事件里 data 行解析出的对象(非 JSON / 非 data 行会被跳过)
10
+ * events —— 这一批完整事件里 data 行解析出的**对象**(非 JSON / 非 data 行会被跳过)
11
11
  * rest —— 还没收完的尾巴,需拼到下一次
12
- * invalid —— 解析失败的行数(便于发现上游格式变化,不抛异常)
12
+ * invalid —— 解析失败、或解析出来不是对象的行数(便于发现上游格式变化,不抛异常)
13
13
  */
14
14
  export function parseSseBuffer(buffer) {
15
15
  const events = [];
@@ -28,7 +28,14 @@ export function parseSseBuffer(buffer) {
28
28
  if (!payload || payload === '[DONE]')
29
29
  continue;
30
30
  try {
31
- events.push(JSON.parse(payload));
31
+ const v = JSON.parse(payload);
32
+ // 只收对象:`data: null` / 数字 / 字符串都不是本协议的帧。放进去会让调用方
33
+ // (client/chat.ts 直接读 obj.error / obj.delta)在 null 上抛 TypeError,
34
+ // 表现成「流式没接上 → 回退普通 /chat」,白烧一轮模型。这类帧计入 invalid。
35
+ if (v && typeof v === 'object')
36
+ events.push(v);
37
+ else
38
+ invalid++;
32
39
  }
33
40
  catch (e) {
34
41
  invalid++;
@@ -12,14 +12,14 @@
12
12
 
13
13
  | 项 | 值 |
14
14
  | --- | --- |
15
- | `package.json` version | 1.1.58 |
16
- | `src/client/version.ts` VERSION | 1.1.58(由 `scripts/gen-version.mjs` 生成) |
17
- | 最近 Gitee tag | `v1.1.58`(提交 `7510365`) |
18
- | Gitee `main` HEAD | `7510365` |
19
- | GitHub `main` | `900e7a8`;tag `v1.1.58` → `900e7a8`(API 解包核对 1.1.58) |
20
- | npm latest | **1.1.58**(2026-11-07 实读;发布返回 202,约 2–4 分钟后可见)。已发布版本:1.1.48 / 1.1.50 / 1.1.51 / 1.1.53 / 1.1.54 / 1.1.56 / 1.1.57 / 1.1.58;1.1.49、1.1.52、1.1.55 仅 Gitee+GitHub(跳档不影响 `latest`) |
15
+ | `package.json` version | 1.1.62 |
16
+ | `src/client/version.ts` VERSION | 1.1.62(由 `scripts/gen-version.mjs` 生成) |
17
+ | 最近 Gitee tag | `v1.1.62`(见 `git tag --list`) |
18
+ | Gitee `main` HEAD | `git log -1` 实读 |
19
+ | GitHub `main` | 发布后 `node tools/gh-verify.mjs v1.1.62` 核对(脚本按 tag 名推期望版本) |
20
+ | npm latest | **1.1.62**(发布后轮询确认;202 受理到可见约 2–4 分钟) |
21
21
  | README 版本 | 无硬编码版本号 |
22
- | `npm test` | **2723 项**:861 单测 + 59 限额 + 58 流式 + 17 说书安全 + 110 行为回归 + 50 语音契约 + 18 客户端冒烟 + 18 宿主冒烟 + 171 语音队列 + 32 TTS 引擎 + 79 数据层 + 112 仓储 + 83 人格 + 57 说书 + 31 聊天路由 + 33 菜单动作 + 74 说书客户端 + 24 连播开关 + 88 内容路由 + 80 渲染层 + 86 缓存存储 + 37 动作 + 97 杂项路由 + 96 聊天客户端 + 84 配置备份 + 171 媒体提醒 + 59 语音桥 + 32 平台 |
22
+ | `npm test` | **3400 项**(47 个套件,`node tools/test-count.mjs` 核准、失败 0) |
23
23
  | `npm pack --dry-run` | **129 个文件 / 17.1 MB**(包);`data/cache`、运行数据、`config.json`、`src/`、`scripts/`、`tools/` 均不进包 |
24
24
  ## 一、版本号规则
25
25
 
@@ -546,3 +546,13 @@ dsh: warning: 1 entry did not activate
546
546
  - **原因**:`onWindowDone` 可重入,没有在途标志;只有 `if (!running) return`,而 Stop→start 后 `running` 又为 true。
547
547
  - **现在做法**(2026-11-07,B83):`asrBusy` 单飞(**忙则丢,不排队**:识别是「说完马上回话」的交互,排队会让第二句等第一段,断网时队列还会无界);`asrEpoch` 在 `start()`/`stop()`/`stopSpeaking()` 各 +1,`prepare` 之后、`res.json()` 之后、catch 里都校验,过期一律不送、不写缓存、不改状态;`abortAsrFlight()` 负责早退清理。
548
548
  - **取舍要记住**:打断让在途识别作废是规格要求(epoch+1)的必然结果——极端「鱼在说话 + 主人同期开口 + 同时停止说话」时,那句要重说。
549
+
550
+ ## 坑 49:读请求体的「超限」有两种语义,混用会让 413 送不到
551
+
552
+ - **现象**:上传超限时客户端只看到 `ECONNRESET`(连接被重置),永远收不到 413;前端把它当成网络故障。
553
+ - **原因**(B89 修复前):`readBody` 一发现超限就 `abortRequest()` → `destroy()` 再抛错。连接被主动断掉,`sendTooLarge()` 写的 413 根本没机会发出去。
554
+ - **现在两种语义(必须分清)**:
555
+ 1. **声明超限**(`content-length` 已超上限):不读流、**不断连**,直接抛 `BODY_TOO_LARGE`;由调用方回 413(`sendTooLarge` 带 `connection: close` 收尾)。
556
+ 2. **流式超限**(没声明或流比声明大):先在一个小预算(`DRAIN_GRACE`,1MB)内把剩余体读掉,让响应能写出去;预算外才断连。
557
+ - **给新路由的规矩**:判定超限后**必须**用 `sendTooLarge` 回包(它带 `connection: close`)。若改用普通 `sendJson`,未读完的 body 会留在连接里,keep-alive 复用就会错位(下个请求读到上一个的残体)。
558
+ - **别再加「先按 content-length 预判再拒」的 workaround**:`story.ts` 曾为了绕开这个坑自己加了一层预判,现在读取层已修好,预判只是省流量,不再是必需。
@@ -300,3 +300,47 @@ Measure-Command { Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3080/api/
300
300
  2. 统计卡四项(数据源/模型/TTS/备份)不再是恒定假值;
301
301
  3. 朗读中喊「别」能立刻停;换唤醒词后不重开待命也能叫醒;
302
302
  4. 说书连播 + 缩放拖拽均正常(缩放不会在拖动后丢失)。
303
+
304
+ ## 十七、2026-11-07 凌晨批次(B78–B88:1.1.59 → 1.1.60)待重启核对
305
+
306
+ > 覆盖:云端 TTS 真接线、ASR 单飞与纪元、叙述锁持有者、读图超时、数据端口收口。
307
+ > 客户端改动刷新(`Ctrl+F5`);宿主改动(index/routes/data)需重启 dsh web。
308
+
309
+ | # | 改动 | 涉及文件 | 为什么 | 怎么验证 |
310
+ |---|------|----------|--------|----------|
311
+ | 1 | 云端 TTS 真的注入 | `src/index.ts`、`host/routes/tts.ts` | `cloudTts` 构造后从未注入,配了 Key 也从没走过云端(三层降级只有两层) | 配了 `cloudVoice.apiKey` 的主人重启后说一句话,日志应出现云端合成而非 edge-tts;失败仍回退 edge-tts |
312
+ | 2 | TTS 常驻 worker 由 `ctx.effect` 回收 | `src/index.ts` | 原来 `dispose()` 传不出来,只能等 5 分钟 idle | 反复禁用/启用插件,`tasklist` 里不应堆积 python 进程 |
313
+ | 3 | ASR 单飞 + 纪元 | `client/voice.ts` | 极短时间两段录音会并发识别;Stop→start 后旧话照送;P0 打断不作废在途识别 | 快速说两句:只应识别一次并回一次;说书时打断再说话,旧话不应被补送 |
314
+ | 4 | 叙述锁持有者令牌 | `client.ts`、`actions/culture.ts` | 诗尾 onDone 会解掉说书的锁(说书段无锁、气泡不更新、麦克风提前拾音) | 念诗播到一半点「讲段书」:说书继续、气泡照更新、麦克风不抢话 |
315
+ | 5 | 念诗 per-call 令牌 | `actions/culture.ts` | 同一使用方连念两首,旧诗的迟到回调会清新诗的锁 | 连点两次「念首诗」:第一首的回调不应打断第二首的进度 |
316
+ | 6 | `sayLine` 改 `ensure()` | `client.ts` | 待命关着刷新后,开场问候/情绪台词只有气泡没声 | 关待命→刷新→触发一句人设台词:应有声音(关「语音朗读」仍全静) |
317
+ | 7 | 读图 30s 超时 | `client/image.ts` | `FileReader` 永不回调时气泡卡在「本鲸看看这张图…」 | 拖一张大图/被占用文件:30s 内应给出失败提示而不是一直转 |
318
+ | 8 | 诗名/曲库走 DataPort | `host/data/*`、`routes/poem-titles.ts`、`culture.ts`、`index.ts` | 两处路由直读 `data/`,违反 ADR 0002;且 `poem-titles` 未注入 `dataPort` | 重启后点「念首诗」「讲个典故」:诗名索引与曲库正常(走端口),不再依赖文件回退 |
319
+
320
+ ### 重启后自检(4 条)
321
+
322
+ 1. 菜单标题栏版本显示 **1.1.60**;
323
+ 2. 配了云端语音 Key 时,日志出现云端合成;
324
+ 3. 念诗↔说书互不抢锁;待命关刷新后问候仍有声;
325
+ 4. 快速连说两句只回一次(ASR 单飞生效)。
326
+
327
+ ## 十八、2026-11-07 凌晨批次(B89–B90:1.1.61 → 1.1.62)待重启核对
328
+
329
+ > 覆盖:413 送达、BOM、SSE 非对象帧、点播意图、APE 判停、云端 TTS 成本熔断、情绪定时器。
330
+ > 客户端刷新(`Ctrl+F5`);宿主改动(http/body/respond/cloud-tts/persona/pet)需重启 dsh web。
331
+
332
+ | # | 改动 | 为什么 | 怎么验证 |
333
+ |---|------|--------|----------|
334
+ | 1 | 超限请求能收到 413 | 原来先 `destroy()` 再回包,客户端只看到连接重置 | 拖一个超大文件导入备份:应提示「太大」而不是网络错误 |
335
+ | 2 | 带 BOM 的 JSON 能解析 | `JSON.parse` 遇 BOM 必抛,带 BOM 的备份包被判 400 | 用 Windows 记事本另存的备份包导入:应成功 |
336
+ | 3 | 非对象 SSE 帧不再回退 | `data: null` 让客户端读 `.error` 抛错 → 白回退一轮 `/chat` | 正常对话不出现「白烧一轮」的延迟 |
337
+ | 4 | 点播意图收紧 | 「换个说法/换个故事/换个声音」被当成念诗 | 说「换个说法」:应回答而不是念诗 |
338
+ | 5 | 云端 TTS 成本熔断可靠 | 用量文件坏掉后限制彻底失效(fail-open) | 把 `cloud-usage.json` 改成目录后再请求:应仍受限并留 `CLOUD-USAGE-FAIL` |
339
+ | 6 | APE 判停不被哨兵带偏 | 首帧 -160 会让噪声底永远回不来(每段等满 6 秒) | 切「自适应」端点后正常说话:停顿应在 1–2 秒内收 |
340
+ | 7 | 情绪定时器可清理 | `setMood` 的回落定时器没有 dispose,组件销毁后还会改状态 | 刷新/卸载页面后不再有残留情绪切换 |
341
+
342
+ ### 重启后自检(3 条)
343
+
344
+ 1. 菜单版本显示 **1.1.62**;
345
+ 2. 超大文件导入提示「太大」而不是网络错误;
346
+ 3. 说「换个说法」不会被念诗打断。
@@ -130,3 +130,11 @@
130
130
  | B86 | 2026-11-07 | 主代理:文档口径(坑 47 叙述锁持有者 / 坑 48 ASR 单飞与纪元;并发纪律「纪元覆盖对话与 ASR」+ `clearNarrate` 只认持有者);`VoiceStats.asrBusyDropped` 计入丢段 | 2845→2846 项 0 失败 | 4dddf98 / 4373330 |
131
131
  | B87 | 2026-11-07 | 数据端口分身:`DataPort` 增可选 `readPoemTitles`/`readMelodies`(本地 mtime 缓存+BOM 容错+拷贝隔离,云端声明即拉/缺失回退);`poem-titles`/`culture` 端口优先、文件回退仅限方法缺失;`index.ts` 补注入 `dataPort`;`test-data-layer` 端口面 6→8;新增 `test-dataport-poem.mjs`(56) | 双 tsc 0 / 2936 项 0 失败 | 见 git |
132
132
  | B88 | 2026-11-07 | 念诗/待命分身:`poemGen` per-call 令牌(旧诗 onDone 不再清新诗的锁)/ `sayLine` 改 `bridge.ensure()`(待命关刷新后问候有声音)/ speak 抛错补 `release()` / 语音清理器不依赖 `earOn`;新增 `test-poem-narration.mjs`(34,含 3 条收尾契约) | 双 tsc 0 / 2936 项 0 失败 | 见 git |
133
+ | B89a | 2026-11-07 | 宿主 HTTP 分身:413 永远送不到(超限先 destroy)/ UTF-8 BOM 变空请求 / 二次写抛 `ERR_HTTP_HEADERS_SENT`;新增 `test-host-http.mjs`(32) | 双 tsc 0 / 3258 项 0 失败 | 见 git |
134
+ | B89b | 2026-11-07 | 客户端网络分身:非对象 SSE 帧进 events(null 让 chat 读 `.error` 抛错白回退一轮)/ 恢复快捷键 keyCode 兜底越权;新增 `test-net-client.mjs`(122+4 变异体) | 双 tsc 0 / 3258 项 0 失败 | 见 git |
135
+ | B89c | 2026-11-07 | 纯函数工具分身:切句器句首标点卡死 / 书名劫持陈述 / 否定句反着做 / 脏 fallback 透传 TTS / `lock(0)` 闭麦 60s 与 expire 界线 / 对仗分类 trim;新增 `test-utils-pure.mjs`(153) | 双 tsc 0 / 3258 项 0 失败 | 见 git |
136
+ | B89d | 2026-11-07 | 主代理:`/chat` 超时补 `TimeoutError`(23) / endpoint 只留 setter 一个写入口 / 多文件拖入先提示「一次一张」+ `test-multidrop.mjs`(10) / 坑 49 两种超限语义 / story 超限注释改真 | 双 tsc 0 / 3258 项 0 失败 | 见 git |
137
+ | B90a | 2026-11-07 | 主代理:`stripStageDirections` 去掉 60 字上限 / `createSentenceBuffer` 硬切不劈代理对(契约同步)/ 语速读侧 trim | 双 tsc 0 / 3400 项 0 失败 | 见 git |
138
+ | B90b | 2026-11-07 | 人格与云端语音分身:阶段语气防原型链 / 云端响应 16MB 上限(声明·流式·错误页)/ 记账写盘失败不再 fail-open(进程内兜底);新增 `test-persona-cloud.mjs`(139+4 变异体) | 双 tsc 0 / 3400 项 0 失败 | 见 git |
139
+ | B90c | 2026-11-07 | 客户端粘合分身:点播劫持追问 / 句末标点整句失效 / 典故成语播报反了 / APE 判停 NaN 与 -160 哨兵 / `steadyWindowFrames` 写死 8 / 气泡 trim / `useLazyRef` 假值重建;新增 `test-client-glue.mjs`(113+6 变异体) | 双 tsc 0 / 3400 项 0 失败 | 见 git |
140
+ | B90d | 2026-11-07 | 主代理:客户端情绪定时器加 dispose 与 holdMs 夹紧 / 碗数负数归零 / `pet.setMood` 情绪白名单 | 双 tsc 0 / 3400 项 0 失败 | 见 git |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-chubby-whale",
3
- "version": "1.1.60",
3
+ "version": "1.1.62",
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-dataport-poem.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-image-reader.mjs && node scripts/test-boundaries-guard.mjs && node scripts/test-assembly.mjs && node scripts/test-asr-concurrency.mjs && node scripts/test-entry-narration.mjs && node scripts/test-poem-narration.mjs && node scripts/test-voice-bridge.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-dataport-poem.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-image-reader.mjs && node scripts/test-boundaries-guard.mjs && node scripts/test-assembly.mjs && node scripts/test-asr-concurrency.mjs && node scripts/test-host-http.mjs && node scripts/test-entry-narration.mjs && node scripts/test-poem-narration.mjs && node scripts/test-multidrop.mjs && node scripts/test-utils-pure.mjs && node scripts/test-net-client.mjs && node scripts/test-persona-cloud.mjs && node scripts/test-client-glue.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",