@animalabs/connectome-host 0.3.10 → 0.4.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.
@@ -0,0 +1,481 @@
1
+ /**
2
+ * TtsRelayModule — streams the agent's live generation to a melodeus-tts-relay
3
+ * server so voice clients (Melodeus, the iOS app) can speak it as it's born.
4
+ *
5
+ * Pure trace-bus tap (docs/observability.md spirit: traces are the outbound
6
+ * wire, they never drive agent logic). The module subscribes to the framework's
7
+ * existing streaming traces and re-emits the relay's bot-side protocol
8
+ * (melodeus-tts-relay/PROTOCOL.md) over one outbound WebSocket:
9
+ *
10
+ * inference:started → activation_start
11
+ * inference:tokens → chunk (visible = blockType 'text')
12
+ * inference:content_block → block_start / block_complete
13
+ * inference:completed → activation_end reason 'complete'
14
+ * inference:aborted → activation_end reason 'abort'
15
+ * inference:failed / :exhausted → activation_end reason 'error'
16
+ *
17
+ * Channel tagging rides the turn-frozen locus the framework already stamps on
18
+ * those traces (`channelId`, the MCPL composite id). The relay speaks raw
19
+ * Discord snowflakes, so the composite's last segment is what goes on the wire.
20
+ * `block_complete.content` is reconstructed here by accumulating that block's
21
+ * chunks — the trace deliberately doesn't duplicate block content.
22
+ *
23
+ * Interruptions (the one inbound message): a voice client reports the words
24
+ * actually voiced before the user spoke over them. Unlike ChapterX — which
25
+ * aborts the in-flight stream and substitutes spokenText as the completion —
26
+ * connectome posts prose at tool-round boundaries, so the message is usually
27
+ * already on Discord. We therefore EDIT it down to the voiced words (matching
28
+ * the interruption against recent `mcpl:speech-routed` traces, which carry
29
+ * channelId + messageId + text) via the discord MCPL's edit_message tool, and
30
+ * drop a note into the agent's context so she knows she was cut off — the
31
+ * next turn shouldn't believe the full paragraph landed.
32
+ *
33
+ * Nothing here touches the host loop: remove the recipe block and the tap is
34
+ * gone. No tools are exposed to the agent (v1).
35
+ */
36
+
37
+ import type {
38
+ Module,
39
+ ModuleContext,
40
+ ProcessEvent,
41
+ ProcessState,
42
+ EventResponse,
43
+ ToolDefinition,
44
+ ToolCall,
45
+ ToolResult,
46
+ TraceEvent,
47
+ } from '@animalabs/agent-framework';
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Config
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export interface TtsRelayModuleConfig {
54
+ /** Relay bot endpoint, e.g. "ws://localhost:8800/bot". */
55
+ url: string;
56
+ /** Shared secret for the relay's /bot auth (BOT_TOKENS). `${VAR}` it. */
57
+ token: string;
58
+ /** Relay-side bot identifier. Defaults to the agent's name at first trace. */
59
+ botId?: string;
60
+ /** Discord user id to stamp on stream events (voice-config mapping key on
61
+ * the client side). Defaults to botId. */
62
+ userId?: string;
63
+ /** Display name stamped on stream events. Defaults to the agent's name. */
64
+ username?: string;
65
+ /** MCPL server id owning edit_message for interruption edits. Default 'discord'. */
66
+ editServerId?: string;
67
+ reconnectIntervalMs?: number;
68
+ /** Drop a context note when an interruption truncates a posted message.
69
+ * Default true. */
70
+ notifyOnInterruption?: boolean;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Relay wire types (bot side of melodeus-tts-relay/PROTOCOL.md)
75
+ // ---------------------------------------------------------------------------
76
+
77
+ type BlockType = 'text' | 'thinking' | 'tool_call' | 'tool_result';
78
+
79
+ interface InterruptionEvent {
80
+ channelId: string; // raw Discord snowflake
81
+ spokenText: string;
82
+ reason: 'user_speech' | 'manual' | 'timeout';
83
+ timestamp: number;
84
+ }
85
+
86
+ /**
87
+ * Minimal WebSocket client for the relay's /bot endpoint. Ported from
88
+ * chatperx/src/tts/relay-client.ts, WHATWG-ified (Bun's global WebSocket —
89
+ * no `ws` dependency). Auto-reconnects except after an auth failure; every
90
+ * send is fire-and-forget and drops silently while disconnected (the relay
91
+ * spec's own semantics: no queueing).
92
+ */
93
+ class RelayBotClient {
94
+ private ws: WebSocket | null = null;
95
+ private authenticated = false;
96
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
97
+ private shouldReconnect = true;
98
+ private onInterruptionHandler: ((event: InterruptionEvent) => void) | null = null;
99
+
100
+ constructor(
101
+ private readonly url: string,
102
+ private readonly token: string,
103
+ private readonly botId: string,
104
+ private readonly reconnectIntervalMs: number,
105
+ ) {}
106
+
107
+ connect(): void {
108
+ this.shouldReconnect = true;
109
+ this.doConnect();
110
+ }
111
+
112
+ disconnect(): void {
113
+ this.shouldReconnect = false;
114
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
115
+ this.authenticated = false;
116
+ if (this.ws) {
117
+ try { this.ws.close(1000, 'module stopping'); } catch { /* already closed */ }
118
+ this.ws = null;
119
+ }
120
+ }
121
+
122
+ isConnected(): boolean {
123
+ return this.ws?.readyState === WebSocket.OPEN && this.authenticated;
124
+ }
125
+
126
+ onInterruption(handler: (event: InterruptionEvent) => void): void {
127
+ this.onInterruptionHandler = handler;
128
+ }
129
+
130
+ /** Send a typed relay message; botId + timestamp are stamped here. */
131
+ emit(type: string, payload: Record<string, unknown>): void {
132
+ if (!this.isConnected()) return;
133
+ try {
134
+ this.ws!.send(JSON.stringify({ type, botId: this.botId, ...payload, timestamp: Date.now() }));
135
+ } catch (err) {
136
+ console.error('[tts-relay] send failed:', err);
137
+ }
138
+ }
139
+
140
+ private doConnect(): void {
141
+ let ws: WebSocket;
142
+ try {
143
+ ws = new WebSocket(this.url);
144
+ } catch (err) {
145
+ console.error(`[tts-relay] cannot open ${this.url}:`, err);
146
+ this.scheduleReconnect();
147
+ return;
148
+ }
149
+ this.ws = ws;
150
+
151
+ ws.onopen = () => {
152
+ try { ws.send(JSON.stringify({ type: 'auth', botId: this.botId, token: this.token })); }
153
+ catch (err) { console.error('[tts-relay] auth send failed:', err); }
154
+ };
155
+
156
+ ws.onmessage = (ev: MessageEvent) => {
157
+ let msg: { type?: string; error?: string } & Record<string, unknown>;
158
+ try { msg = JSON.parse(String(ev.data)); } catch { return; }
159
+ switch (msg.type) {
160
+ case 'auth_ok':
161
+ this.authenticated = true;
162
+ console.error(`[tts-relay] authenticated with ${this.url} as ${this.botId}`);
163
+ break;
164
+ case 'auth_error':
165
+ // Bad token won't heal by retrying — stop, loudly.
166
+ console.error(`[tts-relay] AUTH FAILED (${msg.error}) — relay tap disabled until restart`);
167
+ this.shouldReconnect = false;
168
+ try { ws.close(); } catch { /* noop */ }
169
+ break;
170
+ case 'interruption':
171
+ this.onInterruptionHandler?.({
172
+ channelId: String(msg.channelId ?? ''),
173
+ spokenText: String(msg.spokenText ?? ''),
174
+ reason: (msg.reason as InterruptionEvent['reason']) ?? 'manual',
175
+ timestamp: Number(msg.timestamp ?? Date.now()),
176
+ });
177
+ break;
178
+ default:
179
+ break; // unknown relay message — ignore
180
+ }
181
+ };
182
+
183
+ ws.onclose = () => {
184
+ this.authenticated = false;
185
+ this.scheduleReconnect();
186
+ };
187
+
188
+ ws.onerror = () => {
189
+ // onclose follows and owns the reconnect; nothing to do here.
190
+ };
191
+ }
192
+
193
+ private scheduleReconnect(): void {
194
+ if (!this.shouldReconnect || this.reconnectTimer) return;
195
+ this.reconnectTimer = setTimeout(() => {
196
+ this.reconnectTimer = null;
197
+ if (this.shouldReconnect) this.doConnect();
198
+ }, this.reconnectIntervalMs);
199
+ }
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Module
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /** Per-agent state for the activation currently streaming. */
207
+ interface ActivationState {
208
+ /** MCPL composite channel id from the traces (e.g. discord:guild:123). */
209
+ mcplChannelId?: string;
210
+ /** Raw surface id sent to the relay (last composite segment). */
211
+ rawChannelId?: string;
212
+ /** Whether activation_start has been emitted for this turn. */
213
+ announced: boolean;
214
+ /** Accumulated text per blockIndex, for block_complete.content. */
215
+ blocks: Map<number, string>;
216
+ }
217
+
218
+ /** A recently routed prose segment — the edit target for an interruption. */
219
+ interface RoutedMessage {
220
+ rawChannelId: string;
221
+ mcplChannelId: string;
222
+ messageId?: string;
223
+ text: string;
224
+ at: number;
225
+ }
226
+
227
+ const ROUTED_RING_MAX = 32;
228
+
229
+ /** `discord:<guild>:<id>` / `portal:<id>` / raw → the raw surface id. */
230
+ function rawChannelId(mcplId: string): string {
231
+ const ix = mcplId.lastIndexOf(':');
232
+ return ix === -1 ? mcplId : mcplId.slice(ix + 1);
233
+ }
234
+
235
+ const collapseWs = (s: string): string => s.replace(/\s+/g, ' ').trim();
236
+
237
+ export class TtsRelayModule implements Module {
238
+ readonly name = 'tts-relay';
239
+
240
+ private ctx: ModuleContext | null = null;
241
+ private client: RelayBotClient | null = null;
242
+ private offTrace: (() => void) | null = null;
243
+
244
+ private activations = new Map<string, ActivationState>();
245
+ private routed: RoutedMessage[] = [];
246
+ /** Resolved lazily from the first trace when config omits botId/username. */
247
+ private agentName: string | null = null;
248
+
249
+ constructor(private readonly config: TtsRelayModuleConfig) {}
250
+
251
+ async start(ctx: ModuleContext): Promise<void> {
252
+ this.ctx = ctx;
253
+
254
+ const botId = this.config.botId ?? ctx.getAgents()[0]?.name ?? 'connectome';
255
+ this.client = new RelayBotClient(
256
+ this.config.url,
257
+ this.config.token,
258
+ botId,
259
+ this.config.reconnectIntervalMs ?? 5000,
260
+ );
261
+ this.client.onInterruption((ev) => {
262
+ void this.handleInterruption(ev).catch((err) =>
263
+ console.error('[tts-relay] interruption handling failed:', err));
264
+ });
265
+ this.client.connect();
266
+
267
+ this.offTrace = ctx.onTrace((event) => {
268
+ // A trace listener must never throw into the framework's emit path.
269
+ try { this.onTraceEvent(event); }
270
+ catch (err) { console.error('[tts-relay] trace handler error:', err); }
271
+ });
272
+ }
273
+
274
+ async stop(): Promise<void> {
275
+ this.offTrace?.();
276
+ this.offTrace = null;
277
+ this.client?.disconnect();
278
+ this.client = null;
279
+ this.activations.clear();
280
+ this.routed = [];
281
+ this.ctx = null;
282
+ }
283
+
284
+ getTools(): ToolDefinition[] { return []; }
285
+
286
+ async handleToolCall(call: ToolCall): Promise<ToolResult> {
287
+ return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
288
+ }
289
+
290
+ async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
291
+ return {};
292
+ }
293
+
294
+ // -------------------------------------------------------------------------
295
+ // Trace → relay
296
+ // -------------------------------------------------------------------------
297
+
298
+ private identity(agentName: string): { userId: string; username: string } {
299
+ this.agentName ??= agentName;
300
+ const username = this.config.username ?? agentName;
301
+ const userId = this.config.userId ?? this.config.botId ?? agentName;
302
+ return { userId, username };
303
+ }
304
+
305
+ private activation(agentName: string): ActivationState {
306
+ let st = this.activations.get(agentName);
307
+ if (!st) {
308
+ st = { announced: false, blocks: new Map() };
309
+ this.activations.set(agentName, st);
310
+ }
311
+ return st;
312
+ }
313
+
314
+ /** Adopt a channel from a trace and emit activation_start once we have one. */
315
+ private adoptChannel(st: ActivationState, agentName: string, mcplChannelId?: string): void {
316
+ if (mcplChannelId && !st.mcplChannelId) {
317
+ st.mcplChannelId = mcplChannelId;
318
+ st.rawChannelId = rawChannelId(mcplChannelId);
319
+ }
320
+ if (!st.announced && st.rawChannelId) {
321
+ st.announced = true;
322
+ this.client?.emit('activation_start', {
323
+ channelId: st.rawChannelId,
324
+ ...this.identity(agentName),
325
+ });
326
+ }
327
+ }
328
+
329
+ private endActivation(agentName: string, reason: 'complete' | 'abort' | 'error'): void {
330
+ const st = this.activations.get(agentName);
331
+ this.activations.delete(agentName);
332
+ if (!st?.announced || !st.rawChannelId) return;
333
+ this.client?.emit('activation_end', {
334
+ channelId: st.rawChannelId,
335
+ ...this.identity(agentName),
336
+ reason,
337
+ });
338
+ }
339
+
340
+ private onTraceEvent(event: TraceEvent): void {
341
+ switch (event.type) {
342
+ case 'inference:started': {
343
+ // A context-budget restart re-emits inference:started mid-turn; the
344
+ // existing activation (announced or not) simply carries on.
345
+ const st = this.activation(event.agentName);
346
+ this.adoptChannel(st, event.agentName, event.channelId);
347
+ break;
348
+ }
349
+
350
+ case 'inference:tokens': {
351
+ const st = this.activation(event.agentName);
352
+ this.adoptChannel(st, event.agentName, event.channelId);
353
+ if (!st.rawChannelId) break; // no locus — nothing to voice into
354
+ st.blocks.set(event.blockIndex, (st.blocks.get(event.blockIndex) ?? '') + event.content);
355
+ this.client?.emit('chunk', {
356
+ channelId: st.rawChannelId,
357
+ ...this.identity(event.agentName),
358
+ text: event.content,
359
+ blockIndex: event.blockIndex,
360
+ blockType: event.blockType as BlockType,
361
+ // Voice only spoken prose. Thinking/tool blocks still stream (the
362
+ // protocol carries them for UI), but flagged invisible.
363
+ visible: event.blockType === 'text',
364
+ });
365
+ break;
366
+ }
367
+
368
+ case 'inference:content_block': {
369
+ const st = this.activation(event.agentName);
370
+ this.adoptChannel(st, event.agentName, event.channelId);
371
+ if (!st.rawChannelId) break;
372
+ const base = {
373
+ channelId: st.rawChannelId,
374
+ ...this.identity(event.agentName),
375
+ blockIndex: event.blockIndex,
376
+ blockType: event.blockType as BlockType,
377
+ };
378
+ if (event.phase === 'block_start') {
379
+ this.client?.emit('block_start', base);
380
+ } else {
381
+ // Reconstructed from this block's chunks — the trace itself
382
+ // deliberately omits block content.
383
+ const content = st.blocks.get(event.blockIndex) ?? '';
384
+ st.blocks.delete(event.blockIndex);
385
+ this.client?.emit('block_complete', { ...base, content });
386
+ }
387
+ break;
388
+ }
389
+
390
+ case 'inference:completed':
391
+ this.endActivation(event.agentName, 'complete');
392
+ break;
393
+ case 'inference:aborted':
394
+ this.endActivation(event.agentName, 'abort');
395
+ break;
396
+ case 'inference:failed':
397
+ case 'inference:exhausted':
398
+ this.endActivation(event.agentName, 'error');
399
+ break;
400
+
401
+ case 'mcpl:speech-routed': {
402
+ // Remember what landed where, so an interruption can find its edit
403
+ // target: (channelId, messageId, text) per posted segment.
404
+ this.routed.push({
405
+ rawChannelId: rawChannelId(event.channelId),
406
+ mcplChannelId: event.channelId,
407
+ messageId: event.messageId,
408
+ text: event.text,
409
+ at: event.timestamp,
410
+ });
411
+ if (this.routed.length > ROUTED_RING_MAX) {
412
+ this.routed.splice(0, this.routed.length - ROUTED_RING_MAX);
413
+ }
414
+ break;
415
+ }
416
+
417
+ default:
418
+ break;
419
+ }
420
+ }
421
+
422
+ // -------------------------------------------------------------------------
423
+ // Interruption → edit the posted message down to the voiced words
424
+ // -------------------------------------------------------------------------
425
+
426
+ private async handleInterruption(ev: InterruptionEvent): Promise<void> {
427
+ console.error(
428
+ `[tts-relay] interruption in ${ev.channelId} (${ev.reason}): ${ev.spokenText.length} chars voiced`,
429
+ );
430
+
431
+ const target = this.findInterruptedMessage(ev);
432
+ let edited = false;
433
+
434
+ if (target?.messageId && ev.spokenText.trim().length > 0) {
435
+ const result = await this.ctx!.callTool({
436
+ id: `tts-relay-interrupt-${Date.now()}`,
437
+ name: `mcpl--${this.config.editServerId ?? 'discord'}--edit_message`,
438
+ input: {
439
+ channelId: target.rawChannelId,
440
+ messageId: target.messageId,
441
+ content: ev.spokenText,
442
+ },
443
+ });
444
+ edited = result.success === true;
445
+ if (!edited) {
446
+ console.error(`[tts-relay] edit_message failed for ${target.messageId}:`, result.error);
447
+ }
448
+ } else if (!target?.messageId) {
449
+ console.error('[tts-relay] no routed message matched the interruption — nothing edited');
450
+ }
451
+
452
+ // Tell the agent she was cut off — otherwise her next turn believes the
453
+ // whole paragraph was heard. Plain context note, no inference request:
454
+ // the voice client's transcript of what the human said is the activation.
455
+ if (this.config.notifyOnInterruption !== false) {
456
+ const note = edited
457
+ ? `[voice] Your message was interrupted by ${ev.reason.replace('_', ' ')} — only this much was voiced, and the posted message was trimmed to match: "${ev.spokenText}"`
458
+ : `[voice] Your reply was interrupted by ${ev.reason.replace('_', ' ')} after: "${ev.spokenText}" (the posted message could not be trimmed)`;
459
+ this.ctx?.addMessage('user', [{ type: 'text', text: note }]);
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Match the interruption to the posted segment being voiced when it hit:
465
+ * newest-first, same channel, and the voiced words prefix-match the
466
+ * segment's text (whitespace-collapsed — TTS clients normalize). Falls back
467
+ * to the newest message in the channel: with ROUTED_RING_MAX recency that's
468
+ * almost always the one being read aloud.
469
+ */
470
+ private findInterruptedMessage(ev: InterruptionEvent): RoutedMessage | null {
471
+ const inChannel = this.routed.filter((r) => r.rawChannelId === ev.channelId).reverse();
472
+ if (inChannel.length === 0) return null;
473
+ const spoken = collapseWs(ev.spokenText);
474
+ if (spoken.length > 0) {
475
+ const probe = spoken.slice(0, 80);
476
+ const match = inChannel.find((r) => collapseWs(r.text).startsWith(probe));
477
+ if (match) return match;
478
+ }
479
+ return inChannel[0];
480
+ }
481
+ }
package/src/recipe.ts CHANGED
@@ -28,6 +28,10 @@ export interface RecipeStrategy {
28
28
  headWindowTokens?: number;
29
29
  recentWindowTokens?: number;
30
30
  compressionModel?: string;
31
+ /** Cap compression `max_tokens` at the compression model's OUTPUT ceiling.
32
+ * Required for pre-4.x models (Claude 3 Opus caps at 4096) — without it the
33
+ * summarizer's 16k floor is rejected and the agent never folds. */
34
+ compressionMaxTokens?: number;
31
35
  maxMessageTokens?: number;
32
36
  overBudgetGraceRatio?: number;
33
37
  // Compression/merge tuning passed through to the underlying
@@ -117,6 +121,12 @@ export interface RecipeAgent {
117
121
  * Omitted preserves the compatibility carry-forward in Agent Framework.
118
122
  */
119
123
  sameRoundThinkTextPolicy?: 'public' | 'private';
124
+ /**
125
+ * Prose delivery mode (agent-framework docs/explicit-prose-routing.md).
126
+ * 'explicit' = model prefixes plain text with `>>destination`; unprefixed
127
+ * prose bounces to a clipboard instead of auto-routing. Default 'locus'.
128
+ */
129
+ proseRouting?: 'locus' | 'explicit';
120
130
  /**
121
131
  * Extra Anthropic beta flags sent as the `anthropic-beta` header on every
122
132
  * request (e.g. `["context-1m-2025-08-07"]` for the 1M context window on
@@ -420,6 +430,18 @@ export interface RecipeModules {
420
430
  */
421
431
  webui?: boolean | RecipeWebUi;
422
432
 
433
+ /**
434
+ * Live TTS streaming tap (melodeus-tts-relay). Off by default. When set,
435
+ * the host mirrors the agent's streaming generation — chunks, block
436
+ * boundaries, activation start/end, tagged with the turn's channel — to the
437
+ * relay's `/bot` WebSocket, where voice clients (Melodeus / iOS) speak it.
438
+ * Also handles the relay's `interruption` events: the just-posted Discord
439
+ * message is edited down to the words actually voiced, and a note lands in
440
+ * the agent's context. Pure trace-bus tap — no effect on the agent loop.
441
+ * `token` should be `${VAR}`-substituted from .env, never literal.
442
+ */
443
+ ttsRelay?: RecipeTtsRelay;
444
+
423
445
  /**
424
446
  * Auto-unsubscribe noisy ambient channels. On by default. Per subscribed
425
447
  * channel, the host counts ambient (non-mention, non-DM) characters since the
@@ -428,7 +450,7 @@ export interface RecipeModules {
428
450
  * tracking what's then missed). Counters reset on every activation — the
429
451
  * agent sees all subscribed channels in context, compressed if large — and
430
452
  * persist across restarts. The agent can override the per-channel limit at
431
- * runtime via the `subscription-gc--set_channel_idle_limit` tool.
453
+ * runtime via `agent_settings` (field `channel_idle_limits`).
432
454
  *
433
455
  * `false` disables entirely. `defaultLimitChars` sets the global default
434
456
  * (20000 if omitted). `serverId`/`toolPrefix` target the MCPL surface that
@@ -471,6 +493,25 @@ export interface RecipeWebUi {
471
493
  allowedOrigins?: string[];
472
494
  }
473
495
 
496
+ export interface RecipeTtsRelay {
497
+ /** Relay bot endpoint, e.g. "ws://localhost:8800/bot". */
498
+ url: string;
499
+ /** Shared secret for the relay's /bot auth (its BOT_TOKENS). */
500
+ token: string;
501
+ /** Relay-side bot identifier. Defaults to the agent's name. */
502
+ botId?: string;
503
+ /** Discord user id stamped on stream events (client voice-config key).
504
+ * Defaults to botId. */
505
+ userId?: string;
506
+ /** Display name stamped on stream events. Defaults to the agent's name. */
507
+ username?: string;
508
+ /** MCPL server id owning `edit_message` for interruption trims. Default 'discord'. */
509
+ editServerId?: string;
510
+ reconnectIntervalMs?: number;
511
+ /** Drop a context note when an interruption trims a posted message. Default true. */
512
+ notifyOnInterruption?: boolean;
513
+ }
514
+
474
515
  export interface RecipeFleet {
475
516
  /** Children to manage. autoStart children launch when the framework starts. */
476
517
  children?: RecipeFleetChild[];
@@ -1110,6 +1151,22 @@ export function validateRecipe(raw: unknown): Recipe {
1110
1151
  }
1111
1152
  }
1112
1153
 
1154
+ // Validate ttsRelay if present — url + token are load-bearing, and a
1155
+ // recipe that names the module but can't reach a relay should fail at
1156
+ // load, not silently stream nowhere.
1157
+ if (mods.ttsRelay !== undefined && mods.ttsRelay !== false) {
1158
+ if (typeof mods.ttsRelay !== 'object' || mods.ttsRelay === null) {
1159
+ throw new Error('modules.ttsRelay must be an object ({ url, token, ... })');
1160
+ }
1161
+ const tr = mods.ttsRelay as Record<string, unknown>;
1162
+ if (typeof tr.url !== 'string' || !/^wss?:\/\//.test(tr.url)) {
1163
+ throw new Error('modules.ttsRelay.url must be a ws:// or wss:// URL');
1164
+ }
1165
+ if (typeof tr.token !== 'string' || !tr.token) {
1166
+ throw new Error('modules.ttsRelay.token must be a non-empty string (use ${VAR} substitution)');
1167
+ }
1168
+ }
1169
+
1113
1170
  // Validate fleet if present (object form only — boolean is trivially valid).
1114
1171
  if (mods.fleet && typeof mods.fleet === 'object') {
1115
1172
  const fleet = mods.fleet as Record<string, unknown>;