@linxin666/dsh-pet 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +65 -2
  3. package/README.zh.md +65 -2
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/contracts/pet-manifest-v2.schema.json +281 -0
  7. package/contracts/status-decoration-v1.schema.json +144 -0
  8. package/contracts/voice-pack-v1.schema.json +234 -0
  9. package/lib/client.js +251 -43
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +1139 -100
  12. package/lib/types/access.d.ts +16 -0
  13. package/lib/types/access.d.ts.map +1 -0
  14. package/lib/types/access.js +20 -0
  15. package/lib/types/chatter.d.ts +61 -3
  16. package/lib/types/chatter.d.ts.map +1 -1
  17. package/lib/types/chatter.js +71 -12
  18. package/lib/types/client/PetSettingsCard.d.ts +4 -0
  19. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PetSettingsCard.js +11 -3
  21. package/lib/types/client/PetSprite.d.ts.map +1 -1
  22. package/lib/types/client/PetSprite.js +111 -4
  23. package/lib/types/client/locales.d.ts +4 -0
  24. package/lib/types/client/locales.d.ts.map +1 -1
  25. package/lib/types/client/locales.js +4 -0
  26. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
  27. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
  28. package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
  29. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
  30. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  31. package/lib/types/client/renderers/live2d.js +133 -25
  32. package/lib/types/client/settings-form.d.ts.map +1 -1
  33. package/lib/types/client/settings-form.js +9 -6
  34. package/lib/types/contracts/status-decoration.d.ts +85 -0
  35. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  36. package/lib/types/contracts/status-decoration.js +21 -0
  37. package/lib/types/decoration.d.ts +39 -0
  38. package/lib/types/decoration.d.ts.map +1 -0
  39. package/lib/types/decoration.js +210 -0
  40. package/lib/types/event-projection.d.ts +8 -3
  41. package/lib/types/event-projection.d.ts.map +1 -1
  42. package/lib/types/event-projection.js +9 -4
  43. package/lib/types/image-dimensions.d.ts +26 -0
  44. package/lib/types/image-dimensions.d.ts.map +1 -0
  45. package/lib/types/image-dimensions.js +77 -0
  46. package/lib/types/index.d.ts +2 -0
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +3 -1
  49. package/lib/types/registry.d.ts +55 -2
  50. package/lib/types/registry.d.ts.map +1 -1
  51. package/lib/types/registry.js +240 -16
  52. package/lib/types/routes.d.ts +6 -1
  53. package/lib/types/routes.d.ts.map +1 -1
  54. package/lib/types/routes.js +163 -32
  55. package/lib/types/service.d.ts +33 -0
  56. package/lib/types/service.d.ts.map +1 -1
  57. package/lib/types/service.js +42 -3
  58. package/lib/types/voice-pack.d.ts +98 -0
  59. package/lib/types/voice-pack.d.ts.map +1 -0
  60. package/lib/types/voice-pack.js +384 -0
  61. package/package.json +11 -10
  62. package/src/access.ts +40 -0
  63. package/src/chatter.test.ts +89 -2
  64. package/src/chatter.ts +120 -14
  65. package/src/client/PetSettingsCard.tsx +26 -2
  66. package/src/client/PetSprite.test.tsx +325 -12
  67. package/src/client/PetSprite.tsx +150 -25
  68. package/src/client/locales.ts +4 -0
  69. package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
  70. package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
  71. package/src/client/renderers/live2d/runtime.ts +8 -2
  72. package/src/client/renderers/live2d.test.ts +233 -9
  73. package/src/client/renderers/live2d.ts +143 -30
  74. package/src/client/settings-form.ts +8 -6
  75. package/src/contracts/status-decoration.ts +78 -0
  76. package/src/decoration.test.ts +178 -0
  77. package/src/decoration.ts +220 -0
  78. package/src/event-projection.ts +10 -5
  79. package/src/image-dimensions.test.ts +85 -0
  80. package/src/image-dimensions.ts +76 -0
  81. package/src/index.ts +3 -1
  82. package/src/registry.test.ts +276 -0
  83. package/src/registry.ts +274 -15
  84. package/src/routes.ts +162 -33
  85. package/src/service.ts +59 -3
  86. package/src/voice-pack.test.ts +216 -0
  87. package/src/voice-pack.ts +413 -0
