@linxin666/dsh-pet 0.2.2 → 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 (125) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +129 -3
  3. package/README.zh.md +129 -3
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/assets/whale/pet.json +169 -20
  7. package/assets/whale-refined/pet.json +17 -2
  8. package/contracts/pet-manifest-v2.schema.json +281 -0
  9. package/contracts/status-decoration-v1.schema.json +144 -0
  10. package/contracts/voice-pack-v1.schema.json +234 -0
  11. package/lib/client.js +581 -38
  12. package/lib/client.js.map +1 -1
  13. package/lib/index.js +1684 -59
  14. package/lib/live2d-vendor.js +995 -0
  15. package/lib/live2d-vendor.js.map +1 -0
  16. package/lib/types/chatter.d.ts +61 -3
  17. package/lib/types/chatter.d.ts.map +1 -1
  18. package/lib/types/chatter.js +71 -12
  19. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  20. package/lib/types/client/PetDockEntry.js +2 -1
  21. package/lib/types/client/PetSettingsCard.d.ts +14 -0
  22. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  23. package/lib/types/client/PetSettingsCard.js +24 -1
  24. package/lib/types/client/PetSprite.d.ts +7 -1
  25. package/lib/types/client/PetSprite.d.ts.map +1 -1
  26. package/lib/types/client/PetSprite.js +120 -12
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/index.js +5 -0
  29. package/lib/types/client/locales.d.ts +14 -0
  30. package/lib/types/client/locales.d.ts.map +1 -1
  31. package/lib/types/client/locales.js +14 -0
  32. package/lib/types/client/phase-stream.d.ts +22 -0
  33. package/lib/types/client/phase-stream.d.ts.map +1 -0
  34. package/lib/types/client/phase-stream.js +28 -0
  35. package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
  36. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
  37. package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
  38. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
  39. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
  40. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
  41. package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
  42. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
  43. package/lib/types/client/renderers/live2d/runtime.js +63 -0
  44. package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
  45. package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
  46. package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
  47. package/lib/types/client/renderers/live2d.d.ts +52 -0
  48. package/lib/types/client/renderers/live2d.d.ts.map +1 -0
  49. package/lib/types/client/renderers/live2d.js +192 -0
  50. package/lib/types/client/renderers/registry.d.ts +32 -0
  51. package/lib/types/client/renderers/registry.d.ts.map +1 -0
  52. package/lib/types/client/renderers/registry.js +49 -0
  53. package/lib/types/client/settings-form.d.ts.map +1 -1
  54. package/lib/types/client/settings-form.js +9 -6
  55. package/lib/types/contracts/renderer.d.ts +50 -0
  56. package/lib/types/contracts/renderer.d.ts.map +1 -0
  57. package/lib/types/contracts/renderer.js +15 -0
  58. package/lib/types/contracts/status-decoration.d.ts +85 -0
  59. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  60. package/lib/types/contracts/status-decoration.js +21 -0
  61. package/lib/types/decoration.d.ts +39 -0
  62. package/lib/types/decoration.d.ts.map +1 -0
  63. package/lib/types/decoration.js +210 -0
  64. package/lib/types/event-projection.d.ts +8 -3
  65. package/lib/types/event-projection.d.ts.map +1 -1
  66. package/lib/types/event-projection.js +9 -4
  67. package/lib/types/index.d.ts +2 -0
  68. package/lib/types/index.d.ts.map +1 -1
  69. package/lib/types/index.js +2 -0
  70. package/lib/types/manifest-v2.d.ts +129 -0
  71. package/lib/types/manifest-v2.d.ts.map +1 -0
  72. package/lib/types/manifest-v2.js +387 -0
  73. package/lib/types/model3.d.ts +26 -0
  74. package/lib/types/model3.d.ts.map +1 -0
  75. package/lib/types/model3.js +96 -0
  76. package/lib/types/registry.d.ts +112 -5
  77. package/lib/types/registry.d.ts.map +1 -1
  78. package/lib/types/registry.js +395 -38
  79. package/lib/types/routes.d.ts +39 -2
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +312 -13
  82. package/lib/types/service.d.ts +38 -1
  83. package/lib/types/service.d.ts.map +1 -1
  84. package/lib/types/service.js +46 -3
  85. package/lib/types/voice-pack.d.ts +98 -0
  86. package/lib/types/voice-pack.d.ts.map +1 -0
  87. package/lib/types/voice-pack.js +384 -0
  88. package/package.json +13 -10
  89. package/src/chatter.test.ts +89 -2
  90. package/src/chatter.ts +120 -14
  91. package/src/client/PetDockEntry.tsx +19 -11
  92. package/src/client/PetSettingsCard.tsx +57 -0
  93. package/src/client/PetSprite.test.tsx +265 -12
  94. package/src/client/PetSprite.tsx +165 -33
  95. package/src/client/index.ts +6 -0
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/phase-stream.test.ts +76 -0
  98. package/src/client/phase-stream.ts +39 -0
  99. package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
  100. package/src/client/renderers/PetRendererSwitch.tsx +48 -0
  101. package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
  102. package/src/client/renderers/live2d/runtime.test.ts +61 -0
  103. package/src/client/renderers/live2d/runtime.ts +119 -0
  104. package/src/client/renderers/live2d/vendor-entry.ts +14 -0
  105. package/src/client/renderers/live2d.test.ts +263 -0
  106. package/src/client/renderers/live2d.ts +231 -0
  107. package/src/client/renderers/registry.ts +58 -0
  108. package/src/client/settings-form.ts +8 -6
  109. package/src/client/settings-section.module.css +23 -0
  110. package/src/contracts/renderer.ts +54 -0
  111. package/src/contracts/status-decoration.ts +78 -0
  112. package/src/decoration.test.ts +178 -0
  113. package/src/decoration.ts +220 -0
  114. package/src/event-projection.ts +10 -5
  115. package/src/index.ts +2 -0
  116. package/src/manifest-v2.test.ts +251 -0
  117. package/src/manifest-v2.ts +414 -0
  118. package/src/model3.test.ts +79 -0
  119. package/src/model3.ts +91 -0
  120. package/src/registry.test.ts +438 -2
  121. package/src/registry.ts +470 -38
  122. package/src/routes.ts +328 -14
  123. package/src/service.ts +65 -3
  124. package/src/voice-pack.test.ts +216 -0
  125. package/src/voice-pack.ts +413 -0
