@linxin666/dsh-pet 0.2.3 → 0.2.4

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 (70) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +64 -1
  3. package/README.zh.md +64 -1
  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 +144 -18
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +979 -70
  12. package/lib/types/chatter.d.ts +61 -3
  13. package/lib/types/chatter.d.ts.map +1 -1
  14. package/lib/types/chatter.js +71 -12
  15. package/lib/types/client/PetSettingsCard.d.ts +4 -0
  16. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/PetSettingsCard.js +3 -1
  18. package/lib/types/client/PetSprite.d.ts.map +1 -1
  19. package/lib/types/client/PetSprite.js +103 -4
  20. package/lib/types/client/locales.d.ts +4 -0
  21. package/lib/types/client/locales.d.ts.map +1 -1
  22. package/lib/types/client/locales.js +4 -0
  23. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  24. package/lib/types/client/renderers/live2d.js +13 -1
  25. package/lib/types/client/settings-form.d.ts.map +1 -1
  26. package/lib/types/client/settings-form.js +9 -6
  27. package/lib/types/contracts/status-decoration.d.ts +85 -0
  28. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  29. package/lib/types/contracts/status-decoration.js +21 -0
  30. package/lib/types/decoration.d.ts +39 -0
  31. package/lib/types/decoration.d.ts.map +1 -0
  32. package/lib/types/decoration.js +210 -0
  33. package/lib/types/event-projection.d.ts +8 -3
  34. package/lib/types/event-projection.d.ts.map +1 -1
  35. package/lib/types/event-projection.js +9 -4
  36. package/lib/types/index.d.ts +2 -0
  37. package/lib/types/index.d.ts.map +1 -1
  38. package/lib/types/index.js +2 -0
  39. package/lib/types/registry.d.ts +55 -2
  40. package/lib/types/registry.d.ts.map +1 -1
  41. package/lib/types/registry.js +198 -16
  42. package/lib/types/routes.d.ts.map +1 -1
  43. package/lib/types/routes.js +131 -3
  44. package/lib/types/service.d.ts +33 -0
  45. package/lib/types/service.d.ts.map +1 -1
  46. package/lib/types/service.js +42 -3
  47. package/lib/types/voice-pack.d.ts +98 -0
  48. package/lib/types/voice-pack.d.ts.map +1 -0
  49. package/lib/types/voice-pack.js +384 -0
  50. package/package.json +11 -10
  51. package/src/chatter.test.ts +89 -2
  52. package/src/chatter.ts +120 -14
  53. package/src/client/PetSettingsCard.tsx +18 -0
  54. package/src/client/PetSprite.test.tsx +254 -12
  55. package/src/client/PetSprite.tsx +142 -25
  56. package/src/client/locales.ts +4 -0
  57. package/src/client/renderers/live2d.test.ts +23 -2
  58. package/src/client/renderers/live2d.ts +14 -1
  59. package/src/client/settings-form.ts +8 -6
  60. package/src/contracts/status-decoration.ts +78 -0
  61. package/src/decoration.test.ts +178 -0
  62. package/src/decoration.ts +220 -0
  63. package/src/event-projection.ts +10 -5
  64. package/src/index.ts +2 -0
  65. package/src/registry.test.ts +223 -0
  66. package/src/registry.ts +235 -15
  67. package/src/routes.ts +128 -3
  68. package/src/service.ts +59 -3
  69. package/src/voice-pack.test.ts +216 -0
  70. package/src/voice-pack.ts +413 -0
package/lib/index.js CHANGED
@@ -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,191 @@ 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/contracts/status-decoration.ts
2256
+ /** Contract version decorations declare against (independent of manifests). */
2257
+ const PET_DECORATION_API_VERSION = "x-org.linxin666.pet-center/status-decoration-v1";
1689
2258
  //#endregion
1690
2259
  //#region src/model3.ts
