@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/src/registry.ts CHANGED
@@ -27,12 +27,15 @@
27
27
  * @module @linxin666/dsh-pet/registry
28
28
  */
29
29
 
30
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
30
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
31
31
  import { homedir } from 'node:os'
32
32
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
33
33
  import { fileURLToPath } from 'node:url'
34
34
  import type { ActivityPhase, PetAnimation } from './state.ts'
35
35
  import { normalizePetRemarks, type PetRemarks, type PetRemarksManifest } from './remarks.ts'
36
+ import { mergeVoicePacks, normalizeVoicePack, type PetPanelView, type VoicePack } from './voice-pack.ts'
37
+ import { parseDecorationManifest } from './decoration.ts'
38
+ import { PET_DECORATION_API_VERSION, type DecorationView } from './contracts/status-decoration.ts'
36
39
  import { dshHome } from './dsh-home.ts'
37
40
  import { parsePetManifest, type PetManifestLive2d, type PetManifestV2, type PetRendererKind } from './manifest-v2.ts'
38
41
  import { collectModel3References } from './model3.ts'
@@ -212,6 +215,8 @@ export interface PetDefinition {
212
215
  atlasUrl: string
213
216
  /** Browser URL of the manifest (served by the host asset route). */
214
217
  manifestUrl: string
218
+ /** Hover-panel chrome overrides (voice.json 'panel'; pet-center M4). */
219
+ panel?: PetPanelView
215
220
  }
216
221
 
217
222
  /** A resolved pet plus its host-side file location. */
@@ -228,6 +233,11 @@ export interface PetEntry extends PetDefinition {
228
233
  servable: readonly string[]
229
234
  /** Normalized per-pet remark pools (manifest 'remarks'), when declared. */
230
235
  remarks?: PetRemarks
236
+ /**
237
+ * Normalized per-pet voice pack (the directory's voice.json; pet-center
238
+ * M4). Host-side only — the browser half receives its 'panel' slice.
239
+ */
240
+ voice?: VoicePack
231
241
  }
232
242
 
233
243
  /** Registry load result: resolved entries plus load warnings. */
@@ -239,6 +249,32 @@ export interface PetRegistry {
239
249
  byId(id: string): PetEntry | undefined
240
250
  /** The pet an installation falls back to when the selection is unknown. */
241
251
  defaultEntry(): PetEntry
252
+ /**
253
+ * The global voice override ('$DSH_HOME/pets/.voice.json'), when present —
254
+ * layers under every per-pet pack and over the built-in pools (M4, #677).
255
+ */
256
+ globalVoice?: VoicePack
257
+ /**
258
+ * Status decorations (pet-center M5, #567): built-in 'assets/decorations'
259
+ * entries overridden by same-id user entries under
260
+ * '$DSH_HOME/pets/decorations'. Independent of the pet entries. Optional
261
+ * so prebuilt test registries without decorations keep compiling.
262
+ */
263
+ decorations?: DecorationEntry[]
264
+ /** Look up one decoration by id. */
265
+ decorationById?(id: string): DecorationEntry | undefined
266
+ }
267
+
268
+ /** One resolved status decoration plus its host-side file location. */
269
+ export interface DecorationEntry extends DecorationView {
270
+ /** Absolute directory holding the descriptor and strip. */
271
+ dir: string
272
+ /** Strip path relative to 'dir' (declared by the descriptor). */
273
+ entryPath: string
274
+ /** Descriptor-relative files the decoration asset route may serve. */
275
+ servable: readonly string[]
276
+ /** Asset license identifier (required by the descriptor). */
277
+ license: string
242
278
  }
243
279
 
244
280
  /** One structured registry diagnostic (manifest-v2 era). */
@@ -551,20 +587,23 @@ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: str
551
587
  options.warnings?.push(diagnostic.message)
552
588
  }
553
589
  if (!verdict.ok) continue
