@linxin666/dsh-pet 0.3.5 → 0.3.9

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 (88) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -0
  3. package/README.zh.md +4 -0
  4. package/contracts/voice-pack-v1.schema.json +30 -0
  5. package/cordis.patch.yml +1 -1
  6. package/lib/client.js +330 -103
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +145 -28
  9. package/lib/invariant.js +1 -1
  10. package/lib/live2d-vendor.js +106 -95
  11. package/lib/live2d-vendor.js.map +1 -1
  12. package/lib/{state-DrMX22GL.js → state-NvDEoCln.js} +5 -2
  13. package/lib/types/access.d.ts +5 -4
  14. package/lib/types/access.d.ts.map +1 -1
  15. package/lib/types/access.js +2 -12
  16. package/lib/types/affinity.d.ts +2 -0
  17. package/lib/types/affinity.d.ts.map +1 -1
  18. package/lib/types/affinity.js +12 -0
  19. package/lib/types/announce.d.ts +53 -0
  20. package/lib/types/announce.d.ts.map +1 -0
  21. package/lib/types/announce.js +80 -0
  22. package/lib/types/client/PetDockEntry.d.ts +14 -5
  23. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  24. package/lib/types/client/PetDockEntry.js +6 -5
  25. package/lib/types/client/PetSettingsCard.d.ts +2 -1
  26. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  27. package/lib/types/client/PetSprite.d.ts +8 -0
  28. package/lib/types/client/PetSprite.d.ts.map +1 -1
  29. package/lib/types/client/PetSprite.js +36 -5
  30. package/lib/types/client/index.d.ts +8 -5
  31. package/lib/types/client/index.d.ts.map +1 -1
  32. package/lib/types/client/index.js +11 -5
  33. package/lib/types/client/locales.d.ts +20 -2
  34. package/lib/types/client/locales.d.ts.map +1 -1
  35. package/lib/types/client/locales.js +20 -2
  36. package/lib/types/client/pet-store.d.ts +1 -1
  37. package/lib/types/client/pet-store.d.ts.map +1 -1
  38. package/lib/types/client/pet-store.js +1 -1
  39. package/lib/types/client/renderers/frames2d.d.ts +12 -0
  40. package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
  41. package/lib/types/client/renderers/frames2d.js +143 -18
  42. package/lib/types/client/settings-form.d.ts +35 -43
  43. package/lib/types/client/settings-form.d.ts.map +1 -1
  44. package/lib/types/client/settings-form.js +60 -53
  45. package/lib/types/index.d.ts.map +1 -1
  46. package/lib/types/index.js +10 -9
  47. package/lib/types/ledger.d.ts +4 -2
  48. package/lib/types/ledger.d.ts.map +1 -1
  49. package/lib/types/ledger.js +4 -4
  50. package/lib/types/pair-access.d.ts +35 -0
  51. package/lib/types/pair-access.d.ts.map +1 -0
  52. package/lib/types/pair-access.js +19 -0
  53. package/lib/types/remarks.d.ts +1 -1
  54. package/lib/types/remarks.d.ts.map +1 -1
  55. package/lib/types/remarks.js +11 -2
  56. package/lib/types/service.d.ts +18 -0
  57. package/lib/types/service.d.ts.map +1 -1
  58. package/lib/types/service.js +26 -1
  59. package/lib/types/voice-pack.d.ts +5 -0
  60. package/lib/types/voice-pack.d.ts.map +1 -1
  61. package/lib/types/voice-pack.js +36 -2
  62. package/package.json +17 -13
  63. package/src/access.ts +7 -27
  64. package/src/affinity.ts +13 -0
  65. package/src/announce.ts +103 -0
  66. package/src/client/PetDockEntry.test.tsx +1 -1
  67. package/src/client/PetDockEntry.tsx +15 -4
  68. package/src/client/PetSettingsCard.tsx +2 -1
  69. package/src/client/PetSprite.test.tsx +36 -0
  70. package/src/client/PetSprite.tsx +75 -4
  71. package/src/client/gameplay-hud.test.tsx +1 -1
  72. package/src/client/index.test.tsx +2 -2
  73. package/src/client/index.ts +17 -6
  74. package/src/client/locales.ts +20 -2
  75. package/src/client/pet-store.ts +2 -2
  76. package/src/client/pet.module.css +104 -0
  77. package/src/client/renderers/frames2d.test.ts +101 -0
  78. package/src/client/renderers/frames2d.ts +138 -18
  79. package/src/client/settings-form.ts +78 -92
  80. package/src/dsh-home.ts +1 -1
  81. package/src/index.ts +17 -15
  82. package/src/ledger.ts +6 -4
  83. package/src/pair-access.ts +49 -0
  84. package/src/remarks.test.ts +10 -0
  85. package/src/remarks.ts +9 -2
  86. package/src/service.ts +32 -2
  87. package/src/voice-pack.test.ts +39 -0
  88. package/src/voice-pack.ts +37 -2
