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
package/dist/app/tts.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VoiSonaTalkClient = void 0;
|
|
4
|
+
exports.buildTimeline = buildTimeline;
|
|
5
|
+
const RETRY_COOLDOWN_MS = 60_000;
|
|
6
|
+
/** The engine simply not being up yet is a transient, self-healing condition
|
|
7
|
+
* (the MCP bridge relaunches VoiSona Talk), so it must not silence the mascot
|
|
8
|
+
* for a full minute the way a real synthesis error does. */
|
|
9
|
+
const UNREACHABLE_COOLDOWN_MS = 5_000;
|
|
10
|
+
const SYNTH_TIMEOUT_MS = 20_000;
|
|
11
|
+
const POLL_INTERVAL_MS = 150;
|
|
12
|
+
const VOWELS = new Set(['a', 'i', 'u', 'e', 'o']);
|
|
13
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
/**
|
|
15
|
+
* Build lip sync frames from VoiSona's phoneme timing. Consonants open the
|
|
16
|
+
* mouth into the following vowel's shape at the consonant's start; silence,
|
|
17
|
+
* N and cl close it.
|
|
18
|
+
*/
|
|
19
|
+
function buildTimeline(phonemes, durations) {
|
|
20
|
+
const frames = [];
|
|
21
|
+
let t = 0;
|
|
22
|
+
let pendingStart = null;
|
|
23
|
+
for (let i = 0; i < phonemes.length; i++) {
|
|
24
|
+
const p = phonemes[i];
|
|
25
|
+
const d = (durations[i] ?? 0.05) * 1000;
|
|
26
|
+
if (VOWELS.has(p.toLowerCase())) {
|
|
27
|
+
frames.push({ t: pendingStart ?? t, v: p.toLowerCase() });
|
|
28
|
+
pendingStart = null;
|
|
29
|
+
}
|
|
30
|
+
else if (p === 'sil' || p === 'pau' || p === 'N' || p === 'cl') {
|
|
31
|
+
frames.push({ t, v: 'n' });
|
|
32
|
+
pendingStart = null;
|
|
33
|
+
}
|
|
34
|
+
else if (pendingStart === null) {
|
|
35
|
+
pendingStart = t;
|
|
36
|
+
}
|
|
37
|
+
t += d;
|
|
38
|
+
}
|
|
39
|
+
// Collapse consecutive frames with the same mouth shape (e.g. several
|
|
40
|
+
// vowels/consonants in a row that all resolve to the same viseme) down to
|
|
41
|
+
// their first occurrence — the renderer only needs to know when the mouth
|
|
42
|
+
// shape *changes*.
|
|
43
|
+
const deduped = [];
|
|
44
|
+
for (const frame of frames) {
|
|
45
|
+
if (deduped.length === 0 || deduped[deduped.length - 1].v !== frame.v)
|
|
46
|
+
deduped.push(frame);
|
|
47
|
+
}
|
|
48
|
+
return deduped;
|
|
49
|
+
}
|
|
50
|
+
/** Tell "the engine isn't up" apart from a genuine synthesis failure: a dead
|
|
51
|
+
* port makes fetch reject (ECONNREFUSED) or time out, never return an HTTP
|
|
52
|
+
* status, so only those two shapes count as unreachable. */
|
|
53
|
+
function isUnreachable(e) {
|
|
54
|
+
if (!(e instanceof Error))
|
|
55
|
+
return false;
|
|
56
|
+
if (e.name === 'TimeoutError' || e.name === 'AbortError')
|
|
57
|
+
return true;
|
|
58
|
+
const cause = e.cause?.code ?? '';
|
|
59
|
+
return ['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EHOSTUNREACH'].includes(cause);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* VoiSona Talk REST API client (docs: http://localhost:32766/docs/talk_api.html).
|
|
63
|
+
* Synthesizes to memory, retrieves the WAV plus phoneme timing, and lets the
|
|
64
|
+
* renderer play audio in exact sync with the mouth.
|
|
65
|
+
*/
|
|
66
|
+
class VoiSonaTalkClient {
|
|
67
|
+
cfg;
|
|
68
|
+
disabledUntil = 0;
|
|
69
|
+
voiceCache = null;
|
|
70
|
+
runtimeUsername = null;
|
|
71
|
+
runtimePassword = null;
|
|
72
|
+
lastError = null;
|
|
73
|
+
lastSuccessAt = null;
|
|
74
|
+
engineUnreachable = false;
|
|
75
|
+
constructor(cfg) {
|
|
76
|
+
this.cfg = cfg;
|
|
77
|
+
}
|
|
78
|
+
status() {
|
|
79
|
+
return {
|
|
80
|
+
enabled: this.cfg.enabled,
|
|
81
|
+
hasCredentials: this.hasCredentials(),
|
|
82
|
+
coolingDown: Date.now() < this.disabledUntil,
|
|
83
|
+
lastError: this.lastError,
|
|
84
|
+
lastSuccessAt: this.lastSuccessAt,
|
|
85
|
+
engineUnreachable: this.engineUnreachable,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Credentials arrive from the MCP bridge (mcp.json env) and live in memory only. */
|
|
89
|
+
setCredentials(username, password) {
|
|
90
|
+
if (username !== this.runtimeUsername || password !== this.runtimePassword) {
|
|
91
|
+
this.runtimeUsername = username;
|
|
92
|
+
this.runtimePassword = password;
|
|
93
|
+
this.disabledUntil = 0; // new credentials: retry immediately
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
hasCredentials() {
|
|
97
|
+
return Boolean(this.runtimeUsername ?? process.env.UI_CHAN_TTS_USERNAME ?? this.cfg.username);
|
|
98
|
+
}
|
|
99
|
+
base() {
|
|
100
|
+
return `${this.cfg.url}/api/talk/v1`;
|
|
101
|
+
}
|
|
102
|
+
headers() {
|
|
103
|
+
const user = this.runtimeUsername ?? process.env.UI_CHAN_TTS_USERNAME ?? this.cfg.username ?? '';
|
|
104
|
+
const pass = this.runtimePassword ?? process.env.UI_CHAN_TTS_PASSWORD ?? this.cfg.password ?? '';
|
|
105
|
+
return {
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
Authorization: `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
async get(path) {
|
|
111
|
+
const res = await fetch(this.base() + path, {
|
|
112
|
+
headers: this.headers(),
|
|
113
|
+
signal: AbortSignal.timeout(5000),
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok)
|
|
116
|
+
throw new Error(`GET ${path}: HTTP ${res.status}`);
|
|
117
|
+
return res.json();
|
|
118
|
+
}
|
|
119
|
+
async resolveVoice() {
|
|
120
|
+
if (this.voiceCache)
|
|
121
|
+
return this.voiceCache;
|
|
122
|
+
const lang = this.cfg.language ?? 'ja_JP';
|
|
123
|
+
const list = await this.get('/voices');
|
|
124
|
+
const items = list.items ?? [];
|
|
125
|
+
const picked = (this.cfg.voice_name && items.find((v) => v.voice_name === this.cfg.voice_name)) ||
|
|
126
|
+
items.find((v) => v.languages?.includes(lang)) ||
|
|
127
|
+
items[0];
|
|
128
|
+
if (!picked)
|
|
129
|
+
return null;
|
|
130
|
+
const detail = await this.get(`/voices/${encodeURIComponent(picked.voice_name)}/${encodeURIComponent(picked.voice_version)}`);
|
|
131
|
+
this.voiceCache = {
|
|
132
|
+
voice_name: picked.voice_name,
|
|
133
|
+
voice_version: picked.voice_version,
|
|
134
|
+
style_names: detail.style_names ?? [],
|
|
135
|
+
default_style_weights: detail.default_style_weights ?? (detail.style_names ?? []).map((_, i) => (i === 0 ? 1 : 0)),
|
|
136
|
+
};
|
|
137
|
+
return this.voiceCache;
|
|
138
|
+
}
|
|
139
|
+
/** The voice's style names + default weights, for the editor's slider UI.
|
|
140
|
+
* Returns null if the engine is unreachable or has no styles. */
|
|
141
|
+
async listStyles() {
|
|
142
|
+
if (!this.hasCredentials())
|
|
143
|
+
return null;
|
|
144
|
+
try {
|
|
145
|
+
const voice = await this.resolveVoice();
|
|
146
|
+
if (!voice || voice.style_names.length === 0)
|
|
147
|
+
return null;
|
|
148
|
+
return {
|
|
149
|
+
style_names: voice.style_names,
|
|
150
|
+
default_style_weights: voice.default_style_weights,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Convert a { name: weight } style_weights map into the positional array
|
|
158
|
+
* VoiSona expects (ordered by voice.style_names). Pure name lookup — no
|
|
159
|
+
* blending, no defaults mixed in. */
|
|
160
|
+
styleWeights(target, voice) {
|
|
161
|
+
if (!target || voice.style_names.length === 0)
|
|
162
|
+
return undefined;
|
|
163
|
+
const vec = voice.style_names.map((name) => {
|
|
164
|
+
const hit = Object.entries(target).find(([k]) => k.toLowerCase() === name.toLowerCase());
|
|
165
|
+
return hit ? hit[1] : 0;
|
|
166
|
+
});
|
|
167
|
+
return vec.some((w) => w !== 0) ? vec : undefined;
|
|
168
|
+
}
|
|
169
|
+
/** Synthesize one line of speech in the given Cue's baked voice color,
|
|
170
|
+
* layered with this line's ad-lib pitch/speed/volume/intonation. Thin
|
|
171
|
+
* wrapper that resolves the Cue name to its saved voice color, then defers
|
|
172
|
+
* to synthesizeWithVoice. */
|
|
173
|
+
synthesize(text, cue, adlib) {
|
|
174
|
+
return this.synthesizeWithVoice(text, this.cfg.cueVoice?.[cue], adlib);
|
|
175
|
+
}
|
|
176
|
+
/** Synthesize with an explicit voice color instead of a saved Cue name — used
|
|
177
|
+
* by the editor's "試し喋り" to preview an in-progress, not-yet-saved voice. */
|
|
178
|
+
async synthesizeWithVoice(text, cueVoice, adlib) {
|
|
179
|
+
if (!this.cfg.enabled || !this.hasCredentials() || Date.now() < this.disabledUntil)
|
|
180
|
+
return null;
|
|
181
|
+
try {
|
|
182
|
+
const voice = await this.resolveVoice();
|
|
183
|
+
const weights = voice ? this.styleWeights(cueVoice?.style_weights, voice) : undefined;
|
|
184
|
+
const globalParameters = {
|
|
185
|
+
...(weights ? { style_weights: weights } : {}),
|
|
186
|
+
...(cueVoice?.alp !== undefined ? { alp: cueVoice.alp } : {}),
|
|
187
|
+
...(cueVoice?.huskiness !== undefined ? { huskiness: cueVoice.huskiness } : {}),
|
|
188
|
+
...(adlib?.pitch !== undefined ? { pitch: adlib.pitch } : {}),
|
|
189
|
+
...(adlib?.speed !== undefined ? { speed: adlib.speed } : {}),
|
|
190
|
+
...(adlib?.volume !== undefined ? { volume: adlib.volume } : {}),
|
|
191
|
+
...(adlib?.intonation !== undefined ? { intonation: adlib.intonation } : {}),
|
|
192
|
+
};
|
|
193
|
+
const res = await fetch(`${this.base()}/speech-syntheses`, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: this.headers(),
|
|
196
|
+
body: JSON.stringify({
|
|
197
|
+
force_enqueue: true,
|
|
198
|
+
destination: 'memory',
|
|
199
|
+
language: this.cfg.language ?? 'ja_JP',
|
|
200
|
+
text,
|
|
201
|
+
...(voice ? { voice_name: voice.voice_name, voice_version: voice.voice_version } : {}),
|
|
202
|
+
...(Object.keys(globalParameters).length > 0
|
|
203
|
+
? { global_parameters: globalParameters }
|
|
204
|
+
: {}),
|
|
205
|
+
}),
|
|
206
|
+
signal: AbortSignal.timeout(5000),
|
|
207
|
+
});
|
|
208
|
+
if (!res.ok)
|
|
209
|
+
throw new Error(`POST /speech-syntheses: HTTP ${res.status}`);
|
|
210
|
+
const { uuid } = (await res.json());
|
|
211
|
+
const deadline = Date.now() + SYNTH_TIMEOUT_MS;
|
|
212
|
+
let info;
|
|
213
|
+
for (;;) {
|
|
214
|
+
info = await this.get(`/speech-syntheses/${uuid}`);
|
|
215
|
+
if (info.state === 'succeeded')
|
|
216
|
+
break;
|
|
217
|
+
if (info.state === 'failed')
|
|
218
|
+
throw new Error('synthesis failed');
|
|
219
|
+
if (Date.now() > deadline)
|
|
220
|
+
throw new Error('synthesis timed out');
|
|
221
|
+
await sleep(POLL_INTERVAL_MS);
|
|
222
|
+
}
|
|
223
|
+
const wavRes = await fetch(`${this.base()}/speech-syntheses/${uuid}/wav`, {
|
|
224
|
+
headers: this.headers(),
|
|
225
|
+
signal: AbortSignal.timeout(10_000),
|
|
226
|
+
});
|
|
227
|
+
if (!wavRes.ok)
|
|
228
|
+
throw new Error(`GET wav: HTTP ${wavRes.status}`);
|
|
229
|
+
const wav = Buffer.from(await wavRes.arrayBuffer());
|
|
230
|
+
fetch(`${this.base()}/speech-syntheses/${uuid}`, {
|
|
231
|
+
method: 'DELETE',
|
|
232
|
+
headers: this.headers(),
|
|
233
|
+
}).catch(() => { });
|
|
234
|
+
const durations = info.phoneme_durations ?? [];
|
|
235
|
+
const durationMs = Math.round((info.duration ?? durations.reduce((a, b) => a + b, 0)) * 1000);
|
|
236
|
+
this.lastError = null;
|
|
237
|
+
this.lastSuccessAt = new Date().toISOString();
|
|
238
|
+
this.engineUnreachable = false;
|
|
239
|
+
return {
|
|
240
|
+
wavBase64: wav.toString('base64'),
|
|
241
|
+
durationMs,
|
|
242
|
+
timeline: buildTimeline(info.phonemes ?? [], durations),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
catch (e) {
|
|
246
|
+
this.engineUnreachable = isUnreachable(e);
|
|
247
|
+
const cooldown = this.engineUnreachable ? UNREACHABLE_COOLDOWN_MS : RETRY_COOLDOWN_MS;
|
|
248
|
+
this.disabledUntil = Date.now() + cooldown;
|
|
249
|
+
this.lastError = e instanceof Error ? e.message : String(e);
|
|
250
|
+
console.error(this.engineUnreachable
|
|
251
|
+
? `[ui-chan] VoiSona Talk not reachable at ${this.cfg.url} (retrying in ${cooldown / 1000}s): ${this.lastError}`
|
|
252
|
+
: `[ui-chan] VoiSona Talk synthesis failed (retrying after ${cooldown / 1000}s): ${this.lastError}`);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
exports.VoiSonaTalkClient = VoiSonaTalkClient;
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
const node_child_process_1 = require("node:child_process");
|
|
40
|
+
const path = __importStar(require("node:path"));
|
|
41
|
+
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
42
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
43
|
+
const ws_1 = __importDefault(require("ws"));
|
|
44
|
+
const zod_1 = require("zod");
|
|
45
|
+
const persona_1 = require("./app/persona");
|
|
46
|
+
const paths_1 = require("./shared/paths");
|
|
47
|
+
const set_cue_schema_1 = require("./shared/set-cue-schema");
|
|
48
|
+
const projectRoot = path.resolve(__dirname, '..');
|
|
49
|
+
// TTS credentials may live in a .env (package dir or ~/.ui-chan) instead of the
|
|
50
|
+
// shell environment (env vars still win — loadEnvFile does not overwrite them).
|
|
51
|
+
(0, paths_1.loadEnvFiles)(projectRoot);
|
|
52
|
+
const paths = (0, paths_1.resolvePaths)(projectRoot);
|
|
53
|
+
const config = paths.config;
|
|
54
|
+
const port = Number(process.env.UI_CHAN_PORT ?? config.port ?? 8123);
|
|
55
|
+
const wsUrl = `ws://127.0.0.1:${port}`;
|
|
56
|
+
const log = (msg) => process.stderr.write(`[ui-chan-mcp] ${msg}\n`);
|
|
57
|
+
/**
|
|
58
|
+
* The persona also rides along on the MCP handshake as the server's
|
|
59
|
+
* `instructions`.
|
|
60
|
+
*
|
|
61
|
+
* In Claude Code the SessionStart hook already injects persona/ + context/, but
|
|
62
|
+
* a plugin is a Claude-Code-only thing: any other MCP client (Claude Desktop,
|
|
63
|
+
* another agent) would otherwise get the *body* — set_cue and friends — with no
|
|
64
|
+
* character behind it, and drive the mascot in its own voice. `instructions` is
|
|
65
|
+
* the one channel every MCP client receives without the user doing anything, so
|
|
66
|
+
* the character travels with the tools. Set UI_CHAN_NO_PERSONA_INSTRUCTIONS=1
|
|
67
|
+
* to send tools only (Claude Code users who find the double injection wasteful).
|
|
68
|
+
*/
|
|
69
|
+
function personaInstructions() {
|
|
70
|
+
if (process.env.UI_CHAN_NO_PERSONA_INSTRUCTIONS === '1')
|
|
71
|
+
return undefined;
|
|
72
|
+
try {
|
|
73
|
+
return (0, persona_1.buildPersonaText)(paths);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const server = new mcp_js_1.McpServer({ name: 'ui-chan-mcp', version: '0.1.0' }, { instructions: personaInstructions() });
|
|
80
|
+
// ---- WebSocket bridge to the Electron display app ----
|
|
81
|
+
let socket = null;
|
|
82
|
+
let nextId = 1;
|
|
83
|
+
let lastLaunchAt = 0;
|
|
84
|
+
const pending = new Map();
|
|
85
|
+
function ttsCredentials() {
|
|
86
|
+
const username = process.env.UI_CHAN_TTS_USERNAME;
|
|
87
|
+
const password = process.env.UI_CHAN_TTS_PASSWORD;
|
|
88
|
+
return username && password ? { username, password } : undefined;
|
|
89
|
+
}
|
|
90
|
+
const VOISONA_PROBE_TIMEOUT_MS = 1500;
|
|
91
|
+
const VOISONA_READY_TIMEOUT_MS = 20_000;
|
|
92
|
+
const VOISONA_RECHECK_MS = 30_000;
|
|
93
|
+
let lastVoiSonaCheckAt = 0;
|
|
94
|
+
async function voiSonaReachable(url) {
|
|
95
|
+
try {
|
|
96
|
+
await fetch(`${url}/docs/talk_api.html`, {
|
|
97
|
+
signal: AbortSignal.timeout(VOISONA_PROBE_TIMEOUT_MS),
|
|
98
|
+
});
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Launch VoiSona Talk (macOS) if its REST API is not reachable, then wait for
|
|
107
|
+
* the port to actually answer. Firing `open` and returning immediately used to
|
|
108
|
+
* mean the first few lines hit a still-booting engine, and each miss put the
|
|
109
|
+
* TTS client into its retry cooldown — silence for the rest of the warm-up.
|
|
110
|
+
* Throttled, and re-run on tool calls, so quitting the engine mid-session
|
|
111
|
+
* self-heals the same way a closed display app does.
|
|
112
|
+
*/
|
|
113
|
+
async function ensureVoiSonaRunning() {
|
|
114
|
+
const tts = config.tts;
|
|
115
|
+
if (!tts?.enabled)
|
|
116
|
+
return;
|
|
117
|
+
if (Date.now() - lastVoiSonaCheckAt < VOISONA_RECHECK_MS)
|
|
118
|
+
return;
|
|
119
|
+
lastVoiSonaCheckAt = Date.now();
|
|
120
|
+
if (await voiSonaReachable(tts.url))
|
|
121
|
+
return;
|
|
122
|
+
if (process.platform !== 'darwin') {
|
|
123
|
+
log(`TTS engine not reachable at ${tts.url} — start it manually (auto-launch is macOS only)`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const appName = tts.app_name ?? 'VoiSona Talk';
|
|
127
|
+
log(`TTS engine not reachable at ${tts.url} — launching "${appName}"`);
|
|
128
|
+
(0, node_child_process_1.spawn)('open', ['-g', '-a', appName], { stdio: 'ignore' }).unref();
|
|
129
|
+
const deadline = Date.now() + VOISONA_READY_TIMEOUT_MS;
|
|
130
|
+
while (Date.now() < deadline) {
|
|
131
|
+
if (await voiSonaReachable(tts.url)) {
|
|
132
|
+
log(`TTS engine ready at ${tts.url}`);
|
|
133
|
+
lastVoiSonaCheckAt = Date.now();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
137
|
+
}
|
|
138
|
+
log(`TTS engine still not reachable after ${VOISONA_READY_TIMEOUT_MS / 1000}s — speaking silently`);
|
|
139
|
+
lastVoiSonaCheckAt = Date.now();
|
|
140
|
+
}
|
|
141
|
+
/** Identity for the connections panel: what this bridge can honestly say about
|
|
142
|
+
* the session it belongs to. `cwd` is the client's working directory — for
|
|
143
|
+
* Claude Code and OpenCode that is the project being worked on, which is the
|
|
144
|
+
* one field that tells two windows of the same client apart. */
|
|
145
|
+
function identity() {
|
|
146
|
+
const info = server.server.getClientVersion();
|
|
147
|
+
const cwd = process.cwd();
|
|
148
|
+
return {
|
|
149
|
+
client: info?.name,
|
|
150
|
+
clientVersion: info?.version,
|
|
151
|
+
cwd,
|
|
152
|
+
project: path.basename(cwd),
|
|
153
|
+
pid: process.pid,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function agentName() {
|
|
157
|
+
return (process.env.UI_CHAN_AGENT_NAME ??
|
|
158
|
+
server.server.getClientVersion()?.name ??
|
|
159
|
+
`agent-${process.pid}`);
|
|
160
|
+
}
|
|
161
|
+
function tryConnect() {
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
const ws = new ws_1.default(wsUrl);
|
|
164
|
+
ws.once('open', () => resolve(ws));
|
|
165
|
+
ws.once('error', (err) => reject(err));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function launchApp() {
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
if (now - lastLaunchAt < 10_000)
|
|
171
|
+
return;
|
|
172
|
+
lastLaunchAt = now;
|
|
173
|
+
// In a plain Node process, require('electron') resolves to the binary path.
|
|
174
|
+
const electronPath = require('electron');
|
|
175
|
+
log(`launching display app: ${electronPath}`);
|
|
176
|
+
(0, node_child_process_1.spawn)(electronPath, [projectRoot], { detached: true, stdio: 'ignore' }).unref();
|
|
177
|
+
}
|
|
178
|
+
// Concurrent tool calls arriving while disconnected must share one connection
|
|
179
|
+
// attempt — otherwise each call races its own tryConnect(), and only the
|
|
180
|
+
// last one ends up in `socket` while the others leak an open, never-closed
|
|
181
|
+
// WebSocket.
|
|
182
|
+
let connectingPromise = null;
|
|
183
|
+
/**
|
|
184
|
+
* `allowLaunch` is the whole policy in one flag, and only **bridge startup**
|
|
185
|
+
* (≈ session start) passes it.
|
|
186
|
+
*
|
|
187
|
+
* The bridge used to launch the app on *every* tool call. That sounds like
|
|
188
|
+
* self-healing and is actually the user losing the ability to put the mascot
|
|
189
|
+
* away: quitting her was undone by whatever the agent did next. Now a tool call
|
|
190
|
+
* only ever **reconnects** — which covers the case that matters in practice
|
|
191
|
+
* (the app is up, this bridge's socket went stale) without deciding on the
|
|
192
|
+
* user's behalf that she should be on screen. If she is genuinely not running,
|
|
193
|
+
* the call fails saying how to start her, and starting her is a person's
|
|
194
|
+
* choice: `ui-chan start`, a new session, or the panel.
|
|
195
|
+
*/
|
|
196
|
+
function ensureConnected(allowLaunch = false) {
|
|
197
|
+
if (socket && socket.readyState === ws_1.default.OPEN)
|
|
198
|
+
return Promise.resolve(socket);
|
|
199
|
+
if (!connectingPromise) {
|
|
200
|
+
connectingPromise = doConnect(allowLaunch).finally(() => {
|
|
201
|
+
connectingPromise = null;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return connectingPromise;
|
|
205
|
+
}
|
|
206
|
+
async function doConnect(allowLaunch) {
|
|
207
|
+
socket = null;
|
|
208
|
+
let launched = false;
|
|
209
|
+
// Waiting 25s only makes sense while an app we just launched is booting.
|
|
210
|
+
// When we're not launching, a dead socket is an answer, not a race.
|
|
211
|
+
const deadline = Date.now() + (allowLaunch ? 25_000 : 1_500);
|
|
212
|
+
for (;;) {
|
|
213
|
+
let ws = null;
|
|
214
|
+
try {
|
|
215
|
+
ws = await tryConnect();
|
|
216
|
+
ws.on('message', (data) => {
|
|
217
|
+
try {
|
|
218
|
+
const res = JSON.parse(data.toString());
|
|
219
|
+
const p = pending.get(res.id);
|
|
220
|
+
if (p) {
|
|
221
|
+
pending.delete(res.id);
|
|
222
|
+
p.resolve(res);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* ignore malformed frames */
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
ws.on('close', () => {
|
|
230
|
+
if (socket === ws)
|
|
231
|
+
socket = null;
|
|
232
|
+
for (const [id, p] of pending) {
|
|
233
|
+
pending.delete(id);
|
|
234
|
+
p.reject(new Error('connection to display app closed'));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
socket = ws;
|
|
238
|
+
await sendRequest({
|
|
239
|
+
type: 'hello',
|
|
240
|
+
agent: agentName(),
|
|
241
|
+
identity: identity(),
|
|
242
|
+
tts: ttsCredentials(),
|
|
243
|
+
});
|
|
244
|
+
return ws;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// A hello timeout/failure leaves `ws` open (only a real socket-level
|
|
248
|
+
// close nulls it via the handler above) — close it ourselves so a
|
|
249
|
+
// retry doesn't pile up abandoned sockets.
|
|
250
|
+
if (ws && socket === ws)
|
|
251
|
+
socket = null;
|
|
252
|
+
if (ws && ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) {
|
|
253
|
+
try {
|
|
254
|
+
ws.close();
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
/* ignore */
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (allowLaunch && !launched) {
|
|
261
|
+
try {
|
|
262
|
+
launchApp();
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
log(`failed to launch display app: ${e instanceof Error ? e.message : e}`);
|
|
266
|
+
}
|
|
267
|
+
launched = true;
|
|
268
|
+
}
|
|
269
|
+
if (Date.now() > deadline) {
|
|
270
|
+
throw new Error(`ういちゃんは起動していません(${wsUrl} に接続できません)。` +
|
|
271
|
+
'`ui-chan start` で起動してください。');
|
|
272
|
+
}
|
|
273
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function sendRequest(msg) {
|
|
278
|
+
return new Promise((resolve, reject) => {
|
|
279
|
+
if (!socket || socket.readyState !== ws_1.default.OPEN) {
|
|
280
|
+
reject(new Error('not connected'));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const id = nextId++;
|
|
284
|
+
const timer = setTimeout(() => {
|
|
285
|
+
if (pending.delete(id))
|
|
286
|
+
reject(new Error('request to display app timed out'));
|
|
287
|
+
}, 15_000);
|
|
288
|
+
pending.set(id, {
|
|
289
|
+
resolve: (r) => {
|
|
290
|
+
clearTimeout(timer);
|
|
291
|
+
resolve(r);
|
|
292
|
+
},
|
|
293
|
+
reject: (e) => {
|
|
294
|
+
clearTimeout(timer);
|
|
295
|
+
reject(e);
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
socket.send(JSON.stringify({ id, ...msg }));
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
async function callTool(tool, args) {
|
|
302
|
+
await ensureConnected();
|
|
303
|
+
const res = await sendRequest({ type: 'tool', tool, args, agent: agentName() });
|
|
304
|
+
if (!res.ok)
|
|
305
|
+
throw new Error(res.error ?? 'unknown error');
|
|
306
|
+
return res.result;
|
|
307
|
+
}
|
|
308
|
+
function toolResult(result) {
|
|
309
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 1) }] };
|
|
310
|
+
}
|
|
311
|
+
function toolError(e) {
|
|
312
|
+
return {
|
|
313
|
+
content: [{ type: 'text', text: `Error: ${e instanceof Error ? e.message : e}` }],
|
|
314
|
+
isError: true,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/** Every tool handler here does the same "forward to the display app, wrap
|
|
318
|
+
* the result/error for MCP" — this is the one place that pattern lives. */
|
|
319
|
+
function wrapTool(toolName, toArgs = (a) => a) {
|
|
320
|
+
return async (a) => {
|
|
321
|
+
try {
|
|
322
|
+
// Not awaited: the engine can boot while the line is queued, and the TTS
|
|
323
|
+
// client's short unreachable-cooldown picks it up once it answers.
|
|
324
|
+
if (toolName === 'set_cue')
|
|
325
|
+
void ensureVoiSonaRunning();
|
|
326
|
+
return toolResult(await callTool(toolName, toArgs(a)));
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
return toolError(e);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
// ---- Tool definitions ----
|
|
334
|
+
server.registerTool('set_cue', {
|
|
335
|
+
description: "Switch the mascot's Cue — a complete look (face + pose + voice color, baked together as " +
|
|
336
|
+
'one self-contained unit) — and optionally speak a line at the same time. Cue and line are ' +
|
|
337
|
+
'confirmed together in a single call, so the face and the voice never disagree about which ' +
|
|
338
|
+
'Cue is "current". Pick the cue name from the Cue catalog in this persona\'s context. ' +
|
|
339
|
+
'Unknown cue names fall back to "default" ' +
|
|
340
|
+
"(see the returned note, or get_state's warnings). " +
|
|
341
|
+
'text is optional: omit it to change the look silently (e.g. a wordless reaction while you ' +
|
|
342
|
+
'keep working). When text is given, ALWAYS also pass reading (its full hiragana reading) so ' +
|
|
343
|
+
'the mouth lip-syncs to the vowels — kanji cannot be lip-synced without it. reading is also ' +
|
|
344
|
+
'what gets SPOKEN when text contains Latin letters or digits, so it must contain NO Latin ' +
|
|
345
|
+
'letters, digits or symbols at all — anything left in Latin is spelled out letter-by-letter ' +
|
|
346
|
+
'in English. Use your own knowledge of how a Japanese speaker actually SAYS each term rather ' +
|
|
347
|
+
'than transliterating its spelling: an established reading wins however far it is from the ' +
|
|
348
|
+
'spelling (k8s → くーばねてぃす), otherwise judge by word shape (bash → ばっしゅ, NPO → えぬぴーおー). ' +
|
|
349
|
+
'Numbers, units and paths become sound too (3回 → さんかい, ~/.zshrc → ちるだ どっと ずぃーしぇるあーるしー). ' +
|
|
350
|
+
'pitch/speed/volume/intonation are one-line ad-lib performance knobs layered on top of this ' +
|
|
351
|
+
"Cue's baked voice.style_weights/alp/huskiness; leave them unset to just use the Cue's voice as-is.",
|
|
352
|
+
inputSchema: set_cue_schema_1.setCueShape,
|
|
353
|
+
}, wrapTool('set_cue'));
|
|
354
|
+
server.registerTool('get_state', {
|
|
355
|
+
description: 'Get the current mascot state: active Cue, speech queue, connected agents, available cues, ' +
|
|
356
|
+
'and affinity (value, band, and beamReady — whether ういビーム will fire).',
|
|
357
|
+
inputSchema: {},
|
|
358
|
+
}, wrapTool('get_state', () => ({})));
|
|
359
|
+
server.registerTool('adjust_affinity', {
|
|
360
|
+
description: "Nudge うい's affinity toward you (session-only, resets when the app restarts). You choose only a " +
|
|
361
|
+
'DIRECTION and a coarse MAGNITUDE; the engine decides the actual amount via an asymmetric curve — ' +
|
|
362
|
+
'raising is slow and diminishing (climbing high is hard-earned, a single call never overshoots), ' +
|
|
363
|
+
'lowering is easier and eases back to the 0 floor (she cools off fast). So you can NOT jump her to the top. ' +
|
|
364
|
+
"Raise ('up') when the user is warm, praises her, remembers her, or shows her something she likes " +
|
|
365
|
+
"(cute VTubers/JKs, rain); lower ('down') for 旦那面/彼氏面, 塩鮭案件, セクハラ, or insults. " +
|
|
366
|
+
'magnitude: low = 軽い好意/軽い失礼, middle = はっきりした好意/失礼, high = 大きな出来事 (誕生日覚えてた / ドン引き案件). ' +
|
|
367
|
+
'Affinity gates behavior: low = つれない/塩対応寄り, high = デレ, ういビーム only above the beam threshold. ' +
|
|
368
|
+
'Returns the new value, band, applied delta, and beamReady. Check get_state first.',
|
|
369
|
+
inputSchema: {
|
|
370
|
+
direction: zod_1.z.enum(['up', 'down']).describe('Which way to move affinity'),
|
|
371
|
+
magnitude: zod_1.z
|
|
372
|
+
.enum(['low', 'middle', 'high'])
|
|
373
|
+
.describe('How big the event was; the engine scales the actual change from this'),
|
|
374
|
+
},
|
|
375
|
+
}, wrapTool('adjust_affinity'));
|
|
376
|
+
server.registerTool('clear', {
|
|
377
|
+
description: 'Reset the mascot: clear the speech bubble and queue, restore the default Cue.',
|
|
378
|
+
inputSchema: {},
|
|
379
|
+
}, wrapTool('clear', () => ({})));
|
|
380
|
+
server.registerPrompt('persona', {
|
|
381
|
+
title: 'ういちゃんペルソナ',
|
|
382
|
+
description: "Load the mascot's persona (personality, tone, and tool-usage policy) into the conversation. " +
|
|
383
|
+
'Defined in persona/ui-chan.md — edit that file to change the character.',
|
|
384
|
+
}, () => ({
|
|
385
|
+
messages: [
|
|
386
|
+
{
|
|
387
|
+
role: 'user',
|
|
388
|
+
content: { type: 'text', text: (0, persona_1.buildPersonaText)(paths) },
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
}));
|
|
392
|
+
async function main() {
|
|
393
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
394
|
+
await server.connect(transport);
|
|
395
|
+
// Wake everything up as soon as we're connected, rather than waiting for the
|
|
396
|
+
// first tool call: configuring the MCP server should be the whole setup, so
|
|
397
|
+
// ういちゃん is on screen (and able to speak) before anyone says anything to
|
|
398
|
+
// her. Both are fire-and-forget — a mascot that can't start must never stop
|
|
399
|
+
// the tools from working.
|
|
400
|
+
void ensureVoiSonaRunning();
|
|
401
|
+
// The one place that may launch her unprompted (see ensureConnected).
|
|
402
|
+
ensureConnected(true).catch((e) => log(`display app not reachable yet: ${e.message ?? e}`));
|
|
403
|
+
log(`ready (display app at ${wsUrl})`);
|
|
404
|
+
}
|
|
405
|
+
main().catch((e) => {
|
|
406
|
+
log(`fatal: ${e}`);
|
|
407
|
+
process.exit(1);
|
|
408
|
+
});
|