@linxin666/dsh-pet 0.3.4 → 0.3.5

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.
@@ -27,7 +27,7 @@
27
27
  * TypeScript mode: keep it erasable-syntax-only.
28
28
  * @module @linxin666/dsh-pet/voice-pack
29
29
  */
30
- import { STATUS_SCENES, TOOL_CATEGORIES, } from "./chatter.js";
30
+ import { STATUS_SCENES, TOOL_CATEGORIES, WHISPER_CATEGORIES, WHISPER_RESULTS, } from "./chatter.js";
31
31
  /** Schema version this module normalizes (optional field; missing = 1). */
32
32
  export const VOICE_PACK_V1 = 1;
33
33
  /** Hover-panel action buttons a pack can show or hide (canonical order). */
@@ -39,9 +39,6 @@ export const PANEL_STAT_KEYS = ['rank', 'treats', 'points'];
39
39
  /** Hard caps shared by every pool slot (mirrors the remarks discipline). */
40
40
  export const VOICE_POOL_LINES_MAX = 64;
41
41
  export const VOICE_LINE_MAX = 160;
42
- export const VOICE_KEYWORDS_PER_RULE_MAX = 16;
43
- export const VOICE_KEYWORD_MAX = 40;
44
- export const VOICE_RULES_MAX = 32;
45
42
  export const VOICE_LABEL_MAX = 40;
46
43
  export const VOICE_STAT_MAX = 80;
47
44
  /** Any '{token}' placeholder (no nesting, no newlines). */
@@ -112,51 +109,45 @@ export function normalizePool(raw, kind, onWarning = () => { }) {
112
109
  }
113
110
  return pool;
114
111
  }