package/src/routes.ts CHANGED
@@ -9,15 +9,16 @@
9
9
  * @module @linxin666/dsh-pet/routes
10
10
  */
11
11
 
12
- import { existsSync } from 'node:fs'
12
+ import { existsSync, realpathSync, statSync } from 'node:fs'
13
13
  import { readFile } from 'node:fs/promises'
14
- import { join } from 'node:path'
14
+ import { join, sep } from 'node:path'
15
15
  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, 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
+ import { dshHome } from './dsh-home.ts'
21
22
 
22
23
  /** Browser-facing base path of the pet API. */
23
24
  export const PET_API_PREFIX = '/api/pet'
@@ -29,6 +30,53 @@ const MANIFEST_FILE = 'pet.json'
29
30
  const PREVIEW_DIR = 'previews'
30
31
  const PREVIEW_PATTERN = /^[A-Za-z0-9._-]+$/
31
32
 
33
+ /**
34
+ * Per-class size ceilings for served pet assets, in bytes (pet-center M2 P3,
35
+ * issue #623). Constants are tested directly; makePetRoutes accepts an
36
+ * override so tests can exercise the 413 path with tiny caps.
37
+ */
38
+ export const PET_ASSET_CAPS = {
39
+ /** pet.json manifest. */
40
+ manifest: 64 * 1024,
41
+ /** Atlas, preview and Live2D texture imagery. */
42
+ image: 20 * 1024 * 1024,
43
+ /** Live2D model closure files (.moc3, motion/physics/expression JSON; M3). */
44
+ model: 32 * 1024 * 1024,
45
+ } as const
46
+
47
+ /** Size-cap profile the asset route enforces (test seam). */
48
+ export interface PetAssetCaps {
49
+ manifest: number
50
+ image: number
51
+ model: number
52
+ }
53
+
54
+ /** Imagery extensions classify into the image cap; everything else served from a closure is model-class. */
55
+ const IMAGE_EXTENSIONS: ReadonlySet<string> = new Set(['.webp', '.png', '.gif', '.jpg', '.jpeg'])
56
+
57
+ /** Lowercased file extension ('' when none). */
58
+ function extensionOf(file: string): string {
59
+ const dot = file.lastIndexOf('.')
60
+ return dot < 0 ? '' : file.slice(dot).toLowerCase()
61
+ }
62
+
63
+ /**
64
+ * realpath containment: resolve both sides and require the candidate to stay
65
+ * inside the base directory. A pet directory (or an atlas/preview inside it)
66
+ * that is a symlink escaping its root is rejected, never followed.
67
+ */
68
+ export function containedRealpath(base: string, candidate: string): string | undefined {
69
+ try {
70
+ const realBase = realpathSync(base)
71
+ const realCandidate = realpathSync(candidate)
72
+ return realCandidate === realBase || realCandidate.startsWith(realBase + sep)
73
+ ? realCandidate
74
+ : undefined
75
+ } catch {
76
+ return undefined
77
+ }
78
+ }
79
+
32
80
  const MIME_BY_EXT: Readonly<Record<string, string>> = {
33
81
  '.webp': 'image/webp',
34
82
  '.png': 'image/png',
@@ -145,10 +193,14 @@ function dirAliases(registry: PetRegistry): Map<string, PetEntry> {
145
193
  /**
146
194
  * The one asset handler behind the '/pet' prefix. Resolves the pet by id (or
147
195
  * legacy directory alias), then serves exactly the files a manifest declares:
148
- * pet.json, the declared spritesheet path, and optional 'previews/<name>'
149
- * media. Composed pets without a manifest file get a synthesized pet.json.
196
+ * pet.json, the entry's servable set (the sprite2d atlas, or the live2d
197
+ * model3.json plus its reference closure pet-center M3), and optional
198
+ * 'previews/<name>' media. The servable match is an exact string comparison
199
+ * against scan-time normalized paths, so crafted '..' or '.' segments never
200
+ * match; containedRealpath stays as the second layer. Composed pets without
201
+ * a manifest file get a synthesized pet.json.
150
202
  */
151
- function assetHandler(registry: PetRegistry): WebRoute['handler'] {
203
+ function assetHandler(registry: PetRegistry, caps: PetAssetCaps): WebRoute['handler'] {
152
204
  const aliases = dirAliases(registry)
153
205
  return (req: IncomingMessage, res: ServerResponse): void => {
154
206
  if (!guard(req, res)) return
@@ -204,14 +256,14 @@ function assetHandler(registry: PetRegistry): WebRoute['handler'] {
204
256
  const manifestFile = join(entry.dir, MANIFEST_FILE)
205
257
  file = existsSync(manifestFile) ? manifestFile : undefined
206
258
  if (file === undefined) synthesized = true
207
- } else if (rest.length > 0 && rel === entry.spritesheetPath) {
208
- file = join(entry.dir, entry.spritesheetPath)
259
+ } else if (rest.length > 0 && entry.servable.includes(rel)) {
260
+ file = join(entry.dir, rel)
209
261
  } else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1]!)) {
210
262
  const preview = join(entry.dir, PREVIEW_DIR, rest[1]!)
211
263
  file = existsSync(preview) ? preview : undefined
212
264
  }
213
265
  if (synthesized) {
214
- 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')
215
267
  res.writeHead(200, {
216
268
  'content-type': 'application/json; charset=utf-8',
217
269
  'content-length': String(body.byteLength),
@@ -229,12 +281,258 @@ function assetHandler(registry: PetRegistry): WebRoute['handler'] {
229
281
  res.end()
230
282
  return
231
283
  }
232
- const resolved = file
284
+ // realpath containment: a symlink escaping the pet directory is refused.
285
+ const resolved = containedRealpath(entry.dir, file)
286
+ if (resolved === undefined) {
287
+ res.writeHead(403)
288
+ res.end()
289
+ return
290
+ }
291
+ // Enforce the size ceiling before the file is read into memory.
292
+ const cap = rest.length === 1 && rest[0] === MANIFEST_FILE
293
+ ? caps.manifest
294
+ : IMAGE_EXTENSIONS.has(extensionOf(rel)) ? caps.image : caps.model
295
+ try {
296
+ if (statSync(resolved).size > cap) {
297
+ res.writeHead(413)
298
+ res.end()
299
+ return
300
+ }
301
+ } catch {
302
+ res.writeHead(404)
303
+ res.end()
304
+ return
305
+ }
306
+ readFile(resolved).then((body) => {
307
+ res.writeHead(200, {
308
+ 'content-type': mimeFor(resolved),
309
+ 'content-length': String(body.byteLength),
310
+ 'cache-control': 'no-cache',
311
+ })
312
+ if (req.method === 'HEAD') {
313
+ res.end()
314
+ return
315
+ }
316
+ res.end(body)
317
+ }, () => {
318
+ res.writeHead(404)
319
+ res.end()
320
+ })
321
+ }
322
+ }
323
+
324
+ /** Browser-facing base path of the plugin runtime files (pet-center M3). */
325
+ export const PET_RUNTIME_PREFIX = PET_API_PREFIX + '/runtime'
326
+
327
+ /**
328
+ * The runtime files the route may serve, by exact name (no slashes, no
329
+ * user-controlled path segments, so traversal is structurally impossible):
330
+ * the user-supplied Cubism Core from the pet runtime directory (the plugin
331
+ * never bundles or downloads it — issue #623 M1 §0) and the plugin-shipped
332
+ * MIT vendor bundle from the package lib directory.
333
+ */
334
+ const RUNTIME_FILES: Readonly<Record<string, { root: 'runtimeDir' | 'vendorDir' }>> = {
335
+ 'live2dcubismcore.min.js': { root: 'runtimeDir' },
336
+ 'live2d-vendor.js': { root: 'vendorDir' },
337
+ 'live2d-vendor.js.map': { root: 'vendorDir' },
338
+ }
339
+
340
+ /** Size ceiling for one runtime file (the Cubism Core is ~200 KB today). */
341
+ export const PET_RUNTIME_CAP = 16 * 1024 * 1024
342
+
343
+ /** Runtime file roots (test seam; defaults resolve from the environment). */
344
+ export interface PetRuntimeRoots {
345
+ /** User-supplied runtime directory (defaults to '$DSH_HOME/pets/.runtime'). */
346
+ runtimeDir?: string
347
+ /** Plugin vendor bundle directory (defaults to the package 'lib'). */
348
+ vendorDir?: string
349
+ }
350
+
351
+ /**
352
+ * The runtime handler behind '/api/pet/runtime/<name>'. A missing file
353
+ * answers 404 with a JSON marker the client renderer turns into install
354
+ * guidance (the Cubism Core is user-supplied, so its absence is a normal
355
+ * state, not an error).
356
+ */
357
+ function runtimeHandler(roots: { runtimeDir: string; vendorDir: string }): WebRoute['handler'] {
358
+ return (req: IncomingMessage, res: ServerResponse): void => {
359
+ if (!guard(req, res)) return
360
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
361
+ res.writeHead(405)
362
+ res.end()
363
+ return
364
+ }
365
+ let pathname: string
366
+ try {
367
+ pathname = new URL(req.url ?? '/', 'http://pet.local').pathname
368
+ } catch {
369
+ res.writeHead(400)
370
+ res.end()
371
+ return
372
+ }
373
+ const rest = pathname.slice(PET_RUNTIME_PREFIX.length).replace(/^\/+/, '')
374
+ let name: string
375
+ try {
376
+ name = decodeURIComponent(rest)
377
+ } catch {
378
+ res.writeHead(400)
379
+ res.end()
380
+ return
381
+ }
382
+ const spec = RUNTIME_FILES[name]
383
+ // Exact-name allow-list: anything with a path separator never matches.
384
+ if (spec === undefined) {
385
+ res.writeHead(404)
386
+ res.end()
387
+ return
388
+ }
389
+ const base = spec.root === 'runtimeDir' ? roots.runtimeDir : roots.vendorDir
390
+ const file = join(base, name)
391
+ if (!existsSync(file)) {
392
+ json(res, 404, { ok: false, error: 'runtime-file-missing', file: name })
393
+ return
394
+ }
395
+ const resolved = containedRealpath(base, file)
396
+ if (resolved === undefined) {
397
+ res.writeHead(403)
398
+ res.end()
399
+ return
400
+ }
401
+ try {
402
+ if (statSync(resolved).size > PET_RUNTIME_CAP) {
403
+ res.writeHead(413)
404
+ res.end()
405
+ return
406
+ }
407
+ } catch {
408
+ res.writeHead(404)
409
+ res.end()
410
+ return
411
+ }
412
+ readFile(resolved).then((body) => {
413
+ res.writeHead(200, {
414
+ 'content-type': name.endsWith('.map') ? 'application/json' : 'application/javascript; charset=utf-8',
415
+ 'content-length': String(body.byteLength),
416
+ 'cache-control': 'no-cache',
417
+ })
418
+ if (req.method === 'HEAD') {
419
+ res.end()
420
+ return
421
+ }
422
+ res.end(body)
423
+ }, () => {
424
+ res.writeHead(404)
425
+ res.end()
426
+ })
427
+ }
428
+ }
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
+ }
233
530
  readFile(resolved).then((body) => {
234
531
  res.writeHead(200, {
235
532
  'content-type': mimeFor(resolved),
236
533
  'content-length': String(body.byteLength),
237
534
  'cache-control': 'no-cache',
535
+ etag,
238
536
  })
239
537
  if (req.method === 'HEAD') {
240
538
  res.end()
@@ -248,12 +546,13 @@ function assetHandler(registry: PetRegistry): WebRoute['handler'] {
248
546
  }
249
547
  }
250
548
 
251
- /** Build the full route family (API + assets) for one service. */
252
- export function makePetRoutes(deps: { service: PetService }): WebRoute[] {
549
+ /** Build the full route family (API + assets + runtime) for one service. */
550
+ export function makePetRoutes(deps: { service: PetService; assetCaps?: PetAssetCaps } & PetRuntimeRoots): WebRoute[] {
253
551
  const { service } = deps
254
552
  const apiRoutes: WebRoute[] = [
255
553
  getRoute(PET_API_PREFIX + '/state', () => service.state()),
256
554
  getRoute(PET_API_PREFIX + '/pets', () => service.pets()),
555
+ getRoute(PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
257
556
  postRoute(PET_API_PREFIX + '/interact', (body) => {
258
557
  const kind = body.kind as PetInteraction | undefined
259
558
  if (kind !== 'pet' && kind !== 'feed') return Promise.reject(new Error('invalid-kind'))
@@ -285,10 +584,25 @@ export function makePetRoutes(deps: { service: PetService }): WebRoute[] {
285
584
  const assetRoute: WebRoute = {
286
585
  kind: 'prefix',
287
586
  path: PET_ASSET_PREFIX,
288
- handler: assetHandler(service.registrySnapshot()),
587
+ handler: assetHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
588
+ }
589
+
590
+ const runtimeRoute: WebRoute = {
591
+ kind: 'prefix',
592
+ path: PET_RUNTIME_PREFIX,
593
+ handler: runtimeHandler({
594
+ runtimeDir: deps.runtimeDir ?? join(dshHome(), 'pets', '.runtime'),
595
+ vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), 'lib'),
596
+ }),
597
+ }
598
+
599
+ const decorationRoute: WebRoute = {
600
+ kind: 'prefix',
601
+ path: DECORATION_ASSET_PREFIX,
602
+ handler: decorationHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS),
289
603
  }
290
604
 
291
- return [...apiRoutes, assetRoute]
605
+ return [...apiRoutes, assetRoute, runtimeRoute, decorationRoute]
292
606
  }
293
607
 
294
608
  // Re-exported for the package surface (the registry owns the definition now).
package/src/service.ts CHANGED
@@ -36,14 +36,19 @@ 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,
42
44
  type PetDefinition,
43
45
  type PetManifest,
44
46
  type PetRegistry,
47
+ type PetRegistryDiagnostic,
45
48
  } from './registry.ts'
46
- 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'
47
52
  import {
48
53
  defaultPetStateConfig,
49
54
  PetStateMachine,
@@ -64,6 +69,8 @@ export interface PetConfig {
64
69
  persistDir?: string
65
70
  /** Master switch for the plugin (browser half + host routes). */
66
71
  enabled?: boolean
72
+ /** Status-decoration master switch (pet-center M5, #567); defaults to on. */
73
+ decorationEnabled?: boolean
67
74
  /** Prebuilt registry (tests); defaults to scanning the package + user dirs. */
68
75
  registry?: PetRegistry
69
76
  /** Extra manifest entries composed by the embedding application. */
@@ -90,6 +97,11 @@ export interface PetSettingsSection {
90
97
  bottom: number
91
98
  /** Master switch for the plugin (browser half + host routes). */
92
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
93
105
  }
94
106
 
95
107
  /** Settings namespace of the pet capability. Spelled here rather than imported: the browser half spells the same value. */
@@ -158,6 +170,12 @@ export interface PetStateView {
158
170
  * rendered by the client as a distinct whisper bubble.
159
171
  */
160
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
161
179
  }
162
180
 
163
181
  /** Result of `pet.interact`. */
@@ -199,9 +217,17 @@ export class PetService extends Service {
199
217
  private readonly registry: PetRegistry
200
218
  private readonly persistDir: string
201
219
  private enabled: boolean
220
+ /** Status-decoration master switch (M5, #567); mirrored from settings. */
221
+ private decorationEnabled: boolean
202
222
  private disposeActivity: (() => void) | undefined
203
223
  /** Session whose most recent meaningful event currently drives the global pet. */
204
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
205
231
  /**
206
232
  * Per-session activity, most recent last (Map insertion order). Bounded by
207
233
  * MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
@@ -236,10 +262,28 @@ export class PetService extends Service {
236
262
  this.stateConfig = { ...defaultPetStateConfig, ...(config.state ?? {}) }
237
263
  this.machine = new PetStateMachine(this.stateConfig)
238
264
  this.enabled = config.enabled ?? true
265
+ this.decorationEnabled = config.decorationEnabled ?? true
239
266
 
240
267
  this.syncActivity()
241
268
  }
242
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
+
243
287
  /** Whether the pet service consumes session activity while enabled. */
244
288
  isEnabled(): boolean {
245
289
  return this.enabled
@@ -257,7 +301,7 @@ export class PetService extends Service {
257
301
 
258
302
  /** RPC: the registry entries the browser half renders and selects from. */
259
303
  async pets(): Promise<PetDefinition[]> {
260
- return this.registry.entries.map(petEntryView)
304
+ return this.registry.entries.map(entry => petEntryView(entry, this.registry.globalVoice))
261
305
  }
262
306
 
263
307
  /** The loaded registry (the asset routes serve its entries). */
@@ -265,6 +309,21 @@ export class PetService extends Service {
265
309
  return this.registry
266
310
  }
267
311
 
312
+ /** RPC: structured registry diagnostics (pet-center M2, issue #623). */
313
+ async diagnostics(): Promise<{ diagnostics: PetRegistryDiagnostic[] }> {
314
+ return { diagnostics: this.registry.diagnostics }
315
+ }
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
+
268
327
  /** The selected pet's registry entry. */
269
328
  activeEntry(): NonNullable<PetRegistry['entries'][number]> {
270
329
  return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry()
@@ -364,7 +423,7 @@ export class PetService extends Service {
364
423
  let activity = this.sessionActivity.get(session)
365
424
  if (activity === undefined) {
366
425
  activity = {
367
- runtime: emptyProjectionRuntime(),
426
+ runtime: emptyProjectionRuntime(this.voicePools()),
368
427
  machine: new PetStateMachine(this.stateConfig),
369
428
  }
370
429
  this.sessionActivity.set(session, activity)
@@ -443,6 +502,7 @@ export class PetService extends Service {
443
502
  * @param section - the resolved settings section.
444
503
  */
445
504
  applySettingsSection(section: PetSettingsSection): void {
505
+ this.decorationEnabled = section.decorationEnabled ?? true
446
506
  const selected = typeof section.petId === 'string' ? this.registry.byId(section.petId) : undefined
447
507
  if (selected !== undefined) {
448
508
  this.ledger.setPetId(selected.id)
@@ -518,6 +578,7 @@ export class PetService extends Service {
518
578
  const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
519
579
  ? whisper.text
520
580
  : undefined
581
+ const decoration = this.activeDecoration()
521
582
  // Read-only: the ledger settles on economic events only, never on a read,
522
583
  // so polling the state cannot trigger pet.json writes.
523
584
  return {
@@ -527,6 +588,7 @@ export class PetService extends Service {
527
588
  sessionActive: snapshot.sessionActive,
528
589
  sessions,
529
590
  ...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
591
+ ...(decoration === undefined ? {} : { decoration }),
530
592
  affinity: this.ledger.affinityView(Date.now()),
531
593
  display: { ...this.ledger.snapshot.display },
532
594
  pet: {