590
+ let entry: PetEntry | undefined
554
591
  if (verdict.manifest.renderer === 'live2d') {
555
- const entry = resolveLive2dEntry(verdict.manifest, entryDir, options)
556
- if (entry !== undefined) entries.push(entry)
557
- continue
558
- }
559
- const legacy = flattenV2Sprite2d(verdict.manifest)
560
- if (legacy === undefined) {
561
- const note = 'pet ' + verdict.manifest.id + ': sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver'
562
- options.diagnostics?.push({ level: 'error', source: entryDir, message: note })
563
- options.warnings?.push(note)
564
- continue
592
+ entry = resolveLive2dEntry(verdict.manifest, entryDir, options)
593
+ } else {
594
+ const legacy = flattenV2Sprite2d(verdict.manifest)
595
+ if (legacy === undefined) {
596
+ const note = 'pet ' + verdict.manifest.id + ': sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver'
597
+ options.diagnostics?.push({ level: 'error', source: entryDir, message: note })
598
+ options.warnings?.push(note)
599
+ continue
600
+ }
601
+ entry = resolvePetManifest(legacy, entryDir, options)
565
602
  }
566
- const entry = resolvePetManifest(legacy, entryDir, options)
567
- if (entry !== undefined) entries.push(entry)
603
+ if (entry === undefined) continue
604
+ // Optional voice pack (voice.json) — pure content, warn-and-drop (M4).
605
+ const voice = loadVoicePackFile(join(entryDir, 'voice.json'), options)
606
+ entries.push({ ...entry, ...(voice === undefined ? {} : { voice }) })
568
607
  }
569
608
  return entries
570
609
  }
@@ -579,6 +618,138 @@ function readPetJson(file: string, warnings: string[] | undefined): unknown {
579
618
  }
580
619
  }
581
620
 