115
- /** Normalize the ordered keyword-rule list ([] disables the keyword channel). */
116
- export function normalizeWhisperRules(raw, onWarning = () => { }) {
112
+ /** Normalize whisper category pools; a key replaces that category's built-in pool (an explicit empty pool mutes it). */
113
+ export function normalizeWhisperCategories(raw, onWarning = () => { }) {
117
114
  if (raw === undefined)
118
115
  return undefined;
119
- if (!Array.isArray(raw)) {
120
- onWarning('whispers.rules must be an array');
116
+ if (!isRecord(raw)) {
117
+ onWarning('whispers.categories must be an object');
121
118
  return undefined;
122
119
  }
123
- if (raw.length > VOICE_RULES_MAX) {
124
- onWarning('whispers.rules has more than ' + VOICE_RULES_MAX + ' rules; extra rules are ignored');
125
- }
126
- const rules = [];
127
- for (const item of raw.slice(0, VOICE_RULES_MAX)) {
128
- if (!isRecord(item)) {
129
- onWarning('whisper rule must be an object');
120
+ const pools = {};
121
+ for (const key of Object.keys(raw)) {
122
+ if (!WHISPER_CATEGORIES.includes(key)) {
123
+ onWarning('unknown whisper category ' + key + ' ignored');
130
124
  continue;
131
125
  }
132
- for (const key of Object.keys(item)) {
133
- if (key !== 'keywords' && key !== 'pool')
134
- onWarning('unknown whisper rule field ' + key + ' ignored');
135
- }
136
- const keywordsRaw = item.keywords;
137
- const keywords = [];
138
- if (Array.isArray(keywordsRaw)) {
139
- for (const entry of keywordsRaw.slice(0, VOICE_KEYWORDS_PER_RULE_MAX)) {
140
- if (typeof entry !== 'string') {
141
- onWarning('non-string keyword dropped');
142
- continue;
143
- }
144
- const trimmed = entry.trim().toLowerCase().slice(0, VOICE_KEYWORD_MAX);
145
- if (trimmed !== '')
146
- keywords.push(trimmed);
147
- }
148
- if (keywordsRaw.length > VOICE_KEYWORDS_PER_RULE_MAX) {
149
- onWarning('rule has more than ' + VOICE_KEYWORDS_PER_RULE_MAX + ' keywords; extra keywords are ignored');
150
- }
151
- }
152
- const pool = normalizePool(item.pool, 'whisperRule', onWarning);
153
- if (keywords.length === 0 || pool === undefined || pool.length === 0) {
154
- onWarning('whisper rule dropped (needs keywords and a non-empty pool)');
126
+ const pool = normalizePool(raw[key], 'whisperCategory', onWarning);
127
+ if (pool !== undefined)
128
+ pools[key] = pool;
129
+ }
130
+ return Object.keys(pools).length > 0 ? pools : undefined;
131
+ }
132
+ /** Normalize whisper outcome pools; a key replaces that outcome's built-in pool (an explicit empty pool mutes it). */
133
+ export function normalizeWhisperResults(raw, onWarning = () => { }) {
134
+ if (raw === undefined)
135
+ return undefined;
136
+ if (!isRecord(raw)) {
137
+ onWarning('whispers.results must be an object');
138
+ return undefined;
139
+ }
140
+ const pools = {};
141
+ for (const key of Object.keys(raw)) {
142
+ if (!WHISPER_RESULTS.includes(key)) {
143
+ onWarning('unknown whisper result ' + key + ' ignored');
155
144
  continue;
156
145
  }
157
- rules.push({ keywords, pool });
146
+ const pool = normalizePool(raw[key], 'whisperResult', onWarning);
147
+ if (pool !== undefined)
148
+ pools[key] = pool;
158
149
  }
159
- return rules;
150
+ return Object.keys(pools).length > 0 ? pools : undefined;
160
151
  }
161
152
  /** Normalize the panel block (labels / stats / actions; warn-and-drop). */
162
153
  export function normalizePanel(raw, onWarning = () => { }) {
@@ -236,7 +227,7 @@ export function normalizePanel(raw, onWarning = () => { }) {
236
227
  /** Voice-pack top-level fields ('$schema' mirrors the schema twin; drift-locked in tests). */
237
228
  export const VOICE_PACK_KEYS = new Set(['$schema', 'voicePackVersion', 'status', 'tools', 'toolRemaining', 'whispers', 'panel']);
238
229
  /** Allowed whisper-section fields (drift-locked in tests). */
239
- export const WHISPER_KEYS = new Set(['generic', 'rules']);
230
+ export const WHISPER_KEYS = new Set(['categories', 'results']);
240
231
  /**
241
232
  * Normalize one raw voice.json document into a VoicePack, or undefined when
242
233
  * the file cannot serve as a pack at all (non-object root — structure is
@@ -307,15 +298,21 @@ export function normalizeVoicePack(raw, onWarning = () => { }) {
307
298
  }
308
299
  else {
309
300
  for (const key of Object.keys(whispersRaw)) {
310
- if (!WHISPER_KEYS.has(key))
301
+ if (key === 'generic' || key === 'rules') {
302
+ // pet M6: keyword and ambient whisper channels are gone; legacy
303
+ // fields are ignored with a warning, never mapped to the new shape.
304
+ onWarning('whispers.' + key + ' is no longer supported and was ignored');
305
+ }
306
+ else if (!WHISPER_KEYS.has(key)) {
311
307
  onWarning('unknown whispers field ' + key + ' ignored');
308
+ }
312
309
  }
313
- const generic = normalizePool(whispersRaw.generic, 'whisperGeneric', onWarning);
314
- const rules = normalizeWhisperRules(whispersRaw.rules, onWarning);
315
- if (generic !== undefined || rules !== undefined) {
310
+ const categories = normalizeWhisperCategories(whispersRaw.categories, onWarning);
311
+ const results = normalizeWhisperResults(whispersRaw.results, onWarning);
312
+ if (categories !== undefined || results !== undefined) {
316
313
  overrides.whispers = {
317
- ...(generic === undefined ? {} : { generic }),
318
- ...(rules === undefined ? {} : { rules }),
314
+ ...(categories === undefined ? {} : { categories }),
315
+ ...(results === undefined ? {} : { results }),
319
316
  };
320
317
  }
321
318
  }
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.3.4",
4
+ "version": "0.3.5",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -11,8 +11,9 @@ import {
11
11
  TOOL_POOLS,
12
12
  toolArgHint,
13
13
  toolCategory,
14
- WHISPER_GENERIC_POOL,
15
- WHISPER_RULES,
14
+ WHISPER_CATEGORY_POOLS,
15
+ WHISPER_RESULT_POOLS,
16
+ looksLikeTestTool,
16
17
  WhisperEngine,
17
18
  type VoicePackOverrides,
18
19
  } from './chatter.ts'
@@ -127,30 +128,65 @@ describe('toolArgHint', () => {
127
128
  })
128
129
 
129
130
  describe('WhisperEngine', () => {
130
- it('wakes a themed whisper from a keyword in the model output', () => {
131
+ it('speaks a category line for the current situation, then respects the cooldown', () => {
131
132
  const engine = new WhisperEngine()
132
- expect(engine.feed('这里有个错误需要处理', 0)).toBe(WHISPER_RULES[1]!.pool[0])
133
+ expect(engine.feed('thinking', 0)).toBe(WHISPER_CATEGORY_POOLS.thinking[0])
134
+ expect(engine.feed('writing', 1)).toBeUndefined()
133
135
  })
134
136
 
135
- it('suppresses whispers during the cooldown, then rotates the pool', () => {
137
+ it('rotates within each category and keeps the category cursors apart', () => {
136
138
  const engine = new WhisperEngine()
137
- expect(engine.feed('出现一个错误', 0)).toBe(WHISPER_RULES[1]!.pool[0])
138
- expect(engine.feed('又一个错误', 1000)).toBeUndefined()
139
- expect(engine.feed('还有一个错误', 9000)).toBe(WHISPER_RULES[1]!.pool[1])
139
+ expect(engine.feed('thinking', 0)).toBe(WHISPER_CATEGORY_POOLS.thinking[0])
140
+ expect(engine.feed('writing', 9001)).toBe(WHISPER_CATEGORY_POOLS.writing[0])
141
+ expect(engine.feed('thinking', 18002)).toBe(WHISPER_CATEGORY_POOLS.thinking[1])
140
142
  })
141
143
 
142
- it('earns an ambient whisper from output volume alone', () => {
143
- const engine = new WhisperEngine(undefined, 0, 10)
144
- expect(engine.feed('aaaaaaaa', 0)).toBeUndefined()
145
- expect(engine.feed('bbbb', 1)).toBe(WHISPER_GENERIC_POOL[0])
146
- // The counter resets after speaking: another budget must accumulate.
147
- expect(engine.feed('cccc', 2)).toBeUndefined()
148
- expect(engine.feed('dddddddddd', 3)).toBe(WHISPER_GENERIC_POOL[1])
144
+ it('stays quiet on an explicitly muted category', () => {
145
+ const engine = new WhisperEngine(() => ({ whispers: { categories: { thinking: [] } } }))
146
+ expect(engine.feed('thinking', 0)).toBeUndefined()
149
147
  })
150
148
 
151
- it('ignores empty chunks', () => {
152
- const engine = new WhisperEngine(undefined, 0, 0)
153
- expect(engine.feed('', 0)).toBeUndefined()
149
+ it('wakes outcome whispers on their own shorter cooldown and rotates per outcome', () => {
150
+ const engine = new WhisperEngine()
151
+ expect(engine.result('pass', 0)).toBe(WHISPER_RESULT_POOLS.pass[0])
152
+ expect(engine.result('fail', 1000)).toBeUndefined()
153
+ expect(engine.result('fail', 5001)).toBe(WHISPER_RESULT_POOLS.fail[0])
154
+ expect(engine.result('pass', 10002)).toBe(WHISPER_RESULT_POOLS.pass[1])
155
+ })
156
+
157
+ it('paces category and outcome whispers against one shared clock', () => {
158
+ const engine = new WhisperEngine()
159
+ expect(engine.feed('writing', 0)).toBe(WHISPER_CATEGORY_POOLS.writing[0])
160
+ // An outcome right after a category reply must wait the outcome cooldown.
161
+ expect(engine.result('done', 4999)).toBeUndefined()
162
+ expect(engine.result('done', 5000)).toBe(WHISPER_RESULT_POOLS.done[0])
163
+ // After an outcome, the category must wait the full category cooldown
164
+ // measured from the outcome whisper (5000), so 14000 is the next slot.
165
+ expect(engine.feed('thinking', 5001)).toBeUndefined()
166
+ expect(engine.feed('thinking', 14000)).toBe(WHISPER_CATEGORY_POOLS.thinking[0])
167
+ })
168
+
169
+ it('mutes an outcome with an explicit empty pool', () => {
170
+ const engine = new WhisperEngine(() => ({ whispers: { results: { fail: [] } } }))
171
+ expect(engine.result('fail', 0)).toBeUndefined()
172
+ })
173
+ })
174
+
175
+ describe('looksLikeTestTool', () => {
176
+ it('recognizes test tools by name and by shell command', () => {
177
+ expect(looksLikeTestTool('run_tests', undefined)).toBe(true)
178
+ expect(looksLikeTestTool('run_code', '{"command":"npm test"}')).toBe(true)
179
+ expect(looksLikeTestTool('run_code', '{"command":"pnpm run test -- --run"}')).toBe(true)
180
+ expect(looksLikeTestTool('bash', '{"command":"pytest -q"}')).toBe(true)
181
+ expect(looksLikeTestTool('bash', '{"command":"go test ./..."}')).toBe(true)
182
+ expect(looksLikeTestTool('run_code', '{"code":"import pytest"}')).toBe(true)
183
+ })
184
+
185
+ it('does not misfire on ordinary tools or mentions in generic code', () => {
186
+ expect(looksLikeTestTool('bash', '{"command":"node server.js"}')).toBe(false)
187
+ expect(looksLikeTestTool('grep', '{"pattern":"test"}')).toBe(false)
188
+ expect(looksLikeTestTool('read', '{"file_path":"/repo/test/fixture.txt"}')).toBe(false)
189
+ expect(looksLikeTestTool('run_code', '{"code":"server.listen(8080)"}')).toBe(false)
154
190
  })
155
191
  })
156
192
 
@@ -165,8 +201,8 @@ describe('voice-pack overrides (pet-center M4)', () => {
165
201
  },
166
202
  toolRemaining: ['后台还有 {n} 个'],
167
203
  whispers: {
168
- generic: ['自定义碎碎念 A', '自定义碎碎念 B'],
169
- rules: [{ keywords: ['测试通过'], pool: ['自定义全绿'] }],
204
+ categories: { thinking: ['自定义思考', '自定义思考二'] },
205
+ results: { pass: ['自定义全绿'] },
170
206
  },
171
207
  })
172
208
 
@@ -214,28 +250,22 @@ describe('voice-pack overrides (pet-center M4)', () => {
214
250
  expect(voice.scene('done', 5000)).toBe('换声了')
215
251
  })
216
252
 
217
- it('replaces the whisper rules as a whole', () => {
253
+ it('replaces whisper category pools per key', () => {
218
254
  const engine = new WhisperEngine(PACK)
219
- expect(engine.feed('这里有一个错误', 0)).toBeUndefined()
220
- // The built-in error rule is gone; only the pack's rule fires.
221
- expect(engine.feed('测试通过', 1000)).toBe('自定义全绿')
255
+ expect(engine.feed('thinking', 0)).toBe('自定义思考')
256
+ expect(engine.feed('writing', 9000)).toBe(WHISPER_CATEGORY_POOLS.writing[0])
257
+ expect(engine.feed('thinking', 18000)).toBe('自定义思考二')
222
258
  })
223
259
 
224
- it('replaces the ambient generic pool', () => {
225
- const engine = new WhisperEngine(PACK, 0, 3)
226
- expect(engine.feed('aaaa', 0)).toBe('自定义碎碎念 A')
227
- expect(engine.feed('bbbb', 1)).toBe('自定义碎碎念 B')
228
- })
229
-
230
- it('mutes ambient whispers when the generic pool is explicitly empty', () => {
231
- const engine = new WhisperEngine(() => ({ whispers: { generic: [] } }), 0, 3)
232
- expect(engine.feed('aaaa', 0)).toBeUndefined()
233
- expect(engine.feed('bbbb', 1)).toBeUndefined()
260
+ it('replaces whisper outcome pools per key', () => {
261
+ const engine = new WhisperEngine(PACK)
262
+ expect(engine.result('pass', 0)).toBe('自定义全绿')
263
+ expect(engine.result('fail', 5001)).toBe(WHISPER_RESULT_POOLS.fail[0])
234
264
  })
235
265
 
236
- it('disables keyword rules when the rules array is explicitly empty', () => {
237
- const engine = new WhisperEngine(() => ({ whispers: { rules: [] } }), 0, 3)
238
- // No keyword rule matches anymore; volume still earns a built-in ambient.
239
- expect(engine.feed('测试通过', 0)).toBe(WHISPER_GENERIC_POOL[0])
266
+ it('mutes a whisper channel with an explicit empty override', () => {
267
+ const engine = new WhisperEngine(() => ({ whispers: { categories: { thinking: [] }, results: { done: [] } } }))
268
+ expect(engine.feed('thinking', 0)).toBeUndefined()
269
+ expect(engine.result('done', 0)).toBeUndefined()
240
270
  })
241
271
  })