@voxagent/bridge 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +32 -0
  2. package/dist/cli.js +44 -7
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +15 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +15 -1
  7. package/dist/config.js.map +1 -1
  8. package/dist/drivers/claude.d.ts +8 -0
  9. package/dist/drivers/claude.d.ts.map +1 -1
  10. package/dist/drivers/claude.js +105 -32
  11. package/dist/drivers/claude.js.map +1 -1
  12. package/dist/drivers/codex.js +1 -1
  13. package/dist/drivers/codex.js.map +1 -1
  14. package/dist/drivers/inbox.d.ts +26 -0
  15. package/dist/drivers/inbox.d.ts.map +1 -0
  16. package/dist/drivers/inbox.js +53 -0
  17. package/dist/drivers/inbox.js.map +1 -0
  18. package/dist/drivers/types.d.ts +9 -0
  19. package/dist/drivers/types.d.ts.map +1 -1
  20. package/dist/history.d.ts +31 -0
  21. package/dist/history.d.ts.map +1 -0
  22. package/dist/history.js +170 -0
  23. package/dist/history.js.map +1 -0
  24. package/dist/live/frontend.d.ts +136 -0
  25. package/dist/live/frontend.d.ts.map +1 -0
  26. package/dist/live/frontend.js +529 -0
  27. package/dist/live/frontend.js.map +1 -0
  28. package/dist/live/mixer.d.ts +38 -0
  29. package/dist/live/mixer.d.ts.map +1 -0
  30. package/dist/live/mixer.js +113 -0
  31. package/dist/live/mixer.js.map +1 -0
  32. package/dist/live/pcm.d.ts +34 -0
  33. package/dist/live/pcm.d.ts.map +1 -0
  34. package/dist/live/pcm.js +66 -0
  35. package/dist/live/pcm.js.map +1 -0
  36. package/dist/live/prompts.d.ts +65 -0
  37. package/dist/live/prompts.d.ts.map +1 -0
  38. package/dist/live/prompts.js +148 -0
  39. package/dist/live/prompts.js.map +1 -0
  40. package/dist/live/session.d.ts +132 -0
  41. package/dist/live/session.d.ts.map +1 -0
  42. package/dist/live/session.js +300 -0
  43. package/dist/live/session.js.map +1 -0
  44. package/dist/live/speakers.d.ts +54 -0
  45. package/dist/live/speakers.d.ts.map +1 -0
  46. package/dist/live/speakers.js +138 -0
  47. package/dist/live/speakers.js.map +1 -0
  48. package/dist/live/transcript.d.ts +40 -0
  49. package/dist/live/transcript.d.ts.map +1 -0
  50. package/dist/live/transcript.js +72 -0
  51. package/dist/live/transcript.js.map +1 -0
  52. package/dist/protocol.d.ts +63 -4
  53. package/dist/protocol.d.ts.map +1 -1
  54. package/dist/protocol.js.map +1 -1
  55. package/dist/room.d.ts +25 -0
  56. package/dist/room.d.ts.map +1 -1
  57. package/dist/room.js +218 -5
  58. package/dist/room.js.map +1 -1
  59. package/dist/version.d.ts +8 -0
  60. package/dist/version.d.ts.map +1 -0
  61. package/dist/version.js +19 -0
  62. package/dist/version.js.map +1 -0
  63. package/package.json +5 -2
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @file GPT-Live as the agent's voice: ears, mouth and turn-taking.
3
+ *
4
+ * In `--voice-mode live` the bridge stops being text-in/TTS-out. Instead:
5
+ *
6
+ * room mics ──► PcmMixer ──► GPT-Live (one mixed stream) ──► agent track
7
+ * │ │ │
8
+ * └► SpeakerTracker ──────┘ └─ session.delegation.created
9
+ * ("Now speaking: David") │
10
+ * ▼
11
+ * BridgeRoomRuntime runs Claude Code / Codex, then
12
+ * reports progress (thinking) and the result
13
+ * (commentary) back to GPT-Live, which says it.
14
+ *
15
+ * GPT-Live decides when to talk, handles barge-in and backchannels, and
16
+ * picks what to hand off. This class owns everything GPT-Live-specific;
17
+ * `room.ts` only sees delegations and reports outcomes.
18
+ */
19
+ import { type Room } from '@livekit/rtc-node';
20
+ import { type TranscriptFinalEvent, type TranscriptWindowEntry } from '../protocol.js';
21
+ import { type LiveSocketFactory } from './session.js';
22
+ export interface LiveDelegation {
23
+ /** GPT-Live delegation ID; results must quote it. */
24
+ id: string;
25
+ /** User turn for the backend, built from what GPT-Live heard. */
26
+ promptText: string;
27
+ /** The same hand-off phrased for an agent already working (added to its run). */
28
+ followUpText: string;
29
+ /** Best guess at who asked, from the speaker timeline. */
30
+ requester: {
31
+ name: string;
32
+ identity: string;
33
+ } | null;
34
+ /** Speaker-attributed browser finals for the prompt's transcript window. */
35
+ roomTranscript: TranscriptWindowEntry[];
36
+ room: {
37
+ id: string;
38
+ name: string;
39
+ participants: string[];
40
+ };
41
+ }
42
+ export interface LiveVoiceFrontendOptions {
43
+ room: Room;
44
+ apiKey: string;
45
+ model: string;
46
+ voice: string;
47
+ instructions: string;
48
+ assistantName: string;
49
+ roomId: string;
50
+ /** Pair payload's room name — currently the room ID, so finals' names win. */
51
+ roomName: string;
52
+ hostIdentity: string | null;
53
+ onDelegation: (delegation: LiveDelegation) => void;
54
+ /** Test seam. */
55
+ socketFactory?: LiveSocketFactory;
56
+ }
57
+ export declare class LiveVoiceFrontend {
58
+ private readonly options;
59
+ private session;
60
+ /** Bumped per GPT-Live session; delegation IDs don't survive a reconnect. */
61
+ private generation;
62
+ private delegationGeneration;
63
+ private lastProgressAt;
64
+ private readonly mixer;
65
+ private readonly tracker;
66
+ private readonly transcript;
67
+ private readonly publisher;
68
+ private readonly outputChunker;
69
+ private playback;
70
+ private captures;
71
+ private names;
72
+ private roomFinals;
73
+ private roomName;
74
+ private participants;
75
+ private clock;
76
+ private clockOrigin;
77
+ private clockSamples;
78
+ /** Mixed audio sent to the current session = its timeline position. */
79
+ private sessionSamples;
80
+ private lastDelegationMs;
81
+ /** Transcript lines already written to the log (the meeting's paper trail). */
82
+ private loggedLines;
83
+ private muted;
84
+ private closing;
85
+ private reconnectAttempt;
86
+ private reconnectTimer;
87
+ private rosterTimer;
88
+ constructor(options: LiveVoiceFrontendOptions);
89
+ start(): Promise<void>;
90
+ close(): Promise<void>;
91
+ /** System-prompt addendum for a backend run this front end started. */
92
+ backendAddendum(): string;
93
+ /** Browser STT finals: reliable speaker labels for the backend prompt. */
94
+ noteRoomFinal(event: TranscriptFinalEvent): void;
95
+ /** Quiet progress for a delegated task ("Running the tests."). Rate-limited. */
96
+ reportProgress(delegationId: string | null, text: string): void;
97
+ /** The backend's final reply, for GPT-Live to relay in its own words. */
98
+ reportResult(delegationId: string | null, text: string): void;
99
+ reportFailure(delegationId: string | null, message: string): void;
100
+ /** Something the room should hear now, not tied to a task. */
101
+ say(text: string): void;
102
+ /** The UI's Stop button: cut speech now and tell GPT-Live why. */
103
+ stopSpeaking(taskCancelled: boolean): void;
104
+ /** Room-wide agent mute: stop listening and speaking, keep the session. */
105
+ setMuted(muted: boolean): void;
106
+ private openSession;
107
+ private handleSessionEvent;
108
+ private scheduleReconnect;
109
+ /** Recent conversation as text history for a replacement session. */
110
+ private buildSeedHistory;
111
+ private emitDelegation;
112
+ /** Delegation IDs belong to one session; after a reconnect report as general context. */
113
+ private liveDelegationId;
114
+ private forgetDelegation;
115
+ private tick;
116
+ private sendBlock;
117
+ private handleTrackSubscribed;
118
+ private handleTrackUnsubscribed;
119
+ private handleParticipantDisconnected;
120
+ private handleRosterChange;
121
+ private maybeCapture;
122
+ private pumpCapture;
123
+ private stopCapture;
124
+ private playAudio;
125
+ private dropQueuedOutput;
126
+ /**
127
+ * Writes finished transcript lines to the log: what GPT-Live heard (with
128
+ * the tracker's speaker label) and what it said. Lines still growing
129
+ * (touched within LOG_LINE_AFTER_MS of `beforeMs`) wait for a later pass.
130
+ */
131
+ private logTranscript;
132
+ private nameOf;
133
+ private humanNames;
134
+ private sendRoster;
135
+ }
136
+ //# sourceMappingURL=frontend.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frontend.d.ts","sourceRoot":"","sources":["../../src/live/frontend.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,529 @@
1
+ /**
2
+ * @file GPT-Live as the agent's voice: ears, mouth and turn-taking.
3
+ *
4
+ * In `--voice-mode live` the bridge stops being text-in/TTS-out. Instead:
5
+ *
6
+ * room mics ──► PcmMixer ──► GPT-Live (one mixed stream) ──► agent track
7
+ * │ │ │
8
+ * └► SpeakerTracker ──────┘ └─ session.delegation.created
9
+ * ("Now speaking: David") │
10
+ * ▼
11
+ * BridgeRoomRuntime runs Claude Code / Codex, then
12
+ * reports progress (thinking) and the result
13
+ * (commentary) back to GPT-Live, which says it.
14
+ *
15
+ * GPT-Live decides when to talk, handles barge-in and backchannels, and
16
+ * picks what to hand off. This class owns everything GPT-Live-specific;
17
+ * `room.ts` only sees delegations and reports outcomes.
18
+ */
19
+ import { AudioStream, RoomEvent, TrackKind, TrackSource, } from '@livekit/rtc-node';
20
+ import { AgentAudioPublisher } from '../audio/source.js';
21
+ import { log } from '../logging.js';
22
+ import { isAgentIdentity } from '../protocol.js';
23
+ import { PcmMixer } from './mixer.js';
24
+ import { LIVE_SAMPLE_RATE, PcmChunker, SAMPLES_PER_MS } from './pcm.js';
25
+ import { buildBackendAddendum, buildDelegationPrompt, buildFollowUpPrompt, nowSpeakingHint, rosterHint, splitForAppend, } from './prompts.js';
26
+ import { LiveSession, } from './session.js';
27
+ import { SpeakerTracker } from './speakers.js';
28
+ import { LiveTranscript, formatLine, pickDelegationContext, } from './transcript.js';
29
+ /** Mixed audio goes to GPT-Live in 40 ms blocks. */
30
+ const BLOCK_SAMPLES = 40 * SAMPLES_PER_MS;
31
+ /** Output frames into LiveKit, matching the TTS engines' 20 ms framing. */
32
+ const OUTPUT_FRAME_SAMPLES = 20 * SAMPLES_PER_MS;
33
+ const CLOCK_INTERVAL_MS = 20;
34
+ /** Past this much lag (event-loop stall) the clock skips ahead instead of bursting. */
35
+ const MAX_CATCHUP_SAMPLES = 400 * SAMPLES_PER_MS;
36
+ /** Per-source backlog cap — bounds added latency from jitter and drift. */
37
+ const MAX_SOURCE_BACKLOG_SAMPLES = 300 * SAMPLES_PER_MS;
38
+ /** Let trailing transcript fragments land before building a delegation prompt. */
39
+ const DELEGATION_SETTLE_MS = 250;
40
+ const PROGRESS_MIN_GAP_MS = 5_000;
41
+ const ROOM_FINALS_KEPT = 16;
42
+ const SEED_MAX_MESSAGES = 60;
43
+ const SEED_MAX_CHARS = 20_000;
44
+ const RECONNECT_DELAYS_MS = [500, 2_000, 5_000, 10_000, 20_000];
45
+ /** A transcript line is logged once nothing has been added to it for this long. */
46
+ const LOG_LINE_AFTER_MS = 2_500;
47
+ export class LiveVoiceFrontend {
48
+ options;
49
+ session = null;
50
+ /** Bumped per GPT-Live session; delegation IDs don't survive a reconnect. */
51
+ generation = 0;
52
+ delegationGeneration = new Map();
53
+ lastProgressAt = new Map();
54
+ mixer = new PcmMixer({ maxBufferedSamples: MAX_SOURCE_BACKLOG_SAMPLES });
55
+ tracker = new SpeakerTracker();
56
+ transcript = new LiveTranscript();
57
+ publisher = new AgentAudioPublisher(LIVE_SAMPLE_RATE, 1);
58
+ outputChunker = new PcmChunker(OUTPUT_FRAME_SAMPLES);
59
+ playback = Promise.resolve();
60
+ captures = new Map();
61
+ names = new Map();
62
+ roomFinals = [];
63
+ roomName;
64
+ participants = [];
65
+ clock = null;
66
+ clockOrigin = 0;
67
+ clockSamples = 0;
68
+ /** Mixed audio sent to the current session = its timeline position. */
69
+ sessionSamples = 0;
70
+ lastDelegationMs = 0;
71
+ /** Transcript lines already written to the log (the meeting's paper trail). */
72
+ loggedLines = new WeakSet();
73
+ muted = false;
74
+ closing = false;
75
+ reconnectAttempt = 0;
76
+ reconnectTimer = null;
77
+ rosterTimer = null;
78
+ constructor(options) {
79
+ this.options = options;
80
+ this.roomName = options.roomName;
81
+ }
82
+ async start() {
83
+ const { room } = this.options;
84
+ await this.publisher.publish(room);
85
+ room.on(RoomEvent.TrackSubscribed, this.handleTrackSubscribed);
86
+ room.on(RoomEvent.TrackUnsubscribed, this.handleTrackUnsubscribed);
87
+ room.on(RoomEvent.ParticipantConnected, this.handleRosterChange);
88
+ room.on(RoomEvent.ParticipantDisconnected, this.handleParticipantDisconnected);
89
+ for (const participant of room.remoteParticipants.values()) {
90
+ for (const publication of participant.trackPublications.values()) {
91
+ if (publication.track)
92
+ this.maybeCapture(publication.track, publication, participant);
93
+ }
94
+ }
95
+ this.clockOrigin = performance.now();
96
+ this.clockSamples = 0;
97
+ this.clock = setInterval(this.tick, CLOCK_INTERVAL_MS);
98
+ await this.openSession([]);
99
+ }
100
+ async close() {
101
+ if (this.closing)
102
+ return;
103
+ this.closing = true;
104
+ if (this.clock)
105
+ clearInterval(this.clock);
106
+ if (this.reconnectTimer)
107
+ clearTimeout(this.reconnectTimer);
108
+ if (this.rosterTimer)
109
+ clearTimeout(this.rosterTimer);
110
+ const { room } = this.options;
111
+ room.off(RoomEvent.TrackSubscribed, this.handleTrackSubscribed);
112
+ room.off(RoomEvent.TrackUnsubscribed, this.handleTrackUnsubscribed);
113
+ room.off(RoomEvent.ParticipantConnected, this.handleRosterChange);
114
+ room.off(RoomEvent.ParticipantDisconnected, this.handleParticipantDisconnected);
115
+ for (const key of [...this.captures.keys()])
116
+ this.stopCapture(key);
117
+ this.logTranscript(Number.POSITIVE_INFINITY);
118
+ await this.session?.close();
119
+ await this.publisher.close();
120
+ }
121
+ // ---------------------------------------------------------------------
122
+ // Called by the room runtime
123
+ // ---------------------------------------------------------------------
124
+ /** System-prompt addendum for a backend run this front end started. */
125
+ backendAddendum() {
126
+ return buildBackendAddendum({
127
+ assistantName: this.options.assistantName,
128
+ roomName: this.roomName,
129
+ participants: this.participants,
130
+ });
131
+ }
132
+ /** Browser STT finals: reliable speaker labels for the backend prompt. */
133
+ noteRoomFinal(event) {
134
+ if (event.room?.name)
135
+ this.roomName = event.room.name;
136
+ if (event.room?.participants?.length)
137
+ this.participants = event.room.participants;
138
+ this.roomFinals.push({ speaker: event.speaker, text: event.text, timestampMs: event.timestampMs });
139
+ if (this.roomFinals.length > ROOM_FINALS_KEPT)
140
+ this.roomFinals.shift();
141
+ }
142
+ /** Quiet progress for a delegated task ("Running the tests."). Rate-limited. */
143
+ reportProgress(delegationId, text) {
144
+ const key = delegationId ?? '';
145
+ const now = Date.now();
146
+ if (now - (this.lastProgressAt.get(key) ?? 0) < PROGRESS_MIN_GAP_MS)
147
+ return;
148
+ this.lastProgressAt.set(key, now);
149
+ this.session?.appendThinking(`Backend progress: ${text}`, this.liveDelegationId(delegationId));
150
+ }
151
+ /** The backend's final reply, for GPT-Live to relay in its own words. */
152
+ reportResult(delegationId, text) {
153
+ const chunks = splitForAppend(text);
154
+ const target = this.liveDelegationId(delegationId);
155
+ log.info('live_result', { delegationId, sentAs: target ?? 'general context', chunks: chunks.length, text: text.slice(0, 600) });
156
+ if (chunks.length === 0) {
157
+ this.session?.appendCommentary('The agent finished but did not return a summary.', target);
158
+ }
159
+ for (const chunk of chunks)
160
+ this.session?.appendCommentary(chunk, target);
161
+ if (delegationId)
162
+ this.forgetDelegation(delegationId);
163
+ }
164
+ reportFailure(delegationId, message) {
165
+ log.info('live_result_failed', { delegationId, message: message.slice(0, 300) });
166
+ const detail = message.trim() ? ` ${message.trim().slice(0, 300)}` : '';
167
+ this.session?.appendCommentary(`The agent could not finish that task.${detail}`, this.liveDelegationId(delegationId));
168
+ if (delegationId)
169
+ this.forgetDelegation(delegationId);
170
+ }
171
+ /** Something the room should hear now, not tied to a task. */
172
+ say(text) {
173
+ this.session?.appendCommentary(text, null);
174
+ }
175
+ /** The UI's Stop button: cut speech now and tell GPT-Live why. */
176
+ stopSpeaking(taskCancelled) {
177
+ this.dropQueuedOutput();
178
+ this.session?.appendInstructions(`Someone pressed Stop. Stop speaking immediately and wait to be addressed.${taskCancelled ? ' The task you delegated was cancelled; do not report on it.' : ''}`);
179
+ }
180
+ /** Room-wide agent mute: stop listening and speaking, keep the session. */
181
+ setMuted(muted) {
182
+ if (muted === this.muted)
183
+ return;
184
+ this.muted = muted;
185
+ log.info('live_mute_changed', { muted });
186
+ this.session?.setInputMuted(muted);
187
+ if (muted)
188
+ this.dropQueuedOutput();
189
+ }
190
+ // ---------------------------------------------------------------------
191
+ // Session lifecycle
192
+ // ---------------------------------------------------------------------
193
+ async openSession(input) {
194
+ const generation = ++this.generation;
195
+ const session = new LiveSession({
196
+ apiKey: this.options.apiKey,
197
+ model: this.options.model,
198
+ voice: this.options.voice,
199
+ instructions: this.options.instructions,
200
+ input,
201
+ }, (event) => this.handleSessionEvent(generation, event), this.options.socketFactory);
202
+ this.session = session;
203
+ await session.start();
204
+ this.reconnectAttempt = 0;
205
+ if (this.muted)
206
+ session.setInputMuted(true);
207
+ this.sendRoster();
208
+ }
209
+ handleSessionEvent(generation, event) {
210
+ if (generation !== this.generation)
211
+ return;
212
+ switch (event.type) {
213
+ case 'started':
214
+ // New session timeline: everything time-based restarts at 0.
215
+ this.sessionSamples = 0;
216
+ this.lastDelegationMs = 0;
217
+ this.tracker.reset();
218
+ this.outputChunker.reset();
219
+ log.info('live_session_started', {
220
+ sessionId: event.sessionId,
221
+ expiresInS: event.expiresAt ? Math.round(event.expiresAt - Date.now() / 1000) : null,
222
+ generation,
223
+ });
224
+ return;
225
+ case 'audio':
226
+ this.playAudio(event.samples);
227
+ return;
228
+ case 'transcript':
229
+ this.logTranscript(event.startMs - LOG_LINE_AFTER_MS);
230
+ if (event.role === 'input') {
231
+ const speakerId = this.tracker.speakerAt(event.startMs, event.endMs);
232
+ const name = speakerId ? this.nameOf(speakerId) : null;
233
+ this.transcript.addFragment('room', name, event.delta, event.startMs, event.endMs);
234
+ }
235
+ else {
236
+ this.transcript.addFragment('assistant', null, event.delta, event.startMs, event.endMs);
237
+ }
238
+ return;
239
+ case 'delegation':
240
+ this.delegationGeneration.set(event.id, generation);
241
+ setTimeout(() => this.emitDelegation(event.id, event.offsetMs), DELEGATION_SETTLE_MS);
242
+ return;
243
+ case 'usage':
244
+ log.debug('live_usage', { seconds: event.seconds, contextRatio: event.contextRatio });
245
+ return;
246
+ case 'error':
247
+ log.warn('live_error', { code: event.code, message: event.message, clientEventId: event.clientEventId });
248
+ return;
249
+ case 'closed':
250
+ this.logTranscript(Number.POSITIVE_INFINITY);
251
+ log.info('live_session_closed', { reason: event.reason, usageSeconds: event.usageSeconds });
252
+ if (!this.closing)
253
+ this.scheduleReconnect(event.reason);
254
+ return;
255
+ default: {
256
+ const exhaustive = event;
257
+ log.warn('live_unknown_event', { event: exhaustive });
258
+ }
259
+ }
260
+ }
261
+ scheduleReconnect(reason) {
262
+ if (this.reconnectTimer)
263
+ return;
264
+ const attempt = this.reconnectAttempt++;
265
+ const delay = reason === 'expired' ? 0 : RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
266
+ log.warn('live_session_reconnecting', { reason, attempt: attempt + 1, delayMs: delay });
267
+ this.reconnectTimer = setTimeout(() => {
268
+ this.reconnectTimer = null;
269
+ if (this.closing)
270
+ return;
271
+ const seed = this.buildSeedHistory();
272
+ this.transcript.clear();
273
+ this.openSession(seed).catch((error) => {
274
+ log.error('live_session_start_failed', {
275
+ message: error instanceof Error ? error.message : String(error),
276
+ });
277
+ if (!this.closing)
278
+ this.scheduleReconnect('connection_lost');
279
+ });
280
+ }, delay);
281
+ }
282
+ /** Recent conversation as text history for a replacement session. */
283
+ buildSeedHistory() {
284
+ const items = [];
285
+ let chars = 0;
286
+ const lines = this.transcript.recent(SEED_MAX_MESSAGES);
287
+ for (let i = lines.length - 1; i >= 0; i--) {
288
+ const line = lines[i];
289
+ const text = line.role === 'assistant' ? line.text.trim() : formatLine(line, this.options.assistantName);
290
+ if (!text)
291
+ continue;
292
+ chars += text.length;
293
+ if (chars > SEED_MAX_CHARS)
294
+ break;
295
+ items.unshift(line.role === 'assistant'
296
+ ? { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] }
297
+ : { type: 'message', role: 'user', content: [{ type: 'input_text', text }] });
298
+ }
299
+ if (items.length > 0) {
300
+ items.unshift({
301
+ type: 'message',
302
+ role: 'developer',
303
+ content: [
304
+ {
305
+ type: 'input_text',
306
+ text: 'The voice connection was restarted. The messages below are the recent meeting conversation. Continue without greeting anyone again.',
307
+ },
308
+ ],
309
+ });
310
+ }
311
+ return items;
312
+ }
313
+ emitDelegation(id, offsetMs) {
314
+ const { heard, requester } = pickDelegationContext(this.transcript, this.lastDelegationMs, offsetMs);
315
+ // For an agent already at work: only what's new since the last hand-off.
316
+ const sinceLast = this.transcript.linesSince(this.lastDelegationMs);
317
+ const followUpHeard = sinceLast.length > 0 ? sinceLast : this.transcript.recent(2);
318
+ this.lastDelegationMs = Math.max(this.lastDelegationMs, offsetMs);
319
+ const requesterIdentity = requester
320
+ ? [...this.names.entries()].find(([, name]) => name === requester)?.[0] ?? null
321
+ : null;
322
+ const delegation = {
323
+ id,
324
+ promptText: buildDelegationPrompt({
325
+ assistantName: this.options.assistantName,
326
+ heard,
327
+ roomTranscript: this.roomFinals,
328
+ }),
329
+ followUpText: buildFollowUpPrompt({
330
+ assistantName: this.options.assistantName,
331
+ heard: followUpHeard,
332
+ requester,
333
+ }),
334
+ requester: requester && requesterIdentity ? { name: requester, identity: requesterIdentity } : null,
335
+ roomTranscript: [...this.roomFinals],
336
+ room: {
337
+ id: this.options.roomId,
338
+ name: this.roomName,
339
+ participants: this.participants.length > 0 ? this.participants : this.humanNames(),
340
+ },
341
+ };
342
+ this.logTranscript(Number.POSITIVE_INFINITY);
343
+ log.info('live_delegation', {
344
+ id,
345
+ offsetMs,
346
+ requester: delegation.requester?.name ?? null,
347
+ heardLines: heard.length,
348
+ });
349
+ log.info('live_delegation_prompt', { id, prompt: delegation.promptText });
350
+ this.options.onDelegation(delegation);
351
+ }
352
+ /** Delegation IDs belong to one session; after a reconnect report as general context. */
353
+ liveDelegationId(delegationId) {
354
+ if (!delegationId)
355
+ return null;
356
+ return this.delegationGeneration.get(delegationId) === this.generation ? delegationId : null;
357
+ }
358
+ forgetDelegation(delegationId) {
359
+ this.delegationGeneration.delete(delegationId);
360
+ this.lastProgressAt.delete(delegationId);
361
+ }
362
+ // ---------------------------------------------------------------------
363
+ // Audio in: room mics → mixer → GPT-Live
364
+ // ---------------------------------------------------------------------
365
+ tick = () => {
366
+ const target = Math.floor((performance.now() - this.clockOrigin) * SAMPLES_PER_MS);
367
+ if (target - this.clockSamples > MAX_CATCHUP_SAMPLES) {
368
+ log.debug('live_clock_skip', { lagMs: Math.round((target - this.clockSamples) / SAMPLES_PER_MS) });
369
+ this.clockSamples = target - BLOCK_SAMPLES;
370
+ }
371
+ while (this.clockSamples + BLOCK_SAMPLES <= target) {
372
+ this.clockSamples += BLOCK_SAMPLES;
373
+ this.sendBlock(this.mixer.pull(BLOCK_SAMPLES));
374
+ }
375
+ };
376
+ sendBlock(block) {
377
+ const session = this.session;
378
+ if (!session?.started)
379
+ return;
380
+ session.appendAudio(block.mixed);
381
+ this.sessionSamples += BLOCK_SAMPLES;
382
+ // Sources are per track; the floor is per person.
383
+ const byIdentity = new Map();
384
+ for (const [sourceId, level] of block.levels) {
385
+ const identity = this.captures.get(sourceId)?.identity;
386
+ if (!identity)
387
+ continue;
388
+ byIdentity.set(identity, Math.max(byIdentity.get(identity) ?? 0, level));
389
+ }
390
+ const announce = this.tracker.update(byIdentity, this.sessionSamples / SAMPLES_PER_MS);
391
+ if (announce && !this.muted && this.humanNames().length > 1) {
392
+ const hint = nowSpeakingHint(this.nameOf(announce), announce === this.options.hostIdentity);
393
+ log.info('live_hint', { atMs: Math.round(this.sessionSamples / SAMPLES_PER_MS), hint });
394
+ session.appendThinking(hint);
395
+ }
396
+ }
397
+ handleTrackSubscribed = (track, publication, participant) => {
398
+ this.maybeCapture(track, publication, participant);
399
+ };
400
+ handleTrackUnsubscribed = (_track, publication, participant) => {
401
+ this.stopCapture(sourceKey(participant.identity, publication.sid));
402
+ };
403
+ handleParticipantDisconnected = (participant) => {
404
+ for (const [key, capture] of this.captures) {
405
+ if (capture.identity === participant.identity)
406
+ this.stopCapture(key);
407
+ }
408
+ this.handleRosterChange();
409
+ };
410
+ handleRosterChange = () => {
411
+ if (this.rosterTimer)
412
+ clearTimeout(this.rosterTimer);
413
+ this.rosterTimer = setTimeout(() => {
414
+ this.rosterTimer = null;
415
+ this.sendRoster();
416
+ }, 1_000);
417
+ };
418
+ maybeCapture(track, publication, participant) {
419
+ // Humans' microphones only. Agents (including other bridges, later) are
420
+ // never mixed in: an agent hearing agents is how feedback loops start.
421
+ if (isAgentIdentity(participant.identity))
422
+ return;
423
+ if (track.kind !== TrackKind.KIND_AUDIO)
424
+ return;
425
+ if (publication.source !== TrackSource.SOURCE_MICROPHONE)
426
+ return;
427
+ const key = sourceKey(participant.identity, publication.sid);
428
+ if (this.captures.has(key))
429
+ return;
430
+ this.names.set(participant.identity, participant.name || participant.identity);
431
+ const stream = new AudioStream(track, { sampleRate: LIVE_SAMPLE_RATE, numChannels: 1 });
432
+ const capture = { identity: participant.identity, stream, reader: null };
433
+ this.captures.set(key, capture);
434
+ log.info('live_capture_started', { identity: participant.identity, name: this.nameOf(participant.identity) });
435
+ void this.pumpCapture(key, capture);
436
+ this.handleRosterChange();
437
+ }
438
+ async pumpCapture(key, capture) {
439
+ const reader = capture.stream.getReader();
440
+ capture.reader = reader;
441
+ try {
442
+ for (;;) {
443
+ const { done, value } = await reader.read();
444
+ if (done || !value)
445
+ break;
446
+ this.mixer.push(key, value.data);
447
+ }
448
+ }
449
+ catch (error) {
450
+ log.debug('live_capture_ended', {
451
+ identity: capture.identity,
452
+ message: error instanceof Error ? error.message : String(error),
453
+ });
454
+ }
455
+ finally {
456
+ if (this.captures.get(key) === capture)
457
+ this.stopCapture(key);
458
+ }
459
+ }
460
+ stopCapture(key) {
461
+ const capture = this.captures.get(key);
462
+ if (!capture)
463
+ return;
464
+ this.captures.delete(key);
465
+ this.mixer.remove(key);
466
+ void capture.reader?.cancel().catch(() => undefined);
467
+ log.info('live_capture_stopped', { identity: capture.identity });
468
+ }
469
+ // ---------------------------------------------------------------------
470
+ // Audio out: GPT-Live → agent track
471
+ // ---------------------------------------------------------------------
472
+ playAudio(samples) {
473
+ // GPT-Live streams continuously, silence included. While muted, drop it.
474
+ if (this.muted)
475
+ return;
476
+ for (const frame of this.outputChunker.push(samples)) {
477
+ this.playback = this.playback
478
+ .then(() => this.publisher.pushFrame({ samples: frame, sampleRate: LIVE_SAMPLE_RATE, numChannels: 1 }))
479
+ .catch((error) => {
480
+ log.debug('live_playback_failed', { message: error instanceof Error ? error.message : String(error) });
481
+ });
482
+ }
483
+ }
484
+ dropQueuedOutput() {
485
+ this.outputChunker.reset();
486
+ this.publisher.clearQueue();
487
+ }
488
+ // ---------------------------------------------------------------------
489
+ // People
490
+ // ---------------------------------------------------------------------
491
+ /**
492
+ * Writes finished transcript lines to the log: what GPT-Live heard (with
493
+ * the tracker's speaker label) and what it said. Lines still growing
494
+ * (touched within LOG_LINE_AFTER_MS of `beforeMs`) wait for a later pass.
495
+ */
496
+ logTranscript(beforeMs) {
497
+ for (const line of this.transcript.recent(40)) {
498
+ if (this.loggedLines.has(line) || line.endMs > beforeMs)
499
+ continue;
500
+ this.loggedLines.add(line);
501
+ log.info(line.role === 'assistant' ? 'live_said' : 'live_heard', {
502
+ atS: Math.round(line.startMs / 100) / 10,
503
+ ...(line.role === 'room' ? { speaker: line.speaker ?? 'unknown' } : {}),
504
+ text: line.text.trim(),
505
+ });
506
+ }
507
+ }
508
+ nameOf(identity) {
509
+ return this.names.get(identity) ?? identity;
510
+ }
511
+ humanNames() {
512
+ const names = [];
513
+ for (const participant of this.options.room.remoteParticipants.values()) {
514
+ if (isAgentIdentity(participant.identity))
515
+ continue;
516
+ const name = participant.name || participant.identity;
517
+ this.names.set(participant.identity, name);
518
+ names.push(participant.identity === this.options.hostIdentity ? `${name} (host)` : name);
519
+ }
520
+ return names;
521
+ }
522
+ sendRoster() {
523
+ this.session?.appendThinking(rosterHint(this.humanNames()));
524
+ }
525
+ }
526
+ function sourceKey(identity, trackSid) {
527
+ return `${identity}#${trackSid ?? 'mic'}`;
528
+ }
529
+ //# sourceMappingURL=frontend.js.map