1691
2260
  /**
@@ -2198,24 +2767,28 @@ function scanPetDir(dir, options) {
2198
2767
  options.warnings?.push(diagnostic.message);
2199
2768
  }
2200
2769
  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;
2770
+ let entry;
2771
+ if (verdict.manifest.renderer === "live2d") entry = resolveLive2dEntry(verdict.manifest, entryDir, options);
2772
+ else {
2773
+ const legacy = flattenV2Sprite2d(verdict.manifest);
2774
+ if (legacy === void 0) {
2775
+ const note = "pet " + verdict.manifest.id + ": sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver";
2776
+ options.diagnostics?.push({
2777
+ level: "error",
2778
+ source: entryDir,
2779
+ message: note
2780
+ });
2781
+ options.warnings?.push(note);
2782
+ continue;
2783
+ }
2784
+ entry = resolvePetManifest(legacy, entryDir, options);
2216
2785
  }
2217
- const entry = resolvePetManifest(legacy, entryDir, options);
2218
- if (entry !== void 0) entries.push(entry);
2786
+ if (entry === void 0) continue;
2787
+ const voice = loadVoicePackFile(join(entryDir, "voice.json"), options);
2788
+ entries.push({
2789
+ ...entry,
2790
+ ...voice === void 0 ? {} : { voice }
2791
+ });
2219
2792
  }
2220
2793
  return entries;
2221
2794
  }
@@ -2229,6 +2802,135 @@ function readPetJson(file, warnings) {
2229
2802
  }
2230
2803
  }
2231
2804
  /**
2805
+ * Stat one scanned JSON descriptor with a regular-file + size guard, so a
2806
+ * pathological user file is skipped with a warning instead of stalling or
2807
+ * OOM-ing the host at startup. Returns the Stats, or undefined when the
2808
+ * caller must skip the file (a warning was recorded).
2809
+ */
2810
+ function guardedScannedJsonStat(file, options, what) {
2811
+ let st;
2812
+ try {
2813
+ st = statSync(file);
2814
+ } catch {
2815
+ return;
2816
+ }
2817
+ const warn = (message) => {
2818
+ options.warnings?.push(file + ": " + message);
2819
+ options.diagnostics?.push({
2820
+ level: "warning",
2821
+ source: file,
2822
+ message: file + ": " + message
2823
+ });
2824
+ };
2825
+ if (!st.isFile()) {
2826
+ warn(what + " is not a regular file; ignored");
2827
+ return;
2828
+ }
2829
+ if (st.size > 65536) {
2830
+ warn(what + " exceeds the 65536-byte scan ceiling; ignored");
2831
+ return;
2832
+ }
2833
+ return st;
2834
+ }
2835
+ /**
2836
+ * Load and normalize one optional voice.json (pet-center M4). A missing
2837
+ * file is silent; a broken file warns and drops. The pack is pure content,
2838
+ * so every issue stays a warning — a bad voice.json never rejects a pet.
2839
+ */
2840
+ function loadVoicePackFile(file, options) {
2841
+ if (!existsSync(file)) return void 0;
2842
+ if (guardedScannedJsonStat(file, options, "voice pack") === void 0) return void 0;
2843
+ const warn = (message) => {
2844
+ options.warnings?.push(file + ": " + message);
2845
+ options.diagnostics?.push({
2846
+ level: "warning",
2847
+ source: file,
2848
+ message: file + ": " + message
2849
+ });
2850
+ };
2851
+ let raw;
2852
+ try {
2853
+ raw = JSON.parse(readFileSync(file, "utf8"));
2854
+ } catch (error) {
2855
+ warn("voice pack is not valid JSON; ignored: " + (error instanceof Error ? error.message : String(error)));
2856
+ return;
2857
+ }
2858
+ return normalizeVoicePack(raw, warn);
2859
+ }
2860
+ /** Decoration asset URL prefix (served by the decoration route, M5). */
2861
+ const DECORATION_ASSET_PREFIX = "/api/pet/decoration";
2862
+ /**
2863
+ * Scan one directory of decoration folders ('decoration.json' + strip).
2864
+ * Later scans override earlier ones on id collision; a bad descriptor warns
2865
+ * and skips — the never-throw philosophy holds for decorations too (M5).
2866
+ */
2867
+ function scanDecorationDir(dir, options) {
2868
+ if (!existsSync(dir)) return [];
2869
+ let names = [];
2870
+ try {
2871
+ names = readdirSync(dir).filter((name) => !name.startsWith("."));
2872
+ } catch {
2873
+ return [];
2874
+ }
2875
+ names.sort();
2876
+ const entries = [];
2877
+ for (const name of names) {
2878
+ const entryDir = join(dir, name);
2879
+ const manifestFile = join(entryDir, "decoration.json");
2880
+ if (!existsSync(manifestFile)) continue;
2881
+ if (guardedScannedJsonStat(manifestFile, options, "decoration descriptor") === void 0) continue;
2882
+ let raw;
2883
+ try {
2884
+ raw = JSON.parse(readFileSync(manifestFile, "utf8"));
2885
+ } catch (error) {
2886
+ const message = "skipping " + manifestFile + ": " + (error instanceof Error ? error.message : String(error));
2887
+ options.warnings?.push(message);
2888
+ options.diagnostics?.push({
2889
+ level: "error",
2890
+ source: entryDir,
2891
+ message
2892
+ });
2893
+ continue;
2894
+ }
2895
+ const verdict = parseDecorationManifest(raw, manifestFile);
2896
+ for (const diagnostic of verdict.diagnostics) {
2897
+ options.diagnostics?.push({
2898
+ level: diagnostic.level,
2899
+ source: entryDir,
2900
+ message: diagnostic.message
2901
+ });
2902
+ options.warnings?.push(diagnostic.message);
2903
+ }
2904
+ if (!verdict.ok) continue;
2905
+ const manifest = verdict.manifest;
2906
+ if (!existsSync(join(entryDir, manifest.entry))) {
2907
+ const message = "decoration " + manifest.id + ": strip file missing: " + manifest.entry;
2908
+ options.warnings?.push(message);
2909
+ options.diagnostics?.push({
2910
+ level: "warning",
2911
+ source: entryDir,
2912
+ message
2913
+ });
2914
+ }
2915
+ entries.push({
2916
+ apiVersion: PET_DECORATION_API_VERSION,
2917
+ id: manifest.id,
2918
+ dir: entryDir,
2919
+ entryPath: manifest.entry,
2920
+ servable: ["decoration.json", manifest.entry],
2921
+ license: manifest.license,
2922
+ assetBase: "/api/pet/decoration/" + encodeURIComponent(manifest.id),
2923
+ entryUrl: "/api/pet/decoration/" + encodeURIComponent(manifest.id) + "/" + manifest.entry,
2924
+ cell: manifest.cell,
2925
+ columns: manifest.columns,
2926
+ durations: manifest.durations,
2927
+ loop: manifest.loop,
2928
+ phases: manifest.phases
2929
+ });
2930
+ }
2931
+ return entries;
2932
+ }
2933
+ /**
2232
2934
  * Load the pet registry: built-in 'assets/*' first, then the hatch-pet
2233
2935
  * custom pets directory, then composed 'extra' manifests (each later source
2234
2936
  * overrides an earlier one on id collision). The registry never throws on a
@@ -2262,13 +2964,20 @@ function loadPetRegistry(options) {
2262
2964
  byId.set(entry.id, entry);
2263
2965
  }
2264
2966
  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);
2967
+ let globalVoice;
2968
+ if (dshPetsDir !== "") {
2969
+ for (const entry of scanPetDir(dshPetsDir, {
2970
+ assetPrefix,
2971
+ warnings,
2972
+ diagnostics
2973
+ })) {
2974
+ if (byId.has(entry.id)) warnings.push("user pet " + entry.id + " overrides an earlier registration");
2975
+ byId.set(entry.id, entry);
2976
+ }
2977
+ globalVoice = loadVoicePackFile(join(dshPetsDir, ".voice.json"), {
2978
+ warnings,
2979
+ diagnostics
2980
+ });
2272
2981
  }
2273
2982
  for (const manifest of options.extra ?? []) {
2274
2983
  const raw = manifest.spritesheetPath;
@@ -2284,17 +2993,55 @@ function loadPetRegistry(options) {
2284
2993
  if (byId.has(entry.id)) warnings.push("composed pet " + entry.id + " overrides an earlier registration");
2285
2994
  byId.set(entry.id, entry);
2286
2995
  }
2996
+ const decorationById = /* @__PURE__ */ new Map();
2997
+ for (const entry of scanDecorationDir(join(packageRoot, "assets", "decorations"), {
2998
+ warnings,
2999
+ diagnostics
3000
+ })) decorationById.set(entry.id, entry);
3001
+ if (dshPetsDir !== "") for (const entry of scanDecorationDir(join(dshPetsDir, "decorations"), {
3002
+ warnings,
3003
+ diagnostics
3004
+ })) {
3005
+ if (decorationById.has(entry.id)) warnings.push("user decoration " + entry.id + " overrides the built-in one");
3006
+ decorationById.set(entry.id, entry);
3007
+ }
2287
3008
  const entries = [...byId.values()];
