@finalai/final-agent-provider 0.6.2 → 0.7.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 (94) hide show
  1. package/HOSTING.md +4 -4
  2. package/README.md +94 -2
  3. package/dist/modules/authoring-final-channel.mjs +2 -2
  4. package/dist/modules/authoring-final-channel.mjs.map +2 -2
  5. package/dist/modules/channel-factories.d.mts +5 -7
  6. package/dist/modules/channel-factories.mjs +4 -50
  7. package/dist/modules/channel-factories.mjs.map +2 -2
  8. package/dist/modules/channels/channel-utils.d.mts +132 -0
  9. package/dist/modules/channels/channel-utils.mjs +445 -0
  10. package/dist/modules/channels/channel-utils.mjs.map +7 -0
  11. package/dist/modules/channels/delivery.d.mts +5 -5
  12. package/dist/modules/channels/delivery.mjs.map +1 -1
  13. package/dist/modules/channels/direct-messaging.d.mts +2 -2
  14. package/dist/modules/channels/direct-messaging.mjs.map +2 -2
  15. package/dist/modules/channels/discord-dispatch.d.mts +17 -0
  16. package/dist/modules/channels/discord-dispatch.mjs +139 -0
  17. package/dist/modules/channels/discord-dispatch.mjs.map +7 -0
  18. package/dist/modules/channels/discord-gateway.d.mts +19 -0
  19. package/dist/modules/channels/discord-gateway.mjs +233 -0
  20. package/dist/modules/channels/discord-gateway.mjs.map +7 -0
  21. package/dist/modules/channels/discord.d.mts +9 -0
  22. package/dist/modules/channels/discord.mjs +904 -0
  23. package/dist/modules/channels/discord.mjs.map +7 -0
  24. package/dist/modules/channels/fake-platform.d.mts +2 -7
  25. package/dist/modules/channels/fake-platform.mjs +2 -38
  26. package/dist/modules/channels/fake-platform.mjs.map +2 -2
  27. package/dist/modules/channels/github.d.mts +5 -0
  28. package/dist/modules/channels/github.mjs +720 -0
  29. package/dist/modules/channels/github.mjs.map +7 -0
  30. package/dist/modules/channels/group-messaging.d.mts +2 -2
  31. package/dist/modules/channels/group-messaging.mjs.map +2 -2
  32. package/dist/modules/channels/index.d.mts +7 -3
  33. package/dist/modules/channels/index.mjs +7 -3
  34. package/dist/modules/channels/index.mjs.map +2 -2
  35. package/dist/modules/channels/linear.d.mts +4 -0
  36. package/dist/modules/channels/linear.mjs +621 -0
  37. package/dist/modules/channels/linear.mjs.map +7 -0
  38. package/dist/modules/channels/messaging-channel.d.mts +3 -3
  39. package/dist/modules/channels/messaging-channel.mjs +2 -2
  40. package/dist/modules/channels/messaging-channel.mjs.map +2 -2
  41. package/dist/modules/channels/phone.d.mts +8 -0
  42. package/dist/modules/channels/phone.mjs +382 -0
  43. package/dist/modules/channels/phone.mjs.map +7 -0
  44. package/dist/modules/channels/projection.mjs.map +1 -1
  45. package/dist/modules/channels/reply-receipts.d.mts +31 -0
  46. package/dist/modules/channels/reply-receipts.mjs +63 -0
  47. package/dist/modules/channels/reply-receipts.mjs.map +7 -0
  48. package/dist/modules/channels/routing.d.mts +1 -0
  49. package/dist/modules/channels/routing.mjs +5 -1
  50. package/dist/modules/channels/routing.mjs.map +2 -2
  51. package/dist/modules/channels/slack.d.mts +7 -0
  52. package/dist/modules/channels/slack.mjs +1134 -0
  53. package/dist/modules/channels/slack.mjs.map +7 -0
  54. package/dist/modules/channels/voice-recording.d.mts +7 -0
  55. package/dist/modules/channels/voice-recording.mjs +49 -0
  56. package/dist/modules/channels/voice-recording.mjs.map +7 -0
  57. package/dist/modules/channels/voice.d.mts +33 -0
  58. package/dist/modules/channels/voice.mjs +851 -0
  59. package/dist/modules/channels/voice.mjs.map +7 -0
  60. package/dist/modules/channels/webhook.d.mts +5 -0
  61. package/dist/modules/channels/webhook.mjs +76 -0
  62. package/dist/modules/channels/webhook.mjs.map +7 -0
  63. package/dist/modules/harnesses/acp-describe.mjs +1 -1
  64. package/dist/modules/harnesses/acp-describe.mjs.map +2 -2
  65. package/dist/modules/harnesses/config-fields.d.mts +6 -1
  66. package/dist/modules/harnesses/config-fields.mjs +7 -0
  67. package/dist/modules/harnesses/config-fields.mjs.map +3 -3
  68. package/dist/modules/harnesses/fidelity.mjs +17 -17
  69. package/dist/modules/harnesses/fidelity.mjs.map +2 -2
  70. package/dist/modules/harnesses/remote-claude-code.d.mts +14 -2
  71. package/dist/modules/harnesses/remote-claude-code.mjs +5 -3
  72. package/dist/modules/harnesses/remote-claude-code.mjs.map +2 -2
  73. package/dist/modules/harnesses/remote-url.mjs.map +1 -1
  74. package/dist/modules/harnesses/serverless-nanocode-codex.d.mts +3 -1
  75. package/dist/modules/harnesses/serverless-nanocode-codex.mjs +6 -0
  76. package/dist/modules/harnesses/serverless-nanocode-codex.mjs.map +2 -2
  77. package/dist/modules/runtime/remote-host.mjs.map +1 -1
  78. package/dist/modules/sandboxes/build-scripts.mjs.map +1 -1
  79. package/dist/modules/sandboxes/paths.mjs.map +1 -1
  80. package/dist/modules/testing/harness.mjs +2 -2
  81. package/dist/modules/testing/harness.mjs.map +1 -1
  82. package/package.json +45 -19
  83. package/dist/modules/channels/address-context.d.mts +0 -7
  84. package/dist/modules/channels/address-context.mjs +0 -80
  85. package/dist/modules/channels/address-context.mjs.map +0 -7
  86. package/dist/modules/channels/agent-events.d.mts +0 -103
  87. package/dist/modules/channels/agent-events.mjs +0 -341
  88. package/dist/modules/channels/agent-events.mjs.map +0 -7
  89. package/dist/modules/channels/agent-schedules.d.mts +0 -25
  90. package/dist/modules/channels/agent-schedules.mjs +0 -96
  91. package/dist/modules/channels/agent-schedules.mjs.map +0 -7
  92. package/dist/modules/channels/service-input.d.mts +0 -22
  93. package/dist/modules/channels/service-input.mjs +0 -32
  94. package/dist/modules/channels/service-input.mjs.map +0 -7