package/src/routes.ts CHANGED
@@ -5,7 +5,10 @@
5
5
  * domains are platform-registered, so the pet serves its own API and media —
6
6
  * the same pattern as dsh-remote-web-ui's '/api/pair' family. The asset route
7
7
  * is one prefix registration serving every registry entry (manifest, atlas,
8
- * optional previews), so adding a pet never touches route wiring.
8
+ * optional previews), so adding a pet never touches route wiring. Both the
9
+ * JSON API, the asset prefix, and the Live2D runtime prefix are loopback-only
10
+ * by default; a live paired-device cookie is an extra allow path when
11
+ * remote-web-ui is loaded.
9
12
  * @module @linxin666/dsh-pet/routes
10
13
  */
11
14
 
@@ -13,11 +16,12 @@ import { existsSync, realpathSync, statSync } from 'node:fs'
13
16
  import { readFile } from 'node:fs/promises'
14
17
  import { join, sep } from 'node:path'
15
18
  import type { IncomingMessage, ServerResponse } from 'node:http'
19
+ import type { Context } from '@deepseek-ai/cordis'
16
20
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
17
21
  import type { PetService } from './service.ts'
18
22
  import type { PetInteraction } from './affinity.ts'
19
- import { petEntryView, petPackageRoot, type PetEntry, type PetRegistry } from './registry.ts'
20
- import { isLoopbackRequest } from './loopback.ts'
23
+ import { DECORATION_ASSET_PREFIX, petEntryView, petPackageRoot, type PetEntry, type PetRegistry } from './registry.ts'
24
+ import { isPetAllowed } from './access.ts'
21
25
  import { dshHome } from './dsh-home.ts'
22
26
 
23
27
  /** Browser-facing base path of the pet API. */
@@ -135,20 +139,20 @@ function readJsonBody(req: IncomingMessage): Promise<unknown> {
135
139
  })
136
140
  }
137
141
 
