@linxin666/dsh-pet 0.3.4 → 0.3.6

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 (70) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +13 -10
  3. package/README.zh.md +13 -10
  4. package/contracts/voice-pack-v1.schema.json +53 -28
  5. package/cordis.patch.yml +1 -1
  6. package/lib/client.js +166 -30
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +400 -478
  9. package/lib/invariant.js +1 -1
  10. package/lib/{state-DrMX22GL.js → state-NvDEoCln.js} +5 -2
  11. package/lib/types/access.d.ts +5 -4
  12. package/lib/types/access.d.ts.map +1 -1
  13. package/lib/types/access.js +2 -12
  14. package/lib/types/affinity.d.ts +2 -0
  15. package/lib/types/affinity.d.ts.map +1 -1
  16. package/lib/types/affinity.js +12 -0
  17. package/lib/types/chatter.d.ts +68 -46
  18. package/lib/types/chatter.d.ts.map +1 -1
  19. package/lib/types/chatter.js +243 -301
  20. package/lib/types/client/PetSprite.d.ts.map +1 -1
  21. package/lib/types/client/PetSprite.js +23 -18
  22. package/lib/types/client/index.d.ts.map +1 -1
  23. package/lib/types/client/index.js +26 -9
  24. package/lib/types/client/locales.d.ts +18 -0
  25. package/lib/types/client/locales.d.ts.map +1 -1
  26. package/lib/types/client/locales.js +18 -0
  27. package/lib/types/client/renderers/frames2d.d.ts +12 -0
  28. package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
  29. package/lib/types/client/renderers/frames2d.js +143 -18
  30. package/lib/types/event-projection.d.ts +9 -4
  31. package/lib/types/event-projection.d.ts.map +1 -1
  32. package/lib/types/event-projection.js +46 -14
  33. package/lib/types/ledger.d.ts +4 -2
  34. package/lib/types/ledger.d.ts.map +1 -1
  35. package/lib/types/ledger.js +4 -4
  36. package/lib/types/pair-access.d.ts +35 -0
  37. package/lib/types/pair-access.d.ts.map +1 -0
  38. package/lib/types/pair-access.js +19 -0
  39. package/lib/types/remarks.d.ts +1 -1
  40. package/lib/types/remarks.d.ts.map +1 -1
  41. package/lib/types/remarks.js +11 -2
  42. package/lib/types/routes.d.ts.map +1 -1
  43. package/lib/types/routes.js +8 -3
  44. package/lib/types/service.d.ts +7 -11
  45. package/lib/types/service.d.ts.map +1 -1
  46. package/lib/types/service.js +31 -20
  47. package/lib/types/voice-pack.d.ts +11 -7
  48. package/lib/types/voice-pack.d.ts.map +1 -1
  49. package/lib/types/voice-pack.js +80 -49
  50. package/package.json +1 -1
  51. package/src/access.ts +7 -27
  52. package/src/affinity.ts +13 -0
  53. package/src/chatter.test.ts +68 -38
  54. package/src/chatter.ts +269 -316
  55. package/src/client/PetSprite.test.tsx +50 -15
  56. package/src/client/PetSprite.tsx +30 -25
  57. package/src/client/index.test.tsx +10 -1
  58. package/src/client/index.ts +29 -10
  59. package/src/client/locales.ts +18 -0
  60. package/src/client/renderers/frames2d.test.ts +101 -0
  61. package/src/client/renderers/frames2d.ts +138 -18
  62. package/src/event-projection.ts +57 -16
  63. package/src/ledger.ts +6 -4
  64. package/src/pair-access.ts +49 -0
  65. package/src/remarks.test.ts +10 -0
  66. package/src/remarks.ts +9 -2
  67. package/src/routes.ts +9 -4
  68. package/src/service.ts +36 -30
  69. package/src/voice-pack.test.ts +87 -25
  70. package/src/voice-pack.ts +87 -49
@@ -5,15 +5,26 @@
5
5
  * {@link ProjectionRuntime} per session and feed events in arrival order.
6
6
  *
7
7
  * Status copy comes from the chatter voice (big rotating pools, per-tool
8
- * families, real-argument hints), and streamed model output feeds the murmur
9
- * engine so the pet can whisper its inner voice (碎碎念). The wall clock is
8
+ * families, real-argument hints), and the projection feeds the murmur engine
9
+ * (碎碎念) with the SITUATION thinking/writing during the stream, the
10
+ * running tool family on tool calls, and the STRUCTURED outcome on
11
+ * tool/result and turn/end — so the pet's inner voice always roughly knows
12
+ * what is going on and never mis-fires on output text. The wall clock is
10
13
  * injected by the caller, keeping every projection reproducible.
