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.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +6 -0
- package/.env.example +13 -0
- package/LICENSE +36 -0
- package/README.md +214 -0
- package/agents/mode.md +47 -0
- package/agents/talk.md +23 -0
- package/bin/ui-chan-mcp.mjs +11 -0
- package/bin/ui-chan-node +35 -0
- package/bin/ui-chan.mjs +350 -0
- package/context/AFFINITY.md +94 -0
- package/context/SOUL.md +79 -0
- package/context/VOCABULARY.md +108 -0
- package/cue.schema.json +109 -0
- package/cues/default.json +21 -0
- package/cues/emo_anger.json +14 -0
- package/cues/emo_anger_hi.json +19 -0
- package/cues/emo_anger_lo.json +16 -0
- package/cues/emo_antic.json +18 -0
- package/cues/emo_antic_hi.json +16 -0
- package/cues/emo_antic_lo.json +6 -0
- package/cues/emo_disgust.json +19 -0
- package/cues/emo_disgust_hi.json +20 -0
- package/cues/emo_disgust_lo.json +6 -0
- package/cues/emo_fear.json +19 -0
- package/cues/emo_fear_hi.json +19 -0
- package/cues/emo_fear_lo.json +19 -0
- package/cues/emo_joy.json +17 -0
- package/cues/emo_joy_hi.json +15 -0
- package/cues/emo_joy_lo.json +13 -0
- package/cues/emo_sad.json +15 -0
- package/cues/emo_sad_hi.json +18 -0
- package/cues/emo_sad_lo.json +14 -0
- package/cues/emo_surprise.json +15 -0
- package/cues/emo_surprise_hi.json +14 -0
- package/cues/emo_surprise_lo.json +11 -0
- package/cues/emo_trust.json +16 -0
- package/cues/emo_trust_hi.json +20 -0
- package/cues/emo_trust_lo.json +6 -0
- package/cues/idling_doze_1.json +9 -0
- package/cues/idling_doze_2.json +9 -0
- package/cues/idling_doze_3.json +9 -0
- package/cues/idling_giggle_1.json +9 -0
- package/cues/idling_giggle_2.json +9 -0
- package/cues/idling_lookaround_1.json +10 -0
- package/cues/idling_lookaround_2.json +10 -0
- package/cues/idling_lookaround_3.json +10 -0
- package/cues/idling_ponder.json +9 -0
- package/cues/idling_sigh_1.json +11 -0
- package/cues/idling_sigh_2.json +11 -0
- package/cues/idling_yawn_1.json +9 -0
- package/cues/idling_yawn_2.json +12 -0
- package/cues/idling_yawn_3.json +12 -0
- package/cues/mix_anger_antic.json +16 -0
- package/cues/mix_antic_joy.json +16 -0
- package/cues/mix_disgust_anger.json +17 -0
- package/cues/mix_fear_surprise.json +18 -0
- package/cues/mix_joy_trust.json +19 -0
- package/cues/mix_joy_trust_hi.json +18 -0
- package/cues/mix_sad_disgust.json +12 -0
- package/cues/mix_surprise_sad.json +21 -0
- package/cues/mix_surprise_sad_hi.json +14 -0
- package/cues/mix_trust_fear.json +18 -0
- package/cues/pose_arms_crossed.json +9 -0
- package/cues/pose_banzai.json +17 -0
- package/cues/pose_smug_arms.json +11 -0
- package/cues/pose_smug_hips.json +11 -0
- package/cues/pose_think.json +8 -0
- package/cues/pose_umbrella.json +9 -0
- package/cues/self_guilt.json +20 -0
- package/cues/self_pride.json +10 -0
- package/cues/self_shame.json +19 -0
- package/cues/self_shy.json +16 -0
- package/cues/self_shy_hi.json +17 -0
- package/cues/sys_address.json +9 -0
- package/cues/sys_awkward.json +18 -0
- package/cues/sys_beam.json +18 -0
- package/cues/sys_blank.json +11 -0
- package/cues/sys_care.json +18 -0
- package/cues/sys_confused.json +12 -0
- package/cues/sys_dazed.json +10 -0
- package/cues/sys_dread.json +21 -0
- package/cues/sys_fluster.json +20 -0
- package/cues/sys_intro.json +15 -0
- package/cues/sys_laugh.json +20 -0
- package/cues/sys_neutral.json +5 -0
- package/cues/sys_present.json +9 -0
- package/cues/sys_rain.json +15 -0
- package/cues/sys_refuse.json +18 -0
- package/cues/sys_relief.json +15 -0
- package/cues/sys_sleepy.json +8 -0
- package/cues/sys_smirk.json +10 -0
- package/cues/sys_stream.json +15 -0
- package/cues/sys_success.json +15 -0
- package/cues/sys_think.json +9 -0
- package/dist/app/assets.js +54 -0
- package/dist/app/cues.js +130 -0
- package/dist/app/editor-main.js +199 -0
- package/dist/app/editor-preload.js +17 -0
- package/dist/app/main.js +527 -0
- package/dist/app/persona.js +136 -0
- package/dist/app/preload.js +14 -0
- package/dist/app/state.js +850 -0
- package/dist/app/tts.js +257 -0
- package/dist/mcp-server.js +408 -0
- package/dist/renderer/bundle.js +17983 -0
- package/dist/renderer/editor.html +150 -0
- package/dist/renderer/editor.js +17917 -0
- package/dist/renderer/index.html +425 -0
- package/dist/shared/paths.js +169 -0
- package/dist/shared/set-cue-schema.js +66 -0
- package/dist/shared/types.js +14 -0
- package/docs/CLIENTS.md +110 -0
- package/docs/CUE_AUTHORING.md +76 -0
- package/docs/DEVELOPMENT.md +129 -0
- package/docs/PERSONA.md +61 -0
- package/docs/PSD_LAYERS.md +123 -0
- package/docs/README.md +43 -0
- package/docs/SETUP.html +684 -0
- package/docs/STYLE.md +107 -0
- package/docs/TOOLS.md +33 -0
- package/docs/TROUBLESHOOTING.md +122 -0
- package/docs/TTS.md +61 -0
- package/docs/design/CUE_CATALOG.md +259 -0
- package/docs/images/faces.png +0 -0
- package/docs/images/panel.png +0 -0
- package/hooks/fire-event.js +14 -0
- package/hooks/hooks.json +77 -0
- package/hooks/lib/mascot.js +76 -0
- package/hooks/notify.js +15 -0
- package/hooks/reaction.js +46 -0
- package/hooks/session-start.js +80 -0
- package/package.json +96 -0
- package/persona/ui-chan.md +111 -0
- package/plugins/hermes/ui-chan/__init__.py +98 -0
- package/plugins/hermes/ui-chan/plugin.yaml +7 -0
- package/plugins/opencode/ui-chan.mjs +105 -0
- package/skills/beam/SKILL.md +37 -0
- package/skills/eli14/SKILL.md +139 -0
- package/skills/eli14/references/base.html +495 -0
- package/skills/mode/SKILL.md +67 -0
- package/skills/talk/SKILL.md +27 -0
- package/tools/setup/check-package.mjs +47 -0
- package/tools/setup/clients.mjs +399 -0
- package/tools/setup/doctor.mjs +105 -0
- package/tools/setup/home.mjs +126 -0
- package/tools/setup/prompt.mjs +134 -0
- package/tools/setup/update-check.mjs +8 -0
- package/tools/setup/update.mjs +350 -0
- package/tools/stop-app.mjs +22 -0
- 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;
|