ui-chan-mcp 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +6 -0
  3. package/.env.example +13 -0
  4. package/LICENSE +36 -0
  5. package/README.md +214 -0
  6. package/agents/mode.md +47 -0
  7. package/agents/talk.md +23 -0
  8. package/bin/ui-chan-mcp.mjs +11 -0
  9. package/bin/ui-chan-node +35 -0
  10. package/bin/ui-chan.mjs +350 -0
  11. package/context/AFFINITY.md +94 -0
  12. package/context/SOUL.md +79 -0
  13. package/context/VOCABULARY.md +108 -0
  14. package/cue.schema.json +109 -0
  15. package/cues/default.json +21 -0
  16. package/cues/emo_anger.json +14 -0
  17. package/cues/emo_anger_hi.json +19 -0
  18. package/cues/emo_anger_lo.json +16 -0
  19. package/cues/emo_antic.json +18 -0
  20. package/cues/emo_antic_hi.json +16 -0
  21. package/cues/emo_antic_lo.json +6 -0
  22. package/cues/emo_disgust.json +19 -0
  23. package/cues/emo_disgust_hi.json +20 -0
  24. package/cues/emo_disgust_lo.json +6 -0
  25. package/cues/emo_fear.json +19 -0
  26. package/cues/emo_fear_hi.json +19 -0
  27. package/cues/emo_fear_lo.json +19 -0
  28. package/cues/emo_joy.json +17 -0
  29. package/cues/emo_joy_hi.json +15 -0
  30. package/cues/emo_joy_lo.json +13 -0
  31. package/cues/emo_sad.json +15 -0
  32. package/cues/emo_sad_hi.json +18 -0
  33. package/cues/emo_sad_lo.json +14 -0
  34. package/cues/emo_surprise.json +15 -0
  35. package/cues/emo_surprise_hi.json +14 -0
  36. package/cues/emo_surprise_lo.json +11 -0
  37. package/cues/emo_trust.json +16 -0
  38. package/cues/emo_trust_hi.json +20 -0
  39. package/cues/emo_trust_lo.json +6 -0
  40. package/cues/idling_doze_1.json +9 -0
  41. package/cues/idling_doze_2.json +9 -0
  42. package/cues/idling_doze_3.json +9 -0
  43. package/cues/idling_giggle_1.json +9 -0
  44. package/cues/idling_giggle_2.json +9 -0
  45. package/cues/idling_lookaround_1.json +10 -0
  46. package/cues/idling_lookaround_2.json +10 -0
  47. package/cues/idling_lookaround_3.json +10 -0
  48. package/cues/idling_ponder.json +9 -0
  49. package/cues/idling_sigh_1.json +11 -0
  50. package/cues/idling_sigh_2.json +11 -0
  51. package/cues/idling_yawn_1.json +9 -0
  52. package/cues/idling_yawn_2.json +12 -0
  53. package/cues/idling_yawn_3.json +12 -0
  54. package/cues/mix_anger_antic.json +16 -0
  55. package/cues/mix_antic_joy.json +16 -0
  56. package/cues/mix_disgust_anger.json +17 -0
  57. package/cues/mix_fear_surprise.json +18 -0
  58. package/cues/mix_joy_trust.json +19 -0
  59. package/cues/mix_joy_trust_hi.json +18 -0
  60. package/cues/mix_sad_disgust.json +12 -0
  61. package/cues/mix_surprise_sad.json +21 -0
  62. package/cues/mix_surprise_sad_hi.json +14 -0
  63. package/cues/mix_trust_fear.json +18 -0
  64. package/cues/pose_arms_crossed.json +9 -0
  65. package/cues/pose_banzai.json +17 -0
  66. package/cues/pose_smug_arms.json +11 -0
  67. package/cues/pose_smug_hips.json +11 -0
  68. package/cues/pose_think.json +8 -0
  69. package/cues/pose_umbrella.json +9 -0
  70. package/cues/self_guilt.json +20 -0
  71. package/cues/self_pride.json +10 -0
  72. package/cues/self_shame.json +19 -0
  73. package/cues/self_shy.json +16 -0
  74. package/cues/self_shy_hi.json +17 -0
  75. package/cues/sys_address.json +9 -0
  76. package/cues/sys_awkward.json +18 -0
  77. package/cues/sys_beam.json +18 -0
  78. package/cues/sys_blank.json +11 -0
  79. package/cues/sys_care.json +18 -0
  80. package/cues/sys_confused.json +12 -0
  81. package/cues/sys_dazed.json +10 -0
  82. package/cues/sys_dread.json +21 -0
  83. package/cues/sys_fluster.json +20 -0
  84. package/cues/sys_intro.json +15 -0
  85. package/cues/sys_laugh.json +20 -0
  86. package/cues/sys_neutral.json +5 -0
  87. package/cues/sys_present.json +9 -0
  88. package/cues/sys_rain.json +15 -0
  89. package/cues/sys_refuse.json +18 -0
  90. package/cues/sys_relief.json +15 -0
  91. package/cues/sys_sleepy.json +8 -0
  92. package/cues/sys_smirk.json +10 -0
  93. package/cues/sys_stream.json +15 -0
  94. package/cues/sys_success.json +15 -0
  95. package/cues/sys_think.json +9 -0
  96. package/dist/app/assets.js +54 -0
  97. package/dist/app/cues.js +130 -0
  98. package/dist/app/editor-main.js +199 -0
  99. package/dist/app/editor-preload.js +17 -0
  100. package/dist/app/main.js +527 -0
  101. package/dist/app/persona.js +136 -0
  102. package/dist/app/preload.js +14 -0
  103. package/dist/app/state.js +850 -0
  104. package/dist/app/tts.js +257 -0
  105. package/dist/mcp-server.js +408 -0
  106. package/dist/renderer/bundle.js +17983 -0
  107. package/dist/renderer/editor.html +150 -0
  108. package/dist/renderer/editor.js +17917 -0
  109. package/dist/renderer/index.html +425 -0
  110. package/dist/shared/paths.js +169 -0
  111. package/dist/shared/set-cue-schema.js +66 -0
  112. package/dist/shared/types.js +14 -0
  113. package/docs/CLIENTS.md +110 -0
  114. package/docs/CUE_AUTHORING.md +76 -0
  115. package/docs/DEVELOPMENT.md +129 -0
  116. package/docs/PERSONA.md +61 -0
  117. package/docs/PSD_LAYERS.md +123 -0
  118. package/docs/README.md +43 -0
  119. package/docs/SETUP.html +684 -0
  120. package/docs/STYLE.md +107 -0
  121. package/docs/TOOLS.md +33 -0
  122. package/docs/TROUBLESHOOTING.md +122 -0
  123. package/docs/TTS.md +61 -0
  124. package/docs/design/CUE_CATALOG.md +259 -0
  125. package/docs/images/faces.png +0 -0
  126. package/docs/images/panel.png +0 -0
  127. package/hooks/fire-event.js +14 -0
  128. package/hooks/hooks.json +77 -0
  129. package/hooks/lib/mascot.js +76 -0
  130. package/hooks/notify.js +15 -0
  131. package/hooks/reaction.js +46 -0
  132. package/hooks/session-start.js +80 -0
  133. package/package.json +96 -0
  134. package/persona/ui-chan.md +111 -0
  135. package/plugins/hermes/ui-chan/__init__.py +98 -0
  136. package/plugins/hermes/ui-chan/plugin.yaml +7 -0
  137. package/plugins/opencode/ui-chan.mjs +105 -0
  138. package/skills/beam/SKILL.md +37 -0
  139. package/skills/eli14/SKILL.md +139 -0
  140. package/skills/eli14/references/base.html +495 -0
  141. package/skills/mode/SKILL.md +67 -0
  142. package/skills/talk/SKILL.md +27 -0
  143. package/tools/setup/check-package.mjs +47 -0
  144. package/tools/setup/clients.mjs +399 -0
  145. package/tools/setup/doctor.mjs +105 -0
  146. package/tools/setup/home.mjs +126 -0
  147. package/tools/setup/prompt.mjs +134 -0
  148. package/tools/setup/update-check.mjs +8 -0
  149. package/tools/setup/update.mjs +350 -0
  150. package/tools/stop-app.mjs +22 -0
  151. package/ui-chan.config.json +973 -0
