@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
@@ -360,14 +360,14 @@ export function PetSprite(props) {
360
360
  const statusBubble = feedback === null && sessionBubbles.length === 0
361
361
  ? snapshot?.bubble
362
362
  : undefined;
363
- // The display session's inner whisper (碎碎念) — short inner-voice copy
364
- // woken by the model's output. Instead of a second bubble of its own, a
365
- // fresh whisper takes over the display session's bubble (the stack top, or
366
- // the single status bubble) and re-tints it, so the pet never wears two
367
- // voices at once. Interaction feedback takes over the whole bubble area
368
- // while it plays, so whispers yield to it like status copy.
369
- const whisper = feedback === null ? snapshot?.whisper : undefined;
370
- const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined;
363
+ // Each session's inner whisper (碎碎念) rides its own bubble — short
364
+ // inner-voice copy woken by that session's activity, never the model's or
365
+ // another session's. Instead of a second bubble of its own, a fresh
366
+ // whisper takes over its session's bubble and re-tints it, so the pet
367
+ // never wears two voices at once. Interaction feedback takes over the
368
+ // whole bubble area while it plays, so whispers yield to it like status
369
+ // copy.
370
+ const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined;
371
371
  const displayName = snapshot?.name ?? definition.displayName;
372
372
  // The host-served status decoration (M5, #567); absent = text-only bubbles.
373
373
  const decoration = snapshot?.decoration;
@@ -447,14 +447,13 @@ export function PetSprite(props) {
447
447
  }
448
448
  }
449
449
  props.onPet();
450
- }, role: "button", "aria-label": definition.displayName, children: props.visual }) }), props.hud, feedback !== null && (_jsx("div", { ref: bubbleRef, className: clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet), children: feedback.text }, feedback.at)), feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (_jsxs("div", { ref: bubbleRef, className: styles.bubbleStack, onPointerEnter: () => setStackPeek(true), onPointerLeave: () => setStackPeek(false), children: [visibleSessions.map((session, index) => {
451
- // The whisper rides the display session's bubble the stack's
452
- // primary entry (DOM-first, rendered bottom-most by the reversed
453
- // column so it stays glued to the sprite when extras open above).
454
- // The key swap restarts the entrance animation so the mood change
455
- // reads as the bubble re-speaking.
456
- const speaksWhisper = index === 0 && whisper !== undefined;
457
- const bubble = (_jsxs("button", { type: "button", className: clsx(styles.bubble, styles.bubbleStatus, styles.bubbleClickable, speaksWhisper && styles.bubbleWhisper), title: props.t('pet.openSessionHint'), onClick: () => { props.onOpenSession(session.sessionId); }, children: [index === 0 && !speaksWhisper && decoration !== undefined && (_jsx(StatusOrnament, { decoration: decoration, phase: phase })), speaksWhisper ? whisper : session.bubble] }, speaksWhisper ? 'whisper:' + whisper : session.sessionId));
450
+ }, role: "button", "aria-label": definition.displayName, children: props.visual }) }), props.hud, feedback !== null && (_jsx("div", { ref: bubbleRef, className: clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet), children: feedback.text }, feedback.at)), feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined) && (_jsxs("div", { ref: bubbleRef, className: styles.bubbleStack, onPointerEnter: () => setStackPeek(true), onPointerLeave: () => setStackPeek(false), children: [visibleSessions.map((session, index) => {
451
+ // A session's whisper rides ITS OWN bubble (the stack lead when
452
+ // the current session has one, any bubble when the stack is
453
+ // expanded). The key swap restarts the entrance animation so the
454
+ // mood change reads as the bubble re-speaking.
455
+ const speaksWhisper = session.whisper !== undefined;
456
+ const bubble = (_jsxs("button", { type: "button", className: clsx(styles.bubble, styles.bubbleStatus, styles.bubbleClickable, speaksWhisper && styles.bubbleWhisper), title: props.t('pet.openSessionHint'), onClick: () => { props.onOpenSession(session.sessionId); }, children: [index === 0 && !speaksWhisper && decoration !== undefined && (_jsx(StatusOrnament, { decoration: decoration, phase: phase })), session.whisper ?? session.bubble] }, speaksWhisper ? 'whisper:' + session.whisper : session.sessionId));
458
457
  // The primary bubble carries the '+N' badge while other sessions
459
458
  // hide behind it; the badge toggles the pinned (touch) expansion.
460
459
  if (index !== 0 || sessionBubbles.length <= 1)
@@ -467,7 +466,7 @@ export function PetSprite(props) {
467
466
  e.stopPropagation();
468
467
  setStackPinned(open => !open);
469
468
  }, children: stackOpen ? '×' : '+' + String(sessionBubbles.length - 1) })] }, "primary"));
