@animalabs/connectome-host 0.3.9 → 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/.env.example +7 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
- package/.github/workflows/changelog.yml +32 -0
- package/.github/workflows/publish.yml +46 -0
- package/CHANGELOG.md +140 -0
- package/CONTRIBUTING.md +126 -0
- package/HEADLESS-FLEET-PLAN.md +1 -1
- package/README.md +52 -5
- 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 +5 -4
- package/scripts/release-changelog.ts +32 -0
- package/src/codex-subscription-adapter.ts +938 -0
- package/src/commands.ts +95 -4
- package/src/extensions.ts +201 -0
- package/src/framework-agent-config.ts +20 -9
- package/src/framework-strategy.ts +24 -0
- package/src/index.ts +111 -19
- package/src/logging-bedrock-adapter.ts +145 -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/modules/web-ui-module.ts +45 -1
- package/src/recipe.ts +196 -10
- package/src/tui.ts +527 -137
- package/src/web/protocol.ts +35 -0
- package/test/codex-subscription-adapter.test.ts +226 -0
- package/test/commands-checkpoint-restore.test.ts +118 -0
- package/test/fast-command.test.ts +39 -0
- package/test/recipe-extensions.test.ts +295 -0
- package/test/recipe-provider.test.ts +14 -1
- 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
- package/test/web-ui-observers.test.ts +14 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/web/src/App.tsx +235 -20
- package/web/src/Branches.tsx +150 -0
- package/web/src/Context.tsx +21 -13
- package/web/src/Health.tsx +274 -0
- package/web/src/Lessons.tsx +2 -1
|
@@ -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
|
+
}
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* fan-out across many VMs are the reverse-proxy's job; an optional Basic-Auth
|
|
17
17
|
* check is available as defense-in-depth.
|
|
18
18
|
*
|
|
19
|
-
* See
|
|
19
|
+
* See src/web/protocol.ts for the wire shape and docs/webui-deployment.md
|
|
20
|
+
* for the deployment story.
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
import { CURVE_PAGE_HTML } from './web-ui-curve-page.js';
|
|
@@ -57,6 +58,7 @@ import {
|
|
|
57
58
|
type TokenUsage,
|
|
58
59
|
type CallLedgerSnapshot,
|
|
59
60
|
type McplListMessage,
|
|
61
|
+
type BranchesListMessage,
|
|
60
62
|
type LessonsListMessage,
|
|
61
63
|
} from '../web/protocol.js';
|
|
62
64
|
import {
|
|
@@ -1547,6 +1549,9 @@ export class WebUiModule implements Module {
|
|
|
1547
1549
|
if (client.auth !== 'observer') return false;
|
|
1548
1550
|
if (type === 'ping') return true;
|
|
1549
1551
|
if (type === 'request-history') return client.scopes?.has('messages') ?? false;
|
|
1552
|
+
// Branch listing is conversation-shape metadata (names, fork points) —
|
|
1553
|
+
// same sensitivity tier as the message window, so same scope.
|
|
1554
|
+
if (type === 'request-branches') return client.scopes?.has('messages') ?? false;
|
|
1550
1555
|
return false; // observers are read-only: no user-message/command/mcpl/fleet
|
|
1551
1556
|
}
|
|
1552
1557
|
|
|
@@ -1698,6 +1703,11 @@ export class WebUiModule implements Module {
|
|
|
1698
1703
|
return;
|
|
1699
1704
|
}
|
|
1700
1705
|
|
|
1706
|
+
case 'request-branches': {
|
|
1707
|
+
this.sendBranchesList(client);
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1701
1711
|
case 'mcpl-add': {
|
|
1702
1712
|
try {
|
|
1703
1713
|
const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
|
|
@@ -1983,6 +1993,40 @@ export class WebUiModule implements Module {
|
|
|
1983
1993
|
this.send(client, out);
|
|
1984
1994
|
}
|
|
1985
1995
|
|
|
1996
|
+
/** Build a BranchesListMessage from the agent's context manager. Lineage
|
|
1997
|
+
* (parentId + branchPoint) comes straight from Chronicle's branch records;
|
|
1998
|
+
* the SPA folds it into a tree. */
|
|
1999
|
+
private sendBranchesList(client: ClientState): void {
|
|
2000
|
+
if (!sharedServer?.app) return;
|
|
2001
|
+
const cm = sharedServer.app.framework.getAllAgents()[0]?.getContextManager();
|
|
2002
|
+
if (!cm) {
|
|
2003
|
+
this.send(client, { type: 'error', message: 'no agent context manager' });
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
try {
|
|
2007
|
+
const branches = cm.listBranches();
|
|
2008
|
+
const current = cm.currentBranch();
|
|
2009
|
+
const out: BranchesListMessage = {
|
|
2010
|
+
type: 'branches-list',
|
|
2011
|
+
branches: branches.map((b) => ({
|
|
2012
|
+
id: b.id,
|
|
2013
|
+
name: b.name,
|
|
2014
|
+
head: b.head,
|
|
2015
|
+
...(b.parentId !== undefined ? { parentId: b.parentId } : {}),
|
|
2016
|
+
...(b.branchPoint !== undefined ? { branchPoint: b.branchPoint } : {}),
|
|
2017
|
+
created: b.created instanceof Date ? b.created.getTime() : Number(b.created),
|
|
2018
|
+
})),
|
|
2019
|
+
currentId: current.id,
|
|
2020
|
+
};
|
|
2021
|
+
this.send(client, out);
|
|
2022
|
+
} catch (err) {
|
|
2023
|
+
this.send(client, {
|
|
2024
|
+
type: 'error',
|
|
2025
|
+
message: `branch listing failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1986
2030
|
/** Build a LessonsListMessage from the bound LessonsModule, if present. */
|
|
1987
2031
|
private sendLessonsList(client: ClientState): void {
|
|
1988
2032
|
if (!sharedServer?.app) return;
|