@linxin666/dsh-pet 0.1.19 → 0.2.0

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 (95) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +19 -12
  3. package/README.zh.md +19 -12
  4. package/assets/whale/pet.json +9 -0
  5. package/lib/client.js +202 -38
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +897 -42
  8. package/lib/invariant.js +1 -1
  9. package/lib/{state-Diiq8vjk.js → state-Ch3f4luZ.js} +51 -23
  10. package/lib/types/affinity.d.ts +22 -18
  11. package/lib/types/affinity.d.ts.map +1 -1
  12. package/lib/types/affinity.js +32 -6
  13. package/lib/types/chatter.d.ts +111 -0
  14. package/lib/types/chatter.d.ts.map +1 -0
  15. package/lib/types/chatter.js +666 -0
  16. package/lib/types/client/PetDockEntry.d.ts +2 -0
  17. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  18. package/lib/types/client/PetDockEntry.js +2 -2
  19. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PetSettingsCard.js +2 -2
  21. package/lib/types/client/PetSprite.d.ts +2 -0
  22. package/lib/types/client/PetSprite.d.ts.map +1 -1
  23. package/lib/types/client/PetSprite.js +100 -25
  24. package/lib/types/client/PluginSettingsCard.d.ts +7 -1
  25. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  26. package/lib/types/client/PluginSettingsCard.js +5 -3
  27. package/lib/types/client/index.d.ts +1 -1
  28. package/lib/types/client/index.d.ts.map +1 -1
  29. package/lib/types/client/index.js +31 -6
  30. package/lib/types/client/locales.d.ts +82 -80
  31. package/lib/types/client/locales.d.ts.map +1 -1
  32. package/lib/types/client/locales.js +2 -0
  33. package/lib/types/client/sequences.d.ts +10 -0
  34. package/lib/types/client/sequences.d.ts.map +1 -0
  35. package/lib/types/client/sequences.js +20 -0
  36. package/lib/types/client/settings-form.d.ts.map +1 -1
  37. package/lib/types/client/spritesheet.d.ts +1 -1
  38. package/lib/types/client/spritesheet.d.ts.map +1 -1
  39. package/lib/types/client/spritesheet.js +3 -12
  40. package/lib/types/event-projection.d.ts +14 -1
  41. package/lib/types/event-projection.d.ts.map +1 -1
  42. package/lib/types/event-projection.js +38 -17
  43. package/lib/types/index.d.ts +2 -1
  44. package/lib/types/index.d.ts.map +1 -1
  45. package/lib/types/index.js +15 -13
  46. package/lib/types/invariant.js +2 -2
  47. package/lib/types/ledger.d.ts.map +1 -1
  48. package/lib/types/ledger.js +18 -10
  49. package/lib/types/mount-once.d.ts +25 -0
  50. package/lib/types/mount-once.d.ts.map +1 -0
  51. package/lib/types/mount-once.js +42 -0
  52. package/lib/types/persist.d.ts.map +1 -1
  53. package/lib/types/persist.js +6 -4
  54. package/lib/types/registry.d.ts +7 -1
  55. package/lib/types/registry.d.ts.map +1 -1
  56. package/lib/types/registry.js +37 -1
  57. package/lib/types/remarks.d.ts +2 -0
  58. package/lib/types/remarks.d.ts.map +1 -1
  59. package/lib/types/remarks.js +5 -0
  60. package/lib/types/routes.js +2 -2
  61. package/lib/types/service.d.ts +16 -6
  62. package/lib/types/service.d.ts.map +1 -1
  63. package/lib/types/service.js +26 -8
  64. package/lib/types/state.d.ts +7 -4
  65. package/lib/types/state.d.ts.map +1 -1
  66. package/lib/types/state.js +20 -20
  67. package/package.json +9 -9
  68. package/src/affinity.test.ts +4 -2
  69. package/src/affinity.ts +37 -6
  70. package/src/chatter.test.ts +154 -0
  71. package/src/chatter.ts +697 -0
  72. package/src/client/PetDockEntry.tsx +3 -0
  73. package/src/client/PetSprite.test.tsx +246 -4
  74. package/src/client/PetSprite.tsx +132 -36
  75. package/src/client/PluginSettingsCard.tsx +10 -0
  76. package/src/client/index.ts +26 -3
  77. package/src/client/locales.ts +2 -0
  78. package/src/client/pet-css.test.ts +42 -0
  79. package/src/client/pet.module.css +173 -22
  80. package/src/client/sequences.test.ts +33 -0
  81. package/src/client/sequences.ts +33 -0
  82. package/src/client/spritesheet.ts +3 -13
  83. package/src/event-projection.ts +49 -16
  84. package/src/index.ts +4 -1
  85. package/src/ledger.test.ts +42 -1
  86. package/src/ledger.ts +15 -7
  87. package/src/mount-once.ts +48 -0
  88. package/src/persist.test.ts +18 -1
  89. package/src/persist.ts +3 -1
  90. package/src/registry.test.ts +50 -3
  91. package/src/registry.ts +47 -3
  92. package/src/remarks.ts +6 -0
  93. package/src/service.ts +42 -9
  94. package/src/state.test.ts +13 -0
  95. package/src/state.ts +24 -18
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-pet",
3
3
  "description": "Multi-pet companion plugin for the dsh web GUI: a registry-driven floating pet that reacts to model activity, with per-pet naming, petting/feeding interactions and an affinity score",
