@animalabs/connectome-host 0.3.10 → 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.
@@ -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
+ }