@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.
@@ -12,7 +12,8 @@ import {
12
12
  normalizePanel,
13
13
  normalizePool,
14
14
  normalizeVoicePack,
15
- normalizeWhisperRules,
15
+ normalizeWhisperCategories,
16
+ normalizeWhisperResults,
16
17
  PANEL_ACTIONS,
17
18
  PANEL_LABEL_KEYS,
18
19
  PANEL_STAT_KEYS,
@@ -21,7 +22,7 @@ import {
21
22
  VOICE_PACK_V1,
22
23
  WHISPER_KEYS,
23
24
  } from './voice-pack.ts'
24
- import { STATUS_SCENES, TOOL_CATEGORIES } from './chatter.ts'
25
+ import { STATUS_SCENES, TOOL_CATEGORIES, WHISPER_CATEGORIES, WHISPER_RESULTS } from './chatter.ts'
25
26
  import { petPackageRoot } from './registry.ts'
26
27
 
27
28
  function collectWarnings(pack: unknown): { pack: ReturnType<typeof normalizeVoicePack>; warnings: string[] } {
@@ -85,31 +86,40 @@ describe('normalizePool', () => {
85
86
  })
86
87
 
87
88
  it('preserves an explicit empty array (mute semantics) but not absent', () => {
88
- expect(normalizePool([], 'whisperGeneric')).toEqual([])
89
- expect(normalizePool(undefined, 'whisperGeneric')).toBeUndefined()
89
+ expect(normalizePool([], 'whisperCategory')).toEqual([])
90
+ expect(normalizePool(undefined, 'whisperCategory')).toBeUndefined()
90
91
  })
91
92
  })
92
93
 