@@ -0,0 +1,850 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UiChanState = void 0;
4
+ const types_1 = require("../shared/types");
5
+ const MAX_QUEUE = 20;
6
+ // Who wins when two things want the mascot at once. A running performance can
7
+ // only be preempted by something of equal-or-higher priority: debug (the dev
8
+ // forcing things) > fidget (physical touch, must feel instant) > MCP (the
9
+ // agent) > idling (self-initiated filler, yields to everyone).
10
+ const PRIORITY = { idle: 0, event: 1, mcp: 2, fidget: 3, debug: 4 };
11
+ /** How often the system-idle gate re-reads the OS idle counter. One second is
12
+ * what makes "she wakes up when you come back" feel like a reaction rather
13
+ * than a delayed timer; the read itself is a cheap native property lookup. */
14
+ const SYSTEM_IDLE_POLL_MS = 1000;
15
+ function pickAdlib(args) {
16
+ const { pitch, speed, volume, intonation } = args;
17
+ if (pitch === undefined &&
18
+ speed === undefined &&
19
+ volume === undefined &&
20
+ intonation === undefined) {
21
+ return undefined;
22
+ }
23
+ return { pitch, speed, volume, intonation };
24
+ }
25
+ /** LEN(text): the one place a display duration gets guessed from text length
26
+ * alone. Used only when the caller didn't pin an explicit duration —
27
+ * `startSpeech()` still refines this further once real TTS audio length is
28
+ * known, so this is a first estimate, not the final word. */
29
+ function estimateSpeechDurationMs(text, speech) {
30
+ const estimated = (speech?.baseMs ?? 1500) + text.length * (speech?.msPerChar ?? 120);
31
+ return Math.min(Math.max(estimated, speech?.minMs ?? 2500), speech?.maxMs ?? 20000);
32
+ }
33
+ /** What the TTS engine should actually be handed for this line.
34
+ *
35
+ * VoiSona reads Latin letters as English spelling — `zsh` comes out
36
+ * "ゼッドエスエイチ", `npm` as "エヌピーエム". The agent already supplies
37
+ * `reading` (full hiragana) for lip-sync, so that is the correct pronunciation
38
+ * to speak. We do NOT always prefer it, though: hiragana-only input costs the
39
+ * engine its kanji-based accent estimation, and most lines are pure Japanese
40
+ * where `text` reads better. So the swap is scoped to exactly the broken case
41
+ * — the line contains Latin letters (or digits, same problem) and a reading
42
+ * was given. The bubble still shows `text` either way. */
43
+ function ttsTextFor(text, reading) {
44
+ const yomi = reading?.trim();
45
+ if (!yomi)
46
+ return text;
47
+ return /[A-Za-z0-9]/.test(text) ? yomi : text;
48
+ }
49
+ /** Every idle timer in this class follows the same "hold an id, clear-then-null
50
+ * it" shape (mirrors renderer.ts's clearTimeoutSafe for the browser side). */
51
+ function clearTimeoutSafe(id) {
52
+ if (id !== null)
53
+ clearTimeout(id);
54
+ return null;
55
+ }
56
+ /** clearTimeoutSafe's twin for the one repeating timer (the system-idle poll). */
57
+ function clearIntervalSafe(id) {
58
+ if (id !== null)
59
+ clearInterval(id);
60
+ return null;
61
+ }
62
+ /** Random idle gap in ms, picked uniformly in [minSec, maxSec] (minSec floored to 1s). */
63
+ function randomDelayMs(minSec, maxSec) {
64
+ return randomDelaySec(minSec, maxSec) * 1000;
65
+ }
66
+ /** Same roll in seconds — the system-idle gate counts in OS idle seconds
67
+ * rather than wall-clock ms, so it needs the unrounded number. */
68
+ function randomDelaySec(minSec, maxSec) {
69
+ const min = Math.max(minSec ?? 0, 1);
70
+ const max = Math.max(maxSec ?? min, min);
71
+ return min + Math.random() * (max - min);
72
+ }
73
+ /** Is `hour` inside the inclusive [from, to] window? A window with from > to
74
+ * wraps past midnight (e.g. [22, 4] covers 22,23,0,1,2,3,4). Undefined = always. */
75
+ function hourInWindow(hour, window) {
76
+ if (!window)
77
+ return true;
78
+ const [from, to] = window;
79
+ return from <= to ? hour >= from && hour <= to : hour >= from || hour <= to;
80
+ }
81
+ /** Pick one item by weight. If all weights are 0/undefined, falls back to uniform. */
82
+ function weightedPick(items) {
83
+ const weights = items.map((item) => Math.max(item.weight ?? 1, 0));
84
+ const total = weights.reduce((sum, w) => sum + w, 0);
85
+ if (total <= 0)
86
+ return items[Math.floor(Math.random() * items.length)];
87
+ let roll = Math.random() * total;
88
+ for (let i = 0; i < items.length; i++) {
89
+ roll -= weights[i];
90
+ if (roll <= 0)
91
+ return items[i];
92
+ }
93
+ return items[items.length - 1];
94
+ }
95
+ /** Keep only the last select per radio group (everything but the final path
96
+ * segment), matching the renderer's last-wins-per-folder behavior. */
97
+ function dedupeSelectsByGroup(selects) {
98
+ const lastIndexByGroup = new Map();
99
+ selects.forEach((path, i) => {
100
+ const group = path.slice(0, path.lastIndexOf('/'));
101
+ lastIndexByGroup.set(group, i);
102
+ });
103
+ return selects.filter((_, i) => [...lastIndexByGroup.values()].includes(i));
104
+ }
105
+ class UiChanState {
106
+ config;
107
+ emit;
108
+ synthesize;
109
+ systemIdleSec;
110
+ cues;
111
+ cueState;
112
+ speechQueue = [];
113
+ currentSpeech = null;
114
+ // Priority of the performance currently driving the mascot; only meaningful
115
+ // while one is actually playing (see effectivePriority()).
116
+ activePriority = PRIORITY.idle;
117
+ lastInteractionAt = 0;
118
+ /** Timestamps of recent pokes, for the "stop poking me" reaction. */
119
+ recentPokes = [];
120
+ speechTimer = null;
121
+ idleTimer = null;
122
+ idlingCueTimer = null;
123
+ /** Poll driving the system-idle gate; only armed when the gate is on. */
124
+ idlingTickTimer = null;
125
+ /** True while the user counts as away (OS idle past awaySec). Cleared by the
126
+ * first real input, which is also what triggers the wake performance. */
127
+ userAway = false;
128
+ /** The OS idle reading at which the next IdlingCue becomes eligible. Kept in
129
+ * idle-seconds rather than wall clock so that "ういちゃん just did
130
+ * something" and "the user is still sitting there quietly" compose into one
131
+ * number instead of two competing timers. */
132
+ idlingThresholdSec = 0;
133
+ /** Previous OS idle reading. The counter only climbs while the user is away
134
+ * from the keyboard, so a drop is the one unambiguous input signal. */
135
+ lastSystemIdleSec = 0;
136
+ sequenceHoldTimer = null;
137
+ sequenceActive = false;
138
+ affinity;
139
+ lastCueWarning = null;
140
+ /** throttle key → when that group last spoke. EventCue cooldowns live here
141
+ * (not in the hook) so every caller shares one clock. */
142
+ eventCueLastAt = new Map();
143
+ constructor(config, cues, emit, synthesize,
144
+ /** Seconds since the user last touched keyboard or mouse, OS-wide. Injected
145
+ * (Electron's powerMonitor in main.ts) so this class stays free of
146
+ * Electron; absent = the system-idle gate is simply off. */
147
+ systemIdleSec) {
148
+ this.config = config;
149
+ this.emit = emit;
150
+ this.synthesize = synthesize;
151
+ this.systemIdleSec = systemIdleSec;
152
+ this.cues = cues;
153
+ this.cueState = { cue: types_1.DEFAULT_CUE_NAME, agent: null };
154
+ this.affinity = this.clampAffinity(config.affinity?.default ?? 30);
155
+ this.scheduleIdlingCue();
156
+ }
157
+ /** Swap in a freshly (re)loaded Cue set (e.g. after a hot-reload) and
158
+ * re-render immediately so edits to the currently-worn Cue show up live.
159
+ * If the Cue currently being worn no longer exists in the new set, fall
160
+ * back to default rather than silently drawing default while claiming
161
+ * to still wear the deleted name (cues.ts always guarantees "default"
162
+ * itself survives, even as an empty Cue). */
163
+ setCues(cues) {
164
+ this.cues = cues;
165
+ if (!this.cues[this.cueState.cue]) {
166
+ this.lastCueWarning = `cue "${this.cueState.cue}" disappeared on reload — fell back to "${types_1.DEFAULT_CUE_NAME}".`;
167
+ this.setCueState(types_1.DEFAULT_CUE_NAME, this.cueState.agent);
168
+ }
169
+ this.applyVisual();
170
+ }
171
+ listCues() {
172
+ return Object.keys(this.cues);
173
+ }
174
+ isSpeaking() {
175
+ return this.currentSpeech !== null || this.speechQueue.length > 0;
176
+ }
177
+ isBusy() {
178
+ return this.isSpeaking() || this.sequenceActive;
179
+ }
180
+ /** The priority to beat in order to take over right now. While a performance
181
+ * is actually playing (speaking or running an IdlingCue/fidget sequence) it's
182
+ * that performance's priority; otherwise nothing is playing, so it's `idle`
183
+ * and anyone can start. No manual reset needed — it lapses when playback ends. */
184
+ effectivePriority() {
185
+ return this.currentSpeech !== null || this.sequenceActive ? this.activePriority : PRIORITY.idle;
186
+ }
187
+ clampAffinity(v) {
188
+ const min = this.config.affinity?.min ?? 0;
189
+ const max = this.config.affinity?.max ?? 100;
190
+ if (Number.isNaN(v))
191
+ return min;
192
+ return Math.min(Math.max(Math.round(v), min), max);
193
+ }
194
+ affinityBand() {
195
+ const bands = this.config.affinity?.bands ?? [];
196
+ let best = 'normal';
197
+ let bestAt = Number.NEGATIVE_INFINITY;
198
+ for (const b of bands) {
199
+ if (this.affinity >= b.atLeast && b.atLeast >= bestAt) {
200
+ best = b.name;
201
+ bestAt = b.atLeast;
202
+ }
203
+ }
204
+ return best;
205
+ }
206
+ affinitySnapshot() {
207
+ const threshold = this.config.affinity?.beamThreshold ?? 65;
208
+ return {
209
+ value: this.affinity,
210
+ band: this.affinityBand(),
211
+ beamReady: this.affinity >= threshold,
212
+ };
213
+ }
214
+ /**
215
+ * Affinity change engine.
216
+ *
217
+ * GOAL (the feel we're modelling): うい is a tsundere on a slow burn. Getting
218
+ * her to like you should be *earned* — the reward is the climb — while cooling
219
+ * off should be quick and cheap. Concretely:
220
+ * 1. Down is easier than up. (quick to lose)
221
+ * 2. Up must not spike; rising is gradual. (the reward is the grind)
222
+ * 3. Climbing *high* is especially hard. (dere/ういビーム = a real prize)
223
+ * 4. Falling back to 0 (her guarded neutral) is easy — she resets to cool.
224
+ * 5. No single call can move her far. (agent can't cheat to the top)
225
+ *
226
+ * DESIGN CHOICE: the agent must NOT hand us a raw number (it would just slam
227
+ * +100). It states only a DIRECTION (up/down) and a coarse MAGNITUDE
228
+ * (low/middle/high → base `b`, from config.affinity.steps). We compute the
229
+ * actual step from an asymmetric curve of the current value `a` (max `M`):
230
+ *
231
+ * up: Δ = +b · (1 − a/M) lower `a` ⇒ larger gain; as a→M the gain
232
+ * → 0, so the top is asymptotic and hard to
233
+ * reach. Max possible gain is `b` (at a=0),
234
+ * which caps a single call → satisfies (2)(3)(5).
235
+ *
236
+ * down: Δ = −b · (1 + a/M) higher `a` ⇒ bigger drop (a fall from grace
237
+ * hurts more the warmer she was); as a→0 the
238
+ * drop → −b and clamps at the floor, so she
239
+ * slides back to 0 readily → satisfies (1)(4).
240
+ *
241
+ * The two curves mirror each other around `b`: at any a>0, |down| > |up|, so
242
+ * down always outpaces up (1). `b` (config) is the single knob for overall
243
+ * pace — raise it to make the whole courtship faster. clampAffinity() rounds
244
+ * and bounds to [min,max]; near the top an `up` may round to 0 (intended — she
245
+ * won't budge further without more, bigger moments).
246
+ *
247
+ * NOTE: keep the math here, not in the AI-facing tool description / AFFINITY.md
248
+ * — the agent only needs "pick direction + magnitude", not the formula.
249
+ */
250
+ adjustAffinity(direction, magnitude) {
251
+ if (direction !== 'up' && direction !== 'down') {
252
+ return { ok: false, error: 'direction must be "up" or "down"' };
253
+ }
254
+ const steps = {
255
+ ...types_1.DEFAULT_AFFINITY_STEPS,
256
+ ...(this.config.affinity?.steps ?? {}),
257
+ };
258
+ const base = steps[magnitude];
259
+ if (typeof base !== 'number') {
260
+ return { ok: false, error: `magnitude must be one of ${Object.keys(steps).join('/')}` };
261
+ }
262
+ const max = this.config.affinity?.max ?? 100;
263
+ const frac = max > 0 ? this.affinity / max : 0;
264
+ const delta = direction === 'up' ? base * (1 - frac) : -base * (1 + frac);
265
+ const before = this.affinity;
266
+ this.affinity = this.clampAffinity(this.affinity + delta);
267
+ return {
268
+ ok: true,
269
+ affinity: this.affinity,
270
+ band: this.affinityBand(),
271
+ delta: this.affinity - before,
272
+ beamReady: this.affinitySnapshot().beamReady,
273
+ };
274
+ }
275
+ /** Debug: set affinity to an absolute value (clamped to config bounds). */
276
+ setAffinity(value) {
277
+ if (typeof value !== 'number' || Number.isNaN(value)) {
278
+ return { ok: false, error: 'value must be a number' };
279
+ }
280
+ const before = this.affinity;
281
+ this.affinity = this.clampAffinity(value);
282
+ return {
283
+ ok: true,
284
+ affinity: this.affinity,
285
+ band: this.affinityBand(),
286
+ delta: this.affinity - before,
287
+ beamReady: this.affinitySnapshot().beamReady,
288
+ };
289
+ }
290
+ /** The single visual+speech entry point. Cue and line are confirmed
291
+ * together in one call, so there is no window where the face and the
292
+ * voice disagree about which Cue is "current". */
293
+ setCue(args, agent) {
294
+ // The debug console (agent "debug") is the dev forcing things and outranks
295
+ // everything; a normal agent's set_cue yields to a playing fidget reaction.
296
+ const pri = agent === 'debug' ? PRIORITY.debug : PRIORITY.mcp;
297
+ if (pri < this.effectivePriority()) {
298
+ return { ok: true, cue: args.cue, note: 'suppressed: a higher-priority reaction is playing' };
299
+ }
300
+ this.activePriority = pri;
301
+ this.cancelSequence();
302
+ let cueName = args.cue;
303
+ let note;
304
+ if (!this.cues[cueName]) {
305
+ note = `unknown cue "${cueName}" — fell back to "${types_1.DEFAULT_CUE_NAME}". Use get_state to list available cues.`;
306
+ cueName = types_1.DEFAULT_CUE_NAME;
307
+ }
308
+ this.lastCueWarning = note ?? null;
309
+ this.applyCueLook(cueName, agent);
310
+ if (args.text) {
311
+ const speechResult = this.enqueueSpeech(args.text, cueName, agent, {
312
+ durationMs: args.duration_ms,
313
+ reading: args.reading,
314
+ voice: pickAdlib(args),
315
+ });
316
+ this.scheduleIdleRevert();
317
+ if (!speechResult.ok) {
318
+ // The Cue switch above already happened (and stays), even though the
319
+ // line itself couldn't be queued — say so, or a retry reads as a
320
+ // silent no-op and an agent may re-apply the same Cue pointlessly.
321
+ return { ok: false, error: `${speechResult.error} (cue "${cueName}" was applied)` };
322
+ }
323
+ return note ? { ...speechResult, cue: cueName, note } : { ...speechResult, cue: cueName };
324
+ }
325
+ this.holdVisual(args.duration_ms);
326
+ return note ? { ok: true, cue: cueName, note } : { ok: true, cue: cueName };
327
+ }
328
+ setCueState(cue, agent) {
329
+ this.cueState = { cue, agent };
330
+ }
331
+ /** Switch to a Cue and render it — the visual half of "apply a Cue",
332
+ * shared by set_cue and every CueSequence step. */
333
+ applyCueLook(cueName, agent) {
334
+ this.setCueState(cueName, agent);
335
+ this.applyVisual();
336
+ }
337
+ /** Queue a line without touching the idle timers (used by CueSequence steps).
338
+ * Duration resolution happens in exactly one place: an explicit `durationMs`
339
+ * wins, otherwise `estimateSpeechDurationMs` (LEN(text)) — later refined
340
+ * again in `startSpeech()` once real TTS audio length is known. Every
341
+ * caller that needs to know when this line is actually done (not just
342
+ * "queued") passes `onComplete`, invoked once, whether the line played or
343
+ * the queue was full. */
344
+ enqueueSpeech(text, cue, agent, opts) {
345
+ const { durationMs, reading, voice, onComplete } = opts ?? {};
346
+ if (this.speechQueue.length >= MAX_QUEUE) {
347
+ onComplete?.(); // don't strand a caller waiting on a line that never got queued
348
+ return { ok: false, error: `speech queue is full (${MAX_QUEUE})` };
349
+ }
350
+ const duration = durationMs ?? estimateSpeechDurationMs(text, this.config.speech);
351
+ this.speechQueue.push({ text, durationMs: duration, agent, reading, voice, cue, onComplete });
352
+ const immediate = this.currentSpeech === null;
353
+ this.pumpSpeech();
354
+ return {
355
+ ok: true,
356
+ displayed: immediate,
357
+ queue_length: this.speechQueue.length + (this.currentSpeech && !immediate ? 1 : 0),
358
+ };
359
+ }
360
+ pumpSpeech() {
361
+ if (this.currentSpeech !== null)
362
+ return;
363
+ const next = this.speechQueue.shift();
364
+ if (!next)
365
+ return;
366
+ this.currentSpeech = next;
367
+ void this.startSpeech(next);
368
+ }
369
+ async startSpeech(item) {
370
+ let audio = null;
371
+ if (this.synthesize) {
372
+ const spoken = ttsTextFor(item.text, item.reading);
373
+ audio = await this.synthesize(spoken, item.cue, item.voice).catch(() => null);
374
+ }
375
+ if (this.currentSpeech !== item)
376
+ return; // cleared while synthesizing
377
+ const speech = this.config.speech;
378
+ const durationMs = audio
379
+ ? Math.max(audio.durationMs + (speech?.audioPaddingMs ?? 600), speech?.audioMinMs ?? 1500)
380
+ : item.durationMs;
381
+ this.emit({
382
+ type: 'speech',
383
+ text: item.text,
384
+ reading: item.reading ?? null,
385
+ audio,
386
+ });
387
+ this.speechTimer = setTimeout(() => {
388
+ this.currentSpeech = null;
389
+ this.speechTimer = null;
390
+ this.emit({ type: 'speech', text: null });
391
+ // restore the Cue's mouth after lip sync
392
+ this.applyVisual();
393
+ item.onComplete?.();
394
+ this.pumpSpeech();
395
+ this.scheduleIdleRevert();
396
+ }, durationMs);
397
+ }
398
+ clear() {
399
+ this.speechTimer = clearTimeoutSafe(this.speechTimer);
400
+ this.idleTimer = clearTimeoutSafe(this.idleTimer);
401
+ this.cancelSequence();
402
+ this.speechQueue = [];
403
+ this.currentSpeech = null;
404
+ this.applyCueLook(types_1.DEFAULT_CUE_NAME, null);
405
+ this.emit({ type: 'speech', text: null });
406
+ // clear() is itself activity: without this, the IdlingCue timer left
407
+ // over from before the clear could still fire on its old schedule.
408
+ this.scheduleIdlingCue();
409
+ return { ok: true };
410
+ }
411
+ /** Assert a freshly-set Cue and let it STICK: cancel any pending revert so
412
+ * it can't be yanked back, and postpone IdlingCues. Unlike
413
+ * scheduleIdleRevert it does NOT arm a new revert by default — a silent
414
+ * set_cue holds until the next spoken line ends (or idle takes over) —
415
+ * unless overrideHoldMs is given (set_cue's duration_ms with no text),
416
+ * in which case it eases back to default after that many ms. */
417
+ holdVisual(overrideHoldMs) {
418
+ this.scheduleIdlingCue();
419
+ this.idleTimer = clearTimeoutSafe(this.idleTimer);
420
+ if (overrideHoldMs && overrideHoldMs > 0) {
421
+ this.idleTimer = setTimeout(() => {
422
+ this.idleTimer = null;
423
+ if (this.isSpeaking())
424
+ return;
425
+ this.revertToDefault();
426
+ }, overrideHoldMs);
427
+ }
428
+ }
429
+ /** (Re)start the idle countdown. Fires only when nothing is being said; any activity postpones it. */
430
+ scheduleIdleRevert() {
431
+ this.scheduleIdlingCue();
432
+ this.idleTimer = clearTimeoutSafe(this.idleTimer);
433
+ // A playing sequence governs its own lifetime — don't yank it back early.
434
+ if (this.sequenceActive)
435
+ return;
436
+ const sec = this.config.idle?.revertAfterSec ?? 0;
437
+ if (sec <= 0)
438
+ return;
439
+ this.idleTimer = setTimeout(() => {
440
+ this.idleTimer = null;
441
+ // still talking (or queued) — the speech-end handler reschedules
442
+ if (this.isSpeaking())
443
+ return;
444
+ this.revertToDefault();
445
+ }, sec * 1000);
446
+ }
447
+ /** Tear down whatever is playing — speech, queue, in-flight audio/bubble and
448
+ * any running sequence — so a reaction can start from a clean slate. The
449
+ * caller is responsible for having earned the interruption (see
450
+ * effectivePriority). */
451
+ preempt() {
452
+ this.speechTimer = clearTimeoutSafe(this.speechTimer);
453
+ this.speechQueue = [];
454
+ this.currentSpeech = null;
455
+ this.emit({ type: 'speech', text: null });
456
+ this.cancelSequence();
457
+ }
458
+ /** Cancel any playing CueSequence (called when real activity happens). */
459
+ cancelSequence() {
460
+ this.sequenceHoldTimer = clearTimeoutSafe(this.sequenceHoldTimer);
461
+ this.sequenceActive = false;
462
+ }
463
+ /** Filter a CueSequence pool (IdlingCues or FidgetCues) by the current
464
+ * affinity and time-of-day gates. Shared by idle scheduling and the fidget
465
+ * so both compose with affinity the same way. */
466
+ eligible(items) {
467
+ const hour = new Date().getHours();
468
+ return items.filter((item) => (item.minAffinity === undefined ? true : this.affinity >= item.minAffinity) &&
469
+ (item.maxAffinity === undefined ? true : this.affinity <= item.maxAffinity) &&
470
+ hourInWindow(hour, item.hours));
471
+ }
472
+ eligibleIdlingCues() {
473
+ return this.eligible(this.config.idle?.idlingCues?.items ?? []);
474
+ }
475
+ /** A direct physical interaction (the fidget). FidgetCues PREEMPT whatever's
476
+ * playing — a poked ういちゃん cuts off her current line to react right now,
477
+ * which is what makes touch feel alive — then ease back to default. Gated by
478
+ * a cooldown so mashing doesn't spam interruptions, and the pool is
479
+ * affinity-colored (cold brushes you off, warm gets flustered). */
480
+ onInteraction(kind) {
481
+ if (kind !== 'hover' && kind !== 'poke')
482
+ return;
483
+ // Yields only to debug; preempts MCP and idling.
484
+ if (PRIORITY.fidget < this.effectivePriority())
485
+ return;
486
+ const cfg = this.config.interactions;
487
+ const now = Date.now();
488
+ // Every poke is remembered, including the ones swallowed by the cooldown —
489
+ // mashing is exactly what we want to notice, and the cooldown is there to
490
+ // stop the *reactions* piling up, not to forgive the prodding.
491
+ const spam = cfg?.spam;
492
+ const window = spam?.withinMs ?? 4000;
493
+ this.recentPokes = this.recentPokes.filter((t) => now - t <= window);
494
+ this.recentPokes.push(now);
495
+ if (now - this.lastInteractionAt < (cfg?.cooldownMs ?? 600))
496
+ return;
497
+ const pestered = this.recentPokes.length >= (spam?.count ?? 3);
498
+ const pool = pestered ? (spam?.pool ?? []) : [];
499
+ const item = weightedPick(this.eligible(pool)) ?? weightedPick(this.eligible(cfg?.poke ?? []));
500
+ if (!item?.steps?.length)
501
+ return;
502
+ // Reacting to the pestering resets the tally, so she snaps once and then
503
+ // has to be pestered again — not once per poke forever.
504
+ if (pestered)
505
+ this.recentPokes = [];
506
+ this.lastInteractionAt = now;
507
+ this.preempt();
508
+ this.performSequence(item, 'interaction');
509
+ }
510
+ /** The system-idle gate's config, or null when it's off (disabled, or no
511
+ * idle-time source was injected). */
512
+ systemIdleGate() {
513
+ const gate = this.config.idle?.idlingCues?.systemIdle;
514
+ if (!gate?.enabled || !this.systemIdleSec)
515
+ return null;
516
+ return gate;
517
+ }
518
+ readSystemIdleSec() {
519
+ const sec = this.systemIdleSec?.() ?? 0;
520
+ return Number.isFinite(sec) && sec > 0 ? sec : 0;
521
+ }
522
+ /** (Re)start the Idling countdown. On a lull it occasionally plays one
523
+ * eligible IdlingCue (silent ambient motion or a speaking bit — same
524
+ * mechanism) so the desk has some life. Reschedules itself each time.
525
+ *
526
+ * Two modes, same pool. Ungated, the countdown measures wall time since
527
+ * ういちゃん last did anything. Gated (systemIdle), it measures the *user's*
528
+ * OS idle time instead, and this call just pushes the bar further out along
529
+ * that axis — see tickIdling for the window it lives in. */
530
+ scheduleIdlingCue() {
531
+ this.idlingCueTimer = clearTimeoutSafe(this.idlingCueTimer);
532
+ const idlingCues = this.config.idle?.idlingCues;
533
+ if (!idlingCues?.enabled || !idlingCues.items?.length)
534
+ return;
535
+ if (this.systemIdleGate()) {
536
+ this.idlingThresholdSec =
537
+ this.readSystemIdleSec() + randomDelaySec(idlingCues.minSec, idlingCues.maxSec);
538
+ this.startIdlingTick();
539
+ return;
540
+ }
541
+ this.idlingCueTimer = setTimeout(() => {
542
+ this.idlingCueTimer = null;
543
+ // busy (talking, queued, or already playing one) — wait for the next lull
544
+ if (this.isBusy()) {
545
+ this.scheduleIdlingCue();
546
+ return;
547
+ }
548
+ const eligible = this.eligibleIdlingCues();
549
+ if (eligible.length === 0) {
550
+ this.scheduleIdlingCue();
551
+ return;
552
+ }
553
+ const item = weightedPick(eligible);
554
+ if (item)
555
+ this.performSequence(item, 'idling-cue');
556
+ }, randomDelayMs(idlingCues.minSec, idlingCues.maxSec));
557
+ }
558
+ startIdlingTick() {
559
+ if (this.idlingTickTimer)
560
+ return;
561
+ this.idlingTickTimer = setInterval(() => this.tickIdling(), SYSTEM_IDLE_POLL_MS);
562
+ this.lastSystemIdleSec = this.readSystemIdleSec();
563
+ }
564
+ /** The system-idle gate, once per second.
565
+ *
566
+ * OS idle time `t` is read as a window rather than a threshold:
567
+ *
568
+ * t < minSec the user is working — stay out of the way
569
+ * minSec ≤ t < awaySec their hands are off the keys — this is the moment
570
+ * t ≥ awaySec nobody's there — nod off and stay quiet
571
+ *
572
+ * and the drop in `t` on the way back is what wakes her up. The lower bound
573
+ * alone would still leave her performing to an empty desk, which is the
574
+ * failure the away half exists for. */
575
+ tickIdling() {
576
+ const gate = this.systemIdleGate();
577
+ const idlingCues = this.config.idle?.idlingCues;
578
+ if (!gate || !idlingCues?.enabled || !idlingCues.items?.length) {
579
+ this.idlingTickTimer = clearIntervalSafe(this.idlingTickTimer);
580
+ return;
581
+ }
582
+ const idleSec = this.readSystemIdleSec();
583
+ const dropped = idleSec < this.lastSystemIdleSec;
584
+ this.lastSystemIdleSec = idleSec;
585
+ if (dropped)
586
+ this.onUserActivity(gate);
587
+ // Away is sticky: only real input (above) clears it, so she sleeps through
588
+ // the whole absence instead of waking every gap.
589
+ if (this.userAway)
590
+ return;
591
+ const awaySec = gate.awaySec ?? 0;
592
+ if (awaySec > 0 && idleSec >= awaySec) {
593
+ // Hold the transition until she's actually free: flipping the (sticky)
594
+ // flag mid-line would drop the nodding-off performance on the floor and
595
+ // never retry it, leaving her silently "away" with no visible reason.
596
+ if (this.isBusy())
597
+ return;
598
+ this.userAway = true;
599
+ if (gate.awayCue)
600
+ this.performSequence(gate.awayCue, 'idling-cue');
601
+ return;
602
+ }
603
+ if (idleSec < this.idlingThresholdSec)
604
+ return;
605
+ // Past the bar but mid-performance: hold the bar and take the next lull.
606
+ if (this.isBusy())
607
+ return;
608
+ const eligible = this.eligibleIdlingCues();
609
+ if (eligible.length === 0)
610
+ return;
611
+ const item = weightedPick(eligible);
612
+ // The sequence's own end rearms the threshold via scheduleIdlingCue().
613
+ if (item)
614
+ this.performSequence(item, 'idling-cue');
615
+ }
616
+ /** The user touched something. Wake her if she'd nodded off, and re-arm the
617
+ * bar at the *short* first gap: the interesting moment is the one just after
618
+ * their hands stop, not two silent minutes later. */
619
+ onUserActivity(gate) {
620
+ if (this.userAway) {
621
+ this.userAway = false;
622
+ // Waking up has to cut in: the thing it's interrupting is her own doze,
623
+ // and a wake-up that waits politely for the snoring to finish isn't a
624
+ // reaction to anything. It still yields to the agent — effectivePriority
625
+ // is only `idle` when nothing above idle filler is playing.
626
+ if (gate.wakeCue && this.effectivePriority() <= PRIORITY.idle) {
627
+ this.preempt();
628
+ this.performSequence(gate.wakeCue, 'idling-cue');
629
+ }
630
+ }
631
+ this.idlingThresholdSec = randomDelaySec(gate.minSec, gate.firstMaxSec ?? gate.minSec);
632
+ }
633
+ /** Play a CueSequence: Cue(+line) steps that move together, then ease back to
634
+ * default. Used for both IdlingCues and FidgetCues — `source`
635
+ * ('idling-cue' | 'interaction' | 'debug') sets the priority and tags
636
+ * `cue.agent` / speech `agent`. Any higher-priority activity cancels it. */
637
+ performSequence(cue, source) {
638
+ if (!cue.steps?.length)
639
+ return;
640
+ this.activePriority =
641
+ source === 'interaction'
642
+ ? PRIORITY.fidget
643
+ : source === 'debug'
644
+ ? PRIORITY.debug
645
+ : source === 'event'
646
+ ? PRIORITY.event
647
+ : PRIORITY.idle;
648
+ this.idleTimer = clearTimeoutSafe(this.idleTimer);
649
+ this.sequenceActive = true;
650
+ this.playSequenceStep(cue.steps, 0, source);
651
+ }
652
+ /** Advance one step of a CueSequence. A step with `text` waits for that
653
+ * exact line to actually finish (real TTS duration if synthesized,
654
+ * otherwise LEN(text)) before moving on — `holdMs` is not used for a
655
+ * speaking step, so there is no separately-guessed number that can drift
656
+ * out of sync with what's really being said. A silent step (no `text`)
657
+ * has nothing to wait for, so `holdMs` (default 2000) is what times it. */
658
+ playSequenceStep(steps, i, source) {
659
+ // cancelled by real activity while waiting between steps
660
+ if (!this.sequenceActive)
661
+ return;
662
+ if (i >= steps.length) {
663
+ this.sequenceActive = false;
664
+ // don't cut off a line that's still being read
665
+ if (!this.isSpeaking())
666
+ this.revertToDefault();
667
+ this.scheduleIdlingCue();
668
+ return;
669
+ }
670
+ const step = steps[i];
671
+ const cueName = step.cue && this.cues[step.cue] ? step.cue : this.cueState.cue;
672
+ const advance = () => this.playSequenceStep(steps, i + 1, source);
673
+ this.applyCueLook(cueName, source);
674
+ if (step.text) {
675
+ this.enqueueSpeech(step.text, cueName, source, {
676
+ reading: step.reading,
677
+ onComplete: advance,
678
+ });
679
+ }
680
+ else {
681
+ this.sequenceHoldTimer = clearTimeoutSafe(this.sequenceHoldTimer);
682
+ this.sequenceHoldTimer = setTimeout(advance, step.holdMs ?? 2000);
683
+ }
684
+ }
685
+ /** Ease back to the default Cue, dropping any hold. */
686
+ revertToDefault() {
687
+ this.applyCueLook(types_1.DEFAULT_CUE_NAME, null);
688
+ }
689
+ /** default Cue's directives, then the currently-selected Cue's directives
690
+ * layered on top (later entries win on shared radio groups) — a Cue is the
691
+ * only thing ever composited on top of the shared base. blink falls back
692
+ * to default's own blink when the selected Cue doesn't say either way, so
693
+ * a new Cue file that simply omits `blink` inherits the base look's blink
694
+ * behavior instead of silently going blink-less. */
695
+ composeDirectives(cueName) {
696
+ const base = this.cues[types_1.DEFAULT_CUE_NAME] ?? {};
697
+ const cue = this.cues[cueName] ?? base;
698
+ return {
699
+ directives: {
700
+ select: [...(base.select ?? []), ...(cue.select ?? [])],
701
+ show: [...(base.show ?? []), ...(cue.show ?? [])],
702
+ hide: [...(base.hide ?? []), ...(cue.hide ?? [])],
703
+ },
704
+ blink: cue.blink ?? base.blink ?? false,
705
+ };
706
+ }
707
+ applyVisual() {
708
+ const { directives, blink } = this.composeDirectives(this.cueState.cue);
709
+ this.emit({ type: 'apply', directives, blink });
710
+ }
711
+ /** Fire the EventCue pool for `event` — something happened in the session
712
+ * around her (a command failed, a subagent returned, she's waiting on the
713
+ * user) and she may have something to say about it.
714
+ *
715
+ * All of the judgement lives here rather than in the caller: whether the
716
+ * pool exists, whether this kind of line is on cooldown, whether the dice
717
+ * say to stay quiet, and which of the eligible lines to use. A hook only
718
+ * has to name the event, so every trigger (hooks, the debug console, a
719
+ * future in-app source) throttles against the same clock and honours the
720
+ * same affinity gates.
721
+ *
722
+ * `force` (the debug console) skips the cooldown and the chance roll, and
723
+ * does not start a cooldown of its own, so previewing a line never silences
724
+ * the next real event. Affinity and time gates still apply, so what you see
725
+ * is a line that could genuinely play right now. */
726
+ fireEventCue(event, opts = {}) {
727
+ const cfg = this.config.eventCues;
728
+ if (cfg?.enabled === false)
729
+ return { ok: true, spoke: false, reason: 'eventCues disabled' };
730
+ const group = cfg?.events?.[event];
731
+ if (!group) {
732
+ const known = Object.keys(cfg?.events ?? {});
733
+ return {
734
+ ok: false,
735
+ error: `unknown event "${event}". known: ${known.join(', ') || '(none)'}`,
736
+ };
737
+ }
738
+ const key = group.throttleKey ?? event;
739
+ const cooldownMs = (group.cooldownSec ?? 90) * 1000;
740
+ const lastAt = this.eventCueLastAt.get(key);
741
+ if (!opts.force && lastAt !== undefined && Date.now() - lastAt < cooldownMs) {
742
+ return { ok: true, spoke: false, reason: 'cooldown' };
743
+ }
744
+ if (!opts.force && Math.random() >= (group.chance ?? 1)) {
745
+ return { ok: true, spoke: false, reason: 'chance' };
746
+ }
747
+ const item = weightedPick(this.eligible(group.items ?? []));
748
+ if (!item?.steps?.length)
749
+ return { ok: true, spoke: false, reason: 'no eligible EventCue' };
750
+ // An EventCue reports something real, so it outranks idle filler — but it
751
+ // must never talk over the agent or a poke.
752
+ if (PRIORITY.event < this.effectivePriority()) {
753
+ return { ok: true, spoke: false, reason: 'busy' };
754
+ }
755
+ // A forced preview must not consume the real cooldown, or checking a line
756
+ // in the debug console would silence the next genuine event.
757
+ if (!opts.force)
758
+ this.eventCueLastAt.set(key, Date.now());
759
+ this.cancelSequence();
760
+ this.performSequence(item, opts.force ? 'debug' : 'event');
761
+ return { ok: true, spoke: true, name: item.name };
762
+ }
763
+ /** Debug: list the configured EventCue pools. */
764
+ listEventCues() {
765
+ const cfg = this.config.eventCues;
766
+ return {
767
+ enabled: cfg?.enabled !== false,
768
+ events: Object.entries(cfg?.events ?? {}).map(([event, group]) => ({
769
+ event,
770
+ count: group.items?.length ?? 0,
771
+ cooldownSec: group.cooldownSec ?? 90,
772
+ chance: group.chance ?? 1,
773
+ names: (group.items ?? []).map((i) => i.name).filter((n) => Boolean(n)),
774
+ })),
775
+ };
776
+ }
777
+ /** Every sequence `idle <name>` can force, including the two the system-idle
778
+ * gate owns. Those are reachable by name but never by the random pick —
779
+ * otherwise nodding off would show up mid-session as ordinary filler. They
780
+ * need to be forceable because the real triggers are 15 minutes away, and a
781
+ * performance you can only see by waiting a quarter hour never gets looked
782
+ * at. */
783
+ namedIdleSequences() {
784
+ const idlingCues = this.config.idle?.idlingCues;
785
+ const gate = idlingCues?.systemIdle;
786
+ return [...(idlingCues?.items ?? []), gate?.awayCue, gate?.wakeCue].filter((a) => !!a);
787
+ }
788
+ /** Debug: force-run one IdlingCue immediately (or the named one).
789
+ * Honors minAffinity/weight filters unless a specific name is requested. */
790
+ triggerIdleAction(name) {
791
+ const idlingCues = this.config.idle?.idlingCues;
792
+ if (!idlingCues?.enabled || !idlingCues.items?.length) {
793
+ return { ok: false, error: 'idlingCues are disabled or empty' };
794
+ }
795
+ let item;
796
+ if (name) {
797
+ const pool = this.namedIdleSequences();
798
+ item = pool.find((a) => a.name === name);
799
+ if (!item) {
800
+ const names = pool.map((a) => a.name).filter(Boolean);
801
+ return { ok: false, error: `unknown idlingCue "${name}". known: ${names.join(', ')}` };
802
+ }
803
+ }
804
+ item ??= weightedPick(this.eligibleIdlingCues());
805
+ if (!item?.steps?.length) {
806
+ return { ok: false, error: 'selected idlingCue has no steps' };
807
+ }
808
+ this.cancelSequence();
809
+ this.performSequence(item, 'debug');
810
+ this.scheduleIdlingCue();
811
+ return { ok: true, name: item.name };
812
+ }
813
+ /** Debug: list configured IdlingCues. */
814
+ listIdle() {
815
+ return {
816
+ idlingCues: this.namedIdleSequences().map((a) => ({
817
+ name: a.name,
818
+ stepCount: a.steps?.length ?? 0,
819
+ weight: a.weight,
820
+ minAffinity: a.minAffinity,
821
+ maxAffinity: a.maxAffinity,
822
+ })),
823
+ };
824
+ }
825
+ /** Debug: preview the directives a Cue would produce without wearing it.
826
+ * `select` is deduped to only the winning entry per radio group (the same
827
+ * last-wins rule the renderer applies) so the output shows what actually
828
+ * ends up on screen instead of the raw base+Cue concatenation. */
829
+ previewCue(cueName) {
830
+ const exists = Boolean(this.cues[cueName]);
831
+ const targetCue = exists ? cueName : types_1.DEFAULT_CUE_NAME;
832
+ const { directives, blink } = this.composeDirectives(targetCue);
833
+ return {
834
+ cue: cueName,
835
+ exists,
836
+ fallback: exists ? null : `unknown cue "${cueName}" — previewing default`,
837
+ directives: { ...directives, select: dedupeSelectsByGroup(directives.select ?? []) },
838
+ blink,
839
+ };
840
+ }
841
+ snapshot() {
842
+ return {
843
+ cue: { ...this.cueState },
844
+ currentSpeech: this.currentSpeech,
845
+ speechQueue: [...this.speechQueue],
846
+ cueWarning: this.lastCueWarning,
847
+ };
848
+ }
849
+ }
850
+ exports.UiChanState = UiChanState;