@linxin666/dsh-pet 0.1.14 → 0.1.15

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 (44) hide show
  1. package/lib/client.js +29 -16
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +355 -251
  4. package/lib/invariant.js +1 -1
  5. package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
  6. package/lib/types/affinity.d.ts +15 -0
  7. package/lib/types/affinity.d.ts.map +1 -1
  8. package/lib/types/affinity.js +14 -0
  9. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  10. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PluginSettingsCard.js +27 -7
  12. package/lib/types/client/index.d.ts +1 -1
  13. package/lib/types/client/index.js +3 -3
  14. package/lib/types/client/settings-form.d.ts +14 -5
  15. package/lib/types/client/settings-form.d.ts.map +1 -1
  16. package/lib/types/client/settings-form.js +38 -10
  17. package/lib/types/dsh-home.d.ts +17 -0
  18. package/lib/types/dsh-home.d.ts.map +1 -0
  19. package/lib/types/dsh-home.js +34 -0
  20. package/lib/types/event-projection.d.ts +36 -0
  21. package/lib/types/event-projection.d.ts.map +1 -0
  22. package/lib/types/event-projection.js +98 -0
  23. package/lib/types/ledger.d.ts +77 -0
  24. package/lib/types/ledger.d.ts.map +1 -0
  25. package/lib/types/ledger.js +139 -0
  26. package/lib/types/persist.d.ts +5 -1
  27. package/lib/types/persist.d.ts.map +1 -1
  28. package/lib/types/persist.js +7 -3
  29. package/lib/types/service.d.ts +15 -46
  30. package/lib/types/service.d.ts.map +1 -1
  31. package/lib/types/service.js +47 -204
  32. package/package.json +1 -1
  33. package/src/affinity.ts +33 -0
  34. package/src/client/PluginSettingsCard.tsx +83 -17
  35. package/src/client/index.ts +3 -3
  36. package/src/client/settings-card.module.css +6 -0
  37. package/src/client/settings-form.ts +41 -9
  38. package/src/dsh-home.test.ts +35 -0
  39. package/src/dsh-home.ts +36 -0
  40. package/src/event-projection.ts +128 -0
  41. package/src/ledger.test.ts +67 -0
  42. package/src/ledger.ts +183 -0
  43. package/src/persist.ts +7 -3
  44. package/src/service.ts +60 -274
package/src/service.ts CHANGED
@@ -1,40 +1,36 @@
1
1
  /**
2
- * Pet host service — the `pet.*` RPC domain. Owns the state machine wiring
3
- * (projects official session events and accepts legacy `activity/status`), the
4
- * affinity ledger, and the persisted display config. The API gateway maps
5
- * this service's methods onto `pet.state` / `pet.interact` /
6
- * `pet.setVisible` / `pet.setConfig` for browser consumers.
2
+ * Pet host service — the `pet.*` RPC domain. A composition facade: it wires
3
+ * the pure event projection (`event-projection`) onto the state machine,
4
+ * delegates the affinity economy to the ledger (`ledger`), and routes
5
+ * persistence through `persist`. The API gateway maps these methods onto
6
+ * `pet.state` / `pet.interact` / `pet.setVisible` / `pet.setConfig` for
7
+ * browser consumers.
7
8
  * @module @linxin666/dsh-pet/service
8
9
  */
9
10
 
10
11
  import { Context, Service } from '@deepseek-ai/cordis'
11
12
  import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