3009
+ const decorations = [...decorationById.values()];
2288
3010
  return {
2289
3011
  entries,
2290
3012
  warnings,
2291
3013
  diagnostics,
2292
3014
  byId: (id) => byId.get(id),
2293
- defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0]
3015
+ defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0],
3016
+ ...globalVoice === void 0 ? {} : { globalVoice },
3017
+ decorations,
3018
+ decorationById: (id) => decorationById.get(id)
3019
+ };
3020
+ }
3021
+ /** The built-in default decoration id (M5): the first reference ornament. */
3022
+ const DEFAULT_DECORATION_ID = "whale";
3023
+ /** Strip host-only fields, leaving the browser-visible decoration view. */
3024
+ function decorationView(entry) {
3025
+ return {
3026
+ apiVersion: PET_DECORATION_API_VERSION,
3027
+ id: entry.id,
3028
+ assetBase: entry.assetBase,
3029
+ entryUrl: entry.entryUrl,
3030
+ cell: entry.cell,
3031
+ columns: entry.columns,
3032
+ durations: entry.durations,
3033
+ loop: entry.loop,
3034
+ phases: entry.phases
2294
3035
  };
2295
3036
  }
2296
- /** Strip host-only fields, leaving the client-visible definition. */
2297
- function petEntryView(entry) {
3037
+ /**
3038
+ * Strip host-only fields, leaving the client-visible definition. When the
3039
+ * registry carries a global voice pack, its panel chrome layers under the
3040
+ * entry's own pack (per-slot merge, pet > global), mirroring the voice-pool
3041
+ * layering (pet-center M4, issue #677).
3042
+ */
3043
+ function petEntryView(entry, globalVoice) {
3044
+ const panel = globalVoice === void 0 ? entry.voice?.panel : mergeVoicePacks(globalVoice, entry.voice)?.panel;
2298
3045
  return {
2299
3046
  id: entry.id,
2300
3047
  displayName: entry.displayName,
@@ -2308,7 +3055,8 @@ function petEntryView(entry) {
2308
3055
  tracks: entry.tracks,
2309
3056
  ...entry.sequences === void 0 ? {} : { sequences: entry.sequences },
2310
3057
  atlasUrl: entry.atlasUrl,
2311
- manifestUrl: entry.manifestUrl
3058
+ manifestUrl: entry.manifestUrl,
3059
+ ...panel === void 0 ? {} : { panel }
2312
3060
  };
2313
3061
  }
2314
3062
  /** Hard cap on simultaneously displayed session bubbles (most recent first). */
@@ -2327,10 +3075,18 @@ var PetService = class extends Service {
2327
3075
  registry;
2328
3076
  persistDir;
2329
3077
  enabled;
3078
+ /** Status-decoration master switch (M5, #567); mirrored from settings. */
3079
+ decorationEnabled;
2330
3080
  disposeActivity;
2331
3081
  /** Session whose most recent meaningful event currently drives the global pet. */
2332
3082
  displaySession;
2333
3083
  /**
3084
+ * Effective voice-pack overrides for the currently selected pet (M4,
3085
+ * #677). Cached per pet id; the registry is an immutable snapshot, so the
3086
+ * global pack and each entry's pack cannot change behind the cache.
3087
+ */
3088
+ voiceCache;
3089
+ /**
2334
3090
  * Per-session activity, most recent last (Map insertion order). Bounded by
2335
3091
  * MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
2336
3092
  * disposed sessions are removed by the 'session/disposed' listener.
@@ -2362,8 +3118,26 @@ var PetService = class extends Service {
2362
3118
  };
2363
3119
  this.machine = new PetStateMachine(this.stateConfig);
2364
3120
  this.enabled = config.enabled ?? true;
3121
+ this.decorationEnabled = config.decorationEnabled ?? true;
2365
3122
  this.syncActivity();
2366
3123
  }
3124
+ /**
3125
+ * The draw-time voice-pool provider handed to every projection runtime.
3126
+ * It re-resolves when the selected pet changes, so live engines re-voice
3127
+ * on the next draw without being rebuilt (M4, #677).
3128
+ */
3129
+ voicePools() {
3130
+ return () => {
3131
+ const entry = this.activeEntry();
3132
+ if (this.voiceCache !== void 0 && this.voiceCache.petId === entry.id) return this.voiceCache.overrides;
3133
+ const overrides = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.overrides ?? {};
3134
+ this.voiceCache = {
3135
+ petId: entry.id,
3136
+ overrides
3137
+ };
3138
+ return overrides;
3139
+ };
3140
+ }
2367
3141
  /** Whether the pet service consumes session activity while enabled. */
2368
3142
  isEnabled() {
2369
3143
  return this.enabled;
@@ -2378,7 +3152,7 @@ var PetService = class extends Service {
2378
3152
  }
2379
3153
  /** RPC: the registry entries the browser half renders and selects from. */
2380
3154
  async pets() {
2381
- return this.registry.entries.map(petEntryView);
3155
+ return this.registry.entries.map((entry) => petEntryView(entry, this.registry.globalVoice));
2382
3156
  }
2383
3157
  /** The loaded registry (the asset routes serve its entries). */
2384
3158
  registrySnapshot() {
@@ -2388,6 +3162,15 @@ var PetService = class extends Service {
2388
3162
  async diagnostics() {
2389
3163
  return { diagnostics: this.registry.diagnostics };
2390
3164
  }
3165
+ /**
3166
+ * The active status decoration view (M5, #567): the default 'whale' entry
3167
+ * (user directories override built-ins by id), gated by the master switch.
3168
+ */
3169
+ activeDecoration() {
3170
+ if (!this.decorationEnabled) return void 0;
3171
+ const entry = this.registry.decorationById?.(DEFAULT_DECORATION_ID);
3172
+ return entry === void 0 ? void 0 : decorationView(entry);
3173
+ }
2391
3174
  /** The selected pet's registry entry. */
2392
3175
  activeEntry() {
2393
3176
  return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry();
@@ -2471,7 +3254,7 @@ var PetService = class extends Service {
2471
3254
  let activity = this.sessionActivity.get(session);
2472
3255
  if (activity === void 0) {
2473
3256
  activity = {
2474
- runtime: emptyProjectionRuntime(),
3257
+ runtime: emptyProjectionRuntime(this.voicePools()),
2475
3258
  machine: new PetStateMachine(this.stateConfig)
2476
3259
  };
2477
3260
  this.sessionActivity.set(session, activity);
@@ -2565,6 +3348,7 @@ var PetService = class extends Service {
2565
3348
  * @param section - the resolved settings section.
2566
3349
  */
2567
3350
  applySettingsSection(section) {
3351
+ this.decorationEnabled = section.decorationEnabled ?? true;
2568
3352
  const selected = typeof section.petId === "string" ? this.registry.byId(section.petId) : void 0;
2569
3353
  if (selected !== void 0) {
2570
3354
  this.ledger.setPetId(selected.id);
@@ -2617,6 +3401,7 @@ var PetService = class extends Service {
2617
3401
  }
2618
3402
  const whisper = (this.displaySession === void 0 ? void 0 : this.sessionActivity.get(this.displaySession))?.whisper;
2619
3403
  const freshWhisper = whisper !== void 0 && Date.now() - whisper.at < 8e3 ? whisper.text : void 0;
3404
+ const decoration = this.activeDecoration();
2620
3405
  return {
2621
3406
  animation: snapshot.animation,
2622
3407
  ...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
@@ -2624,6 +3409,7 @@ var PetService = class extends Service {
2624
3409
  sessionActive: snapshot.sessionActive,
2625
3410
  sessions,
2626
3411
  ...freshWhisper === void 0 ? {} : { whisper: freshWhisper },
3412
+ ...decoration === void 0 ? {} : { decoration },
2627
3413
  affinity: this.ledger.affinityView(Date.now()),
2628
3414
  display: { ...this.ledger.snapshot.display },
2629
3415
  pet: {
@@ -2935,7 +3721,7 @@ function assetHandler(registry, caps) {
2935
3721
  file = existsSync(preview) ? preview : void 0;
2936
3722
  }
2937
3723
  if (synthesized) {
2938
- const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), "utf8");
3724
+ const body = Buffer.from(JSON.stringify(petEntryView(entry, registry.globalVoice), null, 2), "utf8");
2939
3725
  res.writeHead(200, {
2940
3726
  "content-type": "application/json; charset=utf-8",
2941
3727
  "content-length": String(body.byteLength),
@@ -3083,6 +3869,121 @@ function runtimeHandler(roots) {
3083
3869
  });
3084
3870
  };
3085
3871
  }
3872
+ /**
3873
+ * The decoration asset handler behind '/api/pet/decoration/<id>/<file>'
3874
+ * (pet-center M5, #567). Serves exactly the files a decoration descriptor
3875
+ * declares — decoration.json and the PNG/WebP strip — by exact allow-list
3876
+ * match, with realpath containment and the same size ceilings as pet
3877
+ * assets. Crafted '..' or '.' segments never match the normalized closure.
3878
+ */
3879
+ function decorationHandler(registry, caps) {
3880
+ return (req, res) => {
3881
+ if (!guard(req, res)) return;
3882
+ if (req.method !== "GET" && req.method !== "HEAD") {
3883
+ res.writeHead(405);
3884
+ res.end();
3885
+ return;
3886
+ }
3887
+ let pathname;
3888
+ try {
3889
+ pathname = new URL(req.url ?? "/", "http://pet.local").pathname;
3890
+ } catch {
3891
+ res.writeHead(400);
3892
+ res.end();
3893
+ return;
3894
+ }
3895
+ const segments = pathname.split("/").filter((segment) => segment !== "");
3896
+ const prefixSegments = DECORATION_ASSET_PREFIX.split("/").filter((segment) => segment !== "");
3897
+ if (segments.length < prefixSegments.length + 2) {
3898
+ res.writeHead(404);
3899
+ res.end();
3900
+ return;
3901
+ }
3902
+ for (let i = 0; i < prefixSegments.length; i += 1) if (segments[i] !== prefixSegments[i]) {
3903
+ res.writeHead(404);
3904
+ res.end();
3905
+ return;
3906
+ }
3907
+ let id;
3908
+ try {
3909
+ id = decodeURIComponent(segments[prefixSegments.length]);
3910
+ } catch {
3911
+ res.writeHead(400);
3912
+ res.end();
3913
+ return;
3914
+ }
3915
+ const entry = registry.decorationById?.(id);
3916
+ if (entry === void 0) {
3917
+ res.writeHead(404);
3918
+ res.end();
3919
+ return;
3920
+ }
3921
+ const rest = [];
3922
+ for (const segment of segments.slice(prefixSegments.length + 1)) {
3923
+ let decoded;
3924
+ try {
3925
+ decoded = decodeURIComponent(segment);
3926
+ } catch {
3927
+ res.writeHead(400);
3928
+ res.end();
3929
+ return;
3930
+ }
3931
+ rest.push(decoded);
3932
+ }
3933
+ const rel = rest.join("/");
3934
+ if (!entry.servable.includes(rel)) {
3935
+ res.writeHead(404);
3936
+ res.end();
3937
+ return;
3938
+ }
3939
+ const file = join(entry.dir, rel);
3940
+ const resolved = containedRealpath(entry.dir, file);
3941
+ if (resolved === void 0) {
3942
+ res.writeHead(403);
3943
+ res.end();
3944
+ return;
3945
+ }
3946
+ const cap = rel === "decoration.json" ? caps.manifest : caps.image;
3947
+ let stat;
3948
+ try {
3949
+ stat = statSync(resolved);
3950
+ if (stat.size > cap) {
3951
+ res.writeHead(413);
3952
+ res.end();
3953
+ return;
3954
+ }
3955
+ } catch {
3956
+ res.writeHead(404);
3957
+ res.end();
3958
+ return;
3959
+ }
3960
+ const etag = "\"" + stat.size.toString(16) + "-" + Math.round(stat.mtimeMs).toString(16) + "\"";
3961
+ if (req.headers["if-none-match"] === etag) {
3962
+ res.writeHead(304, {
3963
+ etag,
3964
+ "cache-control": "no-cache"
3965
+ });
3966
+ res.end();
3967
+ return;
3968
+ }
3969
+ readFile(resolved).then((body) => {
3970
+ res.writeHead(200, {
3971
+ "content-type": mimeFor(resolved),
3972
+ "content-length": String(body.byteLength),
3973
+ "cache-control": "no-cache",
3974
+ etag
3975
+ });
3976
+ if (req.method === "HEAD") {
3977
+ res.end();
3978
+ return;
3979
+ }
3980
+ res.end(body);
3981
+ }, () => {
3982
+ res.writeHead(404);
3983
+ res.end();
3984
+ });
3985
+ };
3986
+ }
3086
3987
  /** Build the full route family (API + assets + runtime) for one service. */
3087
3988
  function makePetRoutes(deps) {
3088
3989
  const { service } = deps;
@@ -3130,10 +4031,16 @@ function makePetRoutes(deps) {
3130
4031
  vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), "lib")
3131
4032
  })
3132
4033
  };
4034
+ const decorationRoute = {
4035
+ kind: "prefix",
4036
+ path: DECORATION_ASSET_PREFIX,
4037
+ handler: decorationHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS)
4038
+ };
3133
4039
  return [
3134
4040
  ...apiRoutes,
3135
4041
  assetRoute,
3136
- runtimeRoute
4042
+ runtimeRoute,
4043
+ decorationRoute
3137
4044
  ];
3138
4045
  }
3139
4046
  //#endregion
@@ -3198,7 +4105,8 @@ function makePetSettingsSchema(fallbackPetId) {
3198
4105
  right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
3199
4106
  bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
3200
4107
  petId: z.string().default(fallbackPetId),
3201
- enabled: z.boolean().default(true)
4108
+ enabled: z.boolean().default(true),
4109
+ decorationEnabled: z.boolean().default(true)
3202
4110
  });
3203
4111
  }
3204
4112
  /** Register the pet service and its API + asset routes on the context. */
@@ -3219,7 +4127,8 @@ function applyImpl(ctx, config = {}) {
3219
4127
  right: service.display().right,
3220
4128
  bottom: service.display().bottom,
3221
4129
  petId: service.selectedPetId(),
3222
- enabled: config.enabled ?? true
4130
+ enabled: config.enabled ?? true,
4131
+ decorationEnabled: config.decorationEnabled ?? true
3223
4132
  };
3224
4133
  const routes = makePetRoutes({ service });
3225
4134
  let disposeRoutes;