@linxin666/dsh-pet 0.1.13 → 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 (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +11 -7
  3. package/README.zh.md +11 -7
  4. package/lib/client.js +38 -17
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +393 -150
  7. package/lib/invariant.js +1 -1
  8. package/lib/{state-C9EyycwI.js → state-CFyJv0sQ.js} +22 -7
  9. package/lib/types/affinity.d.ts +15 -0
  10. package/lib/types/affinity.d.ts.map +1 -1
  11. package/lib/types/affinity.js +14 -0
  12. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PluginSettingsCard.js +27 -7
  15. package/lib/types/client/WhalePet.d.ts.map +1 -1
  16. package/lib/types/client/WhalePet.js +2 -1
  17. package/lib/types/client/index.d.ts +1 -1
  18. package/lib/types/client/index.js +3 -3
  19. package/lib/types/client/settings-form.d.ts +14 -5
  20. package/lib/types/client/settings-form.d.ts.map +1 -1
  21. package/lib/types/client/settings-form.js +38 -10
  22. package/lib/types/dsh-home.d.ts +17 -0
  23. package/lib/types/dsh-home.d.ts.map +1 -0
  24. package/lib/types/dsh-home.js +34 -0
  25. package/lib/types/event-projection.d.ts +36 -0
  26. package/lib/types/event-projection.d.ts.map +1 -0
  27. package/lib/types/event-projection.js +98 -0
  28. package/lib/types/ledger.d.ts +77 -0
  29. package/lib/types/ledger.d.ts.map +1 -0
  30. package/lib/types/ledger.js +139 -0
  31. package/lib/types/persist.d.ts +5 -1
  32. package/lib/types/persist.d.ts.map +1 -1
  33. package/lib/types/persist.js +7 -3
  34. package/lib/types/service.d.ts +24 -44
  35. package/lib/types/service.d.ts.map +1 -1
  36. package/lib/types/service.js +98 -131
  37. package/lib/types/state.d.ts +10 -12
  38. package/lib/types/state.d.ts.map +1 -1
  39. package/lib/types/state.js +14 -10
  40. package/package.json +1 -1
  41. package/src/affinity.ts +33 -0
  42. package/src/client/PluginSettingsCard.tsx +83 -17
  43. package/src/client/WhalePet.test.tsx +25 -1
  44. package/src/client/WhalePet.tsx +6 -0
  45. package/src/client/index.ts +3 -3
  46. package/src/client/pet.module.css +9 -0
  47. package/src/client/settings-card.module.css +6 -0
  48. package/src/client/settings-form.ts +41 -9
  49. package/src/dsh-home.test.ts +35 -0
  50. package/src/dsh-home.ts +36 -0
  51. package/src/event-projection.ts +128 -0
  52. package/src/ledger.test.ts +67 -0
  53. package/src/ledger.ts +183 -0
  54. package/src/persist.ts +7 -3
  55. package/src/service.ts +110 -171
  56. package/src/state.test.ts +4 -1
  57. package/src/state.ts +17 -13
@@ -1,25 +1,44 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/PluginSettingsCard.tsx. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
1
2
  /**
2
- * Shared chrome for the plugin settings card: a disclosure header naming the
3
- * plugin and what its settings govern, the controls inside, and the save that
4
- * writes them. Renders nothing while the namespace is unavailable — a
3
+ * Family-shared chrome for plugin settings cards: a disclosure header naming
4
+ * the plugin and what its settings govern, the controls inside, and the save
5
+ * that writes them. Renders nothing while the namespace is unavailable — a
5
6
  * deployment that does not compose the owning plugin should show no trace of
6
- * it. Mirrors the official ui-plugin-config PluginCard in a self-contained
7
- * slice (this package must not depend on a sibling UI package).
7
+ * it. Inlined into each consumer's client bundle; mirrors the official
8
+ * ui-plugin-config PluginCard in a self-contained slice.
8
9
  */
9
10
 
10
11
  import { useState, type ReactNode } from 'react'
11
12
  import type { CardShell } from './settings-form.ts'
12
- import type { SettingsCardKey } from './locales.ts'
13
13
  import css from './settings-card.module.css'
14
14
 
15
+ /** Copy keys the card chrome itself reads; every consumer locale carries this shared vocabulary. */
16
+ export const CARD_COPY_KEYS = [
17
+ 'settings.collapse',
18
+ 'settings.expand',
19
+ 'settings.notExposed',
20
+ 'settings.unsaved',
21
+ 'settings.readOnly',
22
+ 'settings.saveFailed',
23
+ 'settings.discard',
24
+ 'settings.save',
25
+ 'settings.saving',
26
+ ] as const
27
+
28
+ /** Copy key the card chrome itself reads. */
29
+ export type CardCopyKey = (typeof CARD_COPY_KEYS)[number]
30
+
31
+ /** Key domain for the plugin's own copy (inferred per consumer). */
32
+ export type SettingsCardKey<TKey extends string = string> = TKey | CardCopyKey
33
+
15
34
  /** Card chrome shared by every plugin settings card. */
16
- export interface PluginSettingsCardProps {
35
+ export interface PluginSettingsCardProps<TKey extends string = string> {
17
36
  /** Locale reader for this card's copy. */
18
- t: (key: SettingsCardKey) => string
37
+ t: (key: SettingsCardKey<TKey>, params?: Record<string, string | number>) => string
19
38
  /** Locale key of the plugin's name. */
20
- titleKey: SettingsCardKey
39
+ titleKey: TKey
21
40
  /** Locale key of the line describing what this plugin's settings govern. */
22
- descriptionKey: SettingsCardKey
41
+ descriptionKey: TKey
23
42
  /** The card's form state: availability, writability, and what a save would do. */
24
43
  state: CardShell
25
44
  /** Write every staged edit. */
@@ -35,11 +54,12 @@ export interface PluginSettingsCardProps {
35
54
  * @param props - the plugin's copy keys, its form state, and its controls.
36
55
  * @returns the card, or nothing while the namespace is still loading.
37
56
  */
38
- export function PluginSettingsCard(props: PluginSettingsCardProps) {
57
+ export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
39
58
  const [open, setOpen] = useState(false)
40
59
  const { state } = props
41
60
  if (!state.available) return null
42
61
  const title = props.t(props.titleKey)
62
+ const description = props.t(props.descriptionKey)
43
63
  const blocked = !state.dirty || state.invalid || state.saving
44
64
  const cardClass = open ? `${css.cardOpen} ${css.card}` : css.card
45
65
  // The namespace exists but the Host does not serve it to this client (the
@@ -57,8 +77,8 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
57
77
  onClick={() => { setOpen(!open) }}
58
78
  >
59
79
  <span className={css.headText}>
60
- <span className={css.name}>{title}</span>
61
- <span className={css.description}>{props.t(props.descriptionKey)}</span>
80
+ <span className={css.name} title={title}>{title}</span>
81
+ <span className={css.description} title={description}>{description}</span>
62
82
  </span>
63
83
  <svg
64
84
  width="14"
@@ -94,10 +114,10 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
94
114
  onClick={() => { setOpen(!open) }}
95
115
  >
96
116
  <span className={css.headText}>
97
- <span className={css.name}>{title}</span>
98
- <span className={css.description}>{props.t(props.descriptionKey)}</span>
117
+ <span className={css.name} title={title}>{title}</span>
118
+ <span className={css.description} title={description}>{description}</span>
99
119
  </span>
100
- {state.dirty ? <span className={css.pending}>{props.t('settings.unsaved')}</span> : null}
120
+ {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
101
121
  <svg
102
122
  width="14"
103
123
  height="14"
@@ -259,4 +279,50 @@ export function BooleanField(props: FieldProps & {
259
279
  <p className={css.hint}>{props.hint}</p>
260
280
  </div>
261
281
  )
262
- }
282
+ }
283
+
284
+ /** A staged enumerated field rendered as a select. */
285
+ export function ChoiceField(props: FieldProps & {
286
+ /** Copy for the inherit option (draft text is the empty string). */
287
+ inheritLabel: string
288
+ /** Choices rendered in order; `value` is the draft/stored text. */
289
+ choices: ReadonlyArray<{ value: string; label: string }>
290
+ }) {
291
+ return (
292
+ <div className={css.field}>
293
+ <div className={css.head}>
294
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
295
+ {props.overridden
296
+ ? (
297
+ <span className={css.badges}>
298
+ <span className={css.badge}>{props.overriddenLabel}</span>
299
+ <button
300
+ type="button"
301
+ className={css.reset}
302
+ disabled={props.disabled}
303
+ onClick={props.onReset}
304
+ >
305
+ {props.resetLabel}
306
+ </button>
307
+ </span>
308
+ )
309
+ : null}
310
+ </div>
311
+ <select
312
+ id={props.id}
313
+ className={css.select}
314
+ value={props.text}
315
+ disabled={props.disabled}
316
+ onChange={(event) => { props.onEdit(event.target.value) }}
317
+ >
318
+ <option value="">{props.inheritLabel}</option>
319
+ {props.choices.map(choice => (
320
+ <option key={choice.value} value={choice.value}>{choice.label}</option>
321
+ ))}
322
+ </select>
323
+ <p className={props.invalid ? css.invalid : css.hint}>
324
+ {props.invalid ? props.invalidLabel : props.hint}
325
+ </p>
326
+ </div>
327
+ )
328
+ }
@@ -55,7 +55,7 @@ afterEach(() => {
55
55
  })
56
56
 
57
57
  /** Render the pet with mocked callbacks; returns the rename spy. */
58
- function renderPet(): { onRename: ReturnType<typeof vi.fn> } {
58
+ function renderPet(overrides: Partial<WhalePetProps> = {}): { onRename: ReturnType<typeof vi.fn> } {
59
59
  const onRename = vi.fn()
60
60
  const props: WhalePetProps = {
61
61
  snapshot,
@@ -68,6 +68,7 @@ function renderPet(): { onRename: ReturnType<typeof vi.fn> } {
68
68
  onRename,
69
69
  onFeedbackDone: vi.fn(),
70
70
  t,
71
+ ...overrides,
71
72
  }
72
73
  render(<WhalePet {...props} />)
73
74
  return { onRename }
@@ -136,3 +137,26 @@ describe('WhalePet rename input', () => {
136
137
  expect(screen.queryByPlaceholderText('输入新名字')).toBeNull()
137
138
  })
138
139
  })
140
+
141
+ describe('WhalePet status bubble', () => {
142
+ const workingSnapshot: PetStateView = {
143
+ ...snapshot,
144
+ animation: 'running',
145
+ phase: 'thinking',
146
+ bubble: '正在思考',
147
+ }
148
+
149
+ it('renders host activity when no interaction feedback is active', () => {
150
+ renderPet({ snapshot: workingSnapshot })
151
+ expect(screen.queryByText('正在思考')).not.toBeNull()
152
+ })
153
+
154
+ it('lets transient interaction feedback replace host activity', () => {
155
+ renderPet({
156
+ snapshot: workingSnapshot,
157
+ feedback: { text: '摸摸成功', kind: 'pet', at: 1 },
158
+ })
159
+ expect(screen.queryByText('摸摸成功')).not.toBeNull()
160
+ expect(screen.queryByText('正在思考')).toBeNull()
161
+ })
162
+ })
@@ -228,6 +228,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
228
228
  const pos = dragPos ?? { right: display.right, bottom: display.bottom }
229
229
  const spriteWidth = Math.round(FRAME_WIDTH * spriteScale)
230
230
  const spriteHeight = Math.round(FRAME_HEIGHT * spriteScale)
231
+ const statusBubble = feedback === null && !hovered ? snapshot?.bubble : undefined
231
232
 
232
233
  const float = (
233
234
  <div
@@ -281,6 +282,11 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
281
282
  {feedback.text}
282
283
  </div>
283
284
  )}
285
+ {statusBubble !== undefined && (
286
+ <div className={`${styles.bubble} ${styles.bubbleStatus}`} role="status" aria-live="polite">
287
+ {statusBubble}
288
+ </div>
289
+ )}
284
290
  {hovered && dragRef.current === null && (
285
291
  <div
286
292
  className={styles.panel}
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dsh-pet browser half — mounts the whale-girl as a global floating surface
3
3
  * and drives it from the host's same-origin `/api/pet/*` JSON endpoints: poll
4
- * the host snapshot (~800 ms), forward interactions, persist drag positions.
4
+ * the host snapshot (~2 s), forward interactions, persist drag positions.
5
5
  * The pet is host-global (no session dimension), so it mounts directly onto
6
6
  * `document.body` via a single React root rather than a session-scoped slot —
7
7
  * on the new-conversation screen no session exists, and a dock-mounted pet
@@ -61,7 +61,7 @@ const petApi: PetHttpApi = {
61
61
  }
62
62
 
63
63
  /** Poll interval for the host snapshot. */
64
- const POLL_MS = 800
64
+ const POLL_MS = 2000
65
65
 
66
66
  /** Settings namespace the pet settings card edits (the Host plugin registers it). */
67
67
  const PET_SETTINGS_NS = 'pet'
@@ -161,7 +161,7 @@ export function apply(ctx: ClientContext): void {
161
161
  // change while the page is hidden, so a background interval would
162
162
  // only burn RPCs (browser throttling is an unreliable backstop).
163
163
  // Coming back to the tab refreshes the pet immediately instead of
164
- // waiting out the next 800 ms cycle.
164
+ // waiting out the next 2 s cycle.
165
165
  let timer: number | undefined
166
166
  const stop = (): void => {
167
167
  if (timer !== undefined) {
@@ -37,6 +37,15 @@
37
37
  background: rgba(56, 189, 248, 0.92);
38
38
  }
39
39
 
40
+ .bubbleStatus {
41
+ max-width: min(280px, calc(100vw - 24px));
42
+ overflow: hidden;
43
+ text-overflow: ellipsis;
44
+ background: rgba(15, 23, 42, 0.9);
45
+ border: 1px solid rgba(125, 211, 252, 0.5);
46
+ animation: none;
47
+ }
48
+
40
49
  @keyframes pet-bubble-pop {
41
50
  0% {
42
51
  opacity: 0;
@@ -1,3 +1,4 @@
1
+ /* Generated by scripts/sync-shared.mjs from shared/client/settings/settings-card.module.css. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs". */
1
2
  /* Plugin settings card chrome + staged form fields.
2
3
  * Aligned with the official ui-settings-plugins PluginCard / fields CSS:
3
4
  * same semantic tokens, radius, typography and states so family cards read
@@ -229,6 +230,11 @@
229
230
  outline-offset: 2px;
230
231
  }
231
232
 
233
+ .reset:focus-visible {
234
+ outline: 2px solid var(--dsw-alias-brand-primary);
235
+ outline-offset: 2px;
236
+ }
237
+
232
238
  .input,
233
239
  .select {
234
240
  border: 1px solid var(--dsw-alias-border-l2);
@@ -1,10 +1,11 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
1
2
  /**
2
3
  * Staged form model behind the plugin settings card. A card stages what the
3
4
  * user types and writes it only when they save — the settings write is a
4
5
  * durable, revision-fenced document mutation, so staging keeps what is on
5
- * screen exactly what a save would store. Mirrors the official
6
- * ui-plugin-config card-store pattern in a self-contained slice: this
7
- * package must not depend on a sibling UI package.
6
+ * screen exactly what a save would store. Family-shared slice inlined into
7
+ * each plugin's client bundle; mirrors the official ui-plugin-config
8
+ * card-store pattern.
8
9
  */
9
10
 
10
11
  import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -90,8 +91,17 @@ interface PlannedWrite {
90
91
  run: (() => Promise<boolean>) | undefined
91
92
  }
92
93
 
93
- /** A whole-number field. An empty draft clears the field; any other draft that is not a finite number blocks the save. */
94
- export function numberField(field: string): FieldSpec {
94
+ /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
95
+ export interface NumberConstraints {
96
+ /** The accepted value must be a whole number. */
97
+ integer?: boolean
98
+ /** The accepted value must be at least this. */
99
+ min?: number
100
+ }
101
+
102
+ /** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
103
+ export function numberField(field: string, constraints: NumberConstraints = {}): FieldSpec {
104
+ const { integer = false, min } = constraints
95
105
  return {
96
106
  field,
97
107
  format: value => typeof value === 'number' ? String(value) : '',
@@ -99,7 +109,10 @@ export function numberField(field: string): FieldSpec {
99
109
  const trimmed = text.trim()
100
110
  if (trimmed === '') return { kind: 'clear' }
101
111
  const parsed = Number(trimmed)
102
- return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
112
+ if (!Number.isFinite(parsed)) return undefined
113
+ if (integer && !Number.isInteger(parsed)) return undefined
114
+ if (min !== undefined && parsed < min) return undefined
115
+ return { kind: 'set', value: parsed }
103
116
  },
104
117
  }
105
118
  }
@@ -122,13 +135,27 @@ export function booleanField(field: string): FieldSpec {
122
135
  field,
123
136
  format: value => typeof value === 'boolean' ? String(value) : '',
124
137
  parse: (text) => {
125
- if (text === 'true') return { kind: 'set', value: true }
126
- if (text === 'false') return { kind: 'set', value: false }
138
+ const trimmed = text.trim()
139
+ if (trimmed === '') return { kind: 'clear' }
140
+ if (trimmed === 'true') return { kind: 'set', value: true }
141
+ if (trimmed === 'false') return { kind: 'set', value: false }
127
142
  return undefined
128
143
  },
129
144
  }
130
145
  }
131
146
 
147
+ /** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
148
+ export function choiceField(field: string, choices: readonly string[]): FieldSpec {
149
+ return {
150
+ field,
151
+ format: value => typeof value === 'string' && choices.includes(value) ? value : '',
152
+ parse: (text) => {
153
+ if (text === '') return { kind: 'clear' }
154
+ return choices.includes(text) ? { kind: 'set', value: text } : undefined
155
+ },
156
+ }
157
+ }
158
+
132
159
  /**
133
160
  * Stages one card's edits over one settings namespace and writes them on save.
134
161
  *
@@ -215,6 +242,9 @@ export class CardForm<T> {
215
242
  const plan = this.plan()
216
243
  const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
217
244
  if (plan.length === 0 || this.saving || writes.length !== plan.length) return
245
+ // Snapshot the fields this save writes, so edits staged while it is in
246
+ // flight survive: only the staged keys this save actually wrote are cleared.
247
+ const fields = new Set(plan.map(item => item.field))
218
248
  this.saving = true
219
249
  this.failed = false
220
250
  this.publish()
@@ -222,7 +252,9 @@ export class CardForm<T> {
222
252
  for (const write of writes) {
223
253
  landed = await write() && landed
224
254
  }
225
- if (landed) this.staged.clear()
255
+ if (landed) {
256
+ for (const field of fields) this.staged.delete(field)
257
+ }
226
258
  this.saving = false
227
259
  this.failed = !landed
228
260
  this.publish()
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { join } from 'node:path'
3
+ import { expandHome, resolveDshHome } from './dsh-home.ts'
4
+ import { petHomeDir } from './persist.ts'
5
+
6
+ describe('resolveDshHome', () => {
7
+ it('falls back to ~/.dsh when DSH_HOME is unset', () => {
8
+ const home = join('/home', 'tester')
9
+ expect(resolveDshHome({}, home)).toBe(join(home, '.dsh'))
10
+ })
11
+
12
+ it('prefers the DSH_HOME env override', () => {
13
+ const home = join('/home', 'tester')
14
+ expect(resolveDshHome({ DSH_HOME: '/custom/dsh' }, home)).toBe('/custom/dsh')
15
+ })
16
+
17
+ it('expands a leading ~ (and ~/) against the platform home', () => {
18
+ const home = join('/home', 'tester')
19
+ expect(resolveDshHome({ DSH_HOME: '~/data' }, home)).toBe(join(home, 'data'))
20
+ expect(expandHome('~', home)).toBe(home)
21
+ })
22
+
23
+ it('joins a relative DSH_HOME onto the working directory', () => {
24
+ const home = join('/home', 'tester')
25
+ const rel = resolveDshHome({ DSH_HOME: 'rel/dsh' }, home)
26
+ expect(rel).not.toBe('rel/dsh')
27
+ expect(rel).toBe(join(process.cwd(), 'rel/dsh'))
28
+ })
29
+ })
30
+
31
+ describe('petHomeDir delegates to the shared DSH_HOME resolution', () => {
32
+ it('returns the same value as dshHome under the same env', () => {
33
+ expect(petHomeDir).toBeTypeOf('function')
34
+ })
35
+ })
@@ -0,0 +1,36 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/dsh-home.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
4
+ * environment override wins, the platform home fallback follows. Mirrors
5
+ * what dsh-pet and dsh-liangshen each used to implement locally.
6
+ */
7
+
8
+ import { homedir } from 'node:os'
9
+ import { isAbsolute, join } from 'node:path'
10
+
11
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
12
+ export function expandHome(path: string, home: string = homedir()): string {
13
+ if (path === '~') return home
14
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2))
15
+ return path
16
+ }
17
+
18
+ /**
19
+ * Resolve the DSH home directory.
20
+ * @param env - process environment to read DSH_HOME from.
21
+ * @param home - platform home directory fallback (test seam).
22
+ * @returns the absolute DSH home path.
23
+ */
24
+ export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
25
+ const raw = env.DSH_HOME
26
+ if (raw !== undefined && raw.trim() !== '') {
27
+ const expanded = expandHome(raw.trim(), home)
28
+ return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
29
+ }
30
+ return join(home, '.dsh')
31
+ }
32
+
33
+ /** Resolve the DSH home directory from the live environment. */
34
+ export function dshHome(): string {
35
+ return resolveDshHome()
36
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Official session event projection — pure. Maps the durable DSH session
3
+ * vocabulary onto the pet's visual phases and carries an optional completed-
4
+ * turn reward for the ledger. Holds no state of its own; callers keep a
5
+ * {@link ProjectionRuntime} per session and feed events in arrival order.
6
+ * @module @linxin666/dsh-pet/event-projection
7
+ */
8
+
9
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
10
+ import type { PetStateInput } from './state.ts'
11
+
12
+ /** Runtime shape of the optional legacy activity event. */
13
+ export interface ActivityStatusEventLike {
14
+ phase?: string
15
+ line?: string
16
+ phrase?: string
17
+ }
18
+
19
+ /** Per-session facts needed to project the official event stream. */
20
+ export interface ProjectionRuntime {
21
+ activeTools: Set<string>
22
+ officialEventsSeen: boolean
23
+ stepHadFailure: boolean
24
+ }
25
+
26
+ /** One official event projection, optionally carrying a completed turn reward. */
27
+ export interface PetActivityTransition {
28
+ input: PetStateInput
29
+ completedTurn?: number
30
+ }
31
+
32
+ /** Fresh projection runtime for a newly seen session. */
33
+ export function emptyProjectionRuntime(): ProjectionRuntime {
34
+ return { activeTools: new Set(), officialEventsSeen: false, stepHadFailure: false }
35
+ }
36
+
37
+ /** Keep tool names readable inside the compact status bubble. */
38
+ function displayToolName(name: string): string {
39
+ const compact = name.replace(/\s+/g, ' ').trim() || '工具'
40
+ return compact.length <= 24 ? compact : `${compact.slice(0, 21)}...`
41
+ }
42
+
43
+ /** Whether a legacy phase is part of the pet's supported vocabulary. */
44
+ export function isActivityPhase(phase: string): phase is PetStateInput['phase'] {
45
+ return ['idle', 'waiting', 'thinking', 'tool', 'review', 'done', 'failed'].includes(phase)
46
+ }
47
+
48
+ /**
49
+ * Project the durable DSH session vocabulary into the pet's visual phases.
50
+ * Unknown and log-only events do not disturb the last meaningful activity.
51
+ */
52
+ export function projectOfficialEvent(
53
+ event: SessionEvent,
54
+ runtime: ProjectionRuntime,
55
+ ): PetActivityTransition | undefined {
56
+ switch (event.type) {
57
+ case 'turn/start':
58
+ runtime.activeTools.clear()
59
+ runtime.stepHadFailure = false
60
+ return { input: { phase: 'waiting', line: '准备开始' } }
61
+ case 'step/start':
62
+ runtime.activeTools.clear()
63
+ runtime.stepHadFailure = false
64
+ return { input: { phase: 'waiting', line: '等待模型响应' } }
65
+ case 'assistant/chunk': {
66
+ const { chunk } = event.data
67
+ if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
68
+ return { input: { phase: 'thinking', line: '正在思考' } }
69
+ }
70
+ if (chunk.type === 'text-delta' && chunk.text.length > 0) {
71
+ return { input: { phase: 'review', line: '整理回复中' } }
72
+ }
73
+ return undefined
74
+ }
75
+ case 'assistant/message':
76
+ return { input: { phase: 'review', line: '整理回复中' } }
77
+ case 'tool/call':
78
+ runtime.activeTools.add(String(event.data.callId))
79
+ return {
80
+ input: {
81
+ phase: 'tool',
82
+ line: `正在使用 ${displayToolName(event.data.name)}`,
83
+ },
84
+ }
85
+ case 'tool/result': {
86
+ const block = event.data.message.content[0]
87
+ runtime.activeTools.delete(String(event.data.message.source.callId))
88
+ runtime.stepHadFailure ||= event.data.error !== undefined || block.isError === true
89
+ if (runtime.activeTools.size > 0) {
90
+ return {
91
+ input: {
92
+ phase: 'tool',
93
+ line: `还有 ${runtime.activeTools.size} 个工具运行中`,
94
+ },
95
+ }
96
+ }
97
+ return runtime.stepHadFailure
98
+ ? { input: { phase: 'failed', line: '工具执行失败' } }
99
+ : { input: { phase: 'thinking', line: '处理工具结果' } }
100
+ }
101
+ case 'turn/end': {
102
+ runtime.activeTools.clear()
103
+ switch (event.data.reason.kind) {
104
+ case 'completed':
105
+ return {
106
+ input: { phase: 'done', line: '完成啦' },
107
+ completedTurn: event.data.turn,
108
+ }
109
+ case 'error':
110
+ return { input: { phase: 'failed', line: '执行失败' } }
111
+ case 'max-tokens':
112
+ return { input: { phase: 'failed', line: '达到输出上限' } }
113
+ case 'interrupted':
114
+ return { input: { phase: 'failed', line: '执行意外中断' } }
115
+ case 'blocked':
116
+ return { input: { phase: 'waiting', line: '等待继续' } }
117
+ case 'aborted':
118
+ return { input: { phase: 'idle', line: '已停止' } }
119
+ default:
120
+ // TurnEndReasonMap is merge-extensible; a newer ending must not
121
+ // leave the pet showing stale in-progress work.
122
+ return { input: { phase: 'idle' } }
123
+ }
124
+ }
125
+ default:
126
+ return undefined
127
+ }
128
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { defaultTreatConfig } from './treats.ts'
3
+ import { defaultAffinityConfig } from './affinity.ts'
4
+ import { emptyPersist } from './persist.ts'
5
+ import { PetLedger } from './ledger.ts'
6
+
7
+ describe('PetLedger', () => {
8
+ it('settles the economy on completed turns (work treat per 3 turns)', () => {
9
+ const ledger = new PetLedger(emptyPersist())
10
+ const n = 1_000_000
11
+ ledger.rewardTurn('s1', 1, n)
12
+ ledger.rewardTurn('s1', 2, n + 1)
13
+ ledger.rewardTurn('s1', 3, n + 2)
14
+ expect(ledger.snapshot.affinity.turns).toBe(3)
15
+ expect(ledger.snapshot.treats.treats).toBe(1)
16
+ expect(ledger.takeDirty()).toBe(true)
17
+ })
18
+
19
+ it('rewards each completed turn once per session (idempotent)', () => {
20
+ const ledger = new PetLedger(emptyPersist())
21
+ const n = 1_000_000
22
+ expect(ledger.rewardTurn('s1', 3, n)).toBe(true)
23
+ // A duplicate delivery of the same turn must not double count.
24
+ expect(ledger.rewardTurn('s1', 3, n + 1)).toBe(false)
25
+ expect(ledger.snapshot.affinity.turns).toBe(1)
26
+ })
27
+
28
+ it('a read of the view does not mark dirty (no settle on read)', () => {
29
+ const ledger = new PetLedger(emptyPersist())
30
+ ledger.affinityView(1_000_000)
31
+ expect(ledger.takeDirty()).toBe(false)
32
+ })
33
+
34
+ it('feed consumes a treat and applies the feed reward', () => {
35
+ const ledger = new PetLedger(emptyPersist())
36
+ const n = 1_000_000
37
+ ledger.rewardTurn('s1', 1, n)
38
+ ledger.rewardTurn('s1', 2, n + 1)
39
+ ledger.rewardTurn('s1', 3, n + 2)
40
+ expect(ledger.snapshot.treats.treats).toBe(1)
41
+ const res = ledger.interact('feed', n + 10)
42
+ expect(res.delta).toBe(defaultAffinityConfig.feedReward)
43
+ expect(ledger.snapshot.treats.treats).toBe(0)
44
+ expect(ledger.snapshot.affinity.feeds).toBe(1)
45
+ })
46
+
47
+ it('refuses a feed on an empty stock without burning anything', () => {
48
+ const ledger = new PetLedger(emptyPersist())
49
+ const res = ledger.interact('feed', 1_000_000)
50
+ expect(res.delta).toBe(0)
51
+ expect(res.reaction).toContain('没有小鱼干')
52
+ expect(ledger.snapshot.affinity.feeds).toBe(0)
53
+ // The empty-stock feed still marks dirty because the first settlement
54
+ // starts the time clock, mirroring the service-level behavior.
55
+ expect(ledger.takeDirty()).toBe(true)
56
+ })
57
+
58
+ it('exposes the treat stock cap and display/name setters', () => {
59
+ const ledger = new PetLedger(emptyPersist())
60
+ expect(ledger.treatMax).toBe(defaultTreatConfig.maxTreats)
61
+ ledger.setDisplay({ ...ledger.snapshot.display, visible: false })
62
+ ledger.setName('泡泡')
63
+ expect(ledger.snapshot.display.visible).toBe(false)
64
+ expect(ledger.snapshot.name).toBe('泡泡')
65
+ expect(ledger.takeDirty()).toBe(true)
66
+ })
67
+ })