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,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
+ });