138
- /** Shared route fence: the browser UI is a loopback client; LAN hosts stay out. */
139
- function guard(req: IncomingMessage, res: ServerResponse): boolean {
140
- if (isLoopbackRequest(req)) return true
142
+ /** Shared route fence: loopback always passes; a live paired-device cookie is an extra allow path. */
143
+ function guard(ctx: Context, req: IncomingMessage, res: ServerResponse): boolean {
144
+ if (isPetAllowed(ctx, req)) return true
141
145
  json(res, 403, { ok: false, error: 'forbidden: loopback-only' })
142
146
  return false
143
147
  }
144
148
 
145
149
  /** Wrap one async service call as a GET JSON route. */
146
- function getRoute(path: string, run: () => Promise<unknown>): WebRoute {
150
+ function getRoute(ctx: Context, path: string, run: () => Promise<unknown>): WebRoute {
147
151
  return {
148
152
  kind: 'exact',
149
153
  path,
150
154
  handler: (req: IncomingMessage, res: ServerResponse): void => {
151
- if (!guard(req, res)) return
155
+ if (!guard(ctx, req, res)) return
152
156
  if (!requireMethod(req, res, 'GET')) return
153
157
  run().then((value) => json(res, 200, value), (error) => {
154
158
  json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
@@ -158,12 +162,12 @@ function getRoute(path: string, run: () => Promise<unknown>): WebRoute {
158
162
  }
159
163
 
160
164
  /** Wrap one async service call as a POST JSON route (body passed through). */
161
- function postRoute(path: string, run: (body: Record<string, unknown>) => Promise<unknown>): WebRoute {
165
+ function postRoute(ctx: Context, path: string, run: (body: Record<string, unknown>) => Promise<unknown>): WebRoute {
162
166
  return {
163
167
  kind: 'exact',
164
168
  path,
165
169
  handler: (req: IncomingMessage, res: ServerResponse): Promise<void> => {
166
- if (!guard(req, res)) return Promise.resolve()
170
+ if (!guard(ctx, req, res)) return Promise.resolve()
167
171
  if (!requireMethod(req, res, 'POST')) return Promise.resolve()
168
172
  return readJsonBody(req).then((body) => {
169
173
  const record = (typeof body === 'object' && body !== null) ? body as Record<string, unknown> : {}
@@ -200,10 +204,10 @@ function dirAliases(registry: PetRegistry): Map<string, PetEntry> {
200
204
  * match; containedRealpath stays as the second layer. Composed pets without
201
205
  * a manifest file get a synthesized pet.json.
202
206
  */
203
- function assetHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
207
+ function assetHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
204
208
  const aliases = dirAliases(registry)
205
- return (req: IncomingMessage, res: ServerResponse): void => {
206
- if (!guard(req, res)) return
209
+ return ((req: IncomingMessage, res: ServerResponse) => {
210
+ if (!guard(ctx, req, res)) return
207
211
  if (req.method !== 'GET' && req.method !== 'HEAD') {
208
212
  res.writeHead(405)
209
213
  res.end()
@@ -263,7 +267,7 @@ function assetHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['hand
263
267
  file = existsSync(preview) ? preview : undefined
264
268
  }
265
269
  if (synthesized) {
266
- const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), 'utf8')
270
+ const body = Buffer.from(JSON.stringify(petEntryView(entry, registry.globalVoice), null, 2), 'utf8')
267
271
  res.writeHead(200, {
268
272
  'content-type': 'application/json; charset=utf-8',
269
273
  'content-length': String(body.byteLength),
@@ -303,7 +307,7 @@ function assetHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['hand
303
307
  res.end()
304
308
  return
305
309
  }
306
- readFile(resolved).then((body) => {
310
+ return readFile(resolved).then((body) => {
307
311
  res.writeHead(200, {
308
312
  'content-type': mimeFor(resolved),
309
313
  'content-length': String(body.byteLength),
@@ -318,7 +322,7 @@ function assetHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['hand
318
322
  res.writeHead(404)
319
323
  res.end()
320
324
  })
321
- }
325
+ }) as WebRoute['handler']
322
326
  }
323
327
 
324
328
  /** Browser-facing base path of the plugin runtime files (pet-center M3). */
@@ -354,9 +358,9 @@ export interface PetRuntimeRoots {
354
358
  * guidance (the Cubism Core is user-supplied, so its absence is a normal
355
359
  * state, not an error).
356
360
  */
357
- function runtimeHandler(roots: { runtimeDir: string; vendorDir: string }): WebRoute['handler'] {
358
- return (req: IncomingMessage, res: ServerResponse): void => {
359
- if (!guard(req, res)) return
361
+ function runtimeHandler(ctx: Context, roots: { runtimeDir: string; vendorDir: string }): WebRoute['handler'] {
362
+ return ((req: IncomingMessage, res: ServerResponse) => {
363
+ if (!guard(ctx, req, res)) return
360
364
  if (req.method !== 'GET' && req.method !== 'HEAD') {
361
365
  res.writeHead(405)
362
366
  res.end()
@@ -409,7 +413,7 @@ function runtimeHandler(roots: { runtimeDir: string; vendorDir: string }): WebRo
409
413
  res.end()
410
414
  return
411
415
  }
412
- readFile(resolved).then((body) => {
416
+ return readFile(resolved).then((body) => {
413
417
  res.writeHead(200, {
414
418
  'content-type': name.endsWith('.map') ? 'application/json' : 'application/javascript; charset=utf-8',
415
419
  'content-length': String(body.byteLength),
@@ -424,38 +428,157 @@ function runtimeHandler(roots: { runtimeDir: string; vendorDir: string }): WebRo
424
428
  res.writeHead(404)
425
429
  res.end()
426
430
  })
431
+ }) as WebRoute['handler']
432
+ }
433
+
434
+ /**
435
+ * The decoration asset handler behind '/api/pet/decoration/<id>/<file>'
436
+ * (pet-center M5, #567). Serves exactly the files a decoration descriptor
437
+ * declares — decoration.json and the PNG/WebP strip — by exact allow-list
438
+ * match, with realpath containment and the same size ceilings as pet
439
+ * assets. Crafted '..' or '.' segments never match the normalized closure.
440
+ */
441
+ function decorationHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
442
+ return (req: IncomingMessage, res: ServerResponse): void => {
443
+ if (!guard(ctx, req, res)) return
444
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
445
+ res.writeHead(405)
446
+ res.end()
447
+ return
448
+ }
449
+ let pathname: string
450
+ try {
451
+ pathname = new URL(req.url ?? '/', 'http://pet.local').pathname
452
+ } catch {
453
+ res.writeHead(400)
454
+ res.end()
455
+ return
456
+ }
457
+ const segments = pathname.split('/').filter(segment => segment !== '')
458
+ const prefixSegments = DECORATION_ASSET_PREFIX.split('/').filter(segment => segment !== '')
459
+ if (segments.length < prefixSegments.length + 2) {
460
+ res.writeHead(404)
461
+ res.end()
462
+ return
463
+ }
464
+ for (let i = 0; i < prefixSegments.length; i += 1) {
465
+ if (segments[i] !== prefixSegments[i]) {
466
+ res.writeHead(404)
467
+ res.end()
468
+ return
469
+ }
470
+ }
471
+ let id: string
472
+ try {
473
+ id = decodeURIComponent(segments[prefixSegments.length])
474
+ } catch {
475
+ res.writeHead(400)
476
+ res.end()
477
+ return
478
+ }
479
+ const entry = registry.decorationById?.(id)
480
+ if (entry === undefined) {
481
+ res.writeHead(404)
482
+ res.end()
483
+ return
484
+ }
485
+ const rest: string[] = []
486
+ for (const segment of segments.slice(prefixSegments.length + 1)) {
487
+ let decoded: string
488
+ try {
489
+ decoded = decodeURIComponent(segment)
490
+ } catch {
491
+ res.writeHead(400)
492
+ res.end()
493
+ return
494
+ }
495
+ rest.push(decoded)
496
+ }
497
+ const rel = rest.join('/')
498
+ if (!entry.servable.includes(rel)) {
499
+ res.writeHead(404)
500
+ res.end()
501
+ return
502
+ }
503
+ const file = join(entry.dir, rel)
504
+ const resolved = containedRealpath(entry.dir, file)
505
+ if (resolved === undefined) {
506
+ res.writeHead(403)
507
+ res.end()
508
+ return
509
+ }
510
+ const cap = rel === 'decoration.json' ? caps.manifest : caps.image
511
+ let stat: ReturnType<typeof statSync>
512
+ try {
513
+ stat = statSync(resolved)
514
+ if (stat.size > cap) {
515
+ res.writeHead(413)
516
+ res.end()
517
+ return
518
+ }
519
+ } catch {
520
+ res.writeHead(404)
521
+ res.end()
522
+ return
523
+ }
524
+ // Weak ETag from size + mtime: 'no-cache' forces revalidation, and the
525
+ // validator lets repeat requests settle as 304 — the ornament remounts
526
+ // on whisper and display-session flips, and without a validator each
527
+ // remount would re-download the full strip body.
528
+ const etag = '"' + stat.size.toString(16) + '-' + Math.round(stat.mtimeMs).toString(16) + '"'
529
+ if (req.headers['if-none-match'] === etag) {
530
+ res.writeHead(304, { etag, 'cache-control': 'no-cache' })
531
+ res.end()
532
+ return
533
+ }
534
+ readFile(resolved).then((body) => {
535
+ res.writeHead(200, {
536
+ 'content-type': mimeFor(resolved),
537
+ 'content-length': String(body.byteLength),
538
+ 'cache-control': 'no-cache',
539
+ etag,
540
+ })
541
+ if (req.method === 'HEAD') {
542
+ res.end()
543
+ return
544
+ }
545
+ res.end(body)
546
+ }, () => {
547
+ res.writeHead(404)
548
+ res.end()
549
+ })
427
550
  }
428
551
  }
429
552
 
430
553
  /** Build the full route family (API + assets + runtime) for one service. */
431
- export function makePetRoutes(deps: { service: PetService; assetCaps?: PetAssetCaps } & PetRuntimeRoots): WebRoute[] {
432
- const { service } = deps
554
+ export function makePetRoutes(deps: { service: PetService; ctx: Context; assetCaps?: PetAssetCaps } & PetRuntimeRoots): WebRoute[] {
555
+ const { service, ctx } = deps
433
556
  const apiRoutes: WebRoute[] = [
434
- getRoute(PET_API_PREFIX + '/state', () => service.state()),
435
- getRoute(PET_API_PREFIX + '/pets', () => service.pets()),
436
- getRoute(PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
437
- postRoute(PET_API_PREFIX + '/interact', (body) => {
557
+ getRoute(ctx, PET_API_PREFIX + '/state', () => service.state()),
558
+ getRoute(ctx, PET_API_PREFIX + '/pets', () => service.pets()),
559
+ getRoute(ctx, PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
560
+ postRoute(ctx, PET_API_PREFIX + '/interact', (body) => {
438
561
  const kind = body.kind as PetInteraction | undefined
439
562
  if (kind !== 'pet' && kind !== 'feed') return Promise.reject(new Error('invalid-kind'))
440
563
  return service.interact(kind)
441
564
  }),
442
- postRoute(PET_API_PREFIX + '/set-visible', (body) => {
565
+ postRoute(ctx, PET_API_PREFIX + '/set-visible', (body) => {
443
566
  const visible = body.visible
444
567
  if (typeof visible !== 'boolean') return Promise.reject(new Error('invalid-visible'))
445
568
  return service.setVisible(visible)
446
569
  }),
447
- postRoute(PET_API_PREFIX + '/set-config', (body) => service.setConfig({
570
+ postRoute(ctx, PET_API_PREFIX + '/set-config', (body) => service.setConfig({
448
571
  ...(typeof body.size === 'number' ? { size: body.size } : {}),
449
572
  ...(typeof body.right === 'number' ? { right: body.right } : {}),
450
573
  ...(typeof body.bottom === 'number' ? { bottom: body.bottom } : {}),
451
574
  ...(typeof body.visible === 'boolean' ? { visible: body.visible } : {}),
452
575
  })),
453
- postRoute(PET_API_PREFIX + '/set-name', (body) => {
576
+ postRoute(ctx, PET_API_PREFIX + '/set-name', (body) => {
454
577
  const name = body.name
455
578
  if (typeof name !== 'string') return Promise.reject(new Error('invalid-name'))
456
579
  return service.setName(name)
457
580
  }),
458
- postRoute(PET_API_PREFIX + '/set-pet', (body) => {
581
+ postRoute(ctx, PET_API_PREFIX + '/set-pet', (body) => {
459
582
  const petId = body.petId
460
583
  if (typeof petId !== 'string') return Promise.reject(new Error('invalid-pet'))
461
584
  return service.setPetId(petId)
@@ -465,19 +588,25 @@ export function makePetRoutes(deps: { service: PetService; assetCaps?: PetAssetC
465
588
  const assetRoute: WebRoute = {
466
589
  kind: 'prefix',
467
590
  path: PET_ASSET_PREFIX,
468
- handler: assetHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
591
+ handler: assetHandler(ctx, service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
469
592
  }
470
593
 
471
594
  const runtimeRoute: WebRoute = {
472
595
  kind: 'prefix',
473
596
  path: PET_RUNTIME_PREFIX,
474
- handler: runtimeHandler({
597
+ handler: runtimeHandler(ctx, {
475
598
  runtimeDir: deps.runtimeDir ?? join(dshHome(), 'pets', '.runtime'),
476
599
  vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), 'lib'),
477
600
  }),
478
601
  }
479
602
 
480
- return [...apiRoutes, assetRoute, runtimeRoute]
603
+ const decorationRoute: WebRoute = {
604
+ kind: 'prefix',
605
+ path: DECORATION_ASSET_PREFIX,
606
+ handler: decorationHandler(ctx, service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
607
+ }
608
+
609
+ return [...apiRoutes, assetRoute, runtimeRoute, decorationRoute]
481
610
  }
482
611
 
483
612
  // 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: {