11
14
  * @module @linxin666/dsh-pet/event-projection
12
15
  */
13
16
 
14
17
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
15
18
  import type { PetStateInput } from './state.ts'
16
- import { StatusVoice, toolArgHint, WhisperEngine, type VoicePoolsProvider } from './chatter.ts'
19
+ import {
20
+ StatusVoice,
21
+ toolArgHint,
22
+ toolCategory,
23
+ WhisperEngine,
24
+ whisperCategoryOf,
25
+ looksLikeTestTool,
26
+ type VoicePoolsProvider,
27
+ } from './chatter.ts'
17
28
 
18
29
  /** Runtime shape of the optional legacy activity event. */
19
30
  export interface ActivityStatusEventLike {
@@ -25,11 +36,13 @@ export interface ActivityStatusEventLike {
25
36
  /** Per-session facts needed to project the official event stream. */
26
37
  export interface ProjectionRuntime {
27
38
  activeTools: Set<string>
39
+ /** callIds whose tool looked like a test run, marked at tool/call (pet M6). */
40
+ testCalls: Set<string>
28
41
  officialEventsSeen: boolean
29
42
  stepHadFailure: boolean
30
43
  /** Round-robin status copy voice (scene-stable, cadence-rotated). */
31
44
  voice: StatusVoice
32
- /** Inner-whisper engine fed by the model's own streamed output. */
45
+ /** Inner-whisper engine fed by the projection's situation and outcomes. */
33
46
  whispers: WhisperEngine
34
47
  }
35
48
 
@@ -37,7 +50,7 @@ export interface ProjectionRuntime {
37
50
  export interface PetActivityTransition {
38
51
  input: PetStateInput
39
52
  completedTurn?: number
40
- /** A fresh inner whisper woken by this event's model output, when any. */
53
+ /** A fresh inner whisper woken by this event, when any. */
41
54
  whisper?: string
42
55
  }
43
56
 
@@ -50,6 +63,7 @@ export interface PetActivityTransition {
50
63
  export function emptyProjectionRuntime(pools?: VoicePoolsProvider): ProjectionRuntime {
51
64
  return {
52
65
  activeTools: new Set(),
66
+ testCalls: new Set(),
53
67
  officialEventsSeen: false,
54
68
  stepHadFailure: false,
55
69
  voice: new StatusVoice(pools),
@@ -81,6 +95,7 @@ export function projectOfficialEvent(
81
95
  switch (event.type) {
82
96
  case 'turn/start':
83
97
  runtime.activeTools.clear()
98
+ runtime.testCalls.clear()
84
99
  runtime.stepHadFailure = false
85
100
  return { input: { phase: 'waiting', line: runtime.voice.scene('prepare', nowMs) } }
86
101
  case 'step/start':
@@ -90,14 +105,14 @@ export function projectOfficialEvent(
90
105
  case 'assistant/chunk': {
91
106
  const { chunk } = event.data
92
107
  if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
93
- const whisper = runtime.whispers.feed(chunk.text, nowMs)
108
+ const whisper = runtime.whispers.feed('thinking', nowMs)
94
109
  return {
95
110
  input: { phase: 'thinking', line: runtime.voice.scene('thinking', nowMs) },
96
111
  ...(whisper === undefined ? {} : { whisper }),
97
112
  }
98
113
  }
99
114
  if (chunk.type === 'text-delta' && chunk.text.length > 0) {
100
- const whisper = runtime.whispers.feed(chunk.text, nowMs)
115
+ const whisper = runtime.whispers.feed('writing', nowMs)
101
116
  return {
102
117
  input: { phase: 'review', line: runtime.voice.scene('review', nowMs) },
103
118
  ...(whisper === undefined ? {} : { whisper }),
@@ -107,8 +122,13 @@ export function projectOfficialEvent(
107
122
  }
108
123
  case 'assistant/message':
109
124
  return { input: { phase: 'review', line: runtime.voice.scene('review', nowMs) } }
110
- case 'tool/call':
111
- runtime.activeTools.add(String(event.data.callId))
125
+ case 'tool/call': {
126
+ const callId = String(event.data.callId)
127
+ runtime.activeTools.add(callId)
128
+ // A test-looking call is remembered so its result (the only place the
129
+ // outcome is known) can wake the test-green mood later.
130
+ if (looksLikeTestTool(event.data.name, event.data.arguments)) runtime.testCalls.add(callId)
131
+ const whisper = runtime.whispers.feed(whisperCategoryOf(toolCategory(event.data.name)), nowMs)
112
132
  return {
113
133
  input: {
114
134
  phase: 'tool',
@@ -119,33 +139,54 @@ export function projectOfficialEvent(
119
139
  nowMs,
120
140
  ),
121
141
  },
142
+ ...(whisper === undefined ? {} : { whisper }),
122
143
  }
144
+ }
123
145
  case 'tool/result': {
124
146
  const block = event.data.message.content[0]
125
- runtime.activeTools.delete(String(event.data.message.source.callId))
126
- runtime.stepHadFailure ||= event.data.error !== undefined || block.isError === true
147
+ const callId = String(event.data.message.source.callId)
148
+ const failed = event.data.error !== undefined || block.isError === true
149
+ const wasTest = runtime.testCalls.delete(callId)
150
+ runtime.activeTools.delete(callId)
151
+ runtime.stepHadFailure ||= failed
152
+ const whisper = failed
153
+ ? runtime.whispers.result('fail', nowMs)
154
+ : wasTest
155
+ ? runtime.whispers.result('pass', nowMs)
156
+ : undefined
157
+ const whisperSpread = whisper === undefined ? {} : { whisper }
127
158
  if (runtime.activeTools.size > 0) {
128
159
  return {
129
160
  input: {
130
161
  phase: 'tool',
131
162
  line: runtime.voice.toolRemaining(runtime.activeTools.size, nowMs),
132
163
  },
164
+ ...whisperSpread,
133
165
  }
134
166
  }
135
167
  return runtime.stepHadFailure
136
- ? { input: { phase: 'failed', line: runtime.voice.scene('toolFailed', nowMs) } }
137
- : { input: { phase: 'thinking', line: runtime.voice.scene('toolResult', nowMs) } }
168
+ ? { input: { phase: 'failed', line: runtime.voice.scene('toolFailed', nowMs) }, ...whisperSpread }
169
+ : { input: { phase: 'thinking', line: runtime.voice.scene('toolResult', nowMs) }, ...whisperSpread }
138
170
  }
139
171
  case 'turn/end': {
140
172
  runtime.activeTools.clear()
173
+ runtime.testCalls.clear()
141
174
  switch (event.data.reason.kind) {
142
- case 'completed':
175
+ case 'completed': {
176
+ const whisper = runtime.whispers.result('done', nowMs)
143
177
  return {
144
178
  input: { phase: 'done', line: runtime.voice.scene('done', nowMs) },
145
179
  completedTurn: event.data.turn,
180
+ ...(whisper === undefined ? {} : { whisper }),
146
181
  }
147
- case 'error':
148
- return { input: { phase: 'failed', line: runtime.voice.scene('failed', nowMs) } }
182
+ }
183
+ case 'error': {
184
+ const whisper = runtime.whispers.result('fail', nowMs)
185
+ return {
186
+ input: { phase: 'failed', line: runtime.voice.scene('failed', nowMs) },
187
+ ...(whisper === undefined ? {} : { whisper }),
188
+ }
189
+ }
149
190
  case 'max-tokens':
150
191
  return { input: { phase: 'failed', line: runtime.voice.scene('maxTokens', nowMs) } }
151
192
  case 'interrupted':
package/src/ledger.ts CHANGED
@@ -32,6 +32,8 @@ export interface LedgerConfig {
32
32
  treats?: Partial<TreatConfig>
33
33
  /** Per-pet remark pools for the selected pet (custom slots override built-ins). */
34
34
  remarks?: PetRemarks
35
+ /** Voice-pack fallback remark pools (global or pet voice pack). */
36
+ voiceRemarks?: PetRemarks
35
37
  }
36
38
 
37
39
  /** Result of one ledger interaction (the shape the pet RPC returns). */
@@ -63,7 +65,7 @@ export class PetLedger {
63
65
  constructor(persist: PetPersist, config: LedgerConfig = {}) {
64
66
  this.affinityConfig = { ...defaultAffinityConfig, ...(config.affinity ?? {}) }
65
67
  this.treatConfig = { ...defaultTreatConfig, ...(config.treats ?? {}) }
66
- this.picker = new RemarkPicker(config.remarks)
68
+ this.picker = new RemarkPicker(config.remarks, config.voiceRemarks)
67
69
  this.current = persist
68
70
  }
69
71
 
@@ -124,10 +126,10 @@ export class PetLedger {
124
126
 
125
127
  /**
126
128
  * Swap the reaction pools to another pet's custom remarks (called on pet
127
- * selection). Slots the pet does not declare fall back to built-ins.
129
+ * selection). Slots the pet does not declare fall back to voice packs or built-ins.
128
130
  */
129
- setRemarks(remarks?: PetRemarks): void {
130
- this.picker = new RemarkPicker(remarks)
131
+ setRemarks(remarks?: PetRemarks, voiceRemarks?: PetRemarks): void {
132
+ this.picker = new RemarkPicker(remarks, voiceRemarks)
131
133
  }
132
134
 
133
135
  /**
@@ -0,0 +1,49 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/pair-access.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Pairing trust fence shared by plugins that expose host routes: loopback
4
+ * (the desktop) always passes; a live paired-device cookie is an additional
5
+ * allow path when remote-web-ui is loaded. The consuming plugin never
6
+ * depends on that plugin — without the service the fence stays
7
+ * loopback-only.
8
+ *
9
+ * Per-package wrappers (access.ts) call this with their own name so each
10
+ * plugin keeps a self-describing export; the security decision lives only
11
+ * here.
12
+ */
13
+ import type { IncomingMessage } from 'node:http'
14
+ import { isLoopbackRequest } from './loopback.ts'
15
+
16
+ /** Structural pairing lookup (no package dependency on remote-web-ui). */
17
+ interface PairingAccess {
18
+ isPairedDevice(request: IncomingMessage): boolean
19
+ }
20
+
21
+ /**
22
+ * Structural host-context shape: shared sources carry no @deepseek-ai
23
+ * dependency (the shared package must typecheck standalone), so the fence
24
+ * reads only the two members it needs; cordis Context satisfies this.
25
+ * ctx.get is optional on the test harness; production Context always has it.
26
+ */
27
+ interface LookupCtx {
28
+ get?(name: string, strict?: boolean): unknown
29
+ remoteWebUiPairing?: PairingAccess
30
+ }
31
+
32
+ /**
33
+ * Whether this request may enter the plugin's host routes.
34
+ * @param ctx - host context; may expose remoteWebUiPairing.
35
+ * @param request - the incoming HTTP request.
36
+ * @returns true for loopback, or a live paired-device cookie.
37
+ */
38
+ export function isPairedOrLoopbackAllowed(ctx: LookupCtx, request: IncomingMessage): boolean {
39
+ if (isLoopbackRequest(request)) return true
40
+ const fromGet = typeof ctx.get === 'function' ? ctx.get('remoteWebUiPairing', false) : undefined
41
+ const pairing = (isPairingAccess(fromGet) ? fromGet : ctx.remoteWebUiPairing)
42
+ return pairing?.isPairedDevice(request) === true
43
+ }
44
+
45
+ function isPairingAccess(value: unknown): value is PairingAccess {
46
+ return value !== undefined
47
+ && value !== null
48
+ && typeof (value as PairingAccess).isPairedDevice === 'function'
49
+ }
@@ -54,6 +54,16 @@ describe('RemarkPicker', () => {
54
54
  expect(picker.pick('pet')).toBe('专属摸头台词')
55
55
  expect(picker.pick('feed')).toBe(BUILTIN_REMARKS.feed[0])
56
56
  })
57
+
58
+ it('layers pet custom lines over voice pack lines, falling back to built-ins (#1226)', () => {
59
+ const picker = new RemarkPicker(
60
+ { pet: ['Pet-level line'] },
61
+ { pet: ['Voice-level pet line'], feed: ['Voice-level feed line'] },
62
+ )
63
+ expect(picker.pick('pet')).toBe('Pet-level line')
64
+ expect(picker.pick('feed')).toBe('Voice-level feed line')
65
+ expect(picker.pick('noTreats')).toBe(BUILTIN_REMARKS.noTreats[0])
66
+ })
57
67
  })
58
68
 
59
69
  describe('normalizePetRemarks', () => {
package/src/remarks.ts CHANGED
@@ -184,11 +184,18 @@ export class RemarkPicker {
184
184
  private readonly counters = new Map<RemarkKind, number>()
185
185
  private readonly pools: Record<RemarkKind, readonly string[]>
186
186
 
187
- constructor(overrides?: PetRemarks) {
187
+ constructor(overrides?: PetRemarks, voiceOverrides?: PetRemarks) {
188
188
  this.pools = {} as Record<RemarkKind, readonly string[]>
189
189
  for (const kind of REMARK_KINDS) {
190
190
  const custom = overrides?.[kind]
191
- this.pools[kind] = custom !== undefined && custom.length > 0 ? custom : BUILTIN_REMARKS[kind]
191
+ const voice = voiceOverrides?.[kind]
192
+ if (custom !== undefined && custom.length > 0) {
193
+ this.pools[kind] = custom
194
+ } else if (voice !== undefined && voice.length > 0) {
195
+ this.pools[kind] = voice
196
+ } else {
197
+ this.pools[kind] = BUILTIN_REMARKS[kind]
198
+ }
192
199
  }
193
200
  }
194
201
 
package/src/routes.ts CHANGED
@@ -112,15 +112,15 @@ function guard(ctx: Context, req: IncomingMessage, res: ServerResponse): boolean
112
112
  return false
113
113
  }
114
114
 
115
- /** Wrap one async service call as a GET JSON route. */
116
- function getRoute(ctx: Context, path: string, run: () => Promise<unknown>): WebRoute {
115
+ /** Wrap one async service call as a GET JSON route (request passed through for query params). */
116
+ function getRoute(ctx: Context, path: string, run: (req: IncomingMessage) => Promise<unknown>): WebRoute {
117
117
  return {
118
118
  kind: 'exact',
119
119
  path,
120
120
  handler: (req: IncomingMessage, res: ServerResponse): void => {
121
121
  if (!guard(ctx, req, res)) return
122
122
  if (!requireMethod(req, res, 'GET')) return
123
- run().then((value) => writeJson(res, 200, value), (error) => {
123
+ run(req).then((value) => writeJson(res, 200, value), (error) => {
124
124
  writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
125
125
  })
126
126
  },
@@ -525,7 +525,12 @@ function decorationHandler(ctx: Context, registry: PetRegistry, caps: PetAssetCa
525
525
  export function makePetRoutes(deps: { service: PetService; ctx: Context; assetCaps?: PetAssetCaps } & PetRuntimeRoots): WebRoute[] {
526
526
  const { service, ctx } = deps
527
527
  const apiRoutes: WebRoute[] = [
528
- getRoute(ctx, PET_API_PREFIX + '/state', () => service.state()),
528
+ getRoute(ctx, PET_API_PREFIX + '/state', (req) => {
529
+ // The browser half reports the GUI's current session id so the bubble
530
+ // stack can lead with the session the user is actually looking at.
531
+ const current = new URL(req.url ?? '/', 'http://pet.local').searchParams.get('current')
532
+ return service.state(current === null || current === '' ? undefined : current)
533
+ }),
529
534
  getRoute(ctx, PET_API_PREFIX + '/pets', () => service.pets()),
530
535
  getRoute(ctx, PET_API_PREFIX + '/diagnostics', () => service.diagnostics()),
531
536
  postRoute(ctx, PET_API_PREFIX + '/interact', (body) => {
package/src/service.ts CHANGED
@@ -137,6 +137,8 @@ export interface PetSessionView {
137
137
  bubble: string
138
138
  /** This session's raw activity phase. */
139
139
  phase: PetStateSnapshot['phase']
140
+ /** This session's fresh inner whisper (碎碎念), when one is within its TTL. */
141
+ whisper?: string
140
142
  }
141
143
 
142
144
  /** Hard cap on simultaneously displayed session bubbles (most recent first). */
@@ -149,10 +151,10 @@ export interface PetStateView {
149
151
  phase: PetStateSnapshot['phase']
150
152
  sessionActive: boolean
151
153
  /**
152
- * Per-session bubbles for every concurrently active TOP-LEVEL session,
153
- * most recent first; optional so older hosts without the multi-session
154
- * view stay consumable. The single 'bubble' above mirrors the display
155
- * session.
154
+ * Per-session bubbles for every concurrently active TOP-LEVEL session:
155
+ * the GUI's current session first when reported, the rest most recently
156
+ * active first; optional so older hosts without the multi-session view
157
+ * stay consumable. The single 'bubble' above mirrors the display session.
156
158
  */
157
159
  sessions?: PetSessionView[]
158
160
  /** Affinity ledger snapshot. */
@@ -177,12 +179,6 @@ export interface PetStateView {
177
179
  /** Stock cap. */
178
180
  max: number
179
181
  }
180
- /**
181
- * The display session's fresh inner whisper (碎碎念), when one is within
182
- * its TTL — short inner-voice copy woken by the model's own output,
183
- * rendered by the client as a distinct whisper bubble.
184
- */
185
- whisper?: string
186
182
  /**
187
183
  * The active status decoration (pet-center M5, #567), when the master
188
184
  * switch is on and the default decoration entry exists. Absent means the
@@ -301,10 +297,12 @@ export class PetService extends Service {
301
297
  persist = { ...persist, petId: this.registry.defaultEntry().id }
302
298
  }
303
299
  const selected = this.registry.byId(persist.petId) ?? this.registry.defaultEntry()
300
+ const voiceRemarks = mergeVoicePacks(this.registry.globalVoice, selected.voice)?.remarks
304
301
  const ledgerConfig: LedgerConfig = {
305
302
  affinity: config.affinity,
306
303
  treats: config.treats,
307
304
  remarks: selected.remarks,
305
+ voiceRemarks,
308
306
  }
309
307
  this.ledger = new PetLedger(persist, ledgerConfig)
310
308
  this.stateConfig = { ...defaultPetStateConfig, ...(config.state ?? {}) }
@@ -338,8 +336,8 @@ export class PetService extends Service {
338
336
  }
339
337
 
340
338
  /** RPC: current pet state snapshot. */
341
- async state(): Promise<PetStateView> {
342
- return this.view()
339
+ async state(currentSessionId?: string): Promise<PetStateView> {
340
+ return this.view(currentSessionId)
343
341
  }
344
342
 
345
343
  /** Current persisted display config (read-only view). */
@@ -394,7 +392,8 @@ export class PetService extends Service {
394
392
  const entry = this.registry.byId(petId)
395
393
  if (entry === undefined) return { ok: false, error: 'unknown-pet' }
396
394
  this.ledger.setPetId(entry.id)
397
- this.ledger.setRemarks(entry.remarks)
395
+ const voiceRemarks = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.remarks
396
+ this.ledger.setRemarks(entry.remarks, voiceRemarks)
398
397
  this.flush()
399
398
  this.syncSettingsFromPet()
400
399
  return { ok: true, petId: entry.id }
@@ -763,37 +762,45 @@ export class PetService extends Service {
763
762
  if (this.ledger.rewardLegacyTurn(Date.now())) this.flush()
764
763
  }
765
764
 
766
- private view(): PetStateView {
765
+ private view(currentSessionId?: string): PetStateView {
767
766
  const snapshot = this.machine.render()
768
767
  const entry = this.activeEntry()
769
- // One bubble per concurrently active TOP-LEVEL session, most recent
770
- // first. Subagent children render no bubble of their own (their activity
771
- // already shows through the spawning conversation's bubble/display, and
772
- // the bubble buttons navigate to GUI sessions, which subagents are not).
773
- // Sessions whose own machine has settled (no bubble copy) drop out, so a
774
- // finished turn does not leave a stale bubble behind.
768
+ // One bubble per concurrently active TOP-LEVEL session. The GUI's current
769
+ // session leads the stack when reported (the browser half passes its
770
+ // session list's 'current'); everything else keeps the most recent
771
+ // meaningful event order. Subagent children render no bubble of their own
772
+ // (their activity already shows through the spawning conversation's
773
+ // bubble/display, and the bubble buttons navigate to GUI sessions, which
774
+ // subagents are not). Sessions whose own machine has settled (no bubble
775
+ // copy) drop out, so a finished turn does not leave a stale bubble behind.
775
776
  const sessions: PetSessionView[] = []
776
777
  for (const [session, activity] of [...this.sessionActivity.entries()].reverse()) {
777
778
  if (sessions.length >= MAX_SESSION_BUBBLES) break
778
779
  if (session.header?.origin === 'subagent') continue
779
780
  const perSession = activity.machine.render()
780
781
  if (perSession.bubble === undefined) continue
782
+ // Each session's whisper rides its own bubble while fresh; an expired
783
+ // whisper simply stops appearing (the client's 2 s poll drops it).
784
+ const whisper = activity.whisper
785
+ const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
786
+ ? whisper.text
787
+ : undefined
781
788
  sessions.push({
782
789
  sessionId: String(session.id),
783
790
  animation: perSession.animation,
784
791
  bubble: perSession.bubble,
785
792
  phase: perSession.phase,
793
+ ...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
786
794
  })
787
795
  }
788
- // The display session's inner whisper rides the global view while fresh;
789
- // an expired whisper simply stops appearing (the client's 2s poll drops it).
790
- const displayActivity = this.displaySession === undefined
791
- ? undefined
792
- : this.sessionActivity.get(this.displaySession)
793
- const whisper = displayActivity?.whisper
794
- const freshWhisper = whisper !== undefined && Date.now() - whisper.at < WHISPER_TTL_MS
795
- ? whisper.text
796
- : undefined
796
+ // The browser half reports the session the user is currently on; that
797
+ // session's bubble leads the stack (its whisper then speaks on the
798
+ // collapsed single bubble). An unreported or absent session keeps the
799
+ // legacy most-recent-first order.
800
+ if (currentSessionId !== undefined) {
801
+ const index = sessions.findIndex(session => session.sessionId === currentSessionId)
802
+ if (index > 0) sessions.unshift(sessions.splice(index, 1)[0]!)
803
+ }
797
804
  const decoration = this.activeDecoration()
798
805
  // Gameplay view: a read-only projection. The settle runs on a copy, so
799
806
  // polling never writes pet.json (verbs settle and persist).
@@ -812,7 +819,6 @@ export class PetService extends Service {
812
819
  phase: snapshot.phase,
813
820
  sessionActive: snapshot.sessionActive,
814
821
  sessions,
815
- ...(freshWhisper === undefined ? {} : { whisper: freshWhisper }),
816
822
  ...(decoration === undefined ? {} : { decoration }),
817
823
  affinity: this.ledger.affinityView(Date.now()),
818
824
  display: { ...this.ledger.snapshot.display },
@@ -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[] } {
@@ -52,6 +53,45 @@ describe('normalizeVoicePack structure', () => {
52
53
  expect(warnings.join('\n')).toContain('mystery')
53
54
  expect(warnings.join('\n')).toContain('voicePackVersion')
54
55
  })
56
+
57
+ it('normalizes top-level remarks and ranks (#1226)', () => {
58
+ const { pack, warnings } = collectWarnings({
59
+ remarks: {
60
+ pet: ['Purr~ So comfortable!'],
61
+ feed: 'Yummy fish!',
62
+ },
63
+ ranks: {
64
+ '0': 'Baby Whale',
65
+ '幼鲸': 'Little Whale',
66
+ },
67
+ })
68
+ expect(pack).toBeDefined()
69
+ expect(pack?.remarks?.pet).toEqual(['Purr~ So comfortable!'])
70
+ expect(pack?.remarks?.feed).toEqual(['Yummy fish!'])
71
+ expect(pack?.ranks).toEqual({
72
+ '0': 'Baby Whale',
73
+ '幼鲸': 'Little Whale',
74
+ })
75
+ expect(warnings).toHaveLength(0)
76
+ })
77
+
78
+ it('merges remarks and ranks across layers (#1226)', () => {
79
+ const base = normalizeVoicePack({
80
+ remarks: { pet: ['Base pet'], feed: ['Base feed'] },
81
+ ranks: { '0': 'Base 0', '25': 'Base 25' },
82
+ })
83
+ const layer = normalizeVoicePack({
84
+ remarks: { pet: ['Layer pet'] },
85
+ ranks: { '0': 'Layer 0' },
86
+ })
87
+ const merged = mergeVoicePacks(base, layer)
88
+ expect(merged?.remarks?.pet).toEqual(['Layer pet'])
89
+ expect(merged?.remarks?.feed).toEqual(['Base feed'])
90
+ expect(merged?.ranks).toEqual({
91
+ '0': 'Layer 0',
92
+ '25': 'Base 25',
93
+ })
94
+ })
55
95
  })
56
96
 
57
97
  describe('normalizePool', () => {
@@ -85,31 +125,40 @@ describe('normalizePool', () => {
85
125
  })
86
126
 
87
127
  it('preserves an explicit empty array (mute semantics) but not absent', () => {
88
- expect(normalizePool([], 'whisperGeneric')).toEqual([])
89
- expect(normalizePool(undefined, 'whisperGeneric')).toBeUndefined()
128
+ expect(normalizePool([], 'whisperCategory')).toEqual([])
129
+ expect(normalizePool(undefined, 'whisperCategory')).toBeUndefined()
90
130
  })
91
131
  })
92
132
 
93
- describe('normalizeWhisperRules', () => {
94
- it('lowercases and trims keywords, drops unusable rules', () => {
133
+ describe('normalizeWhisperCategories', () => {
134
+ it('keeps known categories, keeps an explicit empty pool (mute) and warns on unknown keys', () => {
95
135
  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)
136
+ const pools = normalizeWhisperCategories({ thinking: ['自定义思考'], running: [], bogus: ['x'] }, m => warnings.push(m))
137
+ expect(pools).toEqual({ thinking: ['自定义思考'], running: [] })
138
+ expect(warnings.join('\n')).toContain('unknown whisper category')
103
139
  })
104
140
 
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)
141
+ it('returns undefined for an absent or non-object section', () => {
142
+ expect(normalizeWhisperCategories(undefined)).toBeUndefined()
143
+ const warnings: string[] = []
144
+ expect(normalizeWhisperCategories(['x'], m => warnings.push(m))).toBeUndefined()
145
+ expect(warnings.join('\n')).toContain('must be an object')
146
+ })
147
+ })
148
+
149
+ describe('normalizeWhisperResults', () => {
150
+ it('keeps known outcomes, keeps an explicit empty pool (mute) and warns on unknown keys', () => {
151
+ const warnings: string[] = []
152
+ const pools = normalizeWhisperResults({ pass: ['自定义全绿'], done: [], bogus: ['x'] }, m => warnings.push(m))
153
+ expect(pools).toEqual({ pass: ['自定义全绿'], done: [] })
154
+ expect(warnings.join('\n')).toContain('unknown whisper result')
109
155
  })
110
156
 
111
- it('returns an explicit empty array (disables the keyword channel)', () => {
112
- expect(normalizeWhisperRules([])).toEqual([])
157
+ it('returns undefined for an absent or non-object section', () => {
158
+ expect(normalizeWhisperResults(undefined)).toBeUndefined()
159
+ const warnings: string[] = []
160
+ expect(normalizeWhisperResults('x', m => warnings.push(m))).toBeUndefined()
161
+ expect(warnings.join('\n')).toContain('must be an object')
113
162
  })
114
163
  })
115
164
 
@@ -153,12 +202,22 @@ describe('mergeVoicePacks', () => {
153
202
  expect(merged?.panel?.labels).toEqual({ feed: '宠物投喂', hide: '宠物藏' })
154
203
  })
155
204
 
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: ['宠物全绿'] }] } })
205
+ it('lets the pet pack replace whisper keys while the global stays', () => {
206
+ const global = normalizeVoicePack({ whispers: { categories: { thinking: ['全局思考'] }, results: { pass: ['全局全绿'] } } })
207
+ const pet = normalizeVoicePack({ whispers: { categories: { thinking: ['宠物思考'] } } })
159
208
  const merged = mergeVoicePacks(global, pet)
160
- expect(merged?.overrides.whispers?.generic).toEqual(['全局碎碎念'])
161
- expect(merged?.overrides.whispers?.rules).toEqual([{ keywords: ['测试'], pool: ['宠物全绿'] }])
209
+ expect(merged?.overrides.whispers?.categories?.thinking).toEqual(['宠物思考'])
210
+ expect(merged?.overrides.whispers?.results?.pass).toEqual(['全局全绿'])
211
+ })
212
+
213
+ it('warns and ignores the legacy generic / rules whisper fields', () => {
214
+ const { pack, warnings } = collectWarnings({
215
+ whispers: { generic: ['老环境池'], rules: [{ keywords: ['测试'], pool: ['老全绿'] }], categories: { thinking: ['新思考'] } },
216
+ })
217
+ expect(pack?.overrides.whispers?.categories?.thinking).toEqual(['新思考'])
218
+ expect(pack?.overrides.whispers?.categories?.generic).toBeUndefined()
219
+ expect(warnings.join('\n')).toContain('generic')
220
+ expect(warnings.join('\n')).toContain('rules')
162
221
  })
163
222
 
164
223
  it('returns undefined when every layer is empty', () => {
@@ -200,7 +259,10 @@ describe('voice-pack schema file drift lock', () => {
200
259
  const props = schema.properties
201
260
  expect(new Set(Object.keys(props.status?.properties ?? {}))).toEqual(new Set(STATUS_SCENES))
202
261
  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)
262
+ const whispers = props.whispers?.properties ?? {}
263
+ expect(new Set(Object.keys(whispers))).toEqual(WHISPER_KEYS)
264
+ expect(new Set(Object.keys((whispers.categories as { properties?: Record<string, unknown> })?.properties ?? {}))).toEqual(new Set(WHISPER_CATEGORIES))
265
+ expect(new Set(Object.keys((whispers.results as { properties?: Record<string, unknown> })?.properties ?? {}))).toEqual(new Set(WHISPER_RESULTS))
204
266
  const panel = props.panel?.properties ?? {}
205
267
  const labels = (panel.labels as { properties?: Record<string, unknown> } | undefined)?.properties ?? {}
206
268
  const stats = (panel.stats as { properties?: Record<string, unknown> } | undefined)?.properties ?? {}