4
- "version": "0.1.19",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -58,14 +58,14 @@
58
58
  },
59
59
  "devDependencies": {
60
60
  "@deepseek-ai/cordis": "^4.0.1",
61
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
62
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
63
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
64
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
65
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
66
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
67
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
68
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
61
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
62
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
63
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.7",
64
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
65
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
66
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7",
67
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.7",
68
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
69
69
  "@testing-library/dom": "^10.4.1",
70
70
  "@testing-library/react": "^16.3.2",
71
71
  "@types/node": "^22.20.0",
@@ -26,8 +26,9 @@ describe('applyInteraction', () => {
26
26
  const second = applyInteraction(first.affinity, 'pet', now + defaultAffinityConfig.petCooldownMs - 1)
27
27
  expect(second.accepted).toBe(false)
28
28
  expect(second.delta).toBe(0)
29
- expect(second.affinity).toBe(first.affinity) // same reference: no mutation
29
+ expect(second.affinity).not.toBe(first.affinity)
30
30
  expect(second.affinity.pets).toBe(1)
31
+ expect(second.affinity.petRejects).toBe(1)
31
32
  })
32
33
 
33
34
  it('accepts a pet again after the cooldown elapsed', () => {
@@ -44,8 +45,9 @@ describe('applyInteraction', () => {
44
45
  const second = applyInteraction(first.affinity, 'feed', now + defaultAffinityConfig.feedCooldownMs - 1)
45
46
  expect(second.accepted).toBe(false)
46
47
  expect(second.delta).toBe(0)
47
- expect(second.affinity).toBe(first.affinity)
48
+ expect(second.affinity).not.toBe(first.affinity)
48
49
  expect(second.affinity.feeds).toBe(1)
50
+ expect(second.affinity.feedRejects).toBe(1)
49
51
  })
50
52
 
51
53
  it('clamps points at AFFINITY_MAX', () => {
package/src/affinity.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * @module @linxin666/dsh-pet/affinity
9
9
  */
10
10
 
11
- import { builtinRemark } from './remarks.ts'
11
+ import { BUILTIN_REMARKS, builtinRemark } from './remarks.ts'
12
12
 
13
13
  /** One interaction the user can perform on the pet. */
14
14
  export type PetInteraction = 'pet' | 'feed'
@@ -25,6 +25,10 @@ export interface AffinityState {
25
25
  pets: number
26
26
  /** Total feed count (lifetime). */
27
27
  feeds: number
28
+ /** Total pet attempts rejected by cooldown (lifetime). */
29
+ petRejects: number
30
+ /** Total feed attempts rejected by cooldown (lifetime). */
31
+ feedRejects: number
28
32
  /** Total completed turns witnessed (lifetime). */
29
33
  turns: number
30
34
  }
@@ -75,7 +79,16 @@ export const defaultAffinityConfig: AffinityConfig = {
75
79
  }
76
80
 
77
81
  export function emptyAffinity(): AffinityState {
78
- return { points: 0, lastPetAt: 0, lastFeedAt: 0, pets: 0, feeds: 0, turns: 0 }
82
+ return {
83
+ points: 0,
84
+ lastPetAt: 0,
85
+ lastFeedAt: 0,
86
+ pets: 0,
87
+ feeds: 0,
88
+ petRejects: 0,
89
+ feedRejects: 0,
90
+ turns: 0,
91
+ }
79
92
  }
80
93
 
81
94
  /** Outcome of one interaction. */
@@ -136,6 +149,12 @@ function clamp(points: number): number {
136
149
  return Math.min(AFFINITY_MAX, Math.max(0, points))
137
150
  }
138
151
 
152
+ /** Built-in reaction selected deterministically from a persisted counter. */
153
+ function countedRemark(kind: 'pet' | 'petCooldown' | 'feed' | 'feedCooldown', count: number): string {
154
+ const pool = BUILTIN_REMARKS[kind]
155
+ return pool[Math.max(0, Math.floor(count)) % pool.length] ?? builtinRemark(kind)
156
+ }
157
+
139
158
  /**
140
159
  * Apply one interaction to a copy of the state (immutable style: returns a
141
160
  * new object; the caller replaces the persisted state). Cooldowns only
@@ -151,7 +170,13 @@ export function applyInteraction(
151
170
  const next = { ...state }
152
171
  if (kind === 'pet') {
153
172
  if (state.lastPetAt !== 0 && nowMs - state.lastPetAt < config.petCooldownMs) {
154
- return { affinity: state, delta: 0, reaction: builtinRemark('petCooldown'), accepted: false }
173
+ next.petRejects += 1
174
+ return {
175
+ affinity: next,
176
+ delta: 0,
177
+ reaction: countedRemark('petCooldown', state.petRejects),
178
+ accepted: false,
179
+ }
155
180
  }
156
181
  next.lastPetAt = nowMs
157
182
  next.pets += 1
@@ -159,13 +184,19 @@ export function applyInteraction(
159
184
  return {
160
185
  affinity: next,
161
186
  delta: config.petReward,
162
- reaction: builtinRemark('pet'),
187
+ reaction: countedRemark('pet', state.pets),
163
188
  accepted: true,
164
189
  }
165
190
  }
166
191
  if (kind === 'feed') {
167
192
  if (state.lastFeedAt !== 0 && nowMs - state.lastFeedAt < config.feedCooldownMs) {
168
- return { affinity: state, delta: 0, reaction: builtinRemark('feedCooldown'), accepted: false }
193
+ next.feedRejects += 1
194
+ return {
195
+ affinity: next,
196
+ delta: 0,
197
+ reaction: countedRemark('feedCooldown', state.feedRejects),
198
+ accepted: false,
199
+ }
169
200
  }
170
201
  next.lastFeedAt = nowMs
171
202
  next.feeds += 1
@@ -173,7 +204,7 @@ export function applyInteraction(
173
204
  return {
174
205
  affinity: next,
175
206
  delta: config.feedReward,
176
- reaction: builtinRemark('feed'),
207
+ reaction: countedRemark('feed', state.feeds),
177
208
  accepted: true,
178
209
  }
179
210
  }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Chatter unit tests: the status voice (scene-stable lines, cadence rotation,
3
+ * per-tool families, argument hints) and the murmur engine (keyword moods,
4
+ * cooldown, output-volume budget). Everything is deterministic round-robin,
5
+ * so exact lines are asserted.
6
+ */
7
+ import { describe, expect, it } from 'vitest'
8
+ import {
9
+ STATUS_POOLS,
10
+ StatusVoice,
11
+ TOOL_POOLS,
12
+ toolArgHint,
13
+ toolCategory,
14
+ WHISPER_GENERIC_POOL,
15
+ WHISPER_RULES,
16
+ WhisperEngine,
17
+ } from './chatter.ts'
18
+
19
+ describe('StatusVoice', () => {
20
+ it('opens every scene with its legacy fixed line', () => {
21
+ // The first pool line is the wording the plugin has always shown.
22
+ expect(STATUS_POOLS.prepare[0]).toBe('准备开始')
23
+ expect(STATUS_POOLS.waiting[0]).toBe('等待模型响应')
24
+ expect(STATUS_POOLS.thinking[0]).toBe('正在思考')
25
+ expect(STATUS_POOLS.review[0]).toBe('整理回复中')
26
+ expect(STATUS_POOLS.toolResult[0]).toBe('处理工具结果')
27
+ expect(STATUS_POOLS.done[0]).toBe('完成啦')
28
+ expect(STATUS_POOLS.failed[0]).toBe('执行失败')
29
+ expect(STATUS_POOLS.toolFailed[0]).toBe('工具执行失败')
30
+ })
31
+
32
+ it('keeps the line stable while a scene repeats within the rotation window', () => {
33
+ const voice = new StatusVoice()
34
+ // Streamed chunks re-emit the same scene many times a second: the copy
35
+ // must not flicker per chunk.
36
+ expect(voice.scene('thinking', 1000)).toBe('正在思考')
37
+ expect(voice.scene('thinking', 2000)).toBe('正在思考')
38
+ expect(voice.scene('thinking', 4999)).toBe('正在思考')
39
+ })
40
+
41
+ it('advances the line once the scene persists past the rotation cadence', () => {
42
+ const voice = new StatusVoice()
43
+ expect(voice.scene('thinking', 1000)).toBe('正在思考')
44
+ expect(voice.scene('thinking', 5001)).toBe(STATUS_POOLS.thinking[1])
45
+ expect(voice.scene('thinking', 9002)).toBe(STATUS_POOLS.thinking[2])
46
+ })
47
+
48
+ it('rotates round-robin when a scene is revisited after another scene', () => {
49
+ const voice = new StatusVoice()
50
+ expect(voice.scene('thinking', 0)).toBe(STATUS_POOLS.thinking[0])
51
+ expect(voice.scene('review', 100)).toBe(STATUS_POOLS.review[0])
52
+ expect(voice.scene('thinking', 200)).toBe(STATUS_POOLS.thinking[1])
53
+ })
54
+
55
+ it('interpolates the tool name and argument hint into tool lines', () => {
56
+ const voice = new StatusVoice()
57
+ expect(voice.tool('bash', 'bash', 'npm test', 0)).toBe('正在使用 bash')
58
+ // Same family within the window: the pool line repeats but the CURRENT
59
+ // call's name/hint are interpolated, so copy never goes stale.
60
+ expect(voice.tool('bash', 'bash', 'pnpm build', 100)).toBe('正在使用 bash')
61
+ // Past the cadence the shell family rotates to a hint-carrying line.
62
+ const rotated = voice.tool('bash', 'bash', 'npm test', 5000)
63
+ expect(rotated).toBe(TOOL_POOLS.shell[1]!.replace('{hint}', 'npm test'))
64
+ })
65
+
66
+ it('falls back to the tool name when no argument hint is available', () => {
67
+ const voice = new StatusVoice()
68
+ const line = voice.tool('grep', 'grep', undefined, 5000)
69
+ expect(line).not.toContain('{hint}')
70
+ expect(line).not.toContain('{tool}')
71
+ })
72
+
73
+ it('interpolates the running count into the remaining-tools line', () => {
74
+ const voice = new StatusVoice()
75
+ expect(voice.toolRemaining(1, 0)).toBe('还有 1 个工具运行中')
76
+ expect(voice.toolRemaining(3, 5000)).toContain('3')
77
+ })
78
+ })
79
+
80
+ describe('toolCategory', () => {
81
+ it('maps the common tool vocabulary onto copy families', () => {
82
+ expect(toolCategory('read')).toBe('read')
83
+ expect(toolCategory('write')).toBe('write')
84
+ expect(toolCategory('edit')).toBe('edit')
85
+ expect(toolCategory('str_replace_editor')).toBe('edit')
86
+ expect(toolCategory('run_code')).toBe('shell')
87
+ expect(toolCategory('bash')).toBe('shell')
88
+ expect(toolCategory('grep')).toBe('grep')
89
+ expect(toolCategory('glob')).toBe('find')
90
+ expect(toolCategory('web_search')).toBe('webSearch')
91
+ expect(toolCategory('browserFetch')).toBe('webFetch')
92
+ expect(toolCategory('mcp__mem0__search_memories')).toBe('memory')
93
+ expect(toolCategory('subagent')).toBe('subagent')
94
+ expect(toolCategory('todo_write')).toBe('todo')
95
+ expect(toolCategory('some_future_tool')).toBe('generic')
96
+ })
97
+ })
98
+
99
+ describe('toolArgHint', () => {
100
+ it('extracts the shell command', () => {
101
+ expect(toolArgHint('bash', '{"command":"npm test"}')).toBe('npm test')
102
+ expect(toolArgHint('run_code', '{"code":"console.log(1)"}')).toBe('console.log(1)')
103
+ })
104
+
105
+ it('reduces file paths to their basename', () => {
106
+ expect(toolArgHint('read', '{"file_path":"/repo/src/client/PetSprite.tsx"}')).toBe('PetSprite.tsx')
107
+ })
108
+
109
+ it('extracts search patterns and web queries', () => {
110
+ expect(toolArgHint('grep', '{"pattern":"bubbleStack"}')).toBe('bubbleStack')
111
+ expect(toolArgHint('web_search', '{"query":"dsh 插件"}')).toBe('dsh 插件')
112
+ })
113
+
114
+ it('stays hintless for unparseable or shapeless arguments', () => {
115
+ expect(toolArgHint('bash', 'not json')).toBeUndefined()
116
+ expect(toolArgHint('bash', '[]')).toBeUndefined()
117
+ expect(toolArgHint('bash', '{"timeout":1000}')).toBeUndefined()
118
+ })
119
+
120
+ it('caps long hints so the bubble stays compact', () => {
121
+ const hint = toolArgHint('bash', JSON.stringify({ command: 'x'.repeat(60) }))
122
+ expect(hint).toBeDefined()
123
+ expect(hint!.length).toBeLessThanOrEqual(28)
124
+ expect(hint!.endsWith('...')).toBe(true)
125
+ })
126
+ })
127
+
128
+ describe('WhisperEngine', () => {
129
+ it('wakes a themed whisper from a keyword in the model output', () => {
130
+ const engine = new WhisperEngine()
131
+ expect(engine.feed('这里有个错误需要处理', 0)).toBe(WHISPER_RULES[1]!.pool[0])
132
+ })
133
+
134
+ it('suppresses whispers during the cooldown, then rotates the pool', () => {
135
+ const engine = new WhisperEngine()
136
+ expect(engine.feed('出现一个错误', 0)).toBe(WHISPER_RULES[1]!.pool[0])
137
+ expect(engine.feed('又一个错误', 1000)).toBeUndefined()
138
+ expect(engine.feed('还有一个错误', 9000)).toBe(WHISPER_RULES[1]!.pool[1])
139
+ })
140
+
141
+ it('earns an ambient whisper from output volume alone', () => {
142
+ const engine = new WhisperEngine(0, 10)
143
+ expect(engine.feed('aaaaaaaa', 0)).toBeUndefined()
144
+ expect(engine.feed('bbbb', 1)).toBe(WHISPER_GENERIC_POOL[0])
145
+ // The counter resets after speaking: another budget must accumulate.
146
+ expect(engine.feed('cccc', 2)).toBeUndefined()
147
+ expect(engine.feed('dddddddddd', 3)).toBe(WHISPER_GENERIC_POOL[1])
148
+ })
149
+
150
+ it('ignores empty chunks', () => {
151
+ const engine = new WhisperEngine(0, 0)
152
+ expect(engine.feed('', 0)).toBeUndefined()
153
+ })
154
+ })