@linxin666/dsh-pet 0.2.3 → 0.2.5

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.
Files changed (87) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +65 -2
  3. package/README.zh.md +65 -2
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/contracts/pet-manifest-v2.schema.json +281 -0
  7. package/contracts/status-decoration-v1.schema.json +144 -0
  8. package/contracts/voice-pack-v1.schema.json +234 -0
  9. package/lib/client.js +251 -43
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +1139 -100
  12. package/lib/types/access.d.ts +16 -0
  13. package/lib/types/access.d.ts.map +1 -0
  14. package/lib/types/access.js +20 -0
  15. package/lib/types/chatter.d.ts +61 -3
  16. package/lib/types/chatter.d.ts.map +1 -1
  17. package/lib/types/chatter.js +71 -12
  18. package/lib/types/client/PetSettingsCard.d.ts +4 -0
  19. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PetSettingsCard.js +11 -3
  21. package/lib/types/client/PetSprite.d.ts.map +1 -1
  22. package/lib/types/client/PetSprite.js +111 -4
  23. package/lib/types/client/locales.d.ts +4 -0
  24. package/lib/types/client/locales.d.ts.map +1 -1
  25. package/lib/types/client/locales.js +4 -0
  26. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
  27. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
  28. package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
  29. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
  30. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  31. package/lib/types/client/renderers/live2d.js +133 -25
  32. package/lib/types/client/settings-form.d.ts.map +1 -1
  33. package/lib/types/client/settings-form.js +9 -6
  34. package/lib/types/contracts/status-decoration.d.ts +85 -0
  35. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  36. package/lib/types/contracts/status-decoration.js +21 -0
  37. package/lib/types/decoration.d.ts +39 -0
  38. package/lib/types/decoration.d.ts.map +1 -0
  39. package/lib/types/decoration.js +210 -0
  40. package/lib/types/event-projection.d.ts +8 -3
  41. package/lib/types/event-projection.d.ts.map +1 -1
  42. package/lib/types/event-projection.js +9 -4
  43. package/lib/types/image-dimensions.d.ts +26 -0
  44. package/lib/types/image-dimensions.d.ts.map +1 -0
  45. package/lib/types/image-dimensions.js +77 -0
  46. package/lib/types/index.d.ts +2 -0
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +3 -1
  49. package/lib/types/registry.d.ts +55 -2
  50. package/lib/types/registry.d.ts.map +1 -1
  51. package/lib/types/registry.js +240 -16
  52. package/lib/types/routes.d.ts +6 -1
  53. package/lib/types/routes.d.ts.map +1 -1
  54. package/lib/types/routes.js +163 -32
  55. package/lib/types/service.d.ts +33 -0
  56. package/lib/types/service.d.ts.map +1 -1
  57. package/lib/types/service.js +42 -3
  58. package/lib/types/voice-pack.d.ts +98 -0
  59. package/lib/types/voice-pack.d.ts.map +1 -0
  60. package/lib/types/voice-pack.js +384 -0
  61. package/package.json +11 -10
  62. package/src/access.ts +40 -0
  63. package/src/chatter.test.ts +89 -2
  64. package/src/chatter.ts +120 -14
  65. package/src/client/PetSettingsCard.tsx +26 -2
  66. package/src/client/PetSprite.test.tsx +325 -12
  67. package/src/client/PetSprite.tsx +150 -25
  68. package/src/client/locales.ts +4 -0
  69. package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
  70. package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
  71. package/src/client/renderers/live2d/runtime.ts +8 -2
  72. package/src/client/renderers/live2d.test.ts +233 -9
  73. package/src/client/renderers/live2d.ts +143 -30
  74. package/src/client/settings-form.ts +8 -6
  75. package/src/contracts/status-decoration.ts +78 -0
  76. package/src/decoration.test.ts +178 -0
  77. package/src/decoration.ts +220 -0
  78. package/src/event-projection.ts +10 -5
  79. package/src/image-dimensions.test.ts +85 -0
  80. package/src/image-dimensions.ts +76 -0
  81. package/src/index.ts +3 -1
  82. package/src/registry.test.ts +276 -0
  83. package/src/registry.ts +274 -15
  84. package/src/routes.ts +162 -33
  85. package/src/service.ts +59 -3
  86. package/src/voice-pack.test.ts +216 -0
  87. package/src/voice-pack.ts +413 -0
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@ import { _ as RemarkPicker, a as AFFINITY_MAX, c as applyInteraction, d as empty
2
2
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
3
  import z from "schemastery";
4
4
  import { Service } from "@deepseek-ai/cordis";
5
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
5
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
6
6
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { fileURLToPath } from "node:url";
@@ -145,6 +145,40 @@ const STATUS_POOLS = {
145
145
  "蹲一个继续的指令"
146
146
  ]
147
147
  };
148
+ /** Every status scene key, in declaration order (voice-pack key allow-list). */
149
+ const STATUS_SCENES = [
150
+ "prepare",
151
+ "waiting",
152
+ "thinking",
153
+ "review",
154
+ "toolResult",
155
+ "done",
156
+ "failed",
157
+ "toolFailed",
158
+ "maxTokens",
159
+ "interrupted",
160
+ "blocked"
161
+ ];
162
+ /** Every tool-family key, in declaration order (voice-pack key allow-list). */
163
+ const TOOL_CATEGORIES = [
164
+ "read",
165
+ "write",
166
+ "edit",
167
+ "shell",
168
+ "grep",
169
+ "find",
170
+ "ls",
171
+ "webSearch",
172
+ "webFetch",
173
+ "mcp",
174
+ "memory",
175
+ "subagent",
176
+ "todo",
177
+ "browser",
178
+ "git",
179
+ "ask",
180
+ "generic"
181
+ ];
148
182
  /** Map a raw tool name onto its copy family (working-activity style regexes). */