@@ -0,0 +1,851 @@
1
+ import {
2
+ defineChannel,
3
+ packageRef,
4
+ principalRefSchema
5
+ } from "@finalai/agent-provider";
6
+ import * as z from "zod";
7
+ import { bytesToBase64 } from "@finalai/agent-provider/sandbox/bytes";
8
+ import { recordingRawLimit, recordingWav } from "./voice-recording.mjs";
9
+ import {
10
+ abortable,
11
+ channelBaseUrl,
12
+ confirmOAuth,
13
+ createQueue,
14
+ decisionChoices,
15
+ decisionSummary,
16
+ decisionAnswer,
17
+ textOf,
18
+ turnStatus,
19
+ parseJson,
20
+ personFor,
21
+ responseJson,
22
+ startOAuth,
23
+ takeOAuth
24
+ } from "./channel-utils.mjs";
25
+ const configSchema = z.object({
26
+ baseUrl: z.string().url().optional(),
27
+ oauthAuthorizeUrl: z.string().url().optional(),
28
+ oauthTokenUrl: z.string().url().optional(),
29
+ oauthUserInfoUrl: z.string().url().optional(),
30
+ oauthClientId: z.string().url().optional(),
31
+ oauthResource: z.string().url().optional(),
32
+ voice: z.string().optional()
33
+ });
34
+ const userInfoSchema = z.object({ sub: z.string(), organizationId: z.string(), displayName: z.string() }).transform((value) => ({
35
+ id: value.sub,
36
+ tenant: value.organizationId,
37
+ displayName: value.displayName
38
+ }));
39
+ const startSchema = z.object({
40
+ type: z.literal("start"),
41
+ encoding: z.literal("linear16"),
42
+ sampleRate: z.literal(16e3),
43
+ principal: principalRefSchema,
44
+ recording: z.boolean().default(false)
45
+ });
46
+ const runVoiceConnection = async (options) => {
47
+ const { ctx, socket } = options;
48
+ const failed = new AbortController();
49
+ const signal = AbortSignal.any([socket.signal, failed.signal]);
50
+ const send = (data) => {
51
+ if (!signal.aborted) socket.send(data);
52
+ };
53
+ const connectionKey = crypto.randomUUID();
54
+ const trace = (stage, detail = {}) => {
55
+ const metadata = { connectionId: connectionKey, at: Date.now(), ...detail };
56
+ send(JSON.stringify({ type: "trace", stage, ...metadata }));
57
+ ctx.log(`voice.${stage}`, metadata);
58
+ };
59
+ let activeSpeech = null;
60
+ let activeRefusal = null;
61
+ let refusalPlayback = null;
62
+ let playbackGeneration = 0;
63
+ const speakRefusal = async (message, refusalSignal = signal) => {
64
+ const preceding = refusalPlayback;
65
+ const finished = Promise.withResolvers();
66
+ refusalPlayback = finished.promise;
67
+ const speech = new AbortController();
68
+ activeRefusal?.abort();
69
+ activeRefusal = speech;
70
+ activeSpeech?.abort();
71
+ const playbackVersion = ++playbackGeneration;
72
+ send(JSON.stringify({ type: "speech.start" }));
73
+ const outputSignal = AbortSignal.any([refusalSignal, speech.signal]);
74
+ try {
75
+ await preceding;
76
+ const text = async function* () {
77
+ yield message;
78
+ };
79
+ for await (const frame of abortable(
80
+ ctx.media.synthesize(text(), {
81
+ voice: options.voice,
82
+ encoding: options.encoding,
83
+ sampleRate: options.sampleRate,
84
+ signal: outputSignal
85
+ }),
86
+ outputSignal
87
+ )) {
88
+ if (playbackVersion !== playbackGeneration || outputSignal.aborted) break;
89
+ send(frame);
90
+ }
91
+ } finally {
92
+ speech.abort();
93
+ if (activeRefusal === speech) activeRefusal = null;
94
+ finished.resolve();
95
+ if (refusalPlayback === finished.promise) refusalPlayback = null;
96
+ }
97
+ };
98
+ const person = await personFor(ctx, options.principal);
99
+ const route = await ctx.places.resolve(options.place);
100
+ if (typeof person !== "string" && (person.refused !== "unlinked" || route.kind !== "connected" || route.policy.audience !== "anyone")) {
101
+ send(JSON.stringify({ type: "linked", linkUrl: person.linkUrl, message: person.message }));
102
+ await speakRefusal(person.message);
103
+ socket.close(1008, "Account link required");
104
+ return;
105
+ }
106
+ const from = typeof person === "string" ? person : void 0;
107
+ const conversation = await (async () => {
108
+ try {
109
+ return route.kind === "connected" ? route.conversation : route.defaults == null || from == null ? null : await ctx.places.connect(options.place, { by: from, create: route.defaults.create });
110
+ } catch {
111
+ return null;
112
+ }
113
+ })();
114
+ if (conversation == null) {
115
+ const message = "This call could not connect to a chat. Check your access in Final.";
116
+ send(JSON.stringify({ type: "notice", message }));
117
+ await speakRefusal(message, AbortSignal.any([signal, AbortSignal.timeout(1e4)]));
118
+ send(JSON.stringify({ type: "speech.end", final: true }));
119
+ socket.close(1e3);
120
+ return;
121
+ }
122
+ const recordingEnabled = options.recording !== false;
123
+ const recordingLimit = recordingEnabled ? recordingRawLimit(options.encoding) : null;
124
+ if (recordingEnabled)
125
+ send(
126
+ JSON.stringify({
127
+ type: "recording.notice",
128
+ message: "Your transcript and recording are saved to this chat."
129
+ })
130
+ );
131
+ const audio = createQueue();
132
+ const transcriptionStopped = new AbortController();
133
+ const transcriptionSignal = AbortSignal.any([signal, transcriptionStopped.signal]);
134
+ let terminalRefusal = false;
135
+ let microphoneInputClosed = false;
136
+ const closeAudio = () => {
137
+ microphoneInputClosed = true;
138
+ audio.close();
139
+ };
140
+ const microphoneEnded = new AbortController();
141
+ const microphoneLifecycle = { onEnd: null, stopObservation: null };
142
+ const utterances = createQueue();
143
+ let queuedAudioBytes = 0;
144
+ const transcriptionAudio = async function* () {
145
+ for await (const frame of audio.stream) {
146
+ queuedAudioBytes -= frame.byteLength;
147
+ yield frame;
148
+ }
149
+ };
150
+ let recording = [];
151
+ let recordingSize = 0;
152
+ let segment = 0;
153
+ let generation = 0;
154
+ let activeTurn = false;
155
+ const turnLifecycle = {
156
+ accepting: null,
157
+ activeId: null
158
+ };
159
+ let audioStarted = false;
160
+ let userSpeaking = false;
161
+ let utteranceNumber = 0;
162
+ let decision = null;
163
+ const pendingDecisions = /* @__PURE__ */ new Map();
164
+ const clearDecisions = () => {
165
+ pendingDecisions.clear();
166
+ decision = null;
167
+ };
168
+ const fail = (cause) => {
169
+ if (signal.aborted) return;
170
+ ctx.log("voice connection failed", {
171
+ error: cause instanceof Error ? cause.message : "stream failed"
172
+ });
173
+ send(
174
+ JSON.stringify({ type: "error", message: "The voice connection failed. Please reconnect." })
175
+ );
176
+ socket.close(1011, "Voice connection failed");
177
+ failed.abort();
178
+ closeAudio();
179
+ utterances.close();
180
+ };
181
+ let upload = null;
182
+ const attachRecording = async (part, deadline) => {
183
+ deadline?.throwIfAborted();
184
+ if (part.encoded == null) {
185
+ part.encoded = bytesToBase64(
186
+ recordingWav({
187
+ chunks: part.chunks,
188
+ bytes: part.bytes,
189
+ encoding: options.encoding,
190
+ sampleRate: options.sampleRate
191
+ })
192
+ );
193
+ part.chunks = [];
194
+ }
195
+ const result = await ctx.conversation(conversation).attach({
196
+ from,
197
+ label: `Voice recording, part ${part.index}`,
198
+ origin: { place: options.place },
199
+ idempotencyKey: `${options.place.id}:${connectionKey}:recording:${part.index}`,
200
+ content: [{ type: "audio", data: part.encoded, mimeType: "audio/wav" }]
201
+ });
202
+ if ("refused" in result) throw new Error(result.message);
203
+ };
204
+ const beginRecordingUpload = (deadline) => {
205
+ if (recordingSize === 0) return;
206
+ const part = {
207
+ chunks: recording,
208
+ bytes: recordingSize,
209
+ index: ++segment,
210
+ encoded: null
211
+ };
212
+ recording = [];
213
+ recordingSize = 0;
214
+ upload = {
215
+ segment: part,
216
+ result: attachRecording(part, deadline).then(
217
+ () => ({ ok: true }),
218
+ (error) => ({ error })
219
+ )
220
+ };
221
+ };
222
+ const settleRecordingUpload = async (retry, deadline) => {
223
+ const pending = upload;
224
+ if (pending == null) return;
225
+ const stop = deadline ?? signal;
226
+ const interrupted = Promise.withResolvers();
227
+ const abortUpload = () => interrupted.reject(
228
+ stop.reason instanceof Error ? stop.reason : new Error("Recording upload interrupted.")
229
+ );
230
+ stop.addEventListener("abort", abortUpload, { once: true });
231
+ if (stop.aborted) abortUpload();
232
+ let result;
233
+ try {
234
+ result = await Promise.race([pending.result, interrupted.promise]);
235
+ if ("error" in result) {
236
+ if (!retry) throw result.error;
237
+ await Promise.race([attachRecording(pending.segment, deadline), interrupted.promise]);
238
+ }
239
+ } finally {
240
+ stop.removeEventListener("abort", abortUpload);
241
+ }
242
+ if (upload === pending) upload = null;
243
+ };
244
+ const rotateRecording = async () => {
245
+ await settleRecordingUpload(true);
246
+ signal.throwIfAborted();
247
+ beginRecordingUpload();
248
+ };
249
+ const finishRecording = async () => {
250
+ const deadline = new AbortController();
251
+ const expired = Promise.withResolvers();
252
+ const timer = setTimeout(() => {
253
+ const cause = new Error("Recording cleanup timed out.");
254
+ deadline.abort(cause);
255
+ expired.reject(cause);
256
+ }, 1e4);
257
+ const finish = async () => {
258
+ let failure = null;
259
+ try {
260
+ await settleRecordingUpload(true, deadline.signal);
261
+ } catch (cause) {
262
+ failure = cause instanceof Error ? cause : new Error("Recording attachment failed.");
263
+ upload = null;
264
+ }
265
+ if (recordingSize > 0 && !deadline.signal.aborted) {
266
+ try {
267
+ beginRecordingUpload(deadline.signal);
268
+ await settleRecordingUpload(true, deadline.signal);
269
+ } catch (cause) {
270
+ failure ??= cause instanceof Error ? cause : new Error("Recording attachment failed.");
271
+ }
272
+ }
273
+ if (failure != null) throw failure;
274
+ };
275
+ try {
276
+ await Promise.race([finish(), expired.promise]);
277
+ } finally {
278
+ clearTimeout(timer);
279
+ deadline.abort();
280
+ }
281
+ };
282
+ const reportRefusal = async (message, options2 = {}) => {
283
+ if (terminalRefusal) return;
284
+ const recoverable = options2.recoverable !== false && (from == null || await ctx.people.identify(from).then(
285
+ (principal) => principal != null,
286
+ () => false
287
+ ));
288
+ if (terminalRefusal) return;
289
+ if (recoverable) {
290
+ send(JSON.stringify({ type: "error", message, recoverable: true }));
291
+ await speakRefusal(message);
292
+ return;
293
+ }
294
+ terminalRefusal = true;
295
+ closeAudio();
296
+ transcriptionStopped.abort();
297
+ utterances.close();
298
+ activeSpeech?.abort();
299
+ microphoneLifecycle.stopObservation?.();
300
+ send(JSON.stringify({ type: "notice", message }));
301
+ await speakRefusal(message, AbortSignal.any([signal, AbortSignal.timeout(1e4)]));
302
+ };
303
+ const requestCancellation = async (turnId) => {
304
+ try {
305
+ const result = await ctx.turns.cancel({ conversation, by: from, turnId });
306
+ if ("refused" in result) await reportRefusal(result.message);
307
+ else if (!result.accepted)
308
+ await reportRefusal(
309
+ "No running turn could be interrupted from this connection. Check Final if work is still running."
310
+ );
311
+ } catch {
312
+ await reportRefusal("The turn could not be interrupted. Try stopping it in Final.");
313
+ }
314
+ };
315
+ const cancel = async (withdrawDecision = false) => {
316
+ playbackGeneration += 1;
317
+ activeSpeech?.abort();
318
+ send(JSON.stringify({ type: "speech.start" }));
319
+ if (turnLifecycle.accepting != null) {
320
+ turnLifecycle.accepting.interrupted = true;
321
+ generation += 1;
322
+ return;
323
+ }
324
+ if (activeTurn && (decision == null || withdrawDecision)) {
325
+ clearDecisions();
326
+ generation += 1;
327
+ microphoneLifecycle.stopObservation?.();
328
+ const target = turnLifecycle.activeId;
329
+ if (target != null) await requestCancellation(target);
330
+ }
331
+ };
332
+ const answerDecision = async (text) => {
333
+ if (decision == null) return false;
334
+ const normalized = text.trim().toLowerCase().replace(/[.!?]$/g, "");
335
+ const isDecisionAnswer = ["yes", "no", "approve", "deny"].includes(normalized);
336
+ if (pendingDecisions.size > 1) {
337
+ if (isDecisionAnswer || [...pendingDecisions.values()].some(
338
+ (pending) => decisionAnswer(pending.choices, normalized) != null
339
+ )) {
340
+ await speakRefusal("Multiple actions need approval. Answer them in Final.");
341
+ return true;
342
+ }
343
+ return false;
344
+ }
345
+ if (from == null) {
346
+ if (!isDecisionAnswer) return false;
347
+ await speakRefusal("This action needs approval in Final.");
348
+ return true;
349
+ }
350
+ const choice = decisionAnswer(decision.choices, normalized);
351
+ if (choice == null) {
352
+ if (!["yes", "no", "approve", "deny"].includes(normalized)) return false;
353
+ await speakRefusal("Choose the exact option in Final.");
354
+ return true;
355
+ }
356
+ const decisionId = decision.id;
357
+ try {
358
+ const result = await ctx.decisions.resolve({
359
+ decisionId,
360
+ outcome: choice.outcome,
361
+ by: from
362
+ });
363
+ if ("refused" in result) await reportRefusal(result.message);
364
+ else {
365
+ pendingDecisions.delete(decisionId);
366
+ decision = pendingDecisions.values().next().value ?? null;
367
+ if (!result.resolved) await reportRefusal("This decision is no longer waiting in Final.");
368
+ }
369
+ } catch {
370
+ await reportRefusal(
371
+ "The approval could not be answered. Please try again or answer in Final."
372
+ );
373
+ }
374
+ return true;
375
+ };
376
+ const speak = async (text, version, correlation) => {
377
+ let firstAudio = true;
378
+ for await (const sentence of abortable(text, signal)) {
379
+ while (refusalPlayback != null) await refusalPlayback;
380
+ if (version !== generation || signal.aborted) break;
381
+ const playbackVersion = playbackGeneration;
382
+ const single = async function* () {
383
+ yield sentence;
384
+ };
385
+ const speech = new AbortController();
386
+ activeSpeech = speech;
387
+ const speechSignal = AbortSignal.any([signal, speech.signal]);
388
+ try {
389
+ for await (const frame of abortable(
390
+ ctx.media.synthesize(single(), {
391
+ voice: options.voice,
392
+ encoding: options.encoding,
393
+ sampleRate: options.sampleRate,
394
+ signal: speechSignal
395
+ }),
396
+ speechSignal
397
+ )) {
398
+ if (version !== generation || playbackVersion !== playbackGeneration) break;
399
+ if (firstAudio) {
400
+ firstAudio = false;
401
+ trace("first.audio.out", correlation);
402
+ }
403
+ send(frame);
404
+ }
405
+ } catch (cause) {
406
+ if (!speechSignal.aborted) throw cause;
407
+ } finally {
408
+ speech.abort();
409
+ if (activeSpeech === speech) activeSpeech = null;
410
+ }
411
+ }
412
+ };
413
+ const work = (async () => {
414
+ for await (const utterance of abortable(utterances.stream, signal)) {
415
+ if (terminalRefusal) break;
416
+ const version = ++generation;
417
+ const { text, key, finalAt } = utterance;
418
+ const origin = {
419
+ place: options.place,
420
+ ref: {
421
+ externalKey: key,
422
+ connectionId: connectionKey,
423
+ utterance: key,
424
+ utteranceFinalAt: finalAt
425
+ }
426
+ };
427
+ const accepting = { interrupted: false };
428
+ turnLifecycle.accepting = accepting;
429
+ const posted = from == null ? await ctx.conversation(conversation).input({
430
+ content: [{ type: "text", text }],
431
+ origin,
432
+ idempotencyKey: key,
433
+ waitForAcceptance: true,
434
+ principal: options.principal,
435
+ label: `From ${options.principal.displayName ?? options.principal.id} in ${options.place.title ?? options.place.kind}`
436
+ }) : await ctx.conversation(conversation).post({
437
+ from,
438
+ content: [{ type: "text", text }],
439
+ origin,
440
+ idempotencyKey: key,
441
+ waitForAcceptance: true
442
+ });
443
+ if (turnLifecycle.accepting === accepting) turnLifecycle.accepting = null;
444
+ if (signal.aborted || terminalRefusal) return;
445
+ if (accepting.interrupted || version !== generation) {
446
+ if (!("refused" in posted) && posted.turnId != null) {
447
+ trace("post.accepted", { utterance: key, turnId: posted.turnId });
448
+ await requestCancellation(posted.turnId);
449
+ }
450
+ continue;
451
+ }
452
+ if ("refused" in posted) {
453
+ await reportRefusal(posted.message, { recoverable: false });
454
+ continue;
455
+ }
456
+ if (posted.turnId == null) {
457
+ const message = "I couldn't start a voice reply for this request. Check the chat in Final.";
458
+ await reportRefusal(message);
459
+ continue;
460
+ }
461
+ trace("post.accepted", { utterance: key, turnId: posted.turnId });
462
+ activeTurn = true;
463
+ turnLifecycle.activeId = posted.turnId;
464
+ const sentences = createQueue();
465
+ const output = speak(sentences.stream, version, {
466
+ utterance: key,
467
+ turnId: posted.turnId
468
+ }).catch(fail);
469
+ const observation = new AbortController();
470
+ const observationSignal = AbortSignal.any([signal, observation.signal]);
471
+ const stopObservation = () => observation.abort();
472
+ microphoneLifecycle.stopObservation = stopObservation;
473
+ const microphoneEnd = () => {
474
+ if (decision == null) return;
475
+ sentences.push("Continue this approval in Final.");
476
+ observation.abort();
477
+ };
478
+ microphoneLifecycle.onEnd = microphoneEnd;
479
+ let hasReply = false;
480
+ let buffer = "";
481
+ let first = true;
482
+ const streamed = /* @__PURE__ */ new Set();
483
+ const completed = /* @__PURE__ */ new Set();
484
+ try {
485
+ for await (const event of abortable(
486
+ ctx.observe(conversation, { turnId: posted.turnId, signal: observationSignal }),
487
+ observationSignal
488
+ )) {
489
+ if (version !== generation) break;
490
+ if (event.type === "message.delta") {
491
+ if (first) {
492
+ first = false;
493
+ trace("first.delta", { utterance: key, turnId: posted.turnId });
494
+ }
495
+ if (event.data.delta.type !== "text" || typeof event.data.delta.text !== "string")
496
+ continue;
497
+ if (event.data.delta.text.length > 0) streamed.add(event.data.messageId);
498
+ if (event.data.delta.text.trim().length > 0) hasReply = true;
499
+ buffer += event.data.delta.text;
500
+ let boundary;
501
+ while ((boundary = /[!?](?:\s|$)|(?<!\d)\.(?:\s|$)|\.\s/.exec(buffer)) != null) {
502
+ const end = boundary.index + boundary[0].length;
503
+ sentences.push(buffer.slice(0, end));
504
+ buffer = buffer.slice(end);
505
+ }
506
+ } else if (event.type === "message.completed") {
507
+ const id = event.data.message.id;
508
+ if (!completed.has(id)) {
509
+ if (!streamed.has(id)) buffer += textOf(event.data.message.content);
510
+ if (buffer.trim().length > 0) {
511
+ hasReply = true;
512
+ sentences.push(buffer);
513
+ }
514
+ buffer = "";
515
+ completed.add(id);
516
+ }
517
+ } else if (event.type === "decision.created") {
518
+ const choices = from == null || microphoneEnded.signal.aborted ? [] : decisionChoices(event.data.request);
519
+ decision = { id: event.data.decisionId, choices };
520
+ pendingDecisions.set(decision.id, decision);
521
+ sentences.push(
522
+ `${decisionSummary(event.data.request)} ${pendingDecisions.size > 1 ? "Multiple actions need approval. Answer them in Final." : from == null ? "This action needs approval in Final." : choices.length === 0 ? "I've sent that to you in Final." : choices.filter((choice) => choice.answer === "approve").length === 1 && choices.filter((choice) => choice.answer === "deny").length <= 1 ? `Your options are ${choices.map((choice) => choice.label).join(" or ")}. Do you approve this action? Say yes or no.` : "Choose an option in Final."}`
523
+ );
524
+ if (microphoneEnded.signal.aborted) break;
525
+ } else if (event.type === "decision.resolved") {
526
+ pendingDecisions.delete(event.data.decisionId);
527
+ decision = pendingDecisions.values().next().value ?? null;
528
+ } else if (event.type === "turn.completed") {
529
+ turnLifecycle.activeId = null;
530
+ for (const message of event.data.messages ?? []) {
531
+ if (!streamed.has(message.id) && !completed.has(message.id)) {
532
+ const text2 = textOf(message.content);
533
+ if (text2.trim().length > 0) {
534
+ hasReply = true;
535
+ sentences.push(text2);
536
+ }
537
+ completed.add(message.id);
538
+ }
539
+ }
540
+ if (streamed.size === 0 && completed.size === 0 && event.data.content != null) {
541
+ const text2 = textOf(event.data.content);
542
+ if (text2.trim().length > 0) {
543
+ hasReply = true;
544
+ sentences.push(text2);
545
+ }
546
+ }
547
+ if (buffer.trim().length > 0) {
548
+ sentences.push(buffer);
549
+ buffer = "";
550
+ }
551
+ const status = turnStatus(event.data);
552
+ if (status != null) sentences.push(status);
553
+ else if (!hasReply)
554
+ sentences.push("The turn finished without a spoken reply. Open Final for details.");
555
+ clearDecisions();
556
+ break;
557
+ }
558
+ }
559
+ if (version === generation && buffer.trim().length > 0) sentences.push(buffer);
560
+ } finally {
561
+ clearDecisions();
562
+ observation.abort();
563
+ if (microphoneLifecycle.stopObservation === stopObservation)
564
+ microphoneLifecycle.stopObservation = null;
565
+ if (microphoneLifecycle.onEnd === microphoneEnd) microphoneLifecycle.onEnd = null;
566
+ sentences.close();
567
+ await output;
568
+ activeTurn = false;
569
+ if (turnLifecycle.activeId === posted.turnId) turnLifecycle.activeId = null;
570
+ send(JSON.stringify({ type: "speech.end", final: false }));
571
+ }
572
+ }
573
+ })().catch(fail);
574
+ const transcription = (async () => {
575
+ try {
576
+ for await (const transcript of abortable(
577
+ ctx.media.transcribe(transcriptionAudio(), {
578
+ encoding: options.encoding,
579
+ sampleRate: options.sampleRate,
580
+ endpointing: true,
581
+ signal: transcriptionSignal
582
+ }),
583
+ transcriptionSignal
584
+ )) {
585
+ send(JSON.stringify({ type: "transcript", ...transcript }));
586
+ if (!transcript.final && transcript.text.trim().length > 0 && !userSpeaking) {
587
+ userSpeaking = true;
588
+ await cancel();
589
+ }
590
+ if (transcript.final && transcript.text.trim().length === 0) userSpeaking = false;
591
+ if (transcript.final && transcript.text.trim().length > 0) {
592
+ const key = `${options.place.id}:${connectionKey}:${++utteranceNumber}`;
593
+ const finalAt = Date.now();
594
+ trace("utterance.final", { utterance: key, at: finalAt });
595
+ if (!userSpeaking && (activeTurn || turnLifecycle.accepting != null) && decision == null)
596
+ await cancel();
597
+ userSpeaking = false;
598
+ if (!await answerDecision(transcript.text) && !terminalRefusal) {
599
+ if (decision != null) await cancel(true);
600
+ utterances.push({ text: transcript.text, key, finalAt });
601
+ }
602
+ }
603
+ }
604
+ } finally {
605
+ utterances.close();
606
+ if (!microphoneInputClosed && !signal.aborted)
607
+ fail(new Error("The transcription stream ended unexpectedly."));
608
+ microphoneEnded.abort();
609
+ microphoneLifecycle.onEnd?.();
610
+ }
611
+ })().catch(fail);
612
+ const aborted = Promise.withResolvers();
613
+ const abort = () => {
614
+ aborted.resolve();
615
+ closeAudio();
616
+ utterances.close();
617
+ generation += 1;
618
+ };
619
+ signal.addEventListener("abort", abort, { once: true });
620
+ if (signal.aborted) abort();
621
+ let cleanupFailure = null;
622
+ try {
623
+ for await (const frame of abortable(
624
+ socket.messages,
625
+ AbortSignal.any([signal, microphoneEnded.signal])
626
+ )) {
627
+ if (terminalRefusal) break;
628
+ if (frame instanceof Uint8Array) {
629
+ if (queuedAudioBytes + frame.byteLength > 2 * 1024 * 1024) {
630
+ fail(new Error("Microphone audio exceeded the pending transcription limit."));
631
+ break;
632
+ }
633
+ if (recordingLimit != null) {
634
+ for (let offset = 0; offset < frame.byteLength; ) {
635
+ const length = Math.min(frame.byteLength - offset, recordingLimit - recordingSize);
636
+ recording.push(frame.slice(offset, offset + length));
637
+ recordingSize += length;
638
+ offset += length;
639
+ if (recordingSize === recordingLimit) await rotateRecording();
640
+ }
641
+ }
642
+ if (!audioStarted) {
643
+ audioStarted = true;
644
+ trace("audio.in");
645
+ }
646
+ queuedAudioBytes += frame.byteLength;
647
+ audio.push(frame);
648
+ continue;
649
+ }
650
+ const control = z.object({ type: z.string() }).safeParse(parseJson(frame));
651
+ if (control.success && control.data.type === "cancel") await cancel(true);
652
+ if (control.success && control.data.type === "stop") {
653
+ closeAudio();
654
+ break;
655
+ }
656
+ }
657
+ closeAudio();
658
+ await Promise.race([transcription, aborted.promise]);
659
+ await Promise.race([work, aborted.promise]);
660
+ } catch (cause) {
661
+ fail(cause);
662
+ } finally {
663
+ signal.removeEventListener("abort", abort);
664
+ abort();
665
+ if (signal.aborted) {
666
+ await Promise.race([Promise.all([transcription, work]), aborted.promise]);
667
+ } else await Promise.all([transcription, work]);
668
+ try {
669
+ await finishRecording();
670
+ } catch (cause) {
671
+ if (signal.aborted)
672
+ cleanupFailure = cause instanceof Error ? cause : new Error("Recording attachment failed.");
673
+ else fail(cause);
674
+ }
675
+ if (!signal.aborted) {
676
+ send(JSON.stringify({ type: "speech.end", final: true }));
677
+ socket.close(1e3);
678
+ }
679
+ }
680
+ if (cleanupFailure != null) throw cleanupFailure;
681
+ };
682
+ const oauthClient = async (ctx, config) => {
683
+ if (config.oauthClientId != null) return config.oauthClientId;
684
+ const saved = z.string().url().safeParse(await ctx.store.get("voice-oauth-client"));
685
+ if (saved.success) return saved.data;
686
+ const endpoint = new URL(
687
+ "/oauth2/register",
688
+ config.oauthAuthorizeUrl ?? "https://api.final.ai/oauth2/authorize"
689
+ );
690
+ const result = z.object({ client_id: z.string().url() }).parse(
691
+ await responseJson(
692
+ await ctx.credentials.fetch(endpoint, {
693
+ method: "POST",
694
+ headers: { "content-type": "application/json" },
695
+ body: JSON.stringify({
696
+ client_name: "Final Voice Channel",
697
+ redirect_uris: [`${channelBaseUrl(config)}/b/${ctx.binding.id}/oauth/callback`],
698
+ grant_types: ["authorization_code", "refresh_token"],
699
+ response_types: ["code"],
700
+ token_endpoint_auth_method: "none"
701
+ })
702
+ })
703
+ )
704
+ );
705
+ await ctx.store.set("voice-oauth-client", result.client_id);
706
+ return result.client_id;
707
+ };
708
+ const voiceChannel = (options = {}) => {
709
+ const config = configSchema.parse(options);
710
+ return defineChannel({
711
+ name: "voice",
712
+ config,
713
+ installed: packageRef("@finalai/final-agent-provider/channels/voice"),
714
+ credentials: { app: null },
715
+ identity: {
716
+ kind: "redirect",
717
+ start: async (args, ctx) => startOAuth(
718
+ {
719
+ ...args,
720
+ authorizeUrl: config.oauthAuthorizeUrl ?? "https://api.final.ai/oauth2/authorize",
721
+ clientId: await oauthClient(ctx, config),
722
+ redirectUri: `${channelBaseUrl(config)}/b/${ctx.binding.id}/oauth/callback`,
723
+ scope: "offline_access",
724
+ pkce: true,
725
+ extra: { resource: config.oauthResource ?? "https://api.final.ai/v1/connectors/mcp" }
726
+ },
727
+ ctx
728
+ )
729
+ },
730
+ places: {
731
+ kinds: ["live"],
732
+ defaults: () => ({ create: "direct-with-agent", replies: "origin", audience: "members" })
733
+ },
734
+ receive: async () => ({ ignored: true, reason: "voice-uses-stream" }),
735
+ routes: [
736
+ {
737
+ method: "GET",
738
+ path: "/oauth/callback",
739
+ handler: async (ctx) => {
740
+ const saved = await takeOAuth(ctx.request, ctx);
741
+ if (saved == null)
742
+ return new Response("Invalid or expired link request", { status: 400 });
743
+ const token = z.object({ access_token: z.string() }).parse(
744
+ await responseJson(
745
+ await ctx.credentials.fetch(
746
+ config.oauthTokenUrl ?? "https://api.final.ai/oauth2/token",
747
+ {
748
+ method: "POST",
749
+ body: new URLSearchParams({
750
+ client_id: await oauthClient(ctx, config),
751
+ resource: config.oauthResource ?? "https://api.final.ai/v1/connectors/mcp",
752
+ grant_type: "authorization_code",
753
+ code: saved.code,
754
+ redirect_uri: saved.redirectUri,
755
+ ...saved.verifier == null ? {} : { code_verifier: saved.verifier }
756
+ })
757
+ }
758
+ )
759
+ )
760
+ );
761
+ const principal = userInfoSchema.parse(
762
+ await responseJson(
763
+ await ctx.credentials.fetch(
764
+ config.oauthUserInfoUrl ?? "https://api.final.ai/oauth2/userinfo",
765
+ {
766
+ headers: { authorization: `Bearer ${token.access_token}` }
767
+ }
768
+ )
769
+ )
770
+ );
771
+ return confirmOAuth(
772
+ { ticket: saved.ticket, expected: saved.principal, actual: principal },
773
+ ctx
774
+ );
775
+ }
776
+ },
777
+ {
778
+ method: "WS",
779
+ path: "/stream",
780
+ handler: async (ctx) => {
781
+ const token = ctx.request.headers.get("authorization")?.replace(/^Bearer /, "") ?? new URL(ctx.request.url).searchParams.get("access_token");
782
+ if (token == null && ctx.authenticatedPrincipal == null)
783
+ return new Response("A Final OAuth token is required.", { status: 401 });
784
+ let authenticated = ctx.authenticatedPrincipal;
785
+ if (authenticated == null && token != null) {
786
+ try {
787
+ authenticated = userInfoSchema.parse(
788
+ await responseJson(
789
+ await ctx.credentials.fetch(
790
+ config.oauthUserInfoUrl ?? "https://api.final.ai/oauth2/userinfo",
791
+ {
792
+ headers: { authorization: `Bearer ${token}` }
793
+ }
794
+ )
795
+ )
796
+ );
797
+ } catch {
798
+ return new Response("Invalid Final identity", { status: 401 });
799
+ }
800
+ }
801
+ const principal = principalRefSchema.safeParse(authenticated);
802
+ if (!principal.success) return new Response("Invalid Final identity", { status: 401 });
803
+ ctx.socket.accept();
804
+ const iterator = ctx.socket.messages[Symbol.asyncIterator]();
805
+ const initial = await iterator.next();
806
+ const start = startSchema.safeParse(
807
+ typeof initial.value === "string" ? parseJson(initial.value) : null
808
+ );
809
+ if (!start.success || start.data.principal.id !== principal.data.id || start.data.principal.tenant !== principal.data.tenant) {
810
+ ctx.socket.close(1008, "The speaker does not match the authenticated account.");
811
+ return;
812
+ }
813
+ const remaining = {
814
+ [Symbol.asyncIterator]: () => iterator
815
+ };
816
+ await runVoiceConnection({
817
+ ctx,
818
+ socket: {
819
+ ...ctx.socket,
820
+ signal: ctx.socket.signal,
821
+ messages: remaining,
822
+ accept: (args) => ctx.socket.accept(args),
823
+ send: (data) => ctx.socket.send(data),
824
+ close: (code, reason) => ctx.socket.close(code, reason)
825
+ },
826
+ principal: principal.data,
827
+ place: {
828
+ kind: "live",
829
+ id: crypto.randomUUID(),
830
+ ...principal.data.tenant == null ? {} : { tenant: principal.data.tenant },
831
+ parent: {
832
+ kind: "person",
833
+ id: principal.data.id,
834
+ ...principal.data.tenant == null ? {} : { tenant: principal.data.tenant }
835
+ }
836
+ },
837
+ encoding: start.data.encoding,
838
+ sampleRate: start.data.sampleRate,
839
+ voice: config.voice,
840
+ recording: start.data.recording
841
+ });
842
+ }
843
+ }
844
+ ]
845
+ });
846
+ };
847
+ export {
848
+ runVoiceConnection,
849
+ voiceChannel
850
+ };
851
+ //# sourceMappingURL=voice.mjs.map