13
+ import type { AffinityConfig, PetAffinityView, PetInteraction } from './affinity.ts'
14
+ import type { TreatConfig } from './treats.ts'
12
15
  import {
13
- applyInteraction,
14
- applyTurnReward,
15
- defaultAffinityConfig,
16
- rankOf,
17
- type AffinityConfig,
18
- type AffinityState,
19
- type PetInteraction,
20
- } from './affinity.ts'
16
+ emptyProjectionRuntime,
17
+ isActivityPhase,
18
+ projectOfficialEvent,
19
+ type ActivityStatusEventLike,
20
+ type ProjectionRuntime,
21
+ } from './event-projection.ts'
22
+ import { PetLedger, type LedgerConfig, type LedgerInteractionResult } from './ledger.ts'
21
23
  import {
22
- loadPetPersist,
23
- petHomeDir,
24
- savePetPersist,
24
+ DISPLAY_INSET_MAX,
25
25
  DISPLAY_SIZE_MAX,
26
26
  DISPLAY_SIZE_MIN,
27
- DISPLAY_INSET_MAX,
28
27
  PET_NAME_MAX_LENGTH,
28
+ loadPetPersist,
29
+ petHomeDir,
30
+ savePetPersist,
29
31
  type PetDisplayConfig,
30
32
  type PetPersist,
31
33
  } from './persist.ts'