149
183
  function toolCategory(toolName) {
150
184
  const name = toolName.toLowerCase();
@@ -420,12 +454,14 @@ function toolArgHint(toolName, argumentsJson) {
420
454
  * stretch keeps changing its wording.
421
455
  */
422
456
  var StatusVoice = class {
457
+ pools;
423
458
  rotateMs;
424
459
  counters = /* @__PURE__ */ new Map();
425
460
  lastScene = "";
426
461
  lastLine = "";
427
462
  lastLineAt = Number.NEGATIVE_INFINITY;
428
- constructor(rotateMs = STATUS_ROTATE_MS) {
463
+ constructor(pools = () => BUILTIN_VOICE_PACK, rotateMs = STATUS_ROTATE_MS) {
464
+ this.pools = pools;
429
465
  this.rotateMs = rotateMs;
430
466
  }
431
467
  /** Draw the next line of one pool, advancing its round-robin cursor. */
@@ -442,18 +478,31 @@ var StatusVoice = class {
442
478
  this.lastLineAt = nowMs;
443
479
  return this.lastLine;
444
480
  }
481
+ /**
482
+ * A scene's effective pool: the voice-pack override when it carries lines,
483
+ * else the built-in pool. Empty overrides fall back rather than blank the
484
+ * bubble — a scene line always renders.
485
+ */
486
+ scenePool(scene) {
487
+ const override = this.pools().status?.[scene];
488
+ return override !== void 0 && override.length > 0 ? override : STATUS_POOLS[scene];
489
+ }
445
490
  /** Status line for a phase scene. */
446
491
  scene(scene, nowMs) {
447
- return this.voice("scene:" + scene, "pool:" + scene, STATUS_POOLS[scene], nowMs);
492
+ return this.voice("scene:" + scene, "pool:" + scene, this.scenePool(scene), nowMs);
448
493
  }
449
494
  /** Status line for a tool call, with the real-argument hint when known. */
450
495
  tool(toolName, displayName, hint, nowMs) {
451
496
  const category = toolCategory(toolName);
452
- return this.voice("tool:" + category, "tool:" + category, TOOL_POOLS[category], nowMs).replace("{tool}", displayName).replace("{hint}", hint ?? displayName);
497
+ const override = this.pools().tools?.[category];
498
+ const pool = override !== void 0 && override.length > 0 ? override : TOOL_POOLS[category];
499
+ return this.voice("tool:" + category, "tool:" + category, pool, nowMs).replaceAll("{tool}", displayName).replaceAll("{hint}", hint ?? displayName);
453
500
  }
454
501
  /** Status line while sibling tools still run (always reflects the count). */
455
502
  toolRemaining(count, nowMs) {
456
- return this.voice("toolRemaining", "toolRemaining", TOOL_REMAINING_POOL, nowMs).replace("{n}", String(count));
503
+ const override = this.pools().toolRemaining;
504
+ const pool = override !== void 0 && override.length > 0 ? override : TOOL_REMAINING_POOL;
505
+ return this.voice("toolRemaining", "toolRemaining", pool, nowMs).replaceAll("{n}", String(count));
457
506
  }
458
507
  };
459
508
  /** Murmur pacing: cooldown between whispers and output volume that earns one. */
@@ -786,26 +835,52 @@ const WHISPER_RULES = [
786
835
  ]
787
836
  }
788
837
  ];
838
+ /** The built-in voice pack: the plugin's default copy, unchanged since v1. */
839
+ const BUILTIN_VOICE_PACK = {
840
+ status: STATUS_POOLS,
841
+ tools: TOOL_POOLS,
842
+ toolRemaining: TOOL_REMAINING_POOL,
843
+ whispers: {
844
+ generic: WHISPER_GENERIC_POOL,
845
+ rules: WHISPER_RULES
846
+ }
847
+ };
789
848
  /**
790
849
  * The murmur engine (碎碎念): watches the model's own output and lets the pet
791
850
  * whisper its inner voice. Two ways to earn a whisper:
792
851
  * - a keyword rule matches the fresh chunk text (themed whisper);
793
852
  * - enough output volume flowed by without one (ambient whisper).
794
853
  * A cooldown keeps whispers occasional; all picks are round-robin so tests
795
- * reproduce exact lines.
854
+ * reproduce exact lines. The voice-pack provider (pet-center M4) swaps the
855
+ * pools at draw time, so a pet switch re-voices live engines in place.
796
856
  */
797
857
  var WhisperEngine = class {
858
+ pools;
798
859
  cooldownMs;
799
860
  charBudget;
800
861
  counters = /* @__PURE__ */ new Map();
801
862
  genericCursor = 0;
802
863
  lastWhisperAt = Number.NEGATIVE_INFINITY;
803
864
  charsSinceWhisper = 0;
804
- constructor(cooldownMs = WHISPER_COOLDOWN_MS, charBudget = 420) {
865
+ constructor(pools = () => BUILTIN_VOICE_PACK, cooldownMs = WHISPER_COOLDOWN_MS, charBudget = 420) {
866
+ this.pools = pools;
805
867
  this.cooldownMs = cooldownMs;
806
868
  this.charBudget = charBudget;
807
869
  }
808
870
  /**
871
+ * Effective keyword rules: an override replaces the built-in rules as a
872
+ * whole; an explicit empty array disables keyword-triggered whispers.
873
+ */
874
+ rules() {
875
+ const override = this.pools().whispers?.rules;
876
+ return override === void 0 ? WHISPER_RULES : override;
877
+ }
878
+ /** Effective ambient pool (an explicit empty array mutes ambient whispers). */
879
+ generic() {
880
+ const override = this.pools().whispers?.generic;
881
+ return override === void 0 ? WHISPER_GENERIC_POOL : override;
882
+ }
883
+ /**
809
884
  * Feed one model-output chunk (reasoning or text). Returns the whisper to
810
885
  * show, or undefined when the moment stays quiet.
811
886
  */
@@ -816,8 +891,9 @@ var WhisperEngine = class {
816
891
  return;
817
892
  }
818
893
  const haystack = text.toLowerCase();
819
- for (let ruleIndex = 0; ruleIndex < WHISPER_RULES.length; ruleIndex += 1) {
820
- const rule = WHISPER_RULES[ruleIndex];
894
+ const rules = this.rules();
895
+ for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex += 1) {
896
+ const rule = rules[ruleIndex];
821
897
  if (!rule.keywords.some((keyword) => haystack.includes(keyword))) continue;
822
898
  const index = (this.counters.get(ruleIndex) ?? 0) % rule.pool.length;
823
899
  this.counters.set(ruleIndex, index + 1);
@@ -825,7 +901,9 @@ var WhisperEngine = class {
825
901
  }
826
902
  this.charsSinceWhisper += text.length;
827
903
  if (this.charsSinceWhisper < this.charBudget) return void 0;
828
- const line = WHISPER_GENERIC_POOL[this.genericCursor % WHISPER_GENERIC_POOL.length];
904
+ const generic = this.generic();
905
+ if (generic.length === 0) return void 0;
906
+ const line = generic[this.genericCursor % generic.length];
829
907
  this.genericCursor += 1;
830
908
  return this.speak(line, nowMs);
831
909
  }
@@ -837,14 +915,19 @@ var WhisperEngine = class {
837
915
  };
838
916
  //#endregion
839
917
  //#region src/event-projection.ts
840
- /** Fresh projection runtime for a newly seen session. */
841
- function emptyProjectionRuntime() {
918
+ /**
919
+ * Fresh projection runtime for a newly seen session. The optional voice-pack
920
+ * provider (pet-center M4, issue #677) hands both chatter engines their
921
+ * pools; engines resolve overrides at draw time, so swapping the provider's
922
+ * pack re-voices live runtimes without rebuilding them.
923
+ */
924
+ function emptyProjectionRuntime(pools) {
842
925
  return {
843
926
  activeTools: /* @__PURE__ */ new Set(),
844
927
  officialEventsSeen: false,
845
928
  stepHadFailure: false,
846
- voice: new StatusVoice(),
847
- whispers: new WhisperEngine()
929
+ voice: new StatusVoice(pools),
930
+ whispers: new WhisperEngine(pools)
848
931
  };
849
932
  }
850
933
  /** Keep tool names readable inside the compact status bubble. */
@@ -1373,6 +1456,307 @@ function savePetPersist(data, dir = petHomeDir()) {
1373
1456
  writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
1374
1457
  renameSync(tmp, target);
1375
1458
  }
1459
+ /** Hover-panel action buttons a pack can show or hide (canonical order). */
1460
+ const PANEL_ACTIONS = [
1461
+ "feed",
1462
+ "rename",
1463
+ "hide"
1464
+ ];
1465
+ /** Panel label slots (unset slots keep the client's i18n dictionary copy). */
1466
+ const PANEL_LABEL_KEYS = [
1467
+ "feed",
1468
+ "rename",
1469
+ "hide",
1470
+ "confirm"
1471
+ ];
1472
+ /** Panel stat slots ({rank}/{n}/{points} interpolate the live values). */
1473
+ const PANEL_STAT_KEYS = [
1474
+ "rank",
1475
+ "treats",
1476
+ "points"
1477
+ ];
1478
+ /** Any '{token}' placeholder (no nesting, no newlines). */
1479
+ const PLACEHOLDER_PATTERN = /{[^{}]*}/g;
1480
+ /** Allowed placeholder tokens per pool kind (absent kind = none allowed). */
1481
+ const PLACEHOLDER_WHITELIST = {
1482
+ tools: ["{tool}", "{hint}"],
1483
+ toolRemaining: ["{n}"],
1484
+ stat: [
1485
+ "{rank}",
1486
+ "{n}",
1487
+ "{points}"
1488
+ ]
1489
+ };
1490
+ function isRecord$2(value) {
1491
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1492
+ }
1493
+ /** Trim, length-cap and placeholder-check one copy line; undefined to drop. */
1494
+ function normalizeLine(raw, kind, onWarning) {
1495
+ const trimmed = raw.trim();
1496
+ if (trimmed === "") return void 0;
1497
+ let capped = trimmed.length > 160 ? trimmed.slice(0, 160) : trimmed;
1498
+ const dangling = capped.lastIndexOf("{");
1499
+ if (dangling !== -1 && capped.indexOf("}", dangling) === -1) {
1500
+ onWarning("line cut at an unterminated placeholder; tail dropped: " + capped.slice(0, 40) + "...");
1501
+ capped = capped.slice(0, dangling);
1502
+ }
1503
+ if (capped === "") return void 0;
1504
+ const allowed = PLACEHOLDER_WHITELIST[kind];
1505
+ const tokens = capped.match(PLACEHOLDER_PATTERN) ?? [];
1506
+ for (const token of tokens) {
1507
+ if (allowed?.includes(token) === true) continue;
1508
+ const preview = capped.length > 40 ? capped.slice(0, 40) + "..." : capped;
1509
+ onWarning("line dropped (unsupported placeholder " + token + "): " + preview);
1510
+ return;
1511
+ }
1512
+ return capped;
1513
+ }
1514
+ /**
1515
+ * Normalize one pool slot. Accepts a single line or an array; non-string
1516
+ * entries warn and drop, empty lines drop silently, lines over the length
1517
+ * cap truncate, illegal placeholders drop the line, and pools over the line
1518
+ * cap keep their first lines. An explicit empty pool normalizes to [] (the
1519
+ * whisper channels read that as mute) while an absent slot normalizes to
1520
+ * undefined (the slot keeps the built-in pool).
1521
+ */
1522
+ function normalizePool(raw, kind, onWarning = () => {}) {
1523
+ if (raw === void 0) return void 0;
1524
+ const entries = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : void 0;
1525
+ if (entries === void 0) {
1526
+ onWarning("pool must be a string or an array of strings");
1527
+ return;
1528
+ }
1529
+ if (entries.length > 64) onWarning("pool has more than 64 lines; extra lines are ignored");
1530
+ const pool = [];
1531
+ for (const entry of entries.slice(0, 64)) {
1532
+ if (typeof entry !== "string") {
1533
+ onWarning("non-string pool entry dropped");
1534
+ continue;
1535
+ }
1536
+ const line = normalizeLine(entry, kind, onWarning);
1537
+ if (line !== void 0) pool.push(line);
1538
+ }
1539
+ return pool;
1540
+ }
1541
+ /** Normalize the ordered keyword-rule list ([] disables the keyword channel). */
1542
+ function normalizeWhisperRules(raw, onWarning = () => {}) {
1543
+ if (raw === void 0) return void 0;
1544
+ if (!Array.isArray(raw)) {
1545
+ onWarning("whispers.rules must be an array");
1546
+ return;
1547
+ }
1548
+ if (raw.length > 32) onWarning("whispers.rules has more than 32 rules; extra rules are ignored");
1549
+ const rules = [];
1550
+ for (const item of raw.slice(0, 32)) {
1551
+ if (!isRecord$2(item)) {
1552
+ onWarning("whisper rule must be an object");
1553
+ continue;
1554
+ }
1555
+ for (const key of Object.keys(item)) if (key !== "keywords" && key !== "pool") onWarning("unknown whisper rule field " + key + " ignored");
1556
+ const keywordsRaw = item.keywords;
1557
+ const keywords = [];
1558
+ if (Array.isArray(keywordsRaw)) {
1559
+ for (const entry of keywordsRaw.slice(0, 16)) {
1560
+ if (typeof entry !== "string") {
1561
+ onWarning("non-string keyword dropped");
1562
+ continue;
1563
+ }
1564
+ const trimmed = entry.trim().toLowerCase().slice(0, 40);
1565
+ if (trimmed !== "") keywords.push(trimmed);
1566
+ }
1567
+ if (keywordsRaw.length > 16) onWarning("rule has more than 16 keywords; extra keywords are ignored");
1568
+ }
1569
+ const pool = normalizePool(item.pool, "whisperRule", onWarning);
1570
+ if (keywords.length === 0 || pool === void 0 || pool.length === 0) {
1571
+ onWarning("whisper rule dropped (needs keywords and a non-empty pool)");
1572
+ continue;
1573
+ }
1574
+ rules.push({
1575
+ keywords,
1576
+ pool
1577
+ });
1578
+ }
1579
+ return rules;
1580
+ }
1581
+ /** Normalize the panel block (labels / stats / actions; warn-and-drop). */
1582
+ function normalizePanel(raw, onWarning = () => {}) {
1583
+ if (!isRecord$2(raw)) {
1584
+ onWarning("panel must be an object");
1585
+ return;
1586
+ }
1587
+ const panel = {};
1588
+ const labelsRaw = raw.labels;
1589
+ if (labelsRaw !== void 0) if (!isRecord$2(labelsRaw)) onWarning("panel.labels must be an object");
1590
+ else {
1591
+ const labels = {};
1592
+ for (const key of PANEL_LABEL_KEYS) {
1593
+ const value = labelsRaw[key];
1594
+ if (value === void 0) continue;
1595
+ if (typeof value !== "string") {
1596
+ onWarning("panel.labels." + key + " must be a string");
1597
+ continue;
1598
+ }
1599
+ const line = normalizeLine(value, "label", onWarning);
1600
+ if (line !== void 0) labels[key] = line.slice(0, 40);
1601
+ }
1602
+ if (Object.keys(labels).length > 0) panel.labels = labels;
1603
+ }
1604
+ const statsRaw = raw.stats;
1605
+ if (statsRaw !== void 0) if (!isRecord$2(statsRaw)) onWarning("panel.stats must be an object");
1606
+ else {
1607
+ const stats = {};
1608
+ for (const key of PANEL_STAT_KEYS) {
1609
+ const value = statsRaw[key];
1610
+ if (value === void 0) continue;
1611
+ if (typeof value !== "string") {
1612
+ onWarning("panel.stats." + key + " must be a string");
1613
+ continue;
1614
+ }
1615
+ const line = normalizeLine(value, "stat", onWarning);
1616
+ if (line !== void 0) stats[key] = line.slice(0, 80);
1617
+ }
1618
+ if (Object.keys(stats).length > 0) panel.stats = stats;
1619
+ }
1620
+ const actionsRaw = raw.actions;
1621
+ if (actionsRaw !== void 0) if (!Array.isArray(actionsRaw)) onWarning("panel.actions must be an array");
1622
+ else {
1623
+ const seen = /* @__PURE__ */ new Set();
1624
+ for (const entry of actionsRaw) {
1625
+ if (typeof entry !== "string" || !PANEL_ACTIONS.includes(entry)) {
1626
+ onWarning("unknown panel action dropped: " + String(entry));
1627
+ continue;
1628
+ }
1629
+ seen.add(entry);
1630
+ }
1631
+ panel.actions = PANEL_ACTIONS.filter((action) => seen.has(action));
1632
+ }
1633
+ if (panel.labels === void 0 && panel.stats === void 0 && panel.actions === void 0) return void 0;
1634
+ return panel;
1635
+ }
1636
+ /** Voice-pack top-level fields ('$schema' mirrors the schema twin; drift-locked in tests). */
1637
+ const VOICE_PACK_KEYS = /* @__PURE__ */ new Set([
1638
+ "$schema",
1639
+ "voicePackVersion",
1640
+ "status",
1641
+ "tools",
1642
+ "toolRemaining",
1643
+ "whispers",
1644
+ "panel"
1645
+ ]);
1646
+ /** Allowed whisper-section fields (drift-locked in tests). */
1647
+ const WHISPER_KEYS = /* @__PURE__ */ new Set(["generic", "rules"]);
1648
+ /**
1649
+ * Normalize one raw voice.json document into a VoicePack, or undefined when
1650
+ * the file cannot serve as a pack at all (non-object root — structure is
1651
+ * fail-closed per file). Every slot issue is a warning, never a throw.
1652
+ */
1653
+ function normalizeVoicePack(raw, onWarning = () => {}) {
1654
+ if (raw === void 0) return void 0;
1655
+ if (!isRecord$2(raw)) {
1656
+ onWarning("voice.json must be a JSON object; the file is ignored");
1657
+ return;
1658
+ }
1659
+ for (const key of Object.keys(raw)) if (!VOICE_PACK_KEYS.has(key)) onWarning("unknown top-level field " + key + " ignored");
1660
+ const version = raw.voicePackVersion;
1661
+ if (version !== void 0 && (typeof version !== "number" || version !== 1)) onWarning("voicePackVersion " + String(version) + " is not supported; reading as v1 best-effort");
1662
+ const overrides = {};
1663
+ const statusRaw = raw.status;
1664
+ if (statusRaw !== void 0) if (!isRecord$2(statusRaw)) onWarning("status must be an object");
1665
+ else for (const key of Object.keys(statusRaw)) {
1666
+ if (!STATUS_SCENES.includes(key)) {
1667
+ onWarning("unknown status scene " + key + " ignored");
1668
+ continue;
1669
+ }
1670
+ const pool = normalizePool(statusRaw[key], "status", onWarning);
1671
+ if (pool !== void 0 && pool.length > 0) overrides.status = {
1672
+ ...overrides.status,
1673
+ [key]: pool
1674
+ };
1675
+ }
1676
+ const toolsRaw = raw.tools;
1677
+ if (toolsRaw !== void 0) if (!isRecord$2(toolsRaw)) onWarning("tools must be an object");
1678
+ else for (const key of Object.keys(toolsRaw)) {
1679
+ if (!TOOL_CATEGORIES.includes(key)) {
1680
+ onWarning("unknown tool family " + key + " ignored");
1681
+ continue;
1682
+ }
1683
+ const pool = normalizePool(toolsRaw[key], "tools", onWarning);
1684
+ if (pool !== void 0 && pool.length > 0) overrides.tools = {
1685
+ ...overrides.tools,
1686
+ [key]: pool
1687
+ };
1688
+ }
1689
+ const remainingRaw = raw.toolRemaining;
1690
+ if (remainingRaw !== void 0) {
1691
+ const pool = normalizePool(remainingRaw, "toolRemaining", onWarning);
1692
+ if (pool !== void 0 && pool.length > 0) overrides.toolRemaining = pool;
1693
+ }
1694
+ const whispersRaw = raw.whispers;
1695
+ if (whispersRaw !== void 0) if (!isRecord$2(whispersRaw)) onWarning("whispers must be an object");
1696
+ else {
1697
+ for (const key of Object.keys(whispersRaw)) if (!WHISPER_KEYS.has(key)) onWarning("unknown whispers field " + key + " ignored");
1698
+ const generic = normalizePool(whispersRaw.generic, "whisperGeneric", onWarning);
1699
+ const rules = normalizeWhisperRules(whispersRaw.rules, onWarning);
1700
+ if (generic !== void 0 || rules !== void 0) overrides.whispers = {
1701
+ ...generic === void 0 ? {} : { generic },
1702
+ ...rules === void 0 ? {} : { rules }
1703
+ };
1704
+ }
1705
+ const panel = raw.panel === void 0 ? void 0 : normalizePanel(raw.panel, onWarning);
1706
+ if (overrides.status === void 0 && overrides.tools === void 0 && overrides.toolRemaining === void 0 && overrides.whispers === void 0 && panel === void 0) return;
1707
+ return {
1708
+ overrides,
1709
+ ...panel === void 0 ? {} : { panel }
1710
+ };
1711
+ }
1712
+ /**
1713
+ * Merge voice-pack layers into one pack; later layers win per slot. The
1714
+ * built-in pools are NOT a layer here — the chatter engines fall back to
1715
+ * them per key at draw time. Merge order for a selected pet:
1716
+ * mergeVoicePacks(registry.globalVoice, entry.voice).
1717
+ */
1718
+ function mergeVoicePacks(...layers) {
1719
+ const overrides = {};
1720
+ const labels = {};
1721
+ const stats = {};
1722
+ let actions;
1723
+ let panelSeen = false;
1724
+ let any = false;
1725
+ for (const layer of layers) {
1726
+ if (layer === void 0) continue;
1727
+ any = true;
1728
+ if (layer.overrides.status !== void 0) overrides.status = {
1729
+ ...overrides.status,
1730
+ ...layer.overrides.status
1731
+ };
1732
+ if (layer.overrides.tools !== void 0) overrides.tools = {
1733
+ ...overrides.tools,
1734
+ ...layer.overrides.tools
1735
+ };
1736
+ if (layer.overrides.toolRemaining !== void 0) overrides.toolRemaining = layer.overrides.toolRemaining;
1737
+ if (layer.overrides.whispers !== void 0) overrides.whispers = {
1738
+ ...overrides.whispers,
1739
+ ...layer.overrides.whispers
1740
+ };
1741
+ if (layer.panel !== void 0) {
1742
+ panelSeen = true;
1743
+ if (layer.panel.labels !== void 0) Object.assign(labels, layer.panel.labels);
1744
+ if (layer.panel.stats !== void 0) Object.assign(stats, layer.panel.stats);
1745
+ if (layer.panel.actions !== void 0) actions = layer.panel.actions;
1746
+ }
1747
+ }
1748
+ if (!any) return void 0;
1749
+ const panel = {
1750
+ ...Object.keys(labels).length > 0 ? { labels } : {},
1751
+ ...Object.keys(stats).length > 0 ? { stats } : {},
1752
+ ...actions === void 0 ? {} : { actions }
1753
+ };
1754
+ const panelEmpty = panel.labels === void 0 && panel.stats === void 0 && panel.actions === void 0;
1755
+ return {
1756
+ overrides,
1757
+ ...panelSeen && !panelEmpty ? { panel } : {}
1758
+ };
1759
+ }
1376
1760
  /** Renderer kinds the pet center knows how to dispatch (M1 §2). */
1377
1761
  const PET_RENDERER_KINDS = ["sprite2d", "live2d"];
1378
1762
  /** The seven ActivityPhase semantics (pet-center owned; M1 §1). */
@@ -1385,8 +1769,8 @@ const PET_ACTIVITY_PHASES = [
1385
1769
  "done",
1386
1770
  "failed"
1387
1771
  ];
1388
- const PET_ID_PATTERN$1 = /^[a-z0-9][a-z0-9-]*$/;
1389
- const PATH_SEGMENT_PATTERN$1 = /^[A-Za-z0-9._-]+$/;
1772
+ const PET_ID_PATTERN$2 = /^[a-z0-9][a-z0-9-]*$/;
1773
+ const PATH_SEGMENT_PATTERN$2 = /^[A-Za-z0-9._-]+$/;
1390
1774
  const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
1391
1775
  /**
1392
1776
  * Field allow-lists mirroring contracts/pet-manifest-v2.schema.json. Exported
@@ -1428,7 +1812,7 @@ const KNOWN_LIVE2D = /* @__PURE__ */ new Set([
1428
1812
  "hitAreas",
1429
1813
  "lipSync"
1430
1814
  ]);
1431
- var Diagnostics = class {
1815
+ var Diagnostics$1 = class {
1432
1816
  list = [];
1433
1817
  source;
1434
1818
  constructor(source) {
@@ -1450,10 +1834,10 @@ var Diagnostics = class {
1450
1834
  return this.list.some((d) => d.level === "error");
1451
1835
  }
1452
1836
  };
1453
- function isRecord(value) {
1837
+ function isRecord$1(value) {
1454
1838
  return typeof value === "object" && value !== null && !Array.isArray(value);
1455
1839
  }
1456
- function unknownKeys(source, known) {
1840
+ function unknownKeys$1(source, known) {
1457
1841
  return Object.keys(source).filter((key) => !known.has(key));
1458
1842
  }
1459
1843
  /**
@@ -1466,7 +1850,7 @@ function safeManifestPath(raw) {
1466
1850
  if (isAbsolute(value) || value.includes("\\") || /^[a-z][a-z0-9+.-]*:/i.test(value)) return void 0;
1467
1851
  const segments = value.split("/").filter((segment) => segment !== "");
1468
1852
  if (segments.length === 0) return void 0;
1469
- if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_PATTERN$1.test(segment))) return void 0;
1853
+ if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_PATTERN$2.test(segment))) return void 0;
1470
1854
  return segments.join("/");
1471
1855
  }
1472
1856
  function parseStringBlock(record, key, diag, required) {
@@ -1484,7 +1868,7 @@ function parseStringBlock(record, key, diag, required) {
1484
1868
  /** Validate the phase-keyed string map shape shared by motions/expressions. */
1485
1869
  function parsePhaseStringMap(raw, field, diag) {
1486
1870
  if (raw === void 0) return void 0;
1487
- if (!isRecord(raw)) {
1871
+ if (!isRecord$1(raw)) {
1488
1872
  diag.error("field " + JSON.stringify(field) + " must be an object keyed by activity phase");
1489
1873
  return;
1490
1874
  }
@@ -1505,7 +1889,7 @@ function parsePhaseStringMap(raw, field, diag) {
1505
1889
  /** Structural gate for sequences: content stays warn-and-drop (registry's job). */
1506
1890
  function parseSequences(raw, diag) {
1507
1891
  if (raw === void 0) return void 0;
1508
- if (!isRecord(raw)) {
1892
+ if (!isRecord$1(raw)) {
1509
1893
  diag.warn("sequences must be an object keyed by activity phase; ignoring");
1510
1894
  return;
1511
1895
  }
@@ -1524,16 +1908,16 @@ function parseSequences(raw, diag) {
1524
1908
  return Object.keys(sequences).length === 0 ? void 0 : sequences;
1525
1909
  }
1526
1910
  function parseSprite2dBlock(raw, diag) {
1527
- if (!isRecord(raw)) {
1911
+ if (!isRecord$1(raw)) {
1528
1912
  diag.error("renderer sprite2d requires a \"sprite2d\" block object");
1529
1913
  return;
1530
1914
  }
1531
- const extra = unknownKeys(raw, KNOWN_SPRITE2D);
1915
+ const extra = unknownKeys$1(raw, KNOWN_SPRITE2D);
1532
1916
  if (extra.length > 0) diag.error("sprite2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
1533
1917
  const spritesheetPath = safeManifestPath(raw.spritesheetPath);
1534
1918
  if (spritesheetPath === void 0) diag.error("sprite2d.spritesheetPath must be a safe manifest-relative path");
1535
1919
  const block = { spritesheetPath: spritesheetPath ?? "" };
1536
- if (raw.cell !== void 0) if (!isRecord(raw.cell)) diag.error("sprite2d.cell must be an object { width?, height? }");
1920
+ if (raw.cell !== void 0) if (!isRecord$1(raw.cell)) diag.error("sprite2d.cell must be an object { width?, height? }");
1537
1921
  else block.cell = raw.cell;
1538
1922
  if (raw.columns !== void 0) if (typeof raw.columns !== "number" || !Number.isInteger(raw.columns) || raw.columns < 1) diag.error("sprite2d.columns must be a positive integer");
1539
1923
  else block.columns = raw.columns;
@@ -1541,16 +1925,16 @@ function parseSprite2dBlock(raw, diag) {
1541
1925
  else block.atlasRows = raw.atlasRows;
1542
1926
  if (raw.frames !== void 0) if (!Array.isArray(raw.frames) || raw.frames.some((v) => typeof v !== "number" || !Number.isInteger(v) || v < 0)) diag.error("sprite2d.frames must be an array of non-negative integers");
1543
1927
  else block.frames = raw.frames;
1544
- if (raw.tracks !== void 0) if (!isRecord(raw.tracks)) diag.error("sprite2d.tracks must be an object keyed by animation");
1928
+ if (raw.tracks !== void 0) if (!isRecord$1(raw.tracks)) diag.error("sprite2d.tracks must be an object keyed by animation");
1545
1929
  else block.tracks = raw.tracks;
1546
1930
  return diag.hasErrors ? void 0 : block;
1547
1931
  }
1548
1932
  function parseLive2dBlock(raw, diag) {
1549
- if (!isRecord(raw)) {
1933
+ if (!isRecord$1(raw)) {
1550
1934
  diag.error("renderer live2d requires a \"live2d\" block object");
1551
1935
  return;
1552
1936
  }
1553
- const extra = unknownKeys(raw, KNOWN_LIVE2D);
1937
+ const extra = unknownKeys$1(raw, KNOWN_LIVE2D);
1554
1938
  if (extra.length > 0) diag.error("live2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
1555
1939
  const model = safeManifestPath(raw.model);
1556
1940
  if (model === void 0) diag.error("live2d.model must be a safe manifest-relative path to a .model3.json");
@@ -1564,7 +1948,7 @@ function parseLive2dBlock(raw, diag) {
1564
1948
  };
1565
1949
  if (raw.scale !== void 0) if (typeof raw.scale !== "number" || !Number.isFinite(raw.scale) || raw.scale <= 0 || raw.scale > 10) diag.error("live2d.scale must be a number in (0, 10]");
1566
1950
  else block.scale = raw.scale;
1567
- if (raw.translate !== void 0) if (!isRecord(raw.translate) || raw.translate.x !== void 0 && typeof raw.translate.x !== "number" || raw.translate.y !== void 0 && typeof raw.translate.y !== "number") diag.error("live2d.translate must be an object { x?: number, y?: number }");
1951
+ if (raw.translate !== void 0) if (!isRecord$1(raw.translate) || raw.translate.x !== void 0 && typeof raw.translate.x !== "number" || raw.translate.y !== void 0 && typeof raw.translate.y !== "number") diag.error("live2d.translate must be an object { x?: number, y?: number }");
1568
1952
  else block.translate = raw.translate;
1569
1953
  const expressions = parsePhaseStringMap(raw.expressions, "live2d.expressions", diag);
1570
1954
  if (expressions !== void 0) block.expressions = expressions;
@@ -1577,16 +1961,16 @@ function parseLive2dBlock(raw, diag) {
1577
1961
  /** v1 compat read: map the legacy flat manifest onto the v2 sprite2d shape. */
1578
1962
  function compatV1(source, diag) {
1579
1963
  const id = parseStringBlock(source, "id", diag, true);
1580
- if (id !== void 0 && !PET_ID_PATTERN$1.test(id)) diag.error("id " + JSON.stringify(id) + " is not a lowercase kebab id");
1964
+ if (id !== void 0 && !PET_ID_PATTERN$2.test(id)) diag.error("id " + JSON.stringify(id) + " is not a lowercase kebab id");
1581
1965
  const displayName = typeof source.displayName === "string" && source.displayName.trim() !== "" ? source.displayName.trim() : id;
1582
1966
  const spritesheetPath = safeManifestPath(source.spritesheetPath === void 0 ? "spritesheet.webp" : source.spritesheetPath);
1583
1967
  if (spritesheetPath === void 0) diag.error("spritesheetPath " + JSON.stringify(String(source.spritesheetPath)) + " is not a safe relative path");
1584
1968
  if (source.license === void 0) diag.warn("v1 compat read: no license field; run scripts/dsh-pet-migrate-v2 to migrate this pet");
1585
1969
  const sprite2d = { spritesheetPath: spritesheetPath ?? "spritesheet.webp" };
1586
- if (isRecord(source.cell)) sprite2d.cell = source.cell;
1970
+ if (isRecord$1(source.cell)) sprite2d.cell = source.cell;
1587
1971
  if (typeof source.columns === "number") sprite2d.columns = source.columns;
1588
1972
  if (Array.isArray(source.frames)) sprite2d.frames = source.frames;
1589
- if (isRecord(source.tracks)) sprite2d.tracks = source.tracks;
1973
+ if (isRecord$1(source.tracks)) sprite2d.tracks = source.tracks;
1590
1974
  if (source.spriteVersionNumber === 2) sprite2d.atlasRows = 11;
1591
1975
  const manifest = {
1592
1976
  petManifestVersion: 2,
@@ -1604,11 +1988,11 @@ function compatV1(source, diag) {
1604
1988
  }
1605
1989
  /** Strict v2 validation (fail-closed on structure). */
1606
1990
  function parseV2(source, diag) {
1607
- const extra = unknownKeys(source, KNOWN_TOP_LEVEL);
1991
+ const extra = unknownKeys$1(source, KNOWN_TOP_LEVEL);
1608
1992
  if (extra.length > 0) diag.error("unknown top-level field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
1609
1993
  if (source.petManifestVersion !== 2) diag.error("petManifestVersion must be 2 (got " + JSON.stringify(source.petManifestVersion) + ")");
1610
1994
  const id = parseStringBlock(source, "id", diag, true);
1611
- if (id !== void 0 && (!PET_ID_PATTERN$1.test(id) || id.length > 64)) diag.error("id " + JSON.stringify(id) + " must be a lowercase kebab id of at most 64 chars");
1995
+ if (id !== void 0 && (!PET_ID_PATTERN$2.test(id) || id.length > 64)) diag.error("id " + JSON.stringify(id) + " must be a lowercase kebab id of at most 64 chars");
1612
1996
  const displayName = parseStringBlock(source, "displayName", diag, true);
1613
1997
  const license = parseStringBlock(source, "license", diag, true);
1614
1998
  const rendererRaw = source.renderer === void 0 ? "sprite2d" : source.renderer;
@@ -1640,7 +2024,7 @@ function parseV2(source, diag) {
1640
2024
  }
1641
2025
  const sequences = parseSequences(source.sequences, diag);
1642
2026
  if (sequences !== void 0) manifest.sequences = sequences;
1643
- if (source.remarks !== void 0 && !isRecord(source.remarks)) diag.error("remarks must be an object of remark pools");
2027
+ if (source.remarks !== void 0 && !isRecord$1(source.remarks)) diag.error("remarks must be an object of remark pools");
1644
2028
  else if (source.remarks !== void 0) manifest.remarks = source.remarks;
1645
2029
  return diag.hasErrors ? void 0 : manifest;
1646
2030
  }
@@ -1652,8 +2036,8 @@ function parseV2(source, diag) {
1652
2036
  * @param sourceLabel - human-readable origin for diagnostics (dir or file).
1653
2037
  */
1654
2038
  function parsePetManifest(raw, sourceLabel) {
1655
- const diag = new Diagnostics(sourceLabel);
1656
- if (!isRecord(raw)) {
2039
+ const diag = new Diagnostics$1(sourceLabel);
2040
+ if (!isRecord$1(raw)) {
1657
2041
  diag.error("manifest is not an object");
1658
2042
  return {
1659
2043
  ok: false,
@@ -1686,6 +2070,266 @@ function parsePetManifest(raw, sourceLabel) {
1686
2070
  diagnostics: diag.list
1687
2071
  };
1688
2072
  }
2073
+ const DECORATION_ENTRY_EXTENSIONS = [".webp", ".png"];
2074
+ /** Field allow-list (drift-locked to the schema twin in tests). */
2075
+ const KNOWN_DECORATION_TOP_LEVEL = /* @__PURE__ */ new Set([
2076
+ "$schema",
2077
+ "decorationManifestVersion",
2078
+ "id",
2079
+ "displayName",
2080
+ "license",
2081
+ "entry",
2082
+ "cell",
2083
+ "columns",
2084
+ "frameMs",
2085
+ "durations",
2086
+ "loop",
2087
+ "phases"
2088
+ ]);
2089
+ const PET_ID_PATTERN$1 = /^[a-z0-9][a-z0-9-]*$/;
2090
+ const PATH_SEGMENT_PATTERN$1 = /^[A-Za-z0-9._-]+$/;
2091
+ var Diagnostics = class {
2092
+ list = [];
2093
+ source;
2094
+ constructor(source) {
2095
+ this.source = source;
2096
+ }
2097
+ error(message) {
2098
+ this.list.push({
2099
+ level: "error",
2100
+ message: this.source + ": " + message
2101
+ });
2102
+ }
2103
+ warn(message) {
2104
+ this.list.push({
2105
+ level: "warning",
2106
+ message: this.source + ": " + message
2107
+ });
2108
+ }
2109
+ get hasErrors() {
2110
+ return this.list.some((d) => d.level === "error");
2111
+ }
2112
+ };
2113
+ function isRecord(value) {
2114
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2115
+ }
2116
+ function unknownKeys(source, known) {
2117
+ return Object.keys(source).filter((key) => !known.has(key));
2118
+ }
2119
+ /** Positive integer in [min, max], else undefined. */
2120
+ function finiteInt$1(value, min, max) {
2121
+ return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max ? value : void 0;
2122
+ }
2123
+ /**
2124
+ * Validate a descriptor-relative entry path: no absolute paths, no
2125
+ * backslashes, no traversal, plain safe segments only, and an exact
2126
+ * lowercase PNG/WebP extension (the adopted entry discipline — SVG/CSS are
2127
+ * not accepted). The extension match is case-sensitive on purpose: the
2128
+ * asset route serves the declared path verbatim, so a case-mismatched
2129
+ * suffix (frames.PNG vs frames.png) would pass a lenient check but 403 on
2130
+ * case-sensitive filesystems.
2131
+ * Returns the normalized path or undefined.
2132
+ */
2133
+ function safeDecorationEntry(raw) {
2134
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
2135
+ const value = raw.trim();
2136
+ if (value.length > 256) return void 0;
2137
+ if (isAbsolute(value) || value.includes("\\") || /^[a-z][a-z0-9+.-]*:/i.test(value)) return void 0;
2138
+ const segments = value.split("/").filter((segment) => segment !== "");
2139
+ if (segments.length === 0) return void 0;
2140
+ if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_PATTERN$1.test(segment))) return void 0;
2141
+ const last = segments[segments.length - 1];
2142
+ const dot = last.lastIndexOf(".");
2143
+ if (dot <= 0 || !DECORATION_ENTRY_EXTENSIONS.includes(last.slice(dot))) return void 0;
2144
+ return segments.join("/");
2145
+ }
2146
+ /** Normalize one phase binding value; undefined (warning) on bad content. */
2147
+ function normalizeSegment(raw, columns, diag) {
2148
+ if (raw === "hide") return "hide";
2149
+ if (!isRecord(raw)) {
2150
+ diag.warn("phase binding must be \"hide\" or { from, to }; binding dropped");
2151
+ return;
2152
+ }
2153
+ const from = finiteInt$1(raw.from, 0, columns - 1);
2154
+ const to = finiteInt$1(raw.to, 0, columns - 1);
2155
+ if (from === void 0 || to === void 0 || from > to) {
2156
+ diag.warn("phase frame segment out of range; binding dropped");
2157
+ return;
2158
+ }
2159
+ return {
2160
+ from,
2161
+ to
2162
+ };
2163
+ }
2164
+ /**
2165
+ * Parse and validate one decoration.json document. Fail-closed over the
2166
+ * structure (types, key sets, paths, ranges); phase-binding content issues
2167
+ * drop that binding only (warn-and-drop, the registry never-throw rule).
2168
+ */
2169
+ function parseDecorationManifest(raw, source = "decoration.json") {
2170
+ const diag = new Diagnostics(source);
2171
+ if (!isRecord(raw)) {
2172
+ diag.error("descriptor must be a JSON object");
2173
+ return {
2174
+ ok: false,
2175
+ diagnostics: diag.list
2176
+ };
2177
+ }
2178
+ for (const key of unknownKeys(raw, KNOWN_DECORATION_TOP_LEVEL)) diag.error("unknown top-level field " + JSON.stringify(key));
2179
+ if (raw.decorationManifestVersion !== 1) diag.error("decorationManifestVersion must be 1");
2180
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
2181
+ if (!PET_ID_PATTERN$1.test(id)) diag.error("id must be a lowercase kebab id");
2182
+ if (id.length > 64) diag.error("id must be at most 64 characters");
2183
+ const license = typeof raw.license === "string" ? raw.license.trim() : "";
2184
+ if (license === "") diag.error("license is required (asset provenance)");
2185
+ if (license.length > 128) diag.error("license must be at most 128 characters");
2186
+ const entry = safeDecorationEntry(raw.entry);
2187
+ if (entry === void 0) diag.error("entry must be a safe relative PNG/WebP path");
2188
+ const rawCell = isRecord(raw.cell) ? raw.cell : {};
2189
+ for (const key of Object.keys(rawCell)) if (key !== "width" && key !== "height") diag.warn("unknown cell field " + JSON.stringify(key) + " ignored");
2190
+ const cellWidth = finiteInt$1(rawCell.width, 1, 256);
2191
+ const cellHeight = finiteInt$1(rawCell.height, 1, 256);
2192
+ if (cellWidth === void 0 || cellHeight === void 0) diag.error("cell width/height must be integers in [1, 256]");
2193
+ const columns = finiteInt$1(raw.columns, 1, 16);
2194
+ if (columns === void 0) diag.error("columns must be an integer in [1, 16]");
2195
+ if (diag.hasErrors || id === "" || entry === void 0 || columns === void 0) return {
2196
+ ok: false,
2197
+ diagnostics: diag.list
2198
+ };
2199
+ const displayName = typeof raw.displayName === "string" && raw.displayName.trim() !== "" ? raw.displayName.trim().slice(0, 64) : id;
2200
+ let loop;
2201
+ if (raw.loop === void 0 || typeof raw.loop === "boolean") loop = raw.loop ?? true;
2202
+ else {
2203
+ diag.warn("loop must be a boolean; defaulting to true");
2204
+ loop = true;
2205
+ }
2206
+ const rawDurations = raw.durations;
2207
+ let durations;
2208
+ if (Array.isArray(rawDurations)) {
2209
+ const usable = rawDurations.filter((v) => typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 2e3);
2210
+ if (usable.length !== columns) {
2211
+ diag.warn("durations length must equal columns; using the constant frameMs instead");
2212
+ durations = [];
2213
+ } else durations = usable;
2214
+ } else if (rawDurations !== void 0) {
2215
+ diag.warn("durations must be an array; using the constant frameMs instead");
2216
+ durations = [];
2217
+ } else durations = [];
2218
+ if (durations.length === 0) {
2219
+ const frameMs = finiteInt$1(raw.frameMs, 1, 2e3) ?? 120;
2220
+ durations = Array.from({ length: columns }, () => frameMs);
2221
+ }
2222
+ const phases = {};
2223
+ const rawPhases = raw.phases;
2224
+ if (isRecord(rawPhases)) for (const [key, value] of Object.entries(rawPhases)) {
2225
+ if (!PET_ACTIVITY_PHASES.includes(key)) {
2226
+ diag.warn("unknown phase " + JSON.stringify(key) + "; binding ignored");
2227
+ continue;
2228
+ }
2229
+ const segment = normalizeSegment(value, columns, diag);
2230
+ if (segment !== void 0) phases[key] = segment;
2231
+ }
2232
+ else if (rawPhases !== void 0) diag.warn("phases must be an object; all phases hide");
2233
+ if (!Object.values(phases).some((segment) => segment !== "hide")) diag.warn("no phase shows the ornament; the decoration stays hidden");
2234
+ return {
2235
+ ok: true,
2236
+ manifest: {
2237
+ decorationManifestVersion: 1,
2238
+ id,
2239
+ displayName,
2240
+ license,
2241
+ entry,
2242
+ cell: {
2243
+ width: cellWidth,
2244
+ height: cellHeight
2245
+ },
2246
+ columns,
2247
+ durations,
2248
+ loop,
2249
+ phases
2250
+ },
2251
+ diagnostics: diag.list
2252
+ };
2253
+ }
2254
+ //#endregion
2255
+ //#region src/image-dimensions.ts
2256
+ /**
2257
+ * Minimal PNG/WebP dimension reader — header-only, no decoding, no
2258
+ * dependencies. Used by the decoration registry to verify a strip's actual
2259
+ * pixel geometry matches its descriptor (single-row sprite strip; the client
2260
+ * renders by frame-column offsets, so a mismatched strip silently shows the
2261
+ * wrong frames). Parsing is best-effort: an unrecognized or truncated header
2262
+ * returns undefined (the caller decides whether to warn).
2263
+ *
2264
+ * PNG: signature (8) + IHDR chunk — length (4) + 'IHDR' (4) + width (4) +
2265
+ * height (4), both big-endian uint32 at fixed offsets 16/20.
2266
+ * WebP: RIFF header (12) + chunk — 'VP8X' extended (width-1/height-1 as
2267
+ * little-endian uint24 at 24/27), 'VP8L' lossless (packed 14-bit dims at
2268
+ * 21), or 'VP8 ' lossy (frame header, low 14 bits of the uint16 at 26/28).
2269
+ * @module @linxin666/dsh-pet/image-dimensions
2270
+ */
2271
+ const PNG_SIGNATURE = Buffer.from([
2272
+ 137,
2273
+ 80,
2274
+ 78,
2275
+ 71,
2276
+ 13,
2277
+ 10,
2278
+ 26,
2279
+ 10
2280
+ ]);
2281
+ /** Read the pixel size of a PNG buffer, or undefined when unrecognized. */
2282
+ function pngDimensions(buf) {
2283
+ if (buf.length < 24) return void 0;
2284
+ if (!buf.subarray(0, 8).equals(PNG_SIGNATURE)) return void 0;
2285
+ if (buf.toString("ascii", 12, 16) !== "IHDR") return void 0;
2286
+ return {
2287
+ width: buf.readUInt32BE(16),
2288
+ height: buf.readUInt32BE(20)
2289
+ };
2290
+ }
2291
+ /** Read the pixel size of a WebP buffer, or undefined when unrecognized. */
2292
+ function webpDimensions(buf) {
2293
+ if (buf.length < 21) return void 0;
2294
+ if (buf.toString("ascii", 0, 4) !== "RIFF") return void 0;
2295
+ if (buf.toString("ascii", 8, 12) !== "WEBP") return void 0;
2296
+ const fourcc = buf.toString("ascii", 12, 16);
2297
+ if (fourcc === "VP8X") {
2298
+ if (buf.length < 30) return void 0;
2299
+ return {
2300
+ width: 1 + buf.readUIntLE(24, 3),
2301
+ height: 1 + buf.readUIntLE(27, 3)
2302
+ };
2303
+ }
2304
+ if (fourcc === "VP8L") {
2305
+ if (buf.length < 25) return void 0;
2306
+ const bits = buf.readUInt32LE(21);
2307
+ return {
2308
+ width: 1 + (bits & 16383),
2309
+ height: 1 + (bits >>> 14 & 16383)
2310
+ };
2311
+ }
2312
+ if (fourcc === "VP8 ") {
2313
+ if (buf.length < 30) return void 0;
2314
+ return {
2315
+ width: buf.readUInt16LE(26) & 16383,
2316
+ height: buf.readUInt16LE(28) & 16383
2317
+ };
2318
+ }
2319
+ }
2320
+ /**
2321
+ * Read image pixel dimensions from a PNG or WebP buffer. Returns undefined
2322
+ * for formats this reader does not recognize (never throws). Callers treat
2323
+ * undefined as "cannot verify", not as an error.
2324
+ */
2325
+ function imageDimensions(buf) {
2326
+ if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF") return webpDimensions(buf);
2327
+ return pngDimensions(buf);
2328
+ }
2329
+ //#endregion
2330
+ //#region src/contracts/status-decoration.ts
2331
+ /** Contract version decorations declare against (independent of manifests). */
2332
+ const PET_DECORATION_API_VERSION = "x-org.linxin666.pet-center/status-decoration-v1";
1689
2333
  //#endregion
1690
2334
  //#region src/model3.ts
1691
2335
  /**
@@ -2198,24 +2842,28 @@ function scanPetDir(dir, options) {
2198
2842
  options.warnings?.push(diagnostic.message);
2199
2843
  }
2200
2844
  if (!verdict.ok) continue;
2201
- if (verdict.manifest.renderer === "live2d") {
2202
- const entry = resolveLive2dEntry(verdict.manifest, entryDir, options);
2203
- if (entry !== void 0) entries.push(entry);
2204
- continue;
2205
- }
2206
- const legacy = flattenV2Sprite2d(verdict.manifest);
2207
- if (legacy === void 0) {
2208
- const note = "pet " + verdict.manifest.id + ": sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver";
2209
- options.diagnostics?.push({
2210
- level: "error",
2211
- source: entryDir,
2212
- message: note
2213
- });
2214
- options.warnings?.push(note);
2215
- continue;
2845
+ let entry;
2846
+ if (verdict.manifest.renderer === "live2d") entry = resolveLive2dEntry(verdict.manifest, entryDir, options);
2847
+ else {
2848
+ const legacy = flattenV2Sprite2d(verdict.manifest);
2849
+ if (legacy === void 0) {
2850
+ const note = "pet " + verdict.manifest.id + ": sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver";
2851
+ options.diagnostics?.push({
2852
+ level: "error",
2853
+ source: entryDir,
2854
+ message: note
2855
+ });
2856
+ options.warnings?.push(note);
2857
+ continue;
2858
+ }
2859
+ entry = resolvePetManifest(legacy, entryDir, options);
2216
2860
  }
2217
- const entry = resolvePetManifest(legacy, entryDir, options);
2218
- if (entry !== void 0) entries.push(entry);
2861
+ if (entry === void 0) continue;
2862
+ const voice = loadVoicePackFile(join(entryDir, "voice.json"), options);
2863
+ entries.push({
2864
+ ...entry,
2865
+ ...voice === void 0 ? {} : { voice }
2866
+ });
2219
2867
  }
2220
2868
  return entries;
2221
2869
  }
@@ -2229,6 +2877,167 @@ function readPetJson(file, warnings) {
2229
2877
  }
2230
2878
  }
2231
2879
  /**
2880
+ * Stat one scanned JSON descriptor with a regular-file + size guard, so a
2881
+ * pathological user file is skipped with a warning instead of stalling or
2882
+ * OOM-ing the host at startup. Returns the Stats, or undefined when the
2883
+ * caller must skip the file (a warning was recorded).
2884
+ */
2885
+ function guardedScannedJsonStat(file, options, what) {
2886
+ let st;
2887
+ try {
2888
+ st = statSync(file);
2889
+ } catch {
2890
+ return;
2891
+ }
2892
+ const warn = (message) => {
2893
+ options.warnings?.push(file + ": " + message);
2894
+ options.diagnostics?.push({
2895
+ level: "warning",
2896
+ source: file,
2897
+ message: file + ": " + message
2898
+ });
2899
+ };
2900
+ if (!st.isFile()) {
2901
+ warn(what + " is not a regular file; ignored");
2902
+ return;
2903
+ }
2904
+ if (st.size > 65536) {
2905
+ warn(what + " exceeds the 65536-byte scan ceiling; ignored");
2906
+ return;
2907
+ }
2908
+ return st;
2909
+ }
2910
+ /**
2911
+ * Load and normalize one optional voice.json (pet-center M4). A missing
2912
+ * file is silent; a broken file warns and drops. The pack is pure content,
2913
+ * so every issue stays a warning — a bad voice.json never rejects a pet.
2914
+ */
2915
+ function loadVoicePackFile(file, options) {
2916
+ if (!existsSync(file)) return void 0;
2917
+ if (guardedScannedJsonStat(file, options, "voice pack") === void 0) return void 0;
2918
+ const warn = (message) => {
2919
+ options.warnings?.push(file + ": " + message);
2920
+ options.diagnostics?.push({
2921
+ level: "warning",
2922
+ source: file,
2923
+ message: file + ": " + message
2924
+ });
2925
+ };
2926
+ let raw;
2927
+ try {
2928
+ raw = JSON.parse(readFileSync(file, "utf8"));
2929
+ } catch (error) {
2930
+ warn("voice pack is not valid JSON; ignored: " + (error instanceof Error ? error.message : String(error)));
2931
+ return;
2932
+ }
2933
+ return normalizeVoicePack(raw, warn);
2934
+ }
2935
+ /** Decoration asset URL prefix (served by the decoration route, M5). */
2936
+ const DECORATION_ASSET_PREFIX = "/api/pet/decoration";
2937
+ /** Read the pixel dimensions of a decoration strip (PNG/WebP), if decodable. */
2938
+ function readImageDimensions(file) {
2939
+ let header;
2940
+ try {
2941
+ const fd = openSync(file, "r");
2942
+ try {
2943
+ header = Buffer.alloc(64);
2944
+ const read = readSync(fd, header, 0, header.length, 0);
2945
+ if (read < 0) return void 0;
2946
+ header = header.subarray(0, read);
2947
+ } finally {
2948
+ closeSync(fd);
2949
+ }
2950
+ } catch {
2951
+ return;
2952
+ }
2953
+ return imageDimensions(header);
2954
+ }
2955
+ /**
2956
+ * Scan one directory of decoration folders ('decoration.json' + strip).
2957
+ * Later scans override earlier ones on id collision; a bad descriptor warns
2958
+ * and skips — the never-throw philosophy holds for decorations too (M5).
2959
+ */
2960
+ function scanDecorationDir(dir, options) {
2961
+ if (!existsSync(dir)) return [];
2962
+ let names = [];
2963
+ try {
2964
+ names = readdirSync(dir).filter((name) => !name.startsWith("."));
2965
+ } catch {
2966
+ return [];
2967
+ }
2968
+ names.sort();
2969
+ const entries = [];
2970
+ for (const name of names) {
2971
+ const entryDir = join(dir, name);
2972
+ const manifestFile = join(entryDir, "decoration.json");
2973
+ if (!existsSync(manifestFile)) continue;
2974
+ if (guardedScannedJsonStat(manifestFile, options, "decoration descriptor") === void 0) continue;
2975
+ let raw;
2976
+ try {
2977
+ raw = JSON.parse(readFileSync(manifestFile, "utf8"));
2978
+ } catch (error) {
2979
+ const message = "skipping " + manifestFile + ": " + (error instanceof Error ? error.message : String(error));
2980
+ options.warnings?.push(message);
2981
+ options.diagnostics?.push({
2982
+ level: "error",
2983
+ source: entryDir,
2984
+ message
2985
+ });
2986
+ continue;
2987
+ }
2988
+ const verdict = parseDecorationManifest(raw, manifestFile);
2989
+ for (const diagnostic of verdict.diagnostics) {
2990
+ options.diagnostics?.push({
2991
+ level: diagnostic.level,
2992
+ source: entryDir,
2993
+ message: diagnostic.message
2994
+ });
2995
+ options.warnings?.push(diagnostic.message);
2996
+ }
2997
+ if (!verdict.ok) continue;
2998
+ const manifest = verdict.manifest;
2999
+ if (!existsSync(join(entryDir, manifest.entry))) {
3000
+ const message = "decoration " + manifest.id + ": strip file missing: " + manifest.entry;
3001
+ options.warnings?.push(message);
3002
+ options.diagnostics?.push({
3003
+ level: "warning",
3004
+ source: entryDir,
3005
+ message
3006
+ });
3007
+ } else {
3008
+ const actual = readImageDimensions(join(entryDir, manifest.entry));
3009
+ if (actual !== void 0) {
3010
+ const expectedWidth = manifest.cell.width * manifest.columns;
3011
+ if (actual.width !== expectedWidth || actual.height !== manifest.cell.height) {
3012
+ const message = "decoration " + manifest.id + ": strip " + actual.width + "x" + actual.height + " does not match cell " + manifest.cell.width + "x" + manifest.cell.height + " x " + manifest.columns + " columns (expected " + expectedWidth + "x" + manifest.cell.height + "); frames will render wrong";
3013
+ options.warnings?.push(message);
3014
+ options.diagnostics?.push({
3015
+ level: "warning",
3016
+ source: entryDir,
3017
+ message
3018
+ });
3019
+ }
3020
+ }
3021
+ }
3022
+ entries.push({
3023
+ apiVersion: PET_DECORATION_API_VERSION,
3024
+ id: manifest.id,
3025
+ dir: entryDir,
3026
+ entryPath: manifest.entry,
3027
+ servable: ["decoration.json", manifest.entry],
3028
+ license: manifest.license,
3029
+ assetBase: "/api/pet/decoration/" + encodeURIComponent(manifest.id),
3030
+ entryUrl: "/api/pet/decoration/" + encodeURIComponent(manifest.id) + "/" + manifest.entry,
3031
+ cell: manifest.cell,
3032
+ columns: manifest.columns,
3033
+ durations: manifest.durations,
3034
+ loop: manifest.loop,
3035
+ phases: manifest.phases
3036
+ });
3037
+ }
3038
+ return entries;
3039
+ }
3040
+ /**
2232
3041
  * Load the pet registry: built-in 'assets/*' first, then the hatch-pet
2233
3042
  * custom pets directory, then composed 'extra' manifests (each later source
2234
3043
  * overrides an earlier one on id collision). The registry never throws on a
@@ -2262,13 +3071,20 @@ function loadPetRegistry(options) {
2262
3071
  byId.set(entry.id, entry);
2263
3072
  }
2264
3073
  const dshPetsDir = options.dshPetsDir ?? join(dshHome(), "pets");
2265
- if (dshPetsDir !== "") for (const entry of scanPetDir(dshPetsDir, {
2266
- assetPrefix,
2267
- warnings,
2268
- diagnostics
2269
- })) {
2270
- if (byId.has(entry.id)) warnings.push("user pet " + entry.id + " overrides an earlier registration");
2271
- byId.set(entry.id, entry);
3074
+ let globalVoice;
3075
+ if (dshPetsDir !== "") {
3076
+ for (const entry of scanPetDir(dshPetsDir, {
3077
+ assetPrefix,
3078
+ warnings,
3079
+ diagnostics
3080
+ })) {
3081
+ if (byId.has(entry.id)) warnings.push("user pet " + entry.id + " overrides an earlier registration");
3082
+ byId.set(entry.id, entry);
3083
+ }
3084
+ globalVoice = loadVoicePackFile(join(dshPetsDir, ".voice.json"), {
3085
+ warnings,
3086
+ diagnostics
3087
+ });
2272
3088
  }
2273
3089
  for (const manifest of options.extra ?? []) {
2274
3090
  const raw = manifest.spritesheetPath;
@@ -2284,17 +3100,55 @@ function loadPetRegistry(options) {
2284
3100
  if (byId.has(entry.id)) warnings.push("composed pet " + entry.id + " overrides an earlier registration");
2285
3101
  byId.set(entry.id, entry);
2286
3102
  }
3103
+ const decorationById = /* @__PURE__ */ new Map();
3104
+ for (const entry of scanDecorationDir(join(packageRoot, "assets", "decorations"), {
3105
+ warnings,
3106
+ diagnostics
3107
+ })) decorationById.set(entry.id, entry);
3108
+ if (dshPetsDir !== "") for (const entry of scanDecorationDir(join(dshPetsDir, "decorations"), {
3109
+ warnings,
3110
+ diagnostics
3111
+ })) {
3112
+ if (decorationById.has(entry.id)) warnings.push("user decoration " + entry.id + " overrides the built-in one");
3113
+ decorationById.set(entry.id, entry);
3114
+ }
2287
3115
  const entries = [...byId.values()];
3116
+ const decorations = [...decorationById.values()];
2288
3117
  return {
2289
3118
  entries,
2290
3119
  warnings,
2291
3120
  diagnostics,
2292
3121
  byId: (id) => byId.get(id),
2293
- defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0]
3122
+ defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0],
3123
+ ...globalVoice === void 0 ? {} : { globalVoice },
3124
+ decorations,
3125
+ decorationById: (id) => decorationById.get(id)
2294
3126
  };
2295
3127
  }
2296
- /** Strip host-only fields, leaving the client-visible definition. */
2297
- function petEntryView(entry) {
3128
+ /** The built-in default decoration id (M5): the first reference ornament. */
3129
+ const DEFAULT_DECORATION_ID = "whale";
3130
+ /** Strip host-only fields, leaving the browser-visible decoration view. */
3131
+ function decorationView(entry) {
3132
+ return {
3133
+ apiVersion: PET_DECORATION_API_VERSION,
3134
+ id: entry.id,
3135
+ assetBase: entry.assetBase,
3136
+ entryUrl: entry.entryUrl,
3137
+ cell: entry.cell,
3138
+ columns: entry.columns,
3139
+ durations: entry.durations,
3140
+ loop: entry.loop,
3141
+ phases: entry.phases
3142
+ };
3143
+ }
3144
+ /**
3145
+ * Strip host-only fields, leaving the client-visible definition. When the
3146
+ * registry carries a global voice pack, its panel chrome layers under the
3147
+ * entry's own pack (per-slot merge, pet > global), mirroring the voice-pool
3148
+ * layering (pet-center M4, issue #677).
3149
+ */
3150
+ function petEntryView(entry, globalVoice) {
3151
+ const panel = globalVoice === void 0 ? entry.voice?.panel : mergeVoicePacks(globalVoice, entry.voice)?.panel;
2298
3152
  return {
2299
3153
  id: entry.id,
2300
3154
  displayName: entry.displayName,
@@ -2308,7 +3162,8 @@ function petEntryView(entry) {
2308
3162
  tracks: entry.tracks,
2309
3163
  ...entry.sequences === void 0 ? {} : { sequences: entry.sequences },
2310
3164
  atlasUrl: entry.atlasUrl,
2311
- manifestUrl: entry.manifestUrl
3165
+ manifestUrl: entry.manifestUrl,
3166
+ ...panel === void 0 ? {} : { panel }
2312
3167
  };
2313
3168
  }
2314
3169
  /** Hard cap on simultaneously displayed session bubbles (most recent first). */
@@ -2327,10 +3182,18 @@ var PetService = class extends Service {
2327
3182
  registry;
2328
3183
  persistDir;
2329
3184
  enabled;
3185
+ /** Status-decoration master switch (M5, #567); mirrored from settings. */
3186
+ decorationEnabled;
2330
3187
  disposeActivity;
2331
3188
  /** Session whose most recent meaningful event currently drives the global pet. */
2332
3189
  displaySession;
2333
3190
  /**
3191
+ * Effective voice-pack overrides for the currently selected pet (M4,
3192
+ * #677). Cached per pet id; the registry is an immutable snapshot, so the
3193
+ * global pack and each entry's pack cannot change behind the cache.
3194
+ */
3195
+ voiceCache;
3196
+ /**
2334
3197
  * Per-session activity, most recent last (Map insertion order). Bounded by
2335
3198
  * MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
2336
3199
  * disposed sessions are removed by the 'session/disposed' listener.
@@ -2362,8 +3225,26 @@ var PetService = class extends Service {
2362
3225
  };
2363
3226
  this.machine = new PetStateMachine(this.stateConfig);
2364
3227
  this.enabled = config.enabled ?? true;
3228
+ this.decorationEnabled = config.decorationEnabled ?? true;
2365
3229
  this.syncActivity();
2366
3230
  }
3231
+ /**
3232
+ * The draw-time voice-pool provider handed to every projection runtime.
3233
+ * It re-resolves when the selected pet changes, so live engines re-voice
3234
+ * on the next draw without being rebuilt (M4, #677).
3235
+ */
3236
+ voicePools() {
3237
+ return () => {
3238
+ const entry = this.activeEntry();
3239
+ if (this.voiceCache !== void 0 && this.voiceCache.petId === entry.id) return this.voiceCache.overrides;
3240
+ const overrides = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.overrides ?? {};
3241
+ this.voiceCache = {
3242
+ petId: entry.id,
3243
+ overrides
3244
+ };
3245
+ return overrides;
3246
+ };
3247
+ }
2367
3248
  /** Whether the pet service consumes session activity while enabled. */
2368
3249
  isEnabled() {
2369
3250
  return this.enabled;
@@ -2378,7 +3259,7 @@ var PetService = class extends Service {
2378
3259
  }
2379
3260
  /** RPC: the registry entries the browser half renders and selects from. */
2380
3261
  async pets() {
2381
- return this.registry.entries.map(petEntryView);
3262
+ return this.registry.entries.map((entry) => petEntryView(entry, this.registry.globalVoice));
2382
3263
  }
2383
3264
  /** The loaded registry (the asset routes serve its entries). */
2384
3265
  registrySnapshot() {
@@ -2388,6 +3269,15 @@ var PetService = class extends Service {
2388
3269
  async diagnostics() {
2389
3270
  return { diagnostics: this.registry.diagnostics };
2390
3271
  }
3272
+ /**
3273
+ * The active status decoration view (M5, #567): the default 'whale' entry
3274
+ * (user directories override built-ins by id), gated by the master switch.
3275
+ */
3276
+ activeDecoration() {
3277
+ if (!this.decorationEnabled) return void 0;
3278
+ const entry = this.registry.decorationById?.(DEFAULT_DECORATION_ID);
3279
+ return entry === void 0 ? void 0 : decorationView(entry);
3280
+ }
2391
3281
  /** The selected pet's registry entry. */
2392
3282
  activeEntry() {
2393
3283
  return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry();
@@ -2471,7 +3361,7 @@ var PetService = class extends Service {
2471
3361
  let activity = this.sessionActivity.get(session);
2472
3362
  if (activity === void 0) {
2473
3363
  activity = {
2474
- runtime: emptyProjectionRuntime(),
3364
+ runtime: emptyProjectionRuntime(this.voicePools()),
2475
3365
  machine: new PetStateMachine(this.stateConfig)
2476
3366
  };
2477
3367
  this.sessionActivity.set(session, activity);
@@ -2565,6 +3455,7 @@ var PetService = class extends Service {
2565
3455
  * @param section - the resolved settings section.
2566
3456
  */
2567
3457
  applySettingsSection(section) {
3458
+ this.decorationEnabled = section.decorationEnabled ?? true;
2568
3459
  const selected = typeof section.petId === "string" ? this.registry.byId(section.petId) : void 0;
2569
3460
  if (selected !== void 0) {
2570
3461
  this.ledger.setPetId(selected.id);
@@ -2617,6 +3508,7 @@ var PetService = class extends Service {
2617
3508
  }
2618
3509
  const whisper = (this.displaySession === void 0 ? void 0 : this.sessionActivity.get(this.displaySession))?.whisper;
2619
3510
  const freshWhisper = whisper !== void 0 && Date.now() - whisper.at < 8e3 ? whisper.text : void 0;
3511
+ const decoration = this.activeDecoration();
2620
3512
  return {
2621
3513
  animation: snapshot.animation,
2622
3514
  ...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
@@ -2624,6 +3516,7 @@ var PetService = class extends Service {
2624
3516
  sessionActive: snapshot.sessionActive,
2625
3517
  sessions,
2626
3518
  ...freshWhisper === void 0 ? {} : { whisper: freshWhisper },
3519
+ ...decoration === void 0 ? {} : { decoration },
2627
3520
  affinity: this.ledger.affinityView(Date.now()),
2628
3521
  display: { ...this.ledger.snapshot.display },
2629
3522
  pet: {
@@ -2690,6 +3583,23 @@ function isLoopbackRequest(request) {
2690
3583
  }
2691
3584
  }
2692
3585
  //#endregion
3586
+ //#region src/access.ts
3587
+ /**
3588
+ * Whether this request may enter any /api/pet or /pet asset route.
3589
+ * @param ctx - host context; may expose remoteWebUiPairing.
3590
+ * @param request - the incoming HTTP request.
3591
+ * @returns true for loopback, or a live paired-device cookie.
3592
+ */
3593
+ function isPetAllowed(ctx, request) {
3594
+ if (isLoopbackRequest(request)) return true;
3595
+ const bag = ctx;
3596
+ const fromGet = typeof bag.get === "function" ? bag.get("remoteWebUiPairing", false) : void 0;
3597
+ return (isPairingAccess(fromGet) ? fromGet : bag.remoteWebUiPairing)?.isPairedDevice(request) === true;
3598
+ }
3599
+ function isPairingAccess(value) {
3600
+ return value !== void 0 && value !== null && typeof value.isPairedDevice === "function";
3601
+ }
3602
+ //#endregion
2693
3603
  //#region src/routes.ts
2694
3604
  /**
2695
3605
  * Pet HTTP routes — the browser half talks to the host through plain
@@ -2698,7 +3608,10 @@ function isLoopbackRequest(request) {
2698
3608
  * domains are platform-registered, so the pet serves its own API and media —
2699
3609
  * the same pattern as dsh-remote-web-ui's '/api/pair' family. The asset route
2700
3610
  * is one prefix registration serving every registry entry (manifest, atlas,
2701
- * optional previews), so adding a pet never touches route wiring.
3611
+ * optional previews), so adding a pet never touches route wiring. Both the
3612
+ * JSON API, the asset prefix, and the Live2D runtime prefix are loopback-only
3613
+ * by default; a live paired-device cookie is an extra allow path when
3614
+ * remote-web-ui is loaded.
2702
3615
  * @module @linxin666/dsh-pet/routes
2703
3616
  */
2704
3617
  /** Browser-facing base path of the pet API. */
@@ -2804,9 +3717,9 @@ function readJsonBody(req) {
2804
3717
  req.on("error", reject);
2805
3718
  });
2806
3719
  }
2807
- /** Shared route fence: the browser UI is a loopback client; LAN hosts stay out. */
2808
- function guard(req, res) {
2809
- if (isLoopbackRequest(req)) return true;
3720
+ /** Shared route fence: loopback always passes; a live paired-device cookie is an extra allow path. */
3721
+ function guard(ctx, req, res) {
3722
+ if (isPetAllowed(ctx, req)) return true;
2810
3723
  json(res, 403, {
2811
3724
  ok: false,
2812
3725
  error: "forbidden: loopback-only"
@@ -2814,12 +3727,12 @@ function guard(req, res) {
2814
3727
  return false;
2815
3728
  }
2816
3729
  /** Wrap one async service call as a GET JSON route. */
2817
- function getRoute(path, run) {
3730
+ function getRoute(ctx, path, run) {
2818
3731
  return {
2819
3732
  kind: "exact",
2820
3733
  path,
2821
3734
  handler: (req, res) => {
2822
- if (!guard(req, res)) return;
3735
+ if (!guard(ctx, req, res)) return;
2823
3736
  if (!requireMethod(req, res, "GET")) return;
2824
3737
  run().then((value) => json(res, 200, value), (error) => {
2825
3738
  json(res, 500, {
@@ -2831,12 +3744,12 @@ function getRoute(path, run) {
2831
3744
  };
2832
3745
  }
2833
3746
  /** Wrap one async service call as a POST JSON route (body passed through). */
2834
- function postRoute(path, run) {
3747
+ function postRoute(ctx, path, run) {
2835
3748
  return {
2836
3749
  kind: "exact",
2837
3750
  path,
2838
3751
  handler: (req, res) => {
2839
- if (!guard(req, res)) return Promise.resolve();
3752
+ if (!guard(ctx, req, res)) return Promise.resolve();
2840
3753
  if (!requireMethod(req, res, "POST")) return Promise.resolve();
2841
3754
  return readJsonBody(req).then((body) => {
2842
3755
  return run(typeof body === "object" && body !== null ? body : {}).then((value) => json(res, 200, value), (error) => {
@@ -2873,10 +3786,10 @@ function dirAliases(registry) {
2873
3786
  * match; containedRealpath stays as the second layer. Composed pets without
2874
3787
  * a manifest file get a synthesized pet.json.
2875
3788
  */
2876
- function assetHandler(registry, caps) {
3789
+ function assetHandler(ctx, registry, caps) {
2877
3790
  const aliases = dirAliases(registry);
2878
- return (req, res) => {
2879
- if (!guard(req, res)) return;
3791
+ return ((req, res) => {
3792
+ if (!guard(ctx, req, res)) return;
2880
3793
  if (req.method !== "GET" && req.method !== "HEAD") {
2881
3794
  res.writeHead(405);
2882
3795
  res.end();
@@ -2935,7 +3848,7 @@ function assetHandler(registry, caps) {
2935
3848
  file = existsSync(preview) ? preview : void 0;
2936
3849
  }
2937
3850
  if (synthesized) {
2938
- const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), "utf8");
3851
+ const body = Buffer.from(JSON.stringify(petEntryView(entry, registry.globalVoice), null, 2), "utf8");
2939
3852
  res.writeHead(200, {
2940
3853
  "content-type": "application/json; charset=utf-8",
2941
3854
  "content-length": String(body.byteLength),
@@ -2971,7 +3884,7 @@ function assetHandler(registry, caps) {
2971
3884
  res.end();
2972
3885
  return;
2973
3886
  }
2974
- readFile(resolved).then((body) => {
3887
+ return readFile(resolved).then((body) => {
2975
3888
  res.writeHead(200, {
2976
3889
  "content-type": mimeFor(resolved),
2977
3890
  "content-length": String(body.byteLength),
@@ -2986,7 +3899,7 @@ function assetHandler(registry, caps) {
2986
3899
  res.writeHead(404);
2987
3900
  res.end();
2988
3901
  });
2989
- };
3902
+ });
2990
3903
  }
2991
3904
  /** Browser-facing base path of the plugin runtime files (pet-center M3). */
2992
3905
  const PET_RUNTIME_PREFIX = "/api/pet/runtime";
@@ -3008,9 +3921,9 @@ const RUNTIME_FILES = {
3008
3921
  * guidance (the Cubism Core is user-supplied, so its absence is a normal
3009
3922
  * state, not an error).
3010
3923
  */
3011
- function runtimeHandler(roots) {
3012
- return (req, res) => {
3013
- if (!guard(req, res)) return;
3924
+ function runtimeHandler(ctx, roots) {
3925
+ return ((req, res) => {
3926
+ if (!guard(ctx, req, res)) return;
3014
3927
  if (req.method !== "GET" && req.method !== "HEAD") {
3015
3928
  res.writeHead(405);
3016
3929
  res.end();
@@ -3066,7 +3979,7 @@ function runtimeHandler(roots) {
3066
3979
  res.end();
3067
3980
  return;
3068
3981
  }
3069
- readFile(resolved).then((body) => {
3982
+ return readFile(resolved).then((body) => {
3070
3983
  res.writeHead(200, {
3071
3984
  "content-type": name.endsWith(".map") ? "application/json" : "application/javascript; charset=utf-8",
3072
3985
  "content-length": String(body.byteLength),
@@ -3081,37 +3994,152 @@ function runtimeHandler(roots) {
3081
3994
  res.writeHead(404);
3082
3995
  res.end();
3083
3996
  });
3997
+ });
3998
+ }
3999
+ /**
4000
+ * The decoration asset handler behind '/api/pet/decoration/<id>/<file>'
4001
+ * (pet-center M5, #567). Serves exactly the files a decoration descriptor
4002
+ * declares — decoration.json and the PNG/WebP strip — by exact allow-list
4003
+ * match, with realpath containment and the same size ceilings as pet
4004
+ * assets. Crafted '..' or '.' segments never match the normalized closure.
4005
+ */
4006
+ function decorationHandler(ctx, registry, caps) {
4007
+ return (req, res) => {
4008
+ if (!guard(ctx, req, res)) return;
4009
+ if (req.method !== "GET" && req.method !== "HEAD") {
4010
+ res.writeHead(405);
4011
+ res.end();
4012
+ return;
4013
+ }
4014
+ let pathname;
4015
+ try {
4016
+ pathname = new URL(req.url ?? "/", "http://pet.local").pathname;
4017
+ } catch {
4018
+ res.writeHead(400);
4019
+ res.end();
4020
+ return;
4021
+ }
4022
+ const segments = pathname.split("/").filter((segment) => segment !== "");
4023
+ const prefixSegments = DECORATION_ASSET_PREFIX.split("/").filter((segment) => segment !== "");
4024
+ if (segments.length < prefixSegments.length + 2) {
4025
+ res.writeHead(404);
4026
+ res.end();
4027
+ return;
4028
+ }
4029
+ for (let i = 0; i < prefixSegments.length; i += 1) if (segments[i] !== prefixSegments[i]) {
4030
+ res.writeHead(404);
4031
+ res.end();
4032
+ return;
4033
+ }
4034
+ let id;
4035
+ try {
4036
+ id = decodeURIComponent(segments[prefixSegments.length]);
4037
+ } catch {
4038
+ res.writeHead(400);
4039
+ res.end();
4040
+ return;
4041
+ }
4042
+ const entry = registry.decorationById?.(id);
4043
+ if (entry === void 0) {
4044
+ res.writeHead(404);
4045
+ res.end();
4046
+ return;
4047
+ }
4048
+ const rest = [];
4049
+ for (const segment of segments.slice(prefixSegments.length + 1)) {
4050
+ let decoded;
4051
+ try {
4052
+ decoded = decodeURIComponent(segment);
4053
+ } catch {
4054
+ res.writeHead(400);
4055
+ res.end();
4056
+ return;
4057
+ }
4058
+ rest.push(decoded);
4059
+ }
4060
+ const rel = rest.join("/");
4061
+ if (!entry.servable.includes(rel)) {
4062
+ res.writeHead(404);
4063
+ res.end();
4064
+ return;
4065
+ }
4066
+ const file = join(entry.dir, rel);
4067
+ const resolved = containedRealpath(entry.dir, file);
4068
+ if (resolved === void 0) {
4069
+ res.writeHead(403);
4070
+ res.end();
4071
+ return;
4072
+ }
4073
+ const cap = rel === "decoration.json" ? caps.manifest : caps.image;
4074
+ let stat;
4075
+ try {
4076
+ stat = statSync(resolved);
4077
+ if (stat.size > cap) {
4078
+ res.writeHead(413);
4079
+ res.end();
4080
+ return;
4081
+ }
4082
+ } catch {
4083
+ res.writeHead(404);
4084
+ res.end();
4085
+ return;
4086
+ }
4087
+ const etag = "\"" + stat.size.toString(16) + "-" + Math.round(stat.mtimeMs).toString(16) + "\"";
4088
+ if (req.headers["if-none-match"] === etag) {
4089
+ res.writeHead(304, {
4090
+ etag,
4091
+ "cache-control": "no-cache"
4092
+ });
4093
+ res.end();
4094
+ return;
4095
+ }
4096
+ readFile(resolved).then((body) => {
4097
+ res.writeHead(200, {
4098
+ "content-type": mimeFor(resolved),
4099
+ "content-length": String(body.byteLength),
4100
+ "cache-control": "no-cache",
4101
+ etag
4102
+ });
4103
+ if (req.method === "HEAD") {
4104
+ res.end();
4105
+ return;
4106
+ }
4107
+ res.end(body);
4108
+ }, () => {
4109
+ res.writeHead(404);
4110
+ res.end();
4111
+ });
3084
4112
  };
3085
4113
  }
3086
4114
  /** Build the full route family (API + assets + runtime) for one service. */
3087
4115
  function makePetRoutes(deps) {
3088
- const { service } = deps;
4116
+ const { service, ctx } = deps;
3089
4117
  const apiRoutes = [
3090
- getRoute("/api/pet/state", () => service.state()),
3091
- getRoute("/api/pet/pets", () => service.pets()),
3092
- getRoute("/api/pet/diagnostics", () => service.diagnostics()),
3093
- postRoute("/api/pet/interact", (body) => {
4118
+ getRoute(ctx, "/api/pet/state", () => service.state()),
4119
+ getRoute(ctx, "/api/pet/pets", () => service.pets()),
4120
+ getRoute(ctx, "/api/pet/diagnostics", () => service.diagnostics()),
4121
+ postRoute(ctx, "/api/pet/interact", (body) => {
3094
4122
  const kind = body.kind;
3095
4123
  if (kind !== "pet" && kind !== "feed") return Promise.reject(/* @__PURE__ */ new Error("invalid-kind"));
3096
4124
  return service.interact(kind);
3097
4125
  }),
3098
- postRoute("/api/pet/set-visible", (body) => {
4126
+ postRoute(ctx, "/api/pet/set-visible", (body) => {
3099
4127
  const visible = body.visible;
3100
4128
  if (typeof visible !== "boolean") return Promise.reject(/* @__PURE__ */ new Error("invalid-visible"));
3101
4129
  return service.setVisible(visible);
3102
4130
  }),
3103
- postRoute("/api/pet/set-config", (body) => service.setConfig({
4131
+ postRoute(ctx, "/api/pet/set-config", (body) => service.setConfig({
3104
4132
  ...typeof body.size === "number" ? { size: body.size } : {},
3105
4133
  ...typeof body.right === "number" ? { right: body.right } : {},
3106
4134
  ...typeof body.bottom === "number" ? { bottom: body.bottom } : {},
3107
4135
  ...typeof body.visible === "boolean" ? { visible: body.visible } : {}
3108
4136
  })),
3109
- postRoute("/api/pet/set-name", (body) => {
4137
+ postRoute(ctx, "/api/pet/set-name", (body) => {
3110
4138
  const name = body.name;
3111
4139
  if (typeof name !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-name"));
3112
4140
  return service.setName(name);
3113
4141
  }),
3114
- postRoute("/api/pet/set-pet", (body) => {
4142
+ postRoute(ctx, "/api/pet/set-pet", (body) => {
3115
4143
  const petId = body.petId;
3116
4144
  if (typeof petId !== "string") return Promise.reject(/* @__PURE__ */ new Error("invalid-pet"));
3117
4145
  return service.setPetId(petId);
@@ -3120,20 +4148,26 @@ function makePetRoutes(deps) {
3120
4148
  const assetRoute = {
3121
4149
  kind: "prefix",
3122
4150
  path: PET_ASSET_PREFIX,
3123
- handler: assetHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS)
4151
+ handler: assetHandler(ctx, service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS)
3124
4152
  };
3125
4153
  const runtimeRoute = {
3126
4154
  kind: "prefix",
3127
4155
  path: PET_RUNTIME_PREFIX,
3128
- handler: runtimeHandler({
4156
+ handler: runtimeHandler(ctx, {
3129
4157
  runtimeDir: deps.runtimeDir ?? join(dshHome(), "pets", ".runtime"),
3130
4158
  vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), "lib")
3131
4159
  })
3132
4160
  };
4161
+ const decorationRoute = {
4162
+ kind: "prefix",
4163
+ path: DECORATION_ASSET_PREFIX,
4164
+ handler: decorationHandler(ctx, service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS)
4165
+ };
3133
4166
  return [
3134
4167
  ...apiRoutes,
3135
4168
  assetRoute,
3136
- runtimeRoute
4169
+ runtimeRoute,
4170
+ decorationRoute
3137
4171
  ];
3138
4172
  }
3139
4173
  //#endregion
@@ -3198,7 +4232,8 @@ function makePetSettingsSchema(fallbackPetId) {
3198
4232
  right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
3199
4233
  bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
3200
4234
  petId: z.string().default(fallbackPetId),
3201
- enabled: z.boolean().default(true)
4235
+ enabled: z.boolean().default(true),
4236
+ decorationEnabled: z.boolean().default(true)
3202
4237
  });
3203
4238
  }
3204
4239
  /** Register the pet service and its API + asset routes on the context. */
@@ -3219,9 +4254,13 @@ function applyImpl(ctx, config = {}) {
3219
4254
  right: service.display().right,
3220
4255
  bottom: service.display().bottom,
3221
4256
  petId: service.selectedPetId(),
3222
- enabled: config.enabled ?? true
4257
+ enabled: config.enabled ?? true,
4258
+ decorationEnabled: config.decorationEnabled ?? true
3223
4259
  };
3224
- const routes = makePetRoutes({ service });
4260
+ const routes = makePetRoutes({
4261
+ service,
4262
+ ctx
4263
+ });
3225
4264
  let disposeRoutes;
3226
4265
  const syncRoutes = () => {
3227
4266
  const enabled = current().enabled ?? true;