621
+ /**
622
+ * Scan-time read ceiling for user-authored JSON descriptors (voice.json,
623
+ * .voice.json, decoration.json): the registry reads these synchronously at
624
+ * plugin startup, and a pathological file — multi-GB, or a FIFO/device
625
+ * symlink — must not hang or exhaust the host before the warn-and-drop
626
+ * discipline can apply (review-spd follow-up, pet-center M4/M5).
627
+ */
628
+ export const PET_SCAN_JSON_CAP = 64 * 1024
629
+
630
+ /**
631
+ * Stat one scanned JSON descriptor with a regular-file + size guard, so a
632
+ * pathological user file is skipped with a warning instead of stalling or
633
+ * OOM-ing the host at startup. Returns the Stats, or undefined when the
634
+ * caller must skip the file (a warning was recorded).
635
+ */
636
+ function guardedScannedJsonStat(
637
+ file: string,
638
+ options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
639
+ what: string,
640
+ ): ReturnType<typeof statSync> | undefined {
641
+ let st: ReturnType<typeof statSync>
642
+ try {
643
+ st = statSync(file)
644
+ } catch {
645
+ return undefined
646
+ }
647
+ const warn = (message: string): void => {
648
+ options.warnings?.push(file + ': ' + message)
649
+ options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message })
650
+ }
651
+ if (!st.isFile()) {
652
+ warn(what + ' is not a regular file; ignored')
653
+ return undefined
654
+ }
655
+ if (st.size > PET_SCAN_JSON_CAP) {
656
+ warn(what + ' exceeds the ' + PET_SCAN_JSON_CAP + '-byte scan ceiling; ignored')
657
+ return undefined
658
+ }
659
+ return st
660
+ }
661
+
662
+ /**
663
+ * Load and normalize one optional voice.json (pet-center M4). A missing
664
+ * file is silent; a broken file warns and drops. The pack is pure content,
665
+ * so every issue stays a warning — a bad voice.json never rejects a pet.
666
+ */
667
+ function loadVoicePackFile(
668
+ file: string,
669
+ options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
670
+ ): VoicePack | undefined {
671
+ if (!existsSync(file)) return undefined
672
+ if (guardedScannedJsonStat(file, options, 'voice pack') === undefined) return undefined
673
+ const warn = (message: string): void => {
674
+ options.warnings?.push(file + ': ' + message)
675
+ options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message })
676
+ }
677
+ let raw: unknown
678
+ try {
679
+ raw = JSON.parse(readFileSync(file, 'utf8'))
680
+ } catch (error) {
681
+ warn('voice pack is not valid JSON; ignored: ' + (error instanceof Error ? error.message : String(error)))
682
+ return undefined
683
+ }
684
+ return normalizeVoicePack(raw, warn)
685
+ }
686
+
687
+ /** Decoration asset URL prefix (served by the decoration route, M5). */
688
+ export const DECORATION_ASSET_PREFIX = '/api/pet/decoration'
689
+
690
+ /**
691
+ * Scan one directory of decoration folders ('decoration.json' + strip).
692
+ * Later scans override earlier ones on id collision; a bad descriptor warns
693
+ * and skips — the never-throw philosophy holds for decorations too (M5).
694
+ */
695
+ function scanDecorationDir(dir: string, options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] }): DecorationEntry[] {
696
+ if (!existsSync(dir)) return []
697
+ let names: string[] = []
698
+ try {
699
+ names = readdirSync(dir).filter(name => !name.startsWith('.'))
700
+ } catch {
701
+ return []
702
+ }
703
+ names.sort()
704
+ const entries: DecorationEntry[] = []
705
+ for (const name of names) {
706
+ const entryDir = join(dir, name)
707
+ const manifestFile = join(entryDir, 'decoration.json')
708
+ if (!existsSync(manifestFile)) continue
709
+ if (guardedScannedJsonStat(manifestFile, options, 'decoration descriptor') === undefined) continue
710
+ let raw: unknown
711
+ try {
712
+ raw = JSON.parse(readFileSync(manifestFile, 'utf8'))
713
+ } catch (error) {
714
+ const message = 'skipping ' + manifestFile + ': ' + (error instanceof Error ? error.message : String(error))
715
+ options.warnings?.push(message)
716
+ options.diagnostics?.push({ level: 'error', source: entryDir, message })
717
+ continue
718
+ }
719
+ const verdict = parseDecorationManifest(raw, manifestFile)
720
+ for (const diagnostic of verdict.diagnostics) {
721
+ options.diagnostics?.push({ level: diagnostic.level, source: entryDir, message: diagnostic.message })
722
+ options.warnings?.push(diagnostic.message)
723
+ }
724
+ if (!verdict.ok) continue
725
+ const manifest = verdict.manifest
726
+ // A missing strip keeps the entry listed (mirroring the live2d closure
727
+ // discipline) but earns a diagnostic: the ornament will silently render
728
+ // nothing, and the warning names the file to fix.
729
+ if (!existsSync(join(entryDir, manifest.entry))) {
730
+ const message = 'decoration ' + manifest.id + ': strip file missing: ' + manifest.entry
731
+ options.warnings?.push(message)
732
+ options.diagnostics?.push({ level: 'warning', source: entryDir, message })
733
+ }
734
+ entries.push({
735
+ apiVersion: PET_DECORATION_API_VERSION,
736
+ id: manifest.id,
737
+ dir: entryDir,
738
+ entryPath: manifest.entry,
739
+ servable: ['decoration.json', manifest.entry],
740
+ license: manifest.license,
741
+ assetBase: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id),
742
+ entryUrl: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id) + '/' + manifest.entry,
743
+ cell: manifest.cell,
744
+ columns: manifest.columns,
745
+ durations: manifest.durations,
746
+ loop: manifest.loop,
747
+ phases: manifest.phases,
748
+ })
749
+ }
750
+ return entries
751
+ }
752
+
582
753
  /**
583
754
  * Load the pet registry: built-in 'assets/*' first, then the hatch-pet
584
755
  * custom pets directory, then composed 'extra' manifests (each later source
@@ -611,11 +782,14 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
611
782
 
612
783
  // The pet-center user directory ranks above the legacy hatch-pet source.
613
784
  const dshPetsDir = options.dshPetsDir ?? join(dshHome(), 'pets')
785
+ let globalVoice: VoicePack | undefined
614
786
  if (dshPetsDir !== '') {
615
787
  for (const entry of scanPetDir(dshPetsDir, { assetPrefix, warnings, diagnostics })) {
616
788
  if (byId.has(entry.id)) warnings.push('user pet ' + entry.id + ' overrides an earlier registration')
617
789
  byId.set(entry.id, entry)
618
790
  }
791
+ // The global voice override layers under every per-pet pack (M4, #677).
792
+ globalVoice = loadVoicePackFile(join(dshPetsDir, '.voice.json'), { warnings, diagnostics })
619
793
  }
620
794
 
621
795
  for (const manifest of options.extra ?? []) {
@@ -636,18 +810,63 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
636
810
  byId.set(entry.id, entry)
637
811
  }
638
812
 
813
+ // Status decorations (pet-center M5, #567): built-in entries first,
814
+ // then same-id user entries under '$DSH_HOME/pets/decorations' override.
815
+ const decorationById = new Map<string, DecorationEntry>()
816
+ for (const entry of scanDecorationDir(join(packageRoot, 'assets', 'decorations'), { warnings, diagnostics })) {
817
+ decorationById.set(entry.id, entry)
818
+ }
819
+ if (dshPetsDir !== '') {
820
+ for (const entry of scanDecorationDir(join(dshPetsDir, 'decorations'), { warnings, diagnostics })) {
821
+ if (decorationById.has(entry.id)) {
822
+ warnings.push('user decoration ' + entry.id + ' overrides the built-in one')
823
+ }
824
+ decorationById.set(entry.id, entry)
825
+ }
826
+ }
827
+
639
828
  const entries = [...byId.values()]
829
+ const decorations = [...decorationById.values()]
640
830
  return {
641
831
  entries,
642
832
  warnings,
643
833
  diagnostics,
644
834
  byId: (id: string) => byId.get(id),
645
835
  defaultEntry: () => entries.find(entry => builtinIds.has(entry.id)) ?? entries[0]!,
836
+ ...(globalVoice === undefined ? {} : { globalVoice }),
837
+ decorations,
838
+ decorationById: (id: string) => decorationById.get(id),
646
839
  }
647
840
  }
648
841
 
649
- /** Strip host-only fields, leaving the client-visible definition. */
650
- export function petEntryView(entry: PetEntry): PetDefinition {
842
+ /** The built-in default decoration id (M5): the first reference ornament. */
843
+ export const DEFAULT_DECORATION_ID = 'whale'
844
+
845
+ /** Strip host-only fields, leaving the browser-visible decoration view. */
846
+ export function decorationView(entry: DecorationEntry): DecorationView {
847
+ return {
848
+ apiVersion: PET_DECORATION_API_VERSION,
849
+ id: entry.id,
850
+ assetBase: entry.assetBase,
851
+ entryUrl: entry.entryUrl,
852
+ cell: entry.cell,
853
+ columns: entry.columns,
854
+ durations: entry.durations,
855
+ loop: entry.loop,
856
+ phases: entry.phases,
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Strip host-only fields, leaving the client-visible definition. When the
862
+ * registry carries a global voice pack, its panel chrome layers under the
863
+ * entry's own pack (per-slot merge, pet > global), mirroring the voice-pool
864
+ * layering (pet-center M4, issue #677).
865
+ */
866
+ export function petEntryView(entry: PetEntry, globalVoice?: VoicePack): PetDefinition {
867
+ const panel = globalVoice === undefined
868
+ ? entry.voice?.panel
869
+ : mergeVoicePacks(globalVoice, entry.voice)?.panel
651
870
  return {
652
871
  id: entry.id,
653
872
  displayName: entry.displayName,
@@ -662,6 +881,7 @@ export function petEntryView(entry: PetEntry): PetDefinition {
662
881
  ...(entry.sequences === undefined ? {} : { sequences: entry.sequences }),
663
882
  atlasUrl: entry.atlasUrl,
664
883
  manifestUrl: entry.manifestUrl,
884
+ ...(panel === undefined ? {} : { panel }),
665
885
  }
666
886
  }
667
887
 
package/src/routes.ts CHANGED
@@ -16,7 +16,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
16
16
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
17
17
  import type { PetService } from './service.ts'
18
18
  import type { PetInteraction } from './affinity.ts'
19
- import { petEntryView, petPackageRoot, type PetEntry, type PetRegistry } from './registry.ts'
19
+ import { DECORATION_ASSET_PREFIX, petEntryView, petPackageRoot, type PetEntry, type PetRegistry } from './registry.ts'
20
20
  import { isLoopbackRequest } from './loopback.ts'
21
21
  import { dshHome } from './dsh-home.ts'
22
22
 
@@ -263,7 +263,7 @@ function assetHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['hand
263
263
  file = existsSync(preview) ? preview : undefined
264
264
  }
265
265
  if (synthesized) {
266
- const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), 'utf8')
266
+ const body = Buffer.from(JSON.stringify(petEntryView(entry, registry.globalVoice), null, 2), 'utf8')
267
267
  res.writeHead(200, {
268
268
  'content-type': 'application/json; charset=utf-8',
269
269
  'content-length': String(body.byteLength),
@@ -427,6 +427,125 @@ function runtimeHandler(roots: { runtimeDir: string; vendorDir: string }): WebRo
427
427
  }
428
428
  }
429
429
 
430
+ /**
431
+ * The decoration asset handler behind '/api/pet/decoration/<id>/<file>'
432
+ * (pet-center M5, #567). Serves exactly the files a decoration descriptor
433
+ * declares — decoration.json and the PNG/WebP strip — by exact allow-list
434
+ * match, with realpath containment and the same size ceilings as pet
435
+ * assets. Crafted '..' or '.' segments never match the normalized closure.
436
+ */
437
+ function decorationHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
438
+ return (req: IncomingMessage, res: ServerResponse): void => {
439
+ if (!guard(req, res)) return
440
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
441
+ res.writeHead(405)
442
+ res.end()
443
+ return
444
+ }
445
+ let pathname: string
446
+ try {
447
+ pathname = new URL(req.url ?? '/', 'http://pet.local').pathname
448
+ } catch {
449
+ res.writeHead(400)
450
+ res.end()
451
+ return
452
+ }
453
+ const segments = pathname.split('/').filter(segment => segment !== '')
454
+ const prefixSegments = DECORATION_ASSET_PREFIX.split('/').filter(segment => segment !== '')
455
+ if (segments.length < prefixSegments.length + 2) {
456
+ res.writeHead(404)
457
+ res.end()
458
+ return
459
+ }
460
+ for (let i = 0; i < prefixSegments.length; i += 1) {
461
+ if (segments[i] !== prefixSegments[i]) {
462
+ res.writeHead(404)
463
+ res.end()
464
+ return
465
+ }
466
+ }
467
+ let id: string
468
+ try {
469
+ id = decodeURIComponent(segments[prefixSegments.length])
470
+ } catch {
471
+ res.writeHead(400)
472
+ res.end()
473
+ return
474
+ }
475
+ const entry = registry.decorationById?.(id)
476
+ if (entry === undefined) {
477
+ res.writeHead(404)
478
+ res.end()
479
+ return
480
+ }
481
+ const rest: string[] = []
482
+ for (const segment of segments.slice(prefixSegments.length + 1)) {
483
+ let decoded: string
484
+ try {
485
+ decoded = decodeURIComponent(segment)
486
+ } catch {
487
+ res.writeHead(400)
488
+ res.end()
489
+ return
490
+ }
491
+ rest.push(decoded)
492
+ }
493
+ const rel = rest.join('/')
494
+ if (!entry.servable.includes(rel)) {
495
+ res.writeHead(404)
496
+ res.end()
497
+ return
498
+ }
499
+ const file = join(entry.dir, rel)
500
+ const resolved = containedRealpath(entry.dir, file)
501
+ if (resolved === undefined) {
502
+ res.writeHead(403)
503
+ res.end()
504
+ return
505
+ }
506
+ const cap = rel === 'decoration.json' ? caps.manifest : caps.image
507
+ let stat: ReturnType<typeof statSync>
508
+ try {
509
+ stat = statSync(resolved)
510
+ if (stat.size > cap) {
511
+ res.writeHead(413)
512
+ res.end()
513
+ return
514
+ }
515
+ } catch {
516
+ res.writeHead(404)
517
+ res.end()
518
+ return
519
+ }
520
+ // Weak ETag from size + mtime: 'no-cache' forces revalidation, and the
521
+ // validator lets repeat requests settle as 304 — the ornament remounts
522
+ // on whisper and display-session flips, and without a validator each
523
+ // remount would re-download the full strip body.
524
+ const etag = '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"'
525
+ if (req.headers['if-none-match'] === etag) {
526
+ res.writeHead(304, { etag, 'cache-control': 'no-cache' })
527
+ res.end()
528
+ return
529
+ }
530
+ readFile(resolved).then((body) => {
531
+ res.writeHead(200, {
532
+ 'content-type': mimeFor(resolved),
533
+ 'content-length': String(body.byteLength),
534
+ 'cache-control': 'no-cache',
535
+ etag,
536
+ })
537
+ if (req.method === 'HEAD') {
538
+ res.end()
539
+ return
540
+ }
541
+ res.end(body)
542
+ }, () => {
543
+ res.writeHead(404)
544
+ res.end()
545
+ })
546
+ }
547
+ }
548
+
430
549
  /** Build the full route family (API + assets + runtime) for one service. */
431
550
  export function makePetRoutes(deps: { service: PetService; assetCaps?: PetAssetCaps } & PetRuntimeRoots): WebRoute[] {
432
551
  const { service } = deps
@@ -477,7 +596,13 @@ export function makePetRoutes(deps: { service: PetService; assetCaps?: PetAssetC
477
596
  }),
478
597
  }
479
598
 
480
- return [...apiRoutes, assetRoute, runtimeRoute]
599
+ const decorationRoute: WebRoute = {
600
+ kind: 'prefix',
601
+ path: DECORATION_ASSET_PREFIX,
602
+ handler: decorationHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
603
+ }
604
+
605
+ return [...apiRoutes, assetRoute, runtimeRoute, decorationRoute]
481
606
  }
482
607
 
483
608
  // Re-exported for the package surface (the registry owns the definition now).
package/src/service.ts CHANGED
@@ -36,6 +36,8 @@ import {
36
36
  type PetDisplayConfig,
37
37
  } from './persist.ts'
38
38
  import {
39
+ DEFAULT_DECORATION_ID,
40
+ decorationView,
39
41
  loadPetRegistry,
40
42
  petEntryView,
41
43
  petPackageRoot,
@@ -44,7 +46,9 @@ import {
44
46
  type PetRegistry,
45
47
  type PetRegistryDiagnostic,
46
48
  } from './registry.ts'
47
- import { WHISPER_TTL_MS } from './chatter.ts'
49
+ import { WHISPER_TTL_MS, type VoicePackOverrides, type VoicePoolsProvider } from './chatter.ts'
50
+ import { mergeVoicePacks } from './voice-pack.ts'
51
+ import type { DecorationView } from './contracts/status-decoration.ts'
48
52
  import {
49
53
  defaultPetStateConfig,
50
54
  PetStateMachine,
@@ -65,6 +69,8 @@ export interface PetConfig {
65
69
  persistDir?: string
66
70
  /** Master switch for the plugin (browser half + host routes). */
67
71
  enabled?: boolean
72
+ /** Status-decoration master switch (pet-center M5, #567); defaults to on. */
73
+ decorationEnabled?: boolean
68
74
  /** Prebuilt registry (tests); defaults to scanning the package + user dirs. */
69
75
  registry?: PetRegistry
70
76
  /** Extra manifest entries composed by the embedding application. */
@@ -91,6 +97,11 @@ export interface PetSettingsSection {
91
97
  bottom: number
92
98
  /** Master switch for the plugin (browser half + host routes). */
93
99
  enabled?: boolean
100
+ /**
101
+ * Status-decoration master switch (pet-center M5, #567). Defaults to on;
102
+ * the settings surface mirrors this field and can turn it off.
103
+ */
104
+ decorationEnabled?: boolean
94
105
  }
95
106
 
96
107
  /** Settings namespace of the pet capability. Spelled here rather than imported: the browser half spells the same value. */
@@ -159,6 +170,12 @@ export interface PetStateView {
159
170
  * rendered by the client as a distinct whisper bubble.
160
171
  */
161
172
  whisper?: string
173
+ /**
174
+ * The active status decoration (pet-center M5, #567), when the master
175
+ * switch is on and the default decoration entry exists. Absent means the
176
+ * browser half renders no ornament.
177
+ */
178
+ decoration?: DecorationView
162
179
  }
163
180
 
164
181
  /** Result of `pet.interact`. */
@@ -200,9 +217,17 @@ export class PetService extends Service {
200
217
  private readonly registry: PetRegistry
201
218
  private readonly persistDir: string
202
219
  private enabled: boolean
220
+ /** Status-decoration master switch (M5, #567); mirrored from settings. */
221
+ private decorationEnabled: boolean
203
222
  private disposeActivity: (() => void) | undefined
204
223
  /** Session whose most recent meaningful event currently drives the global pet. */
205
224
  private displaySession: Session | undefined
225
+ /**
226
+ * Effective voice-pack overrides for the currently selected pet (M4,
227
+ * #677). Cached per pet id; the registry is an immutable snapshot, so the
228
+ * global pack and each entry's pack cannot change behind the cache.
229
+ */
230
+ private voiceCache: { petId: string; overrides: VoicePackOverrides } | undefined
206
231
  /**
207
232
  * Per-session activity, most recent last (Map insertion order). Bounded by
208
233
  * MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
@@ -237,10 +262,28 @@ export class PetService extends Service {
237
262
  this.stateConfig = { ...defaultPetStateConfig, ...(config.state ?? {}) }
238
263
  this.machine = new PetStateMachine(this.stateConfig)
239
264
  this.enabled = config.enabled ?? true
265
+ this.decorationEnabled = config.decorationEnabled ?? true
240
266
 
241
267
  this.syncActivity()
242
268
  }
243
269
 
270
+ /**
271
+ * The draw-time voice-pool provider handed to every projection runtime.
272
+ * It re-resolves when the selected pet changes, so live engines re-voice
273
+ * on the next draw without being rebuilt (M4, #677).
274
+ */
275
+ private voicePools(): VoicePoolsProvider {
276
+ return () => {
277
+ const entry = this.activeEntry()
278
+ if (this.voiceCache !== undefined && this.voiceCache.petId === entry.id) {
279
+ return this.voiceCache.overrides
280
+ }
281
+ const overrides = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.overrides ?? {}
282
+ this.voiceCache = { petId: entry.id, overrides }
283
+ return overrides
284
+ }
285
+ }
286
+
244
287
  /** Whether the pet service consumes session activity while enabled. */
245
288
  isEnabled(): boolean {
246
289
  return this.enabled
@@ -258,7 +301,7 @@ export class PetService extends Service {
258
301
 
259
302
  /** RPC: the registry entries the browser half renders and selects from. */
260
303
  async pets(): Promise<PetDefinition[]> {
261
- return this.registry.entries.map(petEntryView)
304
+ return this.registry.entries.map(entry => petEntryView(entry, this.registry.globalVoice))
262
305
  }
263
306
 
264
307
  /** The loaded registry (the asset routes serve its entries). */
@@ -271,6 +314,16 @@ export class PetService extends Service {
271
314
  return { diagnostics: this.registry.diagnostics }
272
315
  }
273
316
 
317
+ /**
318
+ * The active status decoration view (M5, #567): the default 'whale' entry
319
+ * (user directories override built-ins by id), gated by the master switch.
320
+ */
321
+ private activeDecoration(): DecorationView | undefined {
322
+ if (!this.decorationEnabled) return undefined
323
+ const entry = this.registry.decorationById?.(DEFAULT_DECORATION_ID)
324
+ return entry === undefined ? undefined : decorationView(entry)
325
+ }
326
+
274
327
  /** The selected pet's registry entry. */
275
328
  activeEntry(): NonNullable<PetRegistry['entries'][number]> {
276
329
  return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry()
@@ -370,7 +423,7 @@ export class PetService extends Service {
370
423
  let activity = this.sessionActivity.get(session)
371
424
  if (activity === undefined) {
372
425
  activity = {
373
- runtime: emptyProjectionRuntime(),
426
+ runtime: emptyProjectionRuntime(this.voicePools()),
374
427
  machine: new PetStateMachine(this.stateConfig),
375
428
  }
376
429
  this.sessionActivity.set(session, activity)
@@ -449,6 +502,7 @@ export class PetService extends Service {
449
502
  * @param section - the resolved settings section.
450
503
  */
451
504
  applySettingsSection(section: PetSettingsSection): void {
505
+ this.decorationEnabled = section.decorationEnabled ?? true
452
506
  const selected = typeof section.petId === 'string' ? this.registry.byId(section.petId) : undefined
453
507
  if (selected !== undefined) {
454
508
  this.ledger.setPetId(selected.id)
@@ -524,6 +578,7 @@ export class PetService extends Service {
524
578
  const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
525
579
  ? whisper.text
526
580
  : undefined
581
+ const decoration = this.activeDecoration()
527
582
  // Read-only: the ledger settles on economic events only, never on a read,
528
583
  // so polling the state cannot trigger pet.json writes.
529
584
  return {
@@ -533,6 +588,7 @@ export class PetService extends Service {
533
588
  sessionActive: snapshot.sessionActive,
534
589
  sessions,
535
590
  ...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
591
+ ...(decoration === undefined ? {} : { decoration }),
536
592
  affinity: this.ledger.affinityView(Date.now()),
537
593
  display: { ...this.ledger.snapshot.display },
538
594
  pet: {