470
- }), sessionBubbles.length === 0 && (statusBubble !== undefined || whisper !== undefined) && (_jsxs("div", { className: clsx(styles.bubble, styles.bubbleStatus, whisper !== undefined && styles.bubbleWhisper), role: "status", "aria-live": "polite", children: [whisper === undefined && decoration !== undefined && (_jsx(StatusOrnament, { decoration: decoration, phase: phase })), whisper ?? statusBubble] }, whisper === undefined ? 'status' : 'whisper:' + whisper))] })), hovered && dragRef.current === null && (_jsx("div", { ref: panelRef, className: clsx(styles.panel, panelAbove && styles.panelAbove), "data-placement": panelAbove ? 'above' : 'below', style: panelAbove && panelLift > 0
469
+ }), sessionBubbles.length === 0 && statusBubble !== undefined && (_jsxs("div", { className: clsx(styles.bubble, styles.bubbleStatus), role: "status", "aria-live": "polite", children: [decoration !== undefined && (_jsx(StatusOrnament, { decoration: decoration, phase: phase })), statusBubble] }, "status"))] })), hovered && dragRef.current === null && (_jsx("div", { ref: panelRef, className: clsx(styles.panel, panelAbove && styles.panelAbove), "data-placement": panelAbove ? 'above' : 'below', style: panelAbove && panelLift > 0
471
470
  ? { marginBottom: panelLift }
472
471
  : undefined, onPointerEnter: () => {
473
472
  // Reaching the panel (or its bridge) must cancel any hide timer
@@ -499,7 +498,13 @@ export function PetSprite(props) {
499
498
  props.onRename(trimmed);
500
499
  setRenaming(false);
501
500
  }
502
- }, children: panelLabel('confirm', props.t('pet.confirm')) })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: styles.rankRow, children: [_jsx("span", { className: styles.nameCell, children: displayName }), _jsx("span", { className: styles.statRank, children: panelStat('rank', 'pet.rank', { rank: snapshot?.affinity.rank ?? '?' }) })] }), _jsxs("div", { className: styles.rankRow, children: [_jsx("span", { className: styles.statTreats, children: panelStat('treats', 'pet.treats', { n: snapshot?.treats.stocked ?? 0 }) }), _jsx("span", { className: styles.statPoints, children: panelStat('points', 'pet.points', { points: snapshot?.affinity.points ?? 0 }) })] }), _jsxs("div", { className: styles.actions, children: [panelShows('feed') && (_jsx("button", { type: "button", className: styles.action, onClick: props.onFeed, children: panelLabel('feed', props.t('pet.feed')) })), panelShows('rename') && (_jsx("button", { type: "button", className: styles.action, onClick: () => {
501
+ }, children: panelLabel('confirm', props.t('pet.confirm')) })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: styles.rankRow, children: [_jsx("span", { className: styles.nameCell, children: displayName }), _jsx("span", { className: styles.statRank, children: (() => {
502
+ const rawRank = snapshot?.affinity.rank ?? '?';
503
+ const rankKey = `pet.rank.name.${rawRank}`;
504
+ const localized = props.t(rankKey);
505
+ const rankName = localized !== rankKey ? localized : rawRank;
506
+ return panelStat('rank', 'pet.rank', { rank: rankName });
507
+ })() })] }), _jsxs("div", { className: styles.rankRow, children: [_jsx("span", { className: styles.statTreats, children: panelStat('treats', 'pet.treats', { n: snapshot?.treats.stocked ?? 0 }) }), _jsx("span", { className: styles.statPoints, children: panelStat('points', 'pet.points', { points: snapshot?.affinity.points ?? 0 }) })] }), _jsxs("div", { className: styles.actions, children: [panelShows('feed') && (_jsx("button", { type: "button", className: styles.action, onClick: props.onFeed, children: panelLabel('feed', props.t('pet.feed')) })), panelShows('rename') && (_jsx("button", { type: "button", className: styles.action, onClick: () => {
503
508
  // Cancel any pending hide so the rename box cannot
504
509
  // unmount right as the user starts typing (#303).
505
510
  clearHideTimer();
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAwB,aAAa,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAA;AA0EnI,wEAAwE;AACxE,eAAO,MAAM,MAAM,UAA2E,CAAA;AAE9F,qEAAqE;AACrE,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AACxE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACrD,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACtF,YAAY,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AACpE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CA+R9C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAwB,aAAa,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAA;AA4EnI,wEAAwE;AACxE,eAAO,MAAM,MAAM,UAA2E,CAAA;AAE9F,qEAAqE;AACrE,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AACxE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACrD,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC7D,YAAY,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAA;AACtF,YAAY,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AACpE,YAAY,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAgT9C"}
@@ -37,7 +37,8 @@ async function petFetch(path, body) {
37
37
  }
38
38
  /** The live host API instance (always defined; failures surface per call). */
39
39
  const petApi = {
40
- state: () => petFetch('/api/pet/state'),
40
+ state: (currentSessionId) => petFetch('/api/pet/state'
41
+ + (currentSessionId === undefined ? '' : '?current=' + encodeURIComponent(currentSessionId))),
41
42
  pets: () => petFetch('/api/pet/pets'),
42
43
  interact: (kind) => petFetch('/api/pet/interact', { kind }),
43
44
  setVisible: (visible) => petFetch('/api/pet/set-visible', { visible }),
@@ -142,6 +143,19 @@ export function apply(ctx) {
142
143
  const setPets = petStore.actions.setPets;
143
144
  const setState = petStore.actions.setState;
144
145
  const setFeedback = petStore.actions.setFeedback;
146
+ // Clicking a session bubble jumps the GUI to that session; the same
147
+ // sessions face reports which session the user is currently on, so the
148
+ // host can lead the bubble stack with it. A bubble can outlive its
149
+ // disposed session by one poll tick, and the sessions service fails
150
+ // loud on unknown ids, so consult the live list first. The pet's type
151
+ // program also loads the host-side dsh-session package through the
152
+ // service types, whose Context merge declares a different 'sessions'
153
+ // face; pin the browser runtime's outward face here.
154
+ const sessions = ctx.sessions;
155
+ const currentSessionId = () => {
156
+ const current = sessions.list.getSnapshot().current;
157
+ return current === undefined ? undefined : String(current);
158
+ };
145
159
  // The registry list is fetched lazily with retries baked into the poll
146
160
  // cycle: until it lands, the dock entry renders nothing and every 2s
147
161
  // tick tries again. After it lands, one list feeds both the sprite and
@@ -163,7 +177,7 @@ export function apply(ctx) {
163
177
  }
164
178
  const seq = stateSeq + 1;
165
179
  stateSeq = seq;
166
- petApi.state().then((snapshot) => {
180
+ petApi.state(currentSessionId()).then((snapshot) => {
167
181
  if (seq !== stateSeq)
168
182
  return;
169
183
  setSnapshot(snapshot);
@@ -207,13 +221,16 @@ export function apply(ctx) {
207
221
  document.removeEventListener('visibilitychange', onVisibility);
208
222
  };
209
223
  }, 'pet: poll');
210
- // Clicking a session bubble jumps the GUI to that session. A bubble
211
- // can outlive its disposed session by one poll tick, and the sessions
212
- // service fails loud on unknown ids, so consult the live list first.
213
- // The pet's type program also loads the host-side dsh-session package
214
- // through the service types, whose Context merge declares a different
215
- // 'sessions' face; pin the browser runtime's outward face here.
216
- const sessions = ctx.sessions;
224
+ // A current-session switch should re-order the bubble stack right away,
225
+ // not on the next 2s tick. Poll only while the tab is visible, as the
226
+ // poll loop itself does.
227
+ const disposeSessionWatch = ctx.effect(() => {
228
+ const unsubscribe = sessions.list.subscribe(() => {
229
+ if (document.visibilityState === 'visible')
230
+ pollNow();
231
+ });
232
+ return unsubscribe;
233
+ }, 'pet: current-session watch');
217
234
  const openSession = (sessionId) => {
218
235
  const list = sessions.list.getSnapshot();
219
236
  if (list.byId[sessionId] === undefined)
@@ -13,6 +13,15 @@ export declare const zh: {
13
13
  readonly 'pet.namePlaceholder': "输入新名字";
14
14
  readonly 'pet.summon': "召唤{name}";
15
15
  readonly 'pet.rank': "亲密度 {rank}";
16
+ readonly 'pet.rank.name.\u5E7C\u9CB8': "幼鲸";
17
+ readonly 'pet.rank.name.\u4F19\u4F34': "伙伴";
18
+ readonly 'pet.rank.name.\u631A\u53CB': "挚友";
19
+ readonly 'pet.rank.name.\u6DF1\u6D77\u7F81\u7ECA': "深海羁绊";
20
+ readonly 'pet.rank.name.\u5FC3\u6709\u7075\u7280': "心有灵犀";
21
+ readonly 'pet.rank.name.\u4F20\u8BF4\u7F81\u7ECA': "传说羁绊";
22
+ readonly 'pet.rank.name.\u795E\u8BDD\u7F81\u7ECA': "神话羁绊";
23
+ readonly 'pet.rank.name.\u6C38\u6052\u4E4B\u5951': "永恒之契";
24
+ readonly 'pet.rank.name.\u9CB8\u751F\u5171\u6E21': "鲸生共渡";
16
25
  readonly 'pet.points': "{points} 点";
17
26
  readonly 'pet.treats': "小鱼干 ×{n}";
18
27
  readonly 'pet.state.loading': "宠物正在赶来…";
@@ -83,6 +92,15 @@ export declare const en: {
83
92
  readonly 'pet.namePlaceholder': "Enter a new name";
84
93
  readonly 'pet.summon': "Summon {name}";
85
94
  readonly 'pet.rank': "Affinity {rank}";
95
+ readonly 'pet.rank.name.\u5E7C\u9CB8': "Baby Whale";
96
+ readonly 'pet.rank.name.\u4F19\u4F34': "Companion";
97
+ readonly 'pet.rank.name.\u631A\u53CB': "Close Friend";
98
+ readonly 'pet.rank.name.\u6DF1\u6D77\u7F81\u7ECA': "Deep Sea Bond";
99
+ readonly 'pet.rank.name.\u5FC3\u6709\u7075\u7280': "Kindred Spirit";
100
+ readonly 'pet.rank.name.\u4F20\u8BF4\u7F81\u7ECA': "Legendary Bond";
101
+ readonly 'pet.rank.name.\u795E\u8BDD\u7F81\u7ECA': "Mythic Bond";
102
+ readonly 'pet.rank.name.\u6C38\u6052\u4E4B\u5951': "Eternal Covenant";
103
+ readonly 'pet.rank.name.\u9CB8\u751F\u5171\u6E21': "Lifelong Companion";
86
104
  readonly 'pet.points': "{points} pts";
87
105
  readonly 'pet.treats': "Treats ×{n}";
88
106
  readonly 'pet.state.loading': "The pet is on its way…";
@@ -1 +1 @@
1
- {"version":3,"file":"locales.d.ts","sourceRoot":"","sources":["../../../src/client/locales.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,mDAAmD;AACnD,eAAO,MAAM,EAAE,QAAQ,CAAA;AAEvB,oBAAoB;AACpB,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsEL,CAAA;AAEV,oBAAoB;AACpB,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsEL,CAAA;AAEV,oCAAoC;AACpC,MAAM,MAAM,MAAM,GAAG,MAAM,OAAO,EAAE,CAAA;AAEpC,qDAAqD;AACrD,MAAM,MAAM,eAAe,GAAG,MAAM,CAAA;AAEpC;;;;;GAKG;AACH,wBAAgB,UAAU,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAGnD;AAED;;;;;;;GAOG;AACH,wBAAgB,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAQvE;AAED,OAAO,QAAQ,kCAAkC,CAAC;IAChD,UAAU,kBAAkB;QAC1B,uBAAuB;QACvB,GAAG,EAAE,MAAM,CAAA;KACZ;CACF"}
1
+ {"version":3,"file":"locales.d.ts","sourceRoot":"","sources":["../../../src/client/locales.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,mDAAmD;AACnD,eAAO,MAAM,EAAE,QAAQ,CAAA;AAEvB,oBAAoB;AACpB,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+EL,CAAA;AAEV,oBAAoB;AACpB,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+EL,CAAA;AAEV,oCAAoC;AACpC,MAAM,MAAM,MAAM,GAAG,MAAM,OAAO,EAAE,CAAA;AAEpC,qDAAqD;AACrD,MAAM,MAAM,eAAe,GAAG,MAAM,CAAA;AAEpC;;;;;GAKG;AACH,wBAAgB,UAAU,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAGnD;AAED;;;;;;;GAOG;AACH,wBAAgB,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAQvE;AAED,OAAO,QAAQ,kCAAkC,CAAC;IAChD,UAAU,kBAAkB;QAC1B,uBAAuB;QACvB,GAAG,EAAE,MAAM,CAAA;KACZ;CACF"}
@@ -13,6 +13,15 @@ export const zh = {
13
13
  'pet.namePlaceholder': '输入新名字',
14
14
  'pet.summon': '召唤{name}',
15
15
  'pet.rank': '亲密度 {rank}',
16
+ 'pet.rank.name.幼鲸': '幼鲸',
17
+ 'pet.rank.name.伙伴': '伙伴',
18
+ 'pet.rank.name.挚友': '挚友',
19
+ 'pet.rank.name.深海羁绊': '深海羁绊',
20
+ 'pet.rank.name.心有灵犀': '心有灵犀',
21
+ 'pet.rank.name.传说羁绊': '传说羁绊',
22
+ 'pet.rank.name.神话羁绊': '神话羁绊',
23
+ 'pet.rank.name.永恒之契': '永恒之契',
24
+ 'pet.rank.name.鲸生共渡': '鲸生共渡',
16
25
  'pet.points': '{points} 点',
17
26
  'pet.treats': '小鱼干 ×{n}',
18
27
  'pet.state.loading': '宠物正在赶来…',
@@ -85,6 +94,15 @@ export const en = {
85
94
  'pet.namePlaceholder': 'Enter a new name',
86
95
  'pet.summon': 'Summon {name}',
87
96
  'pet.rank': 'Affinity {rank}',
97
+ 'pet.rank.name.幼鲸': 'Baby Whale',
98
+ 'pet.rank.name.伙伴': 'Companion',
99
+ 'pet.rank.name.挚友': 'Close Friend',
100
+ 'pet.rank.name.深海羁绊': 'Deep Sea Bond',
101
+ 'pet.rank.name.心有灵犀': 'Kindred Spirit',
102
+ 'pet.rank.name.传说羁绊': 'Legendary Bond',
103
+ 'pet.rank.name.神话羁绊': 'Mythic Bond',
104
+ 'pet.rank.name.永恒之契': 'Eternal Covenant',
105
+ 'pet.rank.name.鲸生共渡': 'Lifelong Companion',
88
106
  'pet.points': '{points} pts',
89
107
  'pet.treats': 'Treats ×{n}',
90
108
  'pet.state.loading': 'The pet is on its way…',
@@ -8,6 +8,18 @@
8
8
  * phase-mapped track. Rendering never throws: a broken track list degrades
9
9
  * to the first decodable frame, and the 1.2 s stall watchdog re-kicks the
10
10
  * playback chain after timer throttling.
11
+ *
12
+ * Frame presentation has two modes picked once at mount by capability
13
+ * probing:
14
+ * - Canvas bitmap buffer (default where createImageBitmap/fetch/2D context
15
+ * exist): every frame is decoded exactly once into an ImageBitmap during
16
+ * the warm pass and drawn onto one <canvas> - steady-state playback issues
17
+ * zero DOM mutations and zero re-decodes (measured hotspot: swapping
18
+ * <img>.src per frame drove image decode + invalidation every tick).
19
+ * - Classic <img> fallback (jsdom/tests or missing APIs): identical to the
20
+ * historical behavior - cache-warm Image elements plus guarded src swaps,
21
+ * so environments without modern decoding keep working unchanged.
22
+ *
11
23
  * @module @linxin666/dsh-pet/client/renderers/frames2d
12
24
  */
13
25
  import { type PetRenderer, type PetRendererHandle } from '../../contracts/renderer.ts';
@@ -1 +1 @@
1
- {"version":3,"file":"frames2d.d.ts","sourceRoot":"","sources":["../../../../src/client/renderers/frames2d.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAA4B,KAAK,WAAW,EAA2B,KAAK,iBAAiB,EAAE,MAAM,6BAA6B,CAAA;AACzI,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,oEAAoE;AACpE,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,IAAI,EAAE,OAAO,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,8DAA8D;AAC9D,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAA;IAC3C,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;CAClE;AAED,MAAM,WAAW,sBAAuB,SAAQ,iBAAiB;IAC/D,gFAAgF;IAChF,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAAA;IACzC,2DAA2D;IAC3D,YAAY,IAAI,MAAM,CAAA;CACvB;AAoCD,eAAO,MAAM,gBAAgB,EAAE,WAAW,CAAC,iBAAiB,CAgJ3D,CAAA"}
1
+ {"version":3,"file":"frames2d.d.ts","sourceRoot":"","sources":["../../../../src/client/renderers/frames2d.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAA4B,KAAK,WAAW,EAA2B,KAAK,iBAAiB,EAAE,MAAM,6BAA6B,CAAA;AACzI,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAEnD,oEAAoE;AACpE,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,IAAI,EAAE,OAAO,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,8DAA8D;AAC9D,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAA;IAC3C,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;CAClE;AAED,MAAM,WAAW,sBAAuB,SAAQ,iBAAiB;IAC/D,gFAAgF;IAChF,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAAA;IACzC,2DAA2D;IAC3D,YAAY,IAAI,MAAM,CAAA;CACvB;AA0CD,eAAO,MAAM,gBAAgB,EAAE,WAAW,CAAC,iBAAiB,CAsP3D,CAAA"}
@@ -8,6 +8,18 @@
8
8
  * phase-mapped track. Rendering never throws: a broken track list degrades
9
9
  * to the first decodable frame, and the 1.2 s stall watchdog re-kicks the
10
10
  * playback chain after timer throttling.
11
+ *
12
+ * Frame presentation has two modes picked once at mount by capability
13
+ * probing:
14
+ * - Canvas bitmap buffer (default where createImageBitmap/fetch/2D context
15
+ * exist): every frame is decoded exactly once into an ImageBitmap during
16
+ * the warm pass and drawn onto one <canvas> - steady-state playback issues
17
+ * zero DOM mutations and zero re-decodes (measured hotspot: swapping
18
+ * <img>.src per frame drove image decode + invalidation every tick).
19
+ * - Classic <img> fallback (jsdom/tests or missing APIs): identical to the
20
+ * historical behavior - cache-warm Image elements plus guarded src swaps,
21
+ * so environments without modern decoding keep working unchanged.
22
+ *
11
23
  * @module @linxin666/dsh-pet/client/renderers/frames2d
12
24
  */
13
25
  import { PET_RENDERER_API_VERSION } from "../../contracts/renderer.js";
@@ -50,23 +62,83 @@ export const frames2dRenderer = {
50
62
  const reducedMotion = typeof window !== 'undefined'
51
63
  && typeof window.matchMedia === 'function'
52
64
  && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
53
- const img = document.createElement('img');
54
- img.dataset.dshPetFrames2d = ctx.petId;
55
- img.alt = '';
56
- img.draggable = false;
57
- img.style.width = '100%';
58
- img.style.height = '100%';
59
- img.style.objectFit = 'contain';
60
- img.style.pointerEvents = 'none';
61
- ctx.container.appendChild(img);
62
- // Warm the browser cache for every frame up front (same-origin, small
63
- // webp files); playback itself swaps img.src without React state.
64
- for (const track of Object.values(config.tracks)) {
65
- for (const url of track.frames) {
66
- const pre = new Image();
67
- pre.src = url;
65
+ // Capability probe: the canvas path needs decode-to-bitmap, same-origin
66
+ // fetch, and a real 2D context (jsdom returns null and falls back below).
67
+ let canvas = null;
68
+ let context2d = null;
69
+ let img = null;
70
+ try {
71
+ if (typeof createImageBitmap === 'function' && typeof fetch === 'function') {
72
+ const probe = document.createElement('canvas');
73
+ const c2d = probe.getContext('2d');
74
+ if (c2d !== null) {
75
+ canvas = probe;
76
+ context2d = c2d;
77
+ }
68
78
  }
69
79
  }
80
+ catch {
81
+ canvas = null;
82
+ context2d = null;
83
+ }
84
+ if (canvas !== null && context2d !== null) {
85
+ canvas.dataset.dshPetFrames2d = ctx.petId;
86
+ canvas.draggable = false;
87
+ canvas.style.width = '100%';
88
+ canvas.style.height = '100%';
89
+ canvas.style.objectFit = 'contain';
90
+ canvas.style.pointerEvents = 'none';
91
+ ctx.container.appendChild(canvas);
92
+ }
93
+ else {
94
+ img = document.createElement('img');
95
+ img.dataset.dshPetFrames2d = ctx.petId;
96
+ img.alt = '';
97
+ img.draggable = false;
98
+ img.style.width = '100%';
99
+ img.style.height = '100%';
100
+ img.style.objectFit = 'contain';
101
+ img.style.pointerEvents = 'none';
102
+ ctx.container.appendChild(img);
103
+ }
104
+ // Decode cache: one concurrent decode per URL, memoized forever (frames
105
+ // are tiny same-origin webp files; failures resolve undefined and keep
106
+ // the last painted frame instead of breaking playback).
107
+ const decoding = new Map();
108
+ const decodedAll = [];
109
+ const loadFrame = (url) => {
110
+ const cached = decoding.get(url);
111
+ if (cached !== undefined)
112
+ return cached;
113
+ const job = (async () => {
114
+ try {
115
+ const response = await fetch(url);
116
+ if (!response.ok)
117
+ throw new Error('http ' + response.status);
118
+ const bitmap = await createImageBitmap(await response.blob());
119
+ return { source: bitmap, width: bitmap.width, height: bitmap.height };
120
+ }
121
+ catch {
122
+ // Fail-open: classic Image decode keeps non-modern runtimes alive.
123
+ return await new Promise((resolve) => {
124
+ try {
125
+ const pre = new Image();
126
+ pre.onload = () => {
127
+ resolve(pre.naturalWidth > 0 ? { source: pre, width: pre.naturalWidth, height: pre.naturalHeight } : undefined);
128
+ };
129
+ pre.onerror = () => resolve(undefined);
130
+ pre.src = url;
131
+ }
132
+ catch {
133
+ resolve(undefined);
134
+ }
135
+ });
136
+ }
137
+ })();
138
+ decoding.set(url, job);
139
+ decodedAll.push(job.then(() => undefined, () => undefined));
140
+ return job;
141
+ };
70
142
  let disposed = false;
71
143
  let timer;
72
144
  let watchdog;
@@ -74,15 +146,45 @@ export const frames2dRenderer = {
74
146
  let frameIndex = 0;
75
147
  let lastAdvance = Date.now();
76
148
  let override;
149
+ let drawToken = 0;
150
+ let lastDrawnUrl;
77
151
  const trackForPhase = (phase) => {
78
152
  const mapped = config.phases[phase];
79
153
  return mapped !== undefined && config.tracks[mapped] !== undefined ? mapped : config.phases.idle;
80
154
  };
155
+ /** Canvas path: paints the newest requested frame; stale draws drop out. */
156
+ const paintCanvas = (url) => {
157
+ if (context2d === null || canvas === null)
158
+ return;
159
+ const myToken = ++drawToken;
160
+ void loadFrame(url).then((frame) => {
161
+ if (disposed || frame === undefined || myToken !== drawToken)
162
+ return;
163
+ if (lastDrawnUrl === url)
164
+ return;
165
+ lastDrawnUrl = url;
166
+ // Resizing clears the canvas, so size only when it actually differs.
167
+ if (canvas.width !== frame.width || canvas.height !== frame.height) {
168
+ canvas.width = frame.width;
169
+ canvas.height = frame.height;
170
+ }
171
+ else {
172
+ context2d.clearRect(0, 0, canvas.width, canvas.height);
173
+ }
174
+ context2d.drawImage(frame.source, 0, 0);
175
+ }).catch(() => { });
176
+ };
81
177
  const show = (trackId, index) => {
82
178
  const def = config.tracks[trackId];
83
179
  const url = def?.frames[index];
84
- if (url !== undefined && img.getAttribute('src') !== url)
85
- img.src = url;
180
+ if (url === undefined)
181
+ return;
182
+ if (img !== null) {
183
+ if (img.getAttribute('src') !== url)
184
+ img.src = url;
185
+ return;
186
+ }
187
+ paintCanvas(url);
86
188
  };
87
189
  const schedule = (ms) => {
88
190
  if (disposed)
@@ -155,6 +257,13 @@ export const frames2dRenderer = {
155
257
  tick();
156
258
  }, WATCHDOG_MS);
157
259
  }
260
+ // Warm pass: decode every frame up front (tiny same-origin webp files)
261
+ // so loops and phase switches never wait on a first decode - same intent
262
+ // as the historical Image-cache warm loop, now feeding the decode cache.
263
+ for (const warmTrack of Object.values(config.tracks)) {
264
+ for (const warmUrl of warmTrack.frames)
265
+ void loadFrame(warmUrl);
266
+ }
158
267
  play(track);
159
268
  let disposedOnce = false;
160
269
  const dispose = () => {
@@ -167,7 +276,23 @@ export const frames2dRenderer = {
167
276
  clearTimeout(timer);
168
277
  if (watchdog !== undefined)
169
278
  clearInterval(watchdog);
170
- img.remove();
279
+ // Release decoded bitmaps after pending decodes settle; close() is
280
+ // browser-only, so guard it for exotic hosts.
281
+ void Promise.allSettled(decodedAll).then(() => {
282
+ for (const job of decoding.values()) {
283
+ void job.then((frame) => {
284
+ try {
285
+ const maybeClose = frame?.source?.close;
286
+ if (typeof maybeClose === 'function' && frame !== undefined)
287
+ maybeClose.call(frame.source);
288
+ }
289
+ catch { /* already released */ }
290
+ }).catch(() => { });
291
+ }
292
+ decoding.clear();
293
+ });
294
+ canvas?.remove();
295
+ img?.remove();
171
296
  };
172
297
  ctx.onCleanup(dispose);
173
298
  return {
@@ -5,8 +5,11 @@
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
  */
@@ -22,18 +25,20 @@ export interface ActivityStatusEventLike {
22
25
  /** Per-session facts needed to project the official event stream. */
23
26
  export interface ProjectionRuntime {
24
27
  activeTools: Set<string>;
28
+ /** callIds whose tool looked like a test run, marked at tool/call (pet M6). */
29
+ testCalls: Set<string>;
25
30
  officialEventsSeen: boolean;
26
31
  stepHadFailure: boolean;
27
32
  /** Round-robin status copy voice (scene-stable, cadence-rotated). */
28
33
  voice: StatusVoice;
29
- /** Inner-whisper engine fed by the model's own streamed output. */
34
+ /** Inner-whisper engine fed by the projection's situation and outcomes. */
30
35
  whispers: WhisperEngine;
31
36
  }
32
37
  /** One official event projection, optionally carrying a completed turn reward. */
33
38
  export interface PetActivityTransition {
34
39
  input: PetStateInput;
35
40
  completedTurn?: number;
36
- /** A fresh inner whisper woken by this event's model output, when any. */
41
+ /** A fresh inner whisper woken by this event, when any. */
37
42
  whisper?: string;
38
43
  }
39
44
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"event-projection.d.ts","sourceRoot":"","sources":["../../src/event-projection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC/C,OAAO,EAAE,WAAW,EAAe,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,cAAc,CAAA;AAE/F,2DAA2D;AAC3D,MAAM,WAAW,uBAAuB;IACtC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,qEAAqE;AACrE,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB,kBAAkB,EAAE,OAAO,CAAA;IAC3B,cAAc,EAAE,OAAO,CAAA;IACvB,qEAAqE;IACrE,KAAK,EAAE,WAAW,CAAA;IAClB,mEAAmE;IACnE,QAAQ,EAAE,aAAa,CAAA;CACxB;AAED,kFAAkF;AAClF,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,aAAa,CAAA;IACpB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,CAAC,EAAE,kBAAkB,GAAG,iBAAiB,CAQpF;AAQD,wEAAwE;AACxE,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,aAAa,CAAC,OAAO,CAAC,CAE9E;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,YAAY,EACnB,OAAO,EAAE,iBAAiB,EAC1B,KAAK,GAAE,MAAmB,GACzB,qBAAqB,GAAG,SAAS,CAwFnC"}
1
+ {"version":3,"file":"event-projection.d.ts","sourceRoot":"","sources":["../../src/event-projection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC/C,OAAO,EACL,WAAW,EAGX,aAAa,EAGb,KAAK,kBAAkB,EACxB,MAAM,cAAc,CAAA;AAErB,2DAA2D;AAC3D,MAAM,WAAW,uBAAuB;IACtC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,qEAAqE;AACrE,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACxB,+EAA+E;IAC/E,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;IACtB,kBAAkB,EAAE,OAAO,CAAA;IAC3B,cAAc,EAAE,OAAO,CAAA;IACvB,qEAAqE;IACrE,KAAK,EAAE,WAAW,CAAA;IAClB,2EAA2E;IAC3E,QAAQ,EAAE,aAAa,CAAA;CACxB;AAED,kFAAkF;AAClF,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,aAAa,CAAA;IACpB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,CAAC,EAAE,kBAAkB,GAAG,iBAAiB,CASpF;AAQD,wEAAwE;AACxE,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,aAAa,CAAC,OAAO,CAAC,CAE9E;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,YAAY,EACnB,OAAO,EAAE,iBAAiB,EAC1B,KAAK,GAAE,MAAmB,GACzB,qBAAqB,GAAG,SAAS,CAmHnC"}
@@ -5,12 +5,15 @@
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
- import { StatusVoice, toolArgHint, WhisperEngine } from "./chatter.js";
16
+ import { StatusVoice, toolArgHint, toolCategory, WhisperEngine, whisperCategoryOf, looksLikeTestTool, } from "./chatter.js";
14
17
  /**
15
18
  * Fresh projection runtime for a newly seen session. The optional voice-pack
16
19
  * provider (pet-center M4, issue #677) hands both chatter engines their
@@ -20,6 +23,7 @@ import { StatusVoice, toolArgHint, WhisperEngine } from "./chatter.js";
20
23
  export function emptyProjectionRuntime(pools) {
21
24
  return {
22
25
  activeTools: new Set(),
26
+ testCalls: new Set(),
23
27
  officialEventsSeen: false,
24
28
  stepHadFailure: false,
25
29
  voice: new StatusVoice(pools),
@@ -44,6 +48,7 @@ export function projectOfficialEvent(event, runtime, nowMs = Date.now()) {
44
48
  switch (event.type) {
45
49
  case 'turn/start':
46
50
  runtime.activeTools.clear();
51
+ runtime.testCalls.clear();
47
52
  runtime.stepHadFailure = false;
48
53
  return { input: { phase: 'waiting', line: runtime.voice.scene('prepare', nowMs) } };
49
54
  case 'step/start':
@@ -53,14 +58,14 @@ export function projectOfficialEvent(event, runtime, nowMs = Date.now()) {
53
58
  case 'assistant/chunk': {
54
59
  const { chunk } = event.data;
55
60
  if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
56
- const whisper = runtime.whispers.feed(chunk.text, nowMs);
61
+ const whisper = runtime.whispers.feed('thinking', nowMs);
57
62
  return {
58
63
  input: { phase: 'thinking', line: runtime.voice.scene('thinking', nowMs) },
59
64
  ...(whisper === undefined ? {} : { whisper }),
60
65
  };
61
66
  }
62
67
  if (chunk.type === 'text-delta' && chunk.text.length > 0) {
63
- const whisper = runtime.whispers.feed(chunk.text, nowMs);
68
+ const whisper = runtime.whispers.feed('writing', nowMs);
64
69
  return {
65
70
  input: { phase: 'review', line: runtime.voice.scene('review', nowMs) },
66
71
  ...(whisper === undefined ? {} : { whisper }),
@@ -70,40 +75,67 @@ export function projectOfficialEvent(event, runtime, nowMs = Date.now()) {
70
75
  }
71
76
  case 'assistant/message':
72
77
  return { input: { phase: 'review', line: runtime.voice.scene('review', nowMs) } };
73
- case 'tool/call':
74
- runtime.activeTools.add(String(event.data.callId));
78
+ case 'tool/call': {
79
+ const callId = String(event.data.callId);
80
+ runtime.activeTools.add(callId);
81
+ // A test-looking call is remembered so its result (the only place the
82
+ // outcome is known) can wake the test-green mood later.
83
+ if (looksLikeTestTool(event.data.name, event.data.arguments))
84
+ runtime.testCalls.add(callId);
85
+ const whisper = runtime.whispers.feed(whisperCategoryOf(toolCategory(event.data.name)), nowMs);
75
86
  return {
76
87
  input: {
77
88
  phase: 'tool',
78
89
  line: runtime.voice.tool(event.data.name, displayToolName(event.data.name), toolArgHint(event.data.name, event.data.arguments), nowMs),
79
90
  },
91
+ ...(whisper === undefined ? {} : { whisper }),
80
92
  };
93
+ }
81
94
  case 'tool/result': {
82
95
  const block = event.data.message.content[0];
83
- runtime.activeTools.delete(String(event.data.message.source.callId));
84
- runtime.stepHadFailure ||= event.data.error !== undefined || block.isError === true;
96
+ const callId = String(event.data.message.source.callId);
97
+ const failed = event.data.error !== undefined || block.isError === true;
98
+ const wasTest = runtime.testCalls.delete(callId);
99
+ runtime.activeTools.delete(callId);
100
+ runtime.stepHadFailure ||= failed;
101
+ const whisper = failed
102
+ ? runtime.whispers.result('fail', nowMs)
103
+ : wasTest
104
+ ? runtime.whispers.result('pass', nowMs)
105
+ : undefined;
106
+ const whisperSpread = whisper === undefined ? {} : { whisper };
85
107
  if (runtime.activeTools.size > 0) {
86
108
  return {
87
109
  input: {
88
110
  phase: 'tool',
89
111
  line: runtime.voice.toolRemaining(runtime.activeTools.size, nowMs),
90
112
  },
113
+ ...whisperSpread,
91
114
  };
92
115
  }
93
116
  return runtime.stepHadFailure
94
- ? { input: { phase: 'failed', line: runtime.voice.scene('toolFailed', nowMs) } }
95
- : { input: { phase: 'thinking', line: runtime.voice.scene('toolResult', nowMs) } };
117
+ ? { input: { phase: 'failed', line: runtime.voice.scene('toolFailed', nowMs) }, ...whisperSpread }
118
+ : { input: { phase: 'thinking', line: runtime.voice.scene('toolResult', nowMs) }, ...whisperSpread };
96
119
  }
97
120
  case 'turn/end': {
98
121
  runtime.activeTools.clear();
122
+ runtime.testCalls.clear();
99
123
  switch (event.data.reason.kind) {
100
- case 'completed':
124
+ case 'completed': {
125
+ const whisper = runtime.whispers.result('done', nowMs);
101
126
  return {
102
127
  input: { phase: 'done', line: runtime.voice.scene('done', nowMs) },
103
128
  completedTurn: event.data.turn,
129
+ ...(whisper === undefined ? {} : { whisper }),
130
+ };
131
+ }
132
+ case 'error': {
133
+ const whisper = runtime.whispers.result('fail', nowMs);
134
+ return {
135
+ input: { phase: 'failed', line: runtime.voice.scene('failed', nowMs) },
136
+ ...(whisper === undefined ? {} : { whisper }),
104
137
  };
105
- case 'error':
106
- return { input: { phase: 'failed', line: runtime.voice.scene('failed', nowMs) } };
138
+ }
107
139
  case 'max-tokens':
108
140
  return { input: { phase: 'failed', line: runtime.voice.scene('maxTokens', nowMs) } };
109
141
  case 'interrupted':