32
- import {
33
- defaultTreatConfig,
34
- settleTreatGrants,
35
- consumeTreat,
36
- type TreatConfig,
37
- } from './treats.ts'
38
34
  import {
39
35
  defaultPetStateConfig,
40
36
  PetStateMachine,
@@ -87,18 +83,7 @@ export interface PetStateView {
87
83
  phase: PetStateSnapshot['phase']
88
84
  sessionActive: boolean
89
85
  /** Affinity ledger snapshot. */
90
- affinity: {
91
- points: number
92
- rank: string
93
- rankEmoji: string
94
- pets: number
95
- feeds: number
96
- turns: number
97
- /** True while the pet interaction is inside its cooldown. */
98
- petCooldown: boolean
99
- /** True while the feed is inside its cooldown. */
100
- feedCooldown: boolean
101
- }
86
+ affinity: PetAffinityView
102
87
  /** Display configuration. */
103
88
  display: PetDisplayConfig
104
89
  /** User-customizable pet display name. */
@@ -113,14 +98,7 @@ export interface PetStateView {
113
98
  }
114
99
 
115
100
  /** Result of `pet.interact`. */
116
- export interface PetInteractResult {
117
- /** Reaction copy bubble. */
118
- reaction: string
119
- /** Points gained (0 when inside the cooldown). */
120
- delta: number
121
- /** Full affinity snapshot (same shape as state view). */
122
- affinity: PetStateView['affinity']
123
- }
101
+ export type PetInteractResult = LedgerInteractionResult
124
102
 
125
103
  declare module '@deepseek-ai/cordis' {
126
104
  interface Context {
@@ -128,152 +106,33 @@ declare module '@deepseek-ai/cordis' {
128
106
  }
129
107
  }
130
108
 
131
- /** Runtime shape of the optional legacy activity event. */
132
- interface ActivityStatusEventLike {
133
- phase?: string
134
- line?: string
135
- phrase?: string
136
- }
137
-
138
- /** Per-session facts needed to project the official event stream. */
139
- interface SessionActivityRuntime {
140
- activeTools: Set<string>
141
- officialEventsSeen: boolean
142
- stepHadFailure: boolean
143
- }
144
-
145
- /** One official event projection, optionally carrying a completed turn reward. */
146
- interface PetActivityTransition {
147
- input: PetStateInput
148
- completedTurn?: number
149
- }
150
-
151
- /** Keep tool names readable inside the compact status bubble. */
152
- function displayToolName(name: string): string {
153
- const compact = name.replace(/\s+/g, ' ').trim() || '工具'
154
- return compact.length <= 24 ? compact : `${compact.slice(0, 21)}...`
155
- }
156
-
157
- /** Whether a legacy phase is part of the pet's supported vocabulary. */
158
- function isActivityPhase(phase: string): phase is PetStateInput['phase'] {
159
- return ['idle', 'waiting', 'thinking', 'tool', 'review', 'done', 'failed'].includes(phase)
160
- }
161
-
162
- /**
163
- * Project the durable DSH session vocabulary into the pet's visual phases.
164
- * Unknown and log-only events do not disturb the last meaningful activity.
165
- */
166
- function projectOfficialEvent(
167
- event: SessionEvent,
168
- runtime: SessionActivityRuntime,
169
- ): PetActivityTransition | undefined {
170
- switch (event.type) {
171
- case 'turn/start':
172
- runtime.activeTools.clear()
173
- runtime.stepHadFailure = false
174
- return { input: { phase: 'waiting', line: '准备开始' } }
175
- case 'step/start':
176
- runtime.activeTools.clear()
177
- runtime.stepHadFailure = false
178
- return { input: { phase: 'waiting', line: '等待模型响应' } }
179
- case 'assistant/chunk': {
180
- const { chunk } = event.data
181
- if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
182
- return { input: { phase: 'thinking', line: '正在思考' } }
183
- }
184
- if (chunk.type === 'text-delta' && chunk.text.length > 0) {
185
- return { input: { phase: 'review', line: '整理回复中' } }
186
- }
187
- return undefined
188
- }
189
- case 'assistant/message':
190
- return { input: { phase: 'review', line: '整理回复中' } }
191
- case 'tool/call':
192
- runtime.activeTools.add(String(event.data.callId))
193
- return {
194
- input: {
195
- phase: 'tool',
196
- line: `正在使用 ${displayToolName(event.data.name)}`,
197
- },
198
- }
199
- case 'tool/result': {
200
- const block = event.data.message.content[0]
201
- runtime.activeTools.delete(String(event.data.message.source.callId))
202
- runtime.stepHadFailure ||= event.data.error !== undefined || block.isError === true
203
- if (runtime.activeTools.size > 0) {
204
- return {
205
- input: {
206
- phase: 'tool',
207
- line: `还有 ${runtime.activeTools.size} 个工具运行中`,
208
- },
209
- }
210
- }
211
- return runtime.stepHadFailure
212
- ? { input: { phase: 'failed', line: '工具执行失败' } }
213
- : { input: { phase: 'thinking', line: '处理工具结果' } }
214
- }
215
- case 'turn/end': {
216
- runtime.activeTools.clear()
217
- switch (event.data.reason.kind) {
218
- case 'completed':
219
- return {
220
- input: { phase: 'done', line: '完成啦' },
221
- completedTurn: event.data.turn,
222
- }
223
- case 'error':
224
- return { input: { phase: 'failed', line: '执行失败' } }
225
- case 'max-tokens':
226
- return { input: { phase: 'failed', line: '达到输出上限' } }
227
- case 'interrupted':
228
- return { input: { phase: 'failed', line: '执行意外中断' } }
229
- case 'blocked':
230
- return { input: { phase: 'waiting', line: '等待继续' } }
231
- case 'aborted':
232
- return { input: { phase: 'idle', line: '已停止' } }
233
- default:
234
- // TurnEndReasonMap is merge-extensible; a newer ending must not
235
- // leave the pet showing stale in-progress work.
236
- return { input: { phase: 'idle' } }
237
- }
238
- }
239
- default:
240
- return undefined
241
- }
242
- }
243
-
244
109
  /**
245
110
  * Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
246
- * written until a query or interaction arrives; event listeners update only
247
- * in-memory state, and persistence happens on interaction/config changes
248
- * plus every completed turn.
111
+ * written until an economic event or interaction arrives; event listeners
112
+ * update only in-memory state, and persistence happens on economic changes
113
+ * (turn rewards, feeds, config/name changes) — never on a read.
249
114
  */
250
115
  export class PetService extends Service {
251
116
  static inject: string[] = []
252
117
 
253
118
  private readonly machine: PetStateMachine
254
- private readonly affinityConfig: AffinityConfig
255
- private readonly treatConfig: TreatConfig
119
+ private readonly ledger: PetLedger
256
120
  private readonly persistDir: string
257
- private persist: PetPersist
258
- /** Completed turns already rewarded, per session (turn numbers are per-session). */
259
- private rewardedTurns = new Map<string, number>()
260
121
  private enabled: boolean
261
122
  private disposeActivity: (() => void) | undefined
262
123
  /** Session whose most recent meaningful event currently drives the global pet. */
263
124
  private displaySession: Session | undefined
264
- private readonly sessionActivity = new WeakMap<Session, SessionActivityRuntime>()
265
- private lastLegacyTurnRewardAt = 0
125
+ private readonly sessionActivity = new WeakMap<Session, ProjectionRuntime>()
266
126
 
267
127
  constructor(ctx: Context, config: PetConfig = {}) {
268
128
  super(ctx, 'pet')
269
129
  this.persistDir = config.persistDir ?? petHomeDir()
270
- this.affinityConfig = { ...defaultAffinityConfig, ...(config.affinity ?? {}) }
271
- this.treatConfig = { ...defaultTreatConfig, ...(config.treats ?? {}) }
130
+ const ledgerConfig: LedgerConfig = { affinity: config.affinity, treats: config.treats }
131
+ this.ledger = new PetLedger(loadPetPersist(this.persistDir), ledgerConfig)
272
132
  this.machine = new PetStateMachine({
273
133
  ...defaultPetStateConfig,
274
134
  ...(config.state ?? {}),
275
135
  })
276
- this.persist = loadPetPersist(this.persistDir)
277
136
  this.enabled = config.enabled ?? true
278
137
 
279
138
  this.syncActivity()
@@ -291,12 +150,12 @@ export class PetService extends Service {
291
150
 
292
151
  /** Current persisted display config (read-only view). */
293
152
  display(): PetDisplayConfig {
294
- return { ...this.persist.display }
153
+ return { ...this.ledger.snapshot.display }
295
154
  }
296
155
 
297
156
  /** Current persisted pet name (read-only view). */
298
157
  petName(): string {
299
- return this.persist.name
158
+ return this.ledger.snapshot.name
300
159
  }
301
160
 
302
161
  /** Start or stop the session-activity listeners that drive the pet. */
@@ -354,14 +213,10 @@ export class PetService extends Service {
354
213
  }
355
214
 
356
215
  /** Return the projection state associated with one live session. */
357
- private activityRuntime(session: Session): SessionActivityRuntime {
216
+ private activityRuntime(session: Session): ProjectionRuntime {
358
217
  let runtime = this.sessionActivity.get(session)
359
218
  if (runtime === undefined) {
360
- runtime = {
361
- activeTools: new Set(),
362
- officialEventsSeen: false,
363
- stepHadFailure: false,
364
- }
219
+ runtime = emptyProjectionRuntime()
365
220
  this.sessionActivity.set(session, runtime)
366
221
  }
367
222
  return runtime
@@ -377,53 +232,29 @@ export class PetService extends Service {
377
232
  /** RPC: pet or feed the pet. */
378
233
  async interact(kind: PetInteraction): Promise<PetInteractResult> {
379
234
  const nowMs = Date.now()
380
- // Feeding consumes a treat: settle the economy first (work + time
381
- // output since the last settlement), then gate on the feed cooldown
382
- // BEFORE spending stock — a feed inside the cooldown must not burn a
383
- // treat for nothing.
384
- if (kind === 'feed') this.settleTreats(nowMs)
385
- const outcome = applyInteraction(this.persist.affinity, kind, nowMs, this.affinityConfig)
386
- if (kind === 'feed' && !outcome.accepted) {
387
- return { reaction: outcome.reaction, delta: 0, affinity: this.affinityView(this.persist.affinity) }
388
- }
389
- if (kind === 'feed') {
390
- const consume = consumeTreat(this.persist.treats)
391
- if (!consume.ok) {
392
- const affinity = this.affinityView(this.persist.affinity)
393
- return {
394
- reaction: '没有小鱼干了,多陪鲸鱼娘工作一会儿吧~',
395
- delta: 0,
396
- affinity,
397
- }
398
- }
399
- this.persist = { ...this.persist, treats: consume.ledger }
400
- }
401
- if (outcome.accepted) {
402
- this.persist = { ...this.persist, affinity: outcome.affinity }
403
- this.flush()
404
- }
405
- const affinity = this.affinityView(outcome.affinity)
406
- return { reaction: outcome.reaction, delta: outcome.delta, affinity }
235
+ const result = this.ledger.interact(kind, nowMs)
236
+ if (this.ledger.takeDirty()) this.flush()
237
+ return result
407
238
  }
408
239
 
409
240
  /** RPC: show or hide the pet. */
410
241
  async setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }> {
411
- this.persist = { ...this.persist, display: { ...this.persist.display, visible } }
242
+ this.ledger.setDisplay({ ...this.ledger.snapshot.display, visible })
412
243
  this.flush()
413
244
  this.syncSettingsFromPet()
414
- return { ok: true, display: this.persist.display }
245
+ return { ok: true, display: this.ledger.snapshot.display }
415
246
  }
416
247
 
417
248
  /** RPC: update display config (size / position). Values are clamped to whole pixels. */
418
249
  async setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }> {
419
- const next = { ...this.persist.display, ...patch }
250
+ const next = { ...this.ledger.snapshot.display, ...patch }
420
251
  next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, next.size)))
421
252
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)))
422
253
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)))
423
- this.persist = { ...this.persist, display: next }
254
+ this.ledger.setDisplay(next)
424
255
  this.flush()
425
256
  this.syncSettingsFromPet()
426
- return { ok: true, display: this.persist.display }
257
+ return { ok: true, display: this.ledger.snapshot.display }
427
258
  }
428
259
 
429
260
  /** RPC: rename the pet (trimmed, 1–20 chars). */
@@ -431,7 +262,7 @@ export class PetService extends Service {
431
262
  const trimmed = name.trim()
432
263
  if (trimmed === '') return { ok: false, error: 'name-empty' }
433
264
  if (trimmed.length > PET_NAME_MAX_LENGTH) return { ok: false, error: 'name-too-long' }
434
- this.persist = { ...this.persist, name: trimmed }
265
+ this.ledger.setName(trimmed)
435
266
  this.flush()
436
267
  this.syncSettingsFromPet()
437
268
  return { ok: true, name: trimmed }
@@ -444,12 +275,13 @@ export class PetService extends Service {
444
275
  * @param section - the resolved settings section.
445
276
  */
446
277
  applySettingsSection(section: PetSettingsSection): void {
447
- const next = { ...this.persist.display }
278
+ const next = { ...this.ledger.snapshot.display }
448
279
  next.visible = section.visible && (section.enabled ?? true)
449
280
  next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, section.size)))
450
281
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)))
451
282
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)))
452
- this.persist = { ...this.persist, display: next, name: section.name.trim() }
283
+ this.ledger.setDisplay(next)
284
+ this.ledger.setName(section.name.trim())
453
285
  this.flush()
454
286
  }
455
287
 
@@ -457,12 +289,13 @@ export class PetService extends Service {
457
289
  private syncSettingsFromPet(): void {
458
290
  const settings = this.ctx.get('settings', false) as { update(ns: string, patch: object): Promise<void> } | undefined
459
291
  if (settings === undefined) return
292
+ const snapshot = this.ledger.snapshot
460
293
  void settings.update(PET_SETTINGS_NAMESPACE, {
461
- visible: this.persist.display.visible,
462
- size: this.persist.display.size,
463
- right: this.persist.display.right,
464
- bottom: this.persist.display.bottom,
465
- name: this.persist.name,
294
+ visible: snapshot.display.visible,
295
+ size: snapshot.display.size,
296
+ right: snapshot.display.right,
297
+ bottom: snapshot.display.bottom,
298
+ name: snapshot.name,
466
299
  }).catch(() => {
467
300
  // A settings write failure must not break the pet's own persistence.
468
301
  })
@@ -470,83 +303,36 @@ export class PetService extends Service {
470
303
 
471
304
  /** Award the turn reward once per completed turn (idempotent per session + turn). */
472
305
  private rewardTurn(sessionId: string, turn: number): void {
473
- const last = this.rewardedTurns.get(sessionId) ?? 0
474
- if (turn <= last) return
475
- this.rewardedTurns.set(sessionId, turn)
476
- this.applyTurnReward()
306
+ if (this.ledger.rewardTurn(sessionId, turn, Date.now())) this.flush()
477
307
  }
478
308
 
479
309
  /** Preserve turn rewards for installations that only emit legacy activity. */
480
310
  private rewardLegacyTurn(): void {
481
- const nowMs = Date.now()
482
- // A legacy `done` snapshot may repeat during the celebration window.
483
- if (nowMs - this.lastLegacyTurnRewardAt < 5_000) return
484
- this.lastLegacyTurnRewardAt = nowMs
485
- this.applyTurnReward()
486
- }
487
-
488
- /** Persist one accepted completed-turn reward. */
489
- private applyTurnReward(): void {
490
- this.persist = { ...this.persist, affinity: applyTurnReward(this.persist.affinity, this.affinityConfig) }
491
- this.flush()
492
- }
493
-
494
- /**
495
- * Settle the treat economy (work + time output since the last settlement)
496
- * and persist whenever the ledger changed. A zero-gain first settlement
497
- * still starts the time clock (anchor write), which is what lets the
498
- * 30-minute time output ever accrue.
499
- */
500
- private settleTreats(nowMs: number): void {
501
- const settlement = settleTreatGrants(
502
- this.persist.treats,
503
- this.persist.affinity.turns,
504
- nowMs,
505
- this.treatConfig,
506
- )
507
- if (settlement.ledger !== this.persist.treats) {
508
- this.persist = { ...this.persist, treats: settlement.ledger }
509
- this.flush()
510
- }
311
+ if (this.ledger.rewardLegacyTurn(Date.now())) this.flush()
511
312
  }
512
313
 
513
314
  private view(): PetStateView {
514
315
  const snapshot = this.machine.render()
515
- // Time-output treats accrue while the host is idle too; settle on read.
516
- this.settleTreats(Date.now())
316
+ // Read-only: the ledger settles on economic events only, never on a read,
317
+ // so polling the state cannot trigger pet.json writes.
517
318
  return {
518
319
  animation: snapshot.animation,
519
320
  ...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
520
321
  phase: snapshot.phase,
521
322
  sessionActive: snapshot.sessionActive,
522
- affinity: this.affinityView(this.persist.affinity),
523
- display: { ...this.persist.display },
524
- name: this.persist.name,
323
+ affinity: this.ledger.affinityView(Date.now()),
324
+ display: { ...this.ledger.snapshot.display },
325
+ name: this.ledger.snapshot.name,
525
326
  treats: {
526
- stocked: this.persist.treats.treats,
527
- max: this.treatConfig.maxTreats,
327
+ stocked: this.ledger.snapshot.treats.treats,
328
+ max: this.ledger.treatMax,
528
329
  },
529
330
  }
530
331
  }
531
332
 
532
- private affinityView(affinity: AffinityState): PetStateView['affinity'] {
533
- const nowMs = Date.now()
534
- const rank = rankOf(affinity.points)
535
- return {
536
- points: affinity.points,
537
- rank: rank.name,
538
- rankEmoji: rank.emoji,
539
- pets: affinity.pets,
540
- feeds: affinity.feeds,
541
- turns: affinity.turns,
542
- petCooldown: nowMs - affinity.lastPetAt < this.affinityConfig.petCooldownMs,
543
- feedCooldown: nowMs - affinity.lastFeedAt < this.affinityConfig.feedCooldownMs,
544
- }
545
- }
546
-
547
333
  private flush(): void {
548
334
  try {
549
- savePetPersist(this.persist, this.persistDir)
335
+ savePetPersist(this.ledger.snapshot, this.persistDir)
550
336
  } catch {
551
337
  // Persistence is best-effort; the in-memory ledger keeps working.
552
338
  }