@@ -8,8 +8,9 @@
8
8
  * card-store pattern.
9
9
  */
10
10
 
11
- import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
12
- import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
11
+ import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
12
+ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
13
+ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
13
14
 
14
15
  /** The write one field's staged text performs when the card is saved. */
15
16
  export type FieldWrite =
@@ -23,9 +24,11 @@ export interface FieldSpec {
23
24
  /**
24
25
  * Whether the Host treats this field as a secret and redacts its value from
25
26
  * the read-back (role('secret') in the section schema). Redacted secrets are
26
- * never compared against the draft on save; the field lands when the scope
27
- * reports the write succeeded (its secret-set marker under the bridge), so
28
- * a successful secret save is not misreported as failed.
27
+ * never compared against the draft on save: the Host strips them from every
28
+ * wire view layer, so the settled snapshot carries nothing to read back. A
29
+ * staged secret set is judged by the mutation settling; the rest of its
30
+ * batch, when one exists, is still judged by read-back, and the atomic
31
+ * mutation lands every write or none.
29
32
  */
30
33
  secret?: boolean
31
34
  /** Render a stored value as draft text; the empty string when the section carries none. */
@@ -53,8 +56,8 @@ export interface CardShell {
53
56
  available: boolean
54
57
  /**
55
58
  * Whether the namespace is actually served to this client. False when the
56
- * Host deployment does not expose it (e.g. the official apiproxy settings
57
- * allowlist omits third-party namespaces): the card renders an explanation
59
+ * Host deployment does not expose it (e.g. the owning plugin's settings
60
+ * domain is not mounted): the card renders an explanation
58
61
  * instead of its form, so a missing namespace never looks like a missing
59
62
  * plugin.
60
63
  */
@@ -101,13 +104,17 @@ interface StagedEdit {
101
104
  interface PlannedWrite {
102
105
  /** Field this entry writes. */
103
106
  field: string
104
- /** The durable write this entry performs, described for a batched scope. */
107
+ /** The durable write this entry performs, inside the save's one atomic mutation. */
105
108
  op: BatchedWrite
106
- /** Perform the write and report whether the Host holds the staged value afterwards. */
107
- run: (() => Promise<boolean>) | undefined
109
+ /**
110
+ * Read the settled snapshot back and report whether the Host holds this
111
+ * write's effect. Undefined when the draft is not a value the field
112
+ * accepts: there is nothing to write, and the entry blocks the save.
113
+ */
114
+ judge: (() => boolean) | undefined
108
115
  }
109
116
 
110
- /** One durable write a batched settings scope performs. */
117
+ /** One durable write inside the save's atomic scope mutation. */
111
118
  export interface BatchedWrite {
112
119
  /** Field this entry writes. */
113
120
  field: string
@@ -117,36 +124,6 @@ export interface BatchedWrite {
117
124
  value?: unknown
118
125
  }
119
126
 
120
- /** Per-field outcome of one batched scope write. */
121
- export interface BatchedFieldResult {
122
- /** Field this entry writes. */
123
- field: string
124
- /** Whether the Host accepted this field's write (per the read-back view). */
125
- landed: boolean
126
- }
127
-
128
- /**
129
- * Result of a batched scope write. The bridge scope posts every planned write
130
- * in one /mutate so the Host validate hook judges baseURL+model together; a
131
- * batched refusal fails the whole save rather than per-field.
132
- */
133
- export interface BatchResult {
134
- /** Whether the whole mutate was accepted. */
135
- ok: boolean
136
- /** Per-field success, in the request order (always present when ok). */
137
- fields: BatchedFieldResult[]
138
- /** Host rejection code (mutate refused). */
139
- code?: string
140
- /** Host rejection message (mutate refused). */
141
- message?: string
142
- }
143
-
144
- /** The optional batch surface the bridge scope adds over the SettingsScope contract. */
145
- interface BatchedSettingsScope {
146
- /** Write every operation in one scope mutation, reporting per-field success. */
147
- mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
148
- }
149
-
150
127
  /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
151
128
  export interface NumberConstraints {
152
129
  /** The accepted value must be a whole number. */
@@ -188,8 +165,8 @@ export function textField(field: string): FieldSpec {
188
165
  /**
189
166
  * A free-text field the Host treats as a secret and redacts from the read-back
190
167
  * (role('secret') in the section schema). The card still edits it like text,
191
- * but a save never compares the redacted value back and relies on the scope
192
- * reporting the write landed.
168
+ * but a save never compares the redacted value back: the staged set is judged
169
+ * by the mutation settling (see {@link FieldSpec.secret}).
193
170
  */
194
171
  export function secretField(field: string): FieldSpec {
195
172
  return { ...textField(field), secret: true }
@@ -277,7 +254,7 @@ export class CardForm<T> {
277
254
  exposed: snapshot.status === 'ready',
278
255
  writable: snapshot.writable,
279
256
  dirty: plan.length > 0,
280
- invalid: plan.some(item => item.run === undefined),
257
+ invalid: plan.some(item => item.judge === undefined),
281
258
  saving: this.saving,
282
259
  failed: this.failed,
283
260
  ...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
@@ -318,21 +295,25 @@ export class CardForm<T> {
318
295
  }
319
296
 
320
297
  /**
321
- * Write every staged edit, then re-seed from what the Host accepted.
298
+ * Write every staged edit in one atomic scope mutation, then re-seed from
299
+ * what the Host accepted.
322
300
  *
323
- * When the scope carries the optional batch surface (the dsh-web
324
- * bridge scope), every planned write rides one mutation so cross-field
325
- * validate hooks (baseURL+model) judge the batch as a unit instead of
326
- * deadlocking on per-field writes. Otherwise the per-field loop runs.
327
- * A field lands only when the Host reports it held the staged value; a
328
- * landed field's draft is dropped, a failed one stays staged for the user.
329
- * @returns settlement after every write and the read-back.
301
+ * The whole batch rides one mutate, so cross-field validate hooks
302
+ * (baseURL+model) judge it as a unit: the Host either applies every write
303
+ * or refuses the batch. The 0.1.2 scope contract never rejects a refused
304
+ * mutation the scope recovers with a fresh Host view and resolves — so
305
+ * resolution alone proves nothing: the outcome is judged by reading the
306
+ * settled snapshot back, one planned write at a time, and one missed write
307
+ * fails the whole save. A scope that still rejects on refusal (the dsh-web
308
+ * bridge scope) reports through the same failure path with its rejection
309
+ * message. A save that did not land keeps its drafts, so the user can
310
+ * correct them instead of retyping.
311
+ * @returns settlement after the mutation and the read-back.
330
312
  */
331
313
  async save(): Promise<void> {
332
314
  const plan = this.plan()
333
- const valid = plan.filter(item => item.run !== undefined)
315
+ const valid = plan.filter((item): item is PlannedWrite & { judge: () => boolean } => item.judge !== undefined)
334
316
  if (plan.length === 0 || this.saving || valid.length !== plan.length) return
335
- const plannedWrites = valid.map(item => item.op)
336
317
  // Snapshot the staged entries this save writes, so an edit staged while it
337
318
  // is in flight (which replaces the same key) survives: only delete the key
338
319
  // when the entry is still the one this save started from.
@@ -342,36 +323,34 @@ export class CardForm<T> {
342
323
  this.failed = false
343
324
  this.failedReason = undefined
344
325
  this.publish()
345
- const landed = new Set<string>()
346
- const batch = this.batchedScope()
347
- if (batch !== undefined) {
348
- const result = await batch.mutate(plannedWrites)
349
- if (result.ok) {
350
- for (const field of result.fields) {
351
- if (field.landed) landed.add(field.field)
352
- }
353
- } else {
354
- this.failedReason = result.message
355
- }
356
- } else {
357
- for (const item of valid) {
358
- if (await item.run!()) landed.add(item.field)
359
- }
326
+ // One atomic namespace mutation: the 0.1.2 scope contract takes ordered
327
+ // path operations, so the whole staged batch is validated, persisted, and
328
+ // recovered together either every write lands or none does.
329
+ const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
330
+ ? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
331
+ : { op: 'unset', path: [item.field] })
332
+ let failedReason: string | undefined
333
+ try {
334
+ await this.scope.mutate(ops)
335
+ } catch (error) {
336
+ failedReason = error instanceof Error ? error.message : String(error)
360
337
  }
338
+ // The 0.1.2 scope resolves even a refused mutation (it recovers with a
339
+ // fresh view instead of throwing), so resolution alone proves nothing:
340
+ // judge every planned write against the settled snapshot. The mutation is
341
+ // atomic, so one missed write fails the whole save and keeps the drafts.
342
+ const landed = failedReason === undefined && valid.every(item => item.judge())
361
343
  for (const [field, before] of pending) {
362
- if (landed.has(field) && this.staged.get(field) === before) this.staged.delete(field)
344
+ if (landed && this.staged.get(field) === before) this.staged.delete(field)
363
345
  }
364
346
  this.saving = false
365
- this.failed = landed.size !== pending.size
347
+ this.failed = !landed
348
+ // A read-back failure carries no server reason: the card surfaces its
349
+ // generic failure copy; a rejecting scope (the bridge) adds its message.
350
+ this.failedReason = failedReason
366
351
  this.publish()
367
352
  }
368
353
 
369
- /** The scope's batch surface when it supports one; undefined conservatively otherwise. */
370
- private batchedScope(): BatchedSettingsScope | undefined {
371
- const candidate = this.scope as unknown as BatchedSettingsScope | undefined
372
- return typeof candidate?.mutate === 'function' ? candidate : undefined
373
- }
374
-
375
354
  /**
376
355
  * Every staged edit a save would write. An entry whose draft is not a value
377
356
  * its field accepts carries no write: the form is still dirty, and the save
@@ -384,33 +363,40 @@ export class CardForm<T> {
384
363
  for (const [field, staged] of this.staged) {
385
364
  const spec = this.specOf(field)
386
365
  if (staged.clear) {
387
- if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
366
+ if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
388
367
  continue
389
368
  }
390
369
  if (staged.text === spec.format(this.sectionValue(field))) continue
391
370
  const write = spec.parse(staged.text)
392
- if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, run: undefined })
393
- else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
394
- else plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, write.value) })
371
+ if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, judge: undefined })
372
+ else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
373
+ else plan.push({ field, op: { field, op: 'set', value: write.value }, judge: () => this.landedSet(field, write.value) })
395
374
  }
396
375
  return plan
397
376
  }
398
377
 
399
- private async clear(field: string): Promise<boolean> {
400
- await this.scope.unset(field)
401
- return !this.stored(field)
402
- }
403
-
404
- private async store(field: string, value: unknown): Promise<boolean> {
405
- await this.scope.set(field, value)
406
- // A redacted secret never appears in the user layer read-back; judging it
407
- // by value would misreport a successful secret save as failed. The bridge
408
- // reports secret writes through its secret-set markers (batch path); on
409
- // the per-field path the scope resolved, so the write is landed.
378
+ /**
379
+ * Read-back judgment for a planned set: the user layer must hold the
380
+ * intended value once the mutation has settled.
381
+ */
382
+ private landedSet(field: string, value: unknown): boolean {
383
+ // A redacted secret never appears in any wire view layer: the Host strips
384
+ // role('secret') fields and reports them through a sidecar the scope
385
+ // snapshot does not expose, so there is nothing to compare the draft
386
+ // against. Settling is the only signal the form has; the rest of the
387
+ // batch, when one exists, still carries the atomic verdict by read-back.
410
388
  if (this.specOf(field).secret) return true
411
389
  return this.userLayer()?.[field] === value
412
390
  }
413
391
 
392
+ /**
393
+ * Read-back judgment for a planned unset: the field must be gone from the
394
+ * user layer once the mutation has settled.
395
+ */
396
+ private landedUnset(field: string): boolean {
397
+ return !this.stored(field)
398
+ }
399
+
414
400
  private stage(field: string, edit: StagedEdit): void {
415
401
  this.staged.set(field, edit)
416
402
  this.failed = false
package/src/dsh-home.ts CHANGED
@@ -39,4 +39,4 @@ export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: strin
39
39
  /** Resolve the DSH home directory from the live environment. */
40
40
  export function dshHome(): string {
41
41
  return resolveDshHome()
42
- }
42
+ }
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { Context } from '@deepseek-ai/cordis'
15
- import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
15
+ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
16
16
  import type {} from '@deepseek-ai/dsh-host-webserver'
17
17
  import z from 'schemastery'
18
18
  import { PetService, PET_SETTINGS_NAMESPACE, type PetConfig, type PetSettingsSection } from './service.ts'
@@ -189,20 +189,22 @@ function applyImpl(ctx: Context, config: PetConfig = {}): void {
189
189
  disposeRoutes = undefined
190
190
  }
191
191
  }
192
- installSettingsSection(
193
- ctx,
194
- settingsNamespace(PET_SETTINGS_NAMESPACE),
195
- makePetSettingsSchema(service.selectedPetId()),
196
- base,
197
- {
198
- setSource: (source) => { current = source },
199
- onChange: () => {
200
- const section = current()
201
- service.applySettingsSection(section)
202
- service.setEnabled(section.enabled ?? true)
203
- syncRoutes()
192
+ ctx.inject(['settings'], (settingsCtx) => {
193
+ settingsCtx.settings.installSection(
194
+ ctx,
195
+ PET_SETTINGS_NAMESPACE as SettingsNamespace,
196
+ makePetSettingsSchema(service.selectedPetId()),
197
+ base,
198
+ {
199
+ setSource: (source) => { current = source },
200
+ onChange: () => {
201
+ const section = current()
202
+ service.applySettingsSection(section)
203
+ service.setEnabled(section.enabled ?? true)
204
+ syncRoutes()
205
+ },
204
206
  },
205
- },
206
- )
207
+ )
208
+ })
207
209
  syncRoutes()
208
210
  }
package/src/ledger.ts CHANGED
@@ -32,6 +32,8 @@ export interface LedgerConfig {
32
32
  treats?: Partial<TreatConfig>
33
33
  /** Per-pet remark pools for the selected pet (custom slots override built-ins). */
34
34
  remarks?: PetRemarks
35
+ /** Voice-pack fallback remark pools (global or pet voice pack). */
36
+ voiceRemarks?: PetRemarks
35
37
  }
36
38
 
37
39
  /** Result of one ledger interaction (the shape the pet RPC returns). */
@@ -63,7 +65,7 @@ export class PetLedger {
63
65
  constructor(persist: PetPersist, config: LedgerConfig = {}) {
64
66
  this.affinityConfig = { ...defaultAffinityConfig, ...(config.affinity ?? {}) }
65
67
  this.treatConfig = { ...defaultTreatConfig, ...(config.treats ?? {}) }
66
- this.picker = new RemarkPicker(config.remarks)
68
+ this.picker = new RemarkPicker(config.remarks, config.voiceRemarks)
67
69
  this.current = persist
68
70
  }
69
71
 
@@ -124,10 +126,10 @@ export class PetLedger {
124
126
 
125
127
  /**
126
128
  * Swap the reaction pools to another pet's custom remarks (called on pet
127
- * selection). Slots the pet does not declare fall back to built-ins.
129
+ * selection). Slots the pet does not declare fall back to voice packs or built-ins.
128
130
  */
129
- setRemarks(remarks?: PetRemarks): void {
130
- this.picker = new RemarkPicker(remarks)
131
+ setRemarks(remarks?: PetRemarks, voiceRemarks?: PetRemarks): void {
132
+ this.picker = new RemarkPicker(remarks, voiceRemarks)
131
133
  }
132
134
 
133
135
  /**
@@ -0,0 +1,49 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/pair-access.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Pairing trust fence shared by plugins that expose host routes: loopback
4
+ * (the desktop) always passes; a live paired-device cookie is an additional
5
+ * allow path when remote-web-ui is loaded. The consuming plugin never
6
+ * depends on that plugin — without the service the fence stays
7
+ * loopback-only.
8
+ *
9
+ * Per-package wrappers (access.ts) call this with their own name so each
10
+ * plugin keeps a self-describing export; the security decision lives only
11
+ * here.
12
+ */
13
+ import type { IncomingMessage } from 'node:http'
14
+ import { isLoopbackRequest } from './loopback.ts'
15
+
16
+ /** Structural pairing lookup (no package dependency on remote-web-ui). */
17
+ interface PairingAccess {
18
+ isPairedDevice(request: IncomingMessage): boolean
19
+ }
20
+
21
+ /**
22
+ * Structural host-context shape: shared sources carry no @deepseek-ai
23
+ * dependency (the shared package must typecheck standalone), so the fence
24
+ * reads only the two members it needs; cordis Context satisfies this.
25
+ * ctx.get is optional on the test harness; production Context always has it.
26
+ */
27
+ interface LookupCtx {
28
+ get?(name: string, strict?: boolean): unknown
29
+ remoteWebUiPairing?: PairingAccess
30
+ }
31
+
32
+ /**
33
+ * Whether this request may enter the plugin's host routes.
34
+ * @param ctx - host context; may expose remoteWebUiPairing.
35
+ * @param request - the incoming HTTP request.
36
+ * @returns true for loopback, or a live paired-device cookie.
37
+ */
38
+ export function isPairedOrLoopbackAllowed(ctx: LookupCtx, request: IncomingMessage): boolean {
39
+ if (isLoopbackRequest(request)) return true
40
+ const fromGet = typeof ctx.get === 'function' ? ctx.get('remoteWebUiPairing', false) : undefined
41
+ const pairing = (isPairingAccess(fromGet) ? fromGet : ctx.remoteWebUiPairing)
42
+ return pairing?.isPairedDevice(request) === true
43
+ }
44
+
45
+ function isPairingAccess(value: unknown): value is PairingAccess {
46
+ return value !== undefined
47
+ && value !== null
48
+ && typeof (value as PairingAccess).isPairedDevice === 'function'
49
+ }
@@ -54,6 +54,16 @@ describe('RemarkPicker', () => {
54
54
  expect(picker.pick('pet')).toBe('专属摸头台词')
55
55
  expect(picker.pick('feed')).toBe(BUILTIN_REMARKS.feed[0])
56
56
  })
57
+
58
+ it('layers pet custom lines over voice pack lines, falling back to built-ins (#1226)', () => {
59
+ const picker = new RemarkPicker(
60
+ { pet: ['Pet-level line'] },
61
+ { pet: ['Voice-level pet line'], feed: ['Voice-level feed line'] },
62
+ )
63
+ expect(picker.pick('pet')).toBe('Pet-level line')
64
+ expect(picker.pick('feed')).toBe('Voice-level feed line')
65
+ expect(picker.pick('noTreats')).toBe(BUILTIN_REMARKS.noTreats[0])
66
+ })
57
67
  })
58
68
 
59
69
  describe('normalizePetRemarks', () => {
package/src/remarks.ts CHANGED
@@ -184,11 +184,18 @@ export class RemarkPicker {
184
184
  private readonly counters = new Map<RemarkKind, number>()
185
185
  private readonly pools: Record<RemarkKind, readonly string[]>
186
186
 
187
- constructor(overrides?: PetRemarks) {
187
+ constructor(overrides?: PetRemarks, voiceOverrides?: PetRemarks) {
188
188
  this.pools = {} as Record<RemarkKind, readonly string[]>
189
189
  for (const kind of REMARK_KINDS) {
190
190
  const custom = overrides?.[kind]
191
- this.pools[kind] = custom !== undefined && custom.length > 0 ? custom : BUILTIN_REMARKS[kind]
191
+ const voice = voiceOverrides?.[kind]
192
+ if (custom !== undefined && custom.length > 0) {
193
+ this.pools[kind] = custom
194
+ } else if (voice !== undefined && voice.length > 0) {
195
+ this.pools[kind] = voice
196
+ } else {
197
+ this.pools[kind] = BUILTIN_REMARKS[kind]
198
+ }
192
199
  }
193
200
  }
194
201
 
package/src/service.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  import { Context, Service } from '@deepseek-ai/cordis'
16
16
  import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
17
17
  import type { AffinityConfig, PetAffinityView, PetInteraction } from './affinity.ts'
18
+ import { announcementFresh, parseAnnouncement, type PetAnnouncement } from './announce.ts'
18
19
  import type { TreatConfig } from './treats.ts'
19
20
  import {
20
21
  emptyProjectionRuntime,
@@ -191,6 +192,12 @@ export interface PetStateView {
191
192
  * settles the view in memory but never writes pet.json.
192
193
  */
193
194
  gameplay?: PetGameplayStateView
195
+ /**
196
+ * The freshest plugin-authored announcement (dsh-usage linkage), within
197
+ * its TTL; absent when none is fresh. Rendered as a dedicated styled
198
+ * bubble above the session stack.
199
+ */
200
+ announcement?: PetAnnouncement
194
201
  }
195
202
 
196
203
  /** Result of `pet.interact`. */
@@ -224,7 +231,6 @@ declare module '@deepseek-ai/cordis' {
224
231
  pet: PetService
225
232
  }
226
233
  }
227
-
228
234
  /** Per-session pet activity: projection runtime plus the session's own machine. */
229
235
  interface SessionActivity {
230
236
  runtime: ProjectionRuntime
@@ -257,6 +263,8 @@ export class PetService extends Service {
257
263
  private enabled: boolean
258
264
  /** Status-decoration master switch (M5, #567); mirrored from settings. */
259
265
  private decorationEnabled: boolean
266
+ /** The freshest plugin-authored announcement (dsh-usage linkage). */
267
+ private announcement: PetAnnouncement | undefined
260
268
  private disposeActivity: (() => void) | undefined
261
269
  /** Session whose most recent meaningful event currently drives the global pet. */
262
270
  private displaySession: Session | undefined
@@ -297,10 +305,12 @@ export class PetService extends Service {
297
305
  persist = { ...persist, petId: this.registry.defaultEntry().id }
298
306
  }
299
307
  const selected = this.registry.byId(persist.petId) ?? this.registry.defaultEntry()
308
+ const voiceRemarks = mergeVoicePacks(this.registry.globalVoice, selected.voice)?.remarks
300
309
  const ledgerConfig: LedgerConfig = {
301
310
  affinity: config.affinity,
302
311
  treats: config.treats,
303
312
  remarks: selected.remarks,
313
+ voiceRemarks,
304
314
  }
305
315
  this.ledger = new PetLedger(persist, ledgerConfig)
306
316
  this.stateConfig = { ...defaultPetStateConfig, ...(config.state ?? {}) }
@@ -338,6 +348,19 @@ export class PetService extends Service {
338
348
  return this.view(currentSessionId)
339
349
  }
340
350
 
351
+ /**
352
+ * RPC: one plugin-authored announcement bubble (dsh-usage linkage). The
353
+ * payload is validated into a bounded PetAnnouncement; a malformed one is
354
+ * dropped silently — a sibling plugin's bug must never surface as pet
355
+ * breakage. The announcement is in-memory only.
356
+ */
357
+ announce(input: unknown): { ok: boolean } {
358
+ const parsed = parseAnnouncement(input, Date.now())
359
+ if (parsed === undefined) return { ok: false }
360
+ this.announcement = parsed
361
+ return { ok: true }
362
+ }
363
+
341
364
  /** Current persisted display config (read-only view). */
342
365
  display(): PetDisplayConfig {
343
366
  return { ...this.ledger.snapshot.display }
@@ -390,7 +413,8 @@ export class PetService extends Service {
390
413
  const entry = this.registry.byId(petId)
391
414
  if (entry === undefined) return { ok: false, error: 'unknown-pet' }
392
415
  this.ledger.setPetId(entry.id)
393
- this.ledger.setRemarks(entry.remarks)
416
+ const voiceRemarks = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.remarks
417
+ this.ledger.setRemarks(entry.remarks, voiceRemarks)
394
418
  this.flush()
395
419
  this.syncSettingsFromPet()
396
420
  return { ok: true, petId: entry.id }
@@ -810,6 +834,11 @@ export class PetService extends Service {
810
834
  }
811
835
  // Read-only: the ledger settles on economic events only, never on a read,
812
836
  // so polling the state cannot trigger pet.json writes.
837
+ // An expired announcement simply stops appearing (the client's 2 s poll
838
+ // drops it); no timer owns its removal.
839
+ const announcement = this.announcement !== undefined && announcementFresh(this.announcement, Date.now())
840
+ ? this.announcement
841
+ : undefined
813
842
  return {
814
843
  animation: snapshot.animation,
815
844
  ...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
@@ -817,6 +846,7 @@ export class PetService extends Service {
817
846
  sessionActive: snapshot.sessionActive,
818
847
  sessions,
819
848
  ...(decoration === undefined ? {} : { decoration }),
849
+ ...(announcement === undefined ? {} : { announcement }),
820
850
  affinity: this.ledger.affinityView(Date.now()),
821
851
  display: { ...this.ledger.snapshot.display },
822
852
  pet: {
@@ -53,6 +53,45 @@ describe('normalizeVoicePack structure', () => {
53
53
  expect(warnings.join('\n')).toContain('mystery')
54
54
  expect(warnings.join('\n')).toContain('voicePackVersion')
55
55
  })
56
+
57
+ it('normalizes top-level remarks and ranks (#1226)', () => {
58
+ const { pack, warnings } = collectWarnings({
59
+ remarks: {
60
+ pet: ['Purr~ So comfortable!'],
61
+ feed: 'Yummy fish!',
62
+ },
63
+ ranks: {
64
+ '0': 'Baby Whale',
65
+ '幼鲸': 'Little Whale',
66
+ },
67
+ })
68
+ expect(pack).toBeDefined()
69
+ expect(pack?.remarks?.pet).toEqual(['Purr~ So comfortable!'])
70
+ expect(pack?.remarks?.feed).toEqual(['Yummy fish!'])
71
+ expect(pack?.ranks).toEqual({
72
+ '0': 'Baby Whale',
73
+ '幼鲸': 'Little Whale',
74
+ })
75
+ expect(warnings).toHaveLength(0)
76
+ })
77
+
78
+ it('merges remarks and ranks across layers (#1226)', () => {
79
+ const base = normalizeVoicePack({
80
+ remarks: { pet: ['Base pet'], feed: ['Base feed'] },
81
+ ranks: { '0': 'Base 0', '25': 'Base 25' },
82
+ })
83
+ const layer = normalizeVoicePack({
84
+ remarks: { pet: ['Layer pet'] },
85
+ ranks: { '0': 'Layer 0' },
86
+ })
87
+ const merged = mergeVoicePacks(base, layer)
88
+ expect(merged?.remarks?.pet).toEqual(['Layer pet'])
89
+ expect(merged?.remarks?.feed).toEqual(['Base feed'])
90
+ expect(merged?.ranks).toEqual({
91
+ '0': 'Layer 0',
92
+ '25': 'Base 25',
93
+ })
94
+ })
56
95
  })
57
96
 
58
97
  describe('normalizePool', () => {