93
- describe('normalizeWhisperRules', () => {
94
- it('lowercases and trims keywords, drops unusable rules', () => {
94
+ describe('normalizeWhisperCategories', () => {
95
+ it('keeps known categories, keeps an explicit empty pool (mute) and warns on unknown keys', () => {
95
96
  const warnings: string[] = []
96
- const rules = normalizeWhisperRules([
97
- { keywords: [' 测试通过 ', 7], pool: ['全绿'] },
98
- { keywords: [], pool: ['没有关键词'] },
99
- { keywords: ['有词'], pool: [] },
100
- ], m => warnings.push(m))
101
- expect(rules).toEqual([{ keywords: ['测试通过'], pool: ['全绿'] }])
102
- expect(warnings.length).toBeGreaterThan(0)
97
+ const pools = normalizeWhisperCategories({ thinking: ['自定义思考'], running: [], bogus: ['x'] }, m => warnings.push(m))
98
+ expect(pools).toEqual({ thinking: ['自定义思考'], running: [] })
99
+ expect(warnings.join('\n')).toContain('unknown whisper category')
103
100
  })
104
101
 
105
- it('caps the rule count and keyword count', () => {
106
- const many = Array.from({ length: 40 }, (_, i) => ({ keywords: ['k' + i], pool: ['p'] }))
107
- expect(normalizeWhisperRules(many)).toHaveLength(32)
108
- expect(normalizeWhisperRules([{ keywords: Array.from({ length: 20 }, (_, i) => 'k' + i), pool: ['p'] }])![0]!.keywords).toHaveLength(16)
102
+ it('returns undefined for an absent or non-object section', () => {
103
+ expect(normalizeWhisperCategories(undefined)).toBeUndefined()
104
+ const warnings: string[] = []
105
+ expect(normalizeWhisperCategories(['x'], m => warnings.push(m))).toBeUndefined()
106
+ expect(warnings.join('\n')).toContain('must be an object')
107
+ })
108
+ })
109
+
110
+ describe('normalizeWhisperResults', () => {
111
+ it('keeps known outcomes, keeps an explicit empty pool (mute) and warns on unknown keys', () => {
112
+ const warnings: string[] = []
113
+ const pools = normalizeWhisperResults({ pass: ['自定义全绿'], done: [], bogus: ['x'] }, m => warnings.push(m))
114
+ expect(pools).toEqual({ pass: ['自定义全绿'], done: [] })
115
+ expect(warnings.join('\n')).toContain('unknown whisper result')
109
116
  })
110
117
 
111
- it('returns an explicit empty array (disables the keyword channel)', () => {
112
- expect(normalizeWhisperRules([])).toEqual([])
118
+ it('returns undefined for an absent or non-object section', () => {
119
+ expect(normalizeWhisperResults(undefined)).toBeUndefined()
120
+ const warnings: string[] = []
121
+ expect(normalizeWhisperResults('x', m => warnings.push(m))).toBeUndefined()
122
+ expect(warnings.join('\n')).toContain('must be an object')
113
123
  })
114
124
  })
115
125
 
@@ -153,12 +163,22 @@ describe('mergeVoicePacks', () => {
153
163
  expect(merged?.panel?.labels).toEqual({ feed: '宠物投喂', hide: '宠物藏' })
154
164
  })
155
165
 
156
- it('lets the pet pack replace whisper sections while the global stays', () => {
157
- const global = normalizeVoicePack({ whispers: { generic: ['全局碎碎念'] } })
158
- const pet = normalizeVoicePack({ whispers: { rules: [{ keywords: ['测试'], pool: ['宠物全绿'] }] } })
166
+ it('lets the pet pack replace whisper keys while the global stays', () => {
167
+ const global = normalizeVoicePack({ whispers: { categories: { thinking: ['全局思考'] }, results: { pass: ['全局全绿'] } } })
168
+ const pet = normalizeVoicePack({ whispers: { categories: { thinking: ['宠物思考'] } } })
159
169
  const merged = mergeVoicePacks(global, pet)
160
- expect(merged?.overrides.whispers?.generic).toEqual(['全局碎碎念'])
161
- expect(merged?.overrides.whispers?.rules).toEqual([{ keywords: ['测试'], pool: ['宠物全绿'] }])
170
+ expect(merged?.overrides.whispers?.categories?.thinking).toEqual(['宠物思考'])
171
+ expect(merged?.overrides.whispers?.results?.pass).toEqual(['全局全绿'])
172
+ })
173
+
174
+ it('warns and ignores the legacy generic / rules whisper fields', () => {
175
+ const { pack, warnings } = collectWarnings({
176
+ whispers: { generic: ['老环境池'], rules: [{ keywords: ['测试'], pool: ['老全绿'] }], categories: { thinking: ['新思考'] } },
177
+ })
178
+ expect(pack?.overrides.whispers?.categories?.thinking).toEqual(['新思考'])
179
+ expect(pack?.overrides.whispers?.categories?.generic).toBeUndefined()
180
+ expect(warnings.join('\n')).toContain('generic')
181
+ expect(warnings.join('\n')).toContain('rules')
162
182
  })
163
183
 
164
184
  it('returns undefined when every layer is empty', () => {
@@ -200,7 +220,10 @@ describe('voice-pack schema file drift lock', () => {
200
220
  const props = schema.properties
201
221
  expect(new Set(Object.keys(props.status?.properties ?? {}))).toEqual(new Set(STATUS_SCENES))
202
222
  expect(new Set(Object.keys(props.tools?.properties ?? {}))).toEqual(new Set(TOOL_CATEGORIES))
203
- expect(new Set(Object.keys(props.whispers?.properties ?? {}))).toEqual(WHISPER_KEYS)
223
+ const whispers = props.whispers?.properties ?? {}
224
+ expect(new Set(Object.keys(whispers))).toEqual(WHISPER_KEYS)
225
+ expect(new Set(Object.keys((whispers.categories as { properties?: Record<string, unknown> })?.properties ?? {}))).toEqual(new Set(WHISPER_CATEGORIES))
226
+ expect(new Set(Object.keys((whispers.results as { properties?: Record<string, unknown> })?.properties ?? {}))).toEqual(new Set(WHISPER_RESULTS))
204
227
  const panel = props.panel?.properties ?? {}
205
228
  const labels = (panel.labels as { properties?: Record<string, unknown> } | undefined)?.properties ?? {}
206
229
  const stats = (panel.stats as { properties?: Record<string, unknown> } | undefined)?.properties ?? {}
package/src/voice-pack.ts CHANGED
@@ -31,8 +31,11 @@
31
31
  import {
32
32
  STATUS_SCENES,
33
33
  TOOL_CATEGORIES,
34
+ WHISPER_CATEGORIES,
35
+ WHISPER_RESULTS,
34
36
  type VoicePackOverrides,
35
- type WhisperRule,
37
+ type WhisperCategory,
38
+ type WhisperResult,
36
39
  } from './chatter.ts'
37
40
 
38
41
  /** Schema version this module normalizes (optional field; missing = 1). */
@@ -71,9 +74,6 @@ export interface VoicePack {
71
74
  /** Hard caps shared by every pool slot (mirrors the remarks discipline). */
72
75
  export const VOICE_POOL_LINES_MAX = 64
73
76
  export const VOICE_LINE_MAX = 160
74
- export const VOICE_KEYWORDS_PER_RULE_MAX = 16
75
- export const VOICE_KEYWORD_MAX = 40
76
- export const VOICE_RULES_MAX = 32
77
77
  export const VOICE_LABEL_MAX = 40
78
78
  export const VOICE_STAT_MAX = 80
79
79
 
@@ -87,7 +87,7 @@ const PLACEHOLDER_WHITELIST: Partial<Record<PoolKind, readonly string[]>> = {
87
87
  stat: ['{rank}', '{n}', '{points}'],
88
88
  }
89
89
 
90
- type PoolKind = 'status' | 'tools' | 'toolRemaining' | 'whisperGeneric' | 'whisperRule' | 'label' | 'stat'
90
+ type PoolKind = 'status' | 'tools' | 'toolRemaining' | 'whisperCategory' | 'whisperResult' | 'label' | 'stat'
91
91
 
92
92
  function isRecord(value: unknown): value is Record<string, unknown> {
93
93
  return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -151,51 +151,48 @@ export function normalizePool(
151
151
  return pool
152
152
  }
153
153
 
154
- /** Normalize the ordered keyword-rule list ([] disables the keyword channel). */
155
- export function normalizeWhisperRules(
154
+ /** Normalize whisper category pools; a key replaces that category's built-in pool (an explicit empty pool mutes it). */
155
+ export function normalizeWhisperCategories(
156
156
  raw: unknown,
157
157
  onWarning: (message: string) => void = () => {},
158
- ): WhisperRule[] | undefined {
158
+ ): Partial<Record<WhisperCategory, readonly string[]>> | undefined {
159
159
  if (raw === undefined) return undefined
160
- if (!Array.isArray(raw)) {
161
- onWarning('whispers.rules must be an array')
160
+ if (!isRecord(raw)) {
161
+ onWarning('whispers.categories must be an object')
162
162
  return undefined
163
163
  }
164
- if (raw.length > VOICE_RULES_MAX) {
165
- onWarning('whispers.rules has more than ' + VOICE_RULES_MAX + ' rules; extra rules are ignored')
166
- }
167
- const rules: WhisperRule[] = []
168
- for (const item of raw.slice(0, VOICE_RULES_MAX)) {
169
- if (!isRecord(item)) {
170
- onWarning('whisper rule must be an object')
164
+ const pools: Partial<Record<WhisperCategory, readonly string[]>> = {}
165
+ for (const key of Object.keys(raw)) {
166
+ if (!(WHISPER_CATEGORIES as readonly string[]).includes(key)) {
167
+ onWarning('unknown whisper category ' + key + ' ignored')
171
168
  continue
172
169
  }
173
- for (const key of Object.keys(item)) {
174
- if (key !== 'keywords' && key !== 'pool') onWarning('unknown whisper rule field ' + key + ' ignored')
175
- }
176
- const keywordsRaw: unknown = item.keywords
177
- const keywords: string[] = []
178
- if (Array.isArray(keywordsRaw)) {
179
- for (const entry of keywordsRaw.slice(0, VOICE_KEYWORDS_PER_RULE_MAX)) {
180
- if (typeof entry !== 'string') {
181
- onWarning('non-string keyword dropped')
182
- continue
183
- }
184
- const trimmed = entry.trim().toLowerCase().slice(0, VOICE_KEYWORD_MAX)
185
- if (trimmed !== '') keywords.push(trimmed)
186
- }
187
- if (keywordsRaw.length > VOICE_KEYWORDS_PER_RULE_MAX) {
188
- onWarning('rule has more than ' + VOICE_KEYWORDS_PER_RULE_MAX + ' keywords; extra keywords are ignored')
189
- }
190
- }
191
- const pool = normalizePool(item.pool, 'whisperRule', onWarning)
192
- if (keywords.length === 0 || pool === undefined || pool.length === 0) {
193
- onWarning('whisper rule dropped (needs keywords and a non-empty pool)')
170
+ const pool = normalizePool(raw[key], 'whisperCategory', onWarning)
171
+ if (pool !== undefined) pools[key as WhisperCategory] = pool
172
+ }
173
+ return Object.keys(pools).length > 0 ? pools : undefined
174
+ }
175
+
176
+ /** Normalize whisper outcome pools; a key replaces that outcome's built-in pool (an explicit empty pool mutes it). */
177
+ export function normalizeWhisperResults(
178
+ raw: unknown,
179
+ onWarning: (message: string) => void = () => {},
180
+ ): Partial<Record<WhisperResult, readonly string[]>> | undefined {
181
+ if (raw === undefined) return undefined
182
+ if (!isRecord(raw)) {
183
+ onWarning('whispers.results must be an object')
184
+ return undefined
185
+ }
186
+ const pools: Partial<Record<WhisperResult, readonly string[]>> = {}
187
+ for (const key of Object.keys(raw)) {
188
+ if (!(WHISPER_RESULTS as readonly string[]).includes(key)) {
189
+ onWarning('unknown whisper result ' + key + ' ignored')
194
190
  continue
195
191
  }
196
- rules.push({ keywords, pool })
192
+ const pool = normalizePool(raw[key], 'whisperResult', onWarning)
193
+ if (pool !== undefined) pools[key as WhisperResult] = pool
197
194
  }
198
- return rules
195
+ return Object.keys(pools).length > 0 ? pools : undefined
199
196
  }
200
197
 
201
198
  /** Normalize the panel block (labels / stats / actions; warn-and-drop). */
@@ -271,7 +268,7 @@ export function normalizePanel(
271
268
  export const VOICE_PACK_KEYS = new Set(['$schema', 'voicePackVersion', 'status', 'tools', 'toolRemaining', 'whispers', 'panel'])
272
269
 
273
270
  /** Allowed whisper-section fields (drift-locked in tests). */
274
- export const WHISPER_KEYS = new Set(['generic', 'rules'])
271
+ export const WHISPER_KEYS = new Set(['categories', 'results'])
275
272
 
276
273
  /**
277
274
  * Normalize one raw voice.json document into a VoicePack, or undefined when
@@ -340,14 +337,20 @@ export function normalizeVoicePack(
340
337
  onWarning('whispers must be an object')
341
338
  } else {
342
339
  for (const key of Object.keys(whispersRaw)) {
343
- if (!WHISPER_KEYS.has(key)) onWarning('unknown whispers field ' + key + ' ignored')
340
+ if (key === 'generic' || key === 'rules') {
341
+ // pet M6: keyword and ambient whisper channels are gone; legacy
342
+ // fields are ignored with a warning, never mapped to the new shape.
343
+ onWarning('whispers.' + key + ' is no longer supported and was ignored')
344
+ } else if (!WHISPER_KEYS.has(key)) {
345
+ onWarning('unknown whispers field ' + key + ' ignored')
346
+ }
344
347
  }
345
- const generic = normalizePool(whispersRaw.generic, 'whisperGeneric', onWarning)
346
- const rules = normalizeWhisperRules(whispersRaw.rules, onWarning)
347
- if (generic !== undefined || rules !== undefined) {
348
+ const categories = normalizeWhisperCategories(whispersRaw.categories, onWarning)
349
+ const results = normalizeWhisperResults(whispersRaw.results, onWarning)
350
+ if (categories !== undefined || results !== undefined) {
348
351
  overrides.whispers = {
349
- ...(generic === undefined ? {} : { generic }),
350
- ...(rules === undefined ? {} : { rules }),
352
+ ...(categories === undefined ? {} : { categories }),
353
+ ...(results === undefined ? {} : { results }),
351
354
  }
352
355
  }
353
356
  }