@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.
- package/CHANGELOG.md +81 -0
- package/HEADLESS-FLEET-PLAN.md +1 -1
- package/UNIFIED-TREE-PLAN.md +2 -0
- package/docs/AGENT-ONBOARDING.md +9 -8
- package/docs/DEPLOYMENTS.md +2 -2
- package/docs/DEV-ENVIRONMENT.md +28 -26
- package/docs/LOCUS-ROUTING-DESIGN.md +8 -2
- package/package.json +4 -4
- package/src/commands.ts +50 -4
- package/src/framework-agent-config.ts +3 -0
- package/src/framework-strategy.ts +3 -0
- package/src/index.ts +9 -0
- package/src/modules/channel-mode-module.ts +9 -6
- package/src/modules/subscription-gc-module.ts +163 -34
- package/src/modules/tts-relay-module.ts +481 -0
- package/src/recipe.ts +58 -1
- package/src/tui.ts +363 -104
- package/test/commands-checkpoint-restore.test.ts +118 -0
- package/test/subscription-gc-module.test.ts +156 -1
- package/test/tui-quit-confirm.test.ts +42 -0
- package/test/tui-short-agent-name.test.ts +36 -0
|
@@ -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
|
|
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>;
|