@linxin666/dsh-pet 0.1.11 → 0.1.13

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.
package/src/service.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * Pet host service — the `pet.*` RPC domain. Owns the state machine wiring
3
- * (consumes `activity/status` session events and session lifecycle), 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.
3
+ * (maps core rc.6 session events turn/step/tool boundaries — and the
4
+ * session lifecycle onto the pet phases), the affinity ledger, and the
5
+ * persisted display config. The API gateway maps this service's methods onto
6
+ * `pet.state` / `pet.interact` / `pet.setVisible` / `pet.setConfig`
7
+ * for browser consumers.
7
8
  * @module @linxin666/dsh-pet/service
8
9
  */
9
10
 
10
11
  import { Context, Service } from '@deepseek-ai/cordis'
11
- import type { Session } from '@deepseek-ai/dsh-session'
12
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
12
13
  import {
13
14
  applyInteraction,
14
15
  applyTurnReward,
@@ -127,13 +128,6 @@ declare module '@deepseek-ai/cordis' {
127
128
  }
128
129
  }
129
130
 
130
- /** One session/event guard: only the latest activity snapshot matters. */
131
- interface ActivityStatusEventLike {
132
- phase?: string
133
- line?: string
134
- phrase?: string
135
- }
136
-
137
131
  /**
138
132
  * Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
139
133
  * written until a query or interaction arrives; event listeners update only
@@ -148,7 +142,8 @@ export class PetService extends Service {
148
142
  private readonly treatConfig: TreatConfig
149
143
  private readonly persistDir: string
150
144
  private persist: PetPersist
151
- private lastTurnRewardAt = 0
145
+ /** Completed turns already rewarded, per session (turn numbers are per-session). */
146
+ private rewardedTurns = new Map<string, number>()
152
147
  private enabled: boolean
153
148
  private disposeActivity: (() => void) | undefined
154
149
 
@@ -201,20 +196,36 @@ export class PetService extends Service {
201
196
  if (!this.enabled) return
202
197
  this.disposeActivity = (() => {
203
198
  const disposers = [
204
- this.ctx.on('session/event', (_session: Session, event: { type: string; data?: unknown }) => {
205
- if (event.type !== 'activity/status') return
206
- const payload = (event.data ?? {}) as ActivityStatusEventLike
207
- if (payload.phase === undefined) return
208
- const phase = payload.phase as PetStateSnapshot['phase']
209
- // Guard against unknown phases from newer activity trackers.
210
- if (!['idle', 'waiting', 'thinking', 'tool', 'done'].includes(phase)) return
211
- this.machine.onActivityStatus({
212
- phase,
213
- ...(typeof payload.line === 'string' ? { line: payload.line } : {}),
214
- ...(typeof payload.phrase === 'string' ? { phrase: payload.phrase } : {}),
215
- })
216
- this.machine.onSessionActive()
217
- if (phase === 'done') this.rewardTurn()
199
+ this.ctx.on('session/event', (session: Session, event: SessionEvent) => {
200
+ // rc.6 publishes no 'activity/status' event (the working-activity
201
+ // tracker is gone), so the pet derives its phases from the core
202
+ // session vocabulary instead.
203
+ switch (event.type) {
204
+ case 'turn/start':
205
+ this.machine.onSessionActive()
206
+ break
207
+ case 'step/start':
208
+ this.machine.onSessionActive()
209
+ this.machine.onActivityStatus({ phase: 'thinking' })
210
+ break
211
+ case 'tool/call':
212
+ this.machine.onSessionActive()
213
+ this.machine.onActivityStatus({ phase: 'tool', line: 'tool: ' + event.data.name })
214
+ break
215
+ case 'turn/end':
216
+ this.machine.onSessionActive()
217
+ if (event.data.reason.kind === 'completed') {
218
+ this.machine.onActivityStatus({ phase: 'done' })
219
+ this.rewardTurn(String(session.id), event.data.turn)
220
+ } else {
221
+ // Aborted / failed turns clear the working pose instead of
222
+ // freezing the pet on its last phase.
223
+ this.machine.onActivityStatus({ phase: 'idle' })
224
+ }
225
+ break
226
+ default:
227
+ break
228
+ }
218
229
  }),
219
230
  this.ctx.on('session/disposed', () => {
220
231
  this.machine.onSessionDisposed()
@@ -318,19 +329,20 @@ export class PetService extends Service {
318
329
  })
319
330
  }
320
331
 
321
- /** Award the turn reward once per done phase (idempotent per transition). */
322
- private rewardTurn(): void {
323
- const nowMs = Date.now()
324
- // A done phase can repeat while celebrating; only reward the first.
325
- if (nowMs - this.lastTurnRewardAt < 5_000) return
326
- this.lastTurnRewardAt = nowMs
332
+ /** Award the turn reward once per completed turn (idempotent per session + turn). */
333
+ private rewardTurn(sessionId: string, turn: number): void {
334
+ const last = this.rewardedTurns.get(sessionId) ?? 0
335
+ if (turn <= last) return
336
+ this.rewardedTurns.set(sessionId, turn)
327
337
  this.persist = { ...this.persist, affinity: applyTurnReward(this.persist.affinity, this.affinityConfig) }
328
338
  this.flush()
329
339
  }
330
340
 
331
341
  /**
332
- * Settle the treat economy (work + time output since the last
333
- * settlement); persists only when treats were actually granted.
342
+ * Settle the treat economy (work + time output since the last settlement)
343
+ * and persist whenever the ledger changed. A zero-gain first settlement
344
+ * still starts the time clock (anchor write), which is what lets the
345
+ * 30-minute time output ever accrue.
334
346
  */
335
347
  private settleTreats(nowMs: number): void {
336
348
  const settlement = settleTreatGrants(
@@ -339,7 +351,7 @@ export class PetService extends Service {
339
351
  nowMs,
340
352
  this.treatConfig,
341
353
  )
342
- if (settlement.gained > 0) {
354
+ if (settlement.ledger !== this.persist.treats) {
343
355
  this.persist = { ...this.persist, treats: settlement.ledger }
344
356
  this.flush()
345
357
  }
package/src/state.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Pet state machine — pure, clock-injected. Maps the DSH `activity/status`
3
- * phase vocabulary (session events) onto the 9-state Codex pet
4
- * animation contract, plus the session lifecycle transitions the web UI
5
- * exposes (turn end celebration, no-session idle).
2
+ * Pet state machine — pure, clock-injected. Maps the pet's working-phase
3
+ * vocabulary (the service derives it from core session events) onto the
4
+ * 9-state Codex pet animation contract, plus the session lifecycle
5
+ * transitions the web UI exposes (turn end celebration, no-session idle).
6
6
  *
7
7
  * The machine is deliberately dumb: it holds the last input phase, the
8
8
  * animation decision, and a one-shot "celebration" window after `done` so the
@@ -11,7 +11,7 @@
11
11
  * @module @linxin666/dsh-pet/state
12
12
  */
13
13
 
14
- /** The DSH `activity/status` phase vocabulary (wire contract of session events). */
14
+ /** The pet's working-phase vocabulary (derived from core session events by the service). */
15
15
  export type ActivityPhase = 'idle' | 'waiting' | 'thinking' | 'tool' | 'done'
16
16
 
17
17
  /** The Codex-compatible 9-state animation contract (spritesheet rows). */
@@ -28,7 +28,7 @@ export type PetAnimation =
28
28
 
29
29
  /** One input snapshot consumed by the machine. */
30
30
  export interface PetStateInput {
31
- /** Current activity/status phase of the active session. */
31
+ /** Current working phase of the active session. */
32
32
  phase: ActivityPhase
33
33
  /** Human-readable status line (plain text). */
34
34
  line?: string
@@ -110,7 +110,7 @@ export class PetStateMachine {
110
110
  private readonly now: () => number = Date.now,
111
111
  ) {}
112
112
 
113
- /** Consume one `activity/status` session event. */
113
+ /** Consume one phase snapshot (fed by the service from session events). */
114
114
  onActivityStatus(input: PetStateInput): void {
115
115
  this.phase = input.phase
116
116
  this.line = input.line
@@ -23,11 +23,34 @@ describe('settleTreatGrants', () => {
23
23
  expect(s.ledger.lastTreatGrantAt).toBe(1_000 + 90 * 60_000)
24
24
  })
25
25
 
26
- it('does not backfill time output before the first settlement', () => {
26
+ it('does not backfill time output before the first settlement, but starts the clock', () => {
27
27
  const ledger = emptyTreatLedger() // lastTreatGrantAt === 0
28
28
  const s = settleTreatGrants(ledger, 0, 1_000 + 10 * 60 * 60_000, defaultTreatConfig)
29
29
  expect(s.gained).toBe(0)
30
- expect(s.ledger).toBe(ledger)
30
+ expect(s.ledger.treats).toBe(0)
31
+ expect(s.ledger.lastTreatGrantAt).toBe(1_000 + 10 * 60 * 60_000)
32
+ })
33
+
34
+ it('starts the time clock on a zero-gain settlement so later time output accrues (anchor deadlock)', () => {
35
+ // Regression for issue #99: the old code returned the ledger untouched
36
+ // when nothing was due, so lastTreatGrantAt stayed 0 forever and the
37
+ // 30-minute time output could never begin.
38
+ let ledger = emptyTreatLedger()
39
+ const first = settleTreatGrants(ledger, 0, 1_000, defaultTreatConfig)
40
+ expect(first.gained).toBe(0)
41
+ expect(first.ledger.lastTreatGrantAt).toBe(1_000)
42
+ expect(first.ledger.treats).toBe(0)
43
+ ledger = first.ledger
44
+ // Anchored and nothing due: the same object comes back (no persistence
45
+ // churn) and the anchor must not move.
46
+ const same = settleTreatGrants(ledger, 0, 1_000 + 10_000, defaultTreatConfig)
47
+ expect(same.gained).toBe(0)
48
+ expect(same.ledger).toBe(ledger)
49
+ // One full period after the anchor: the time treat finally lands.
50
+ const grant = settleTreatGrants(ledger, 0, 1_000 + defaultTreatConfig.timeTreatMs, defaultTreatConfig)
51
+ expect(grant.gained).toBe(1)
52
+ expect(grant.ledger.treats).toBe(1)
53
+ expect(grant.ledger.lastTreatGrantAt).toBe(1_000 + defaultTreatConfig.timeTreatMs)
31
54
  })
32
55
 
33
56
  it('caps stocked treats at maxTreats', () => {
package/src/treats.ts CHANGED
@@ -58,8 +58,12 @@ function cap(treats: number, max: number): number {
58
58
  * time output counts whole periods since the time anchor
59
59
  * (`lastTreatGrantAt`) and advances only the time anchor. The two sources
60
60
  * are independent so a continuously working user still earns time treats.
61
- * 0 time history never backfills — the clock starts at the first settlement.
62
- * Both sources are clamped by the stock cap.
61
+ * 0 time history never backfills — the clock starts at the first settlement,
62
+ * and even a zero-gain first settlement writes the time anchor so the next
63
+ * elapsed period can accrue (anchor deadlock fix). Both sources are clamped
64
+ * by the stock cap. When the anchor is already set and nothing is due, the
65
+ * input ledger is returned unchanged (same object), so callers can skip
66
+ * persistence cheaply.
63
67
  */
64
68
  export function settleTreatGrants(
65
69
  ledger: TreatLedger,
@@ -74,7 +78,15 @@ export function settleTreatGrants(
74
78
  const timeAnchor = ledger.lastTreatGrantAt === 0 ? nowMs : ledger.lastTreatGrantAt
75
79
  const timeGrants = Math.floor(Math.max(0, nowMs - timeAnchor) / config.timeTreatMs)
76
80
  const gained = workGrants + timeGrants
77
- if (gained <= 0) return { ledger, gained: 0 }
81
+ if (gained <= 0) {
82
+ if (ledger.lastTreatGrantAt === 0) {
83
+ // Zero-gain first settlement: persist the clock start anyway, so the
84
+ // 30-minute time output can begin. Before this fix the anchor stayed 0
85
+ // forever (the deadlock: no grant means no anchor write means no grant).
86
+ return { ledger: { ...ledger, lastTreatGrantAt: nowMs }, gained: 0 }
87
+ }
88
+ return { ledger, gained: 0 }
89
+ }
78
90
  return {
79
91
  ledger: {
80
92
  treats: cap(ledger.treats + gained, config.maxTreats),