@iloveagents/foundry-web-voice 0.1.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/dist/adapter/half-duplex.d.ts +42 -0
  4. package/dist/adapter/half-duplex.js +65 -0
  5. package/dist/adapter/session-config.d.ts +55 -0
  6. package/dist/adapter/session-config.js +148 -0
  7. package/dist/adapter/speech-queue.d.ts +95 -0
  8. package/dist/adapter/speech-queue.js +344 -0
  9. package/dist/adapter/tool-bridge.d.ts +25 -0
  10. package/dist/adapter/tool-bridge.js +37 -0
  11. package/dist/adapter/tool-sync.d.ts +46 -0
  12. package/dist/adapter/tool-sync.js +55 -0
  13. package/dist/adapter/types.d.ts +91 -0
  14. package/dist/adapter/types.js +8 -0
  15. package/dist/adapter/voice-bridge.d.ts +214 -0
  16. package/dist/adapter/voice-bridge.js +539 -0
  17. package/dist/index.d.ts +28 -0
  18. package/dist/index.js +30 -0
  19. package/dist/react/audio-ownership.d.ts +41 -0
  20. package/dist/react/audio-ownership.js +37 -0
  21. package/dist/react/install.d.ts +62 -0
  22. package/dist/react/install.js +100 -0
  23. package/dist/react/relay-answer-watcher.d.ts +16 -0
  24. package/dist/react/relay-answer-watcher.js +45 -0
  25. package/dist/react/use-direct-audio-output.d.ts +23 -0
  26. package/dist/react/use-direct-audio-output.js +44 -0
  27. package/dist/react/voice-audio-sink.d.ts +21 -0
  28. package/dist/react/voice-audio-sink.js +55 -0
  29. package/dist/react/voice-avatar.d.ts +61 -0
  30. package/dist/react/voice-avatar.js +76 -0
  31. package/dist/react/voice-launcher-badge.d.ts +14 -0
  32. package/dist/react/voice-launcher-badge.js +36 -0
  33. package/dist/react/voice-mic-button.d.ts +19 -0
  34. package/dist/react/voice-mic-button.js +53 -0
  35. package/dist/react/voice-module.d.ts +34 -0
  36. package/dist/react/voice-module.js +25 -0
  37. package/dist/react/voice-stage.d.ts +20 -0
  38. package/dist/react/voice-stage.js +64 -0
  39. package/dist/react/voice-status-strip.d.ts +10 -0
  40. package/dist/react/voice-status-strip.js +31 -0
  41. package/dist/react/voice-surface.d.ts +27 -0
  42. package/dist/react/voice-surface.js +289 -0
  43. package/dist/react/voice-ui-store.d.ts +58 -0
  44. package/dist/react/voice-ui-store.js +46 -0
  45. package/dist/react/voice-visualizer.d.ts +37 -0
  46. package/dist/react/voice-visualizer.js +222 -0
  47. package/package.json +71 -0
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Turning a streaming markdown answer into spoken audio.
3
+ *
4
+ * Three separate problems, kept separate:
5
+ *
6
+ * 1. **What to say.** The agent writes markdown for a reader. Read aloud
7
+ * verbatim it becomes "asterisk asterisk important asterisk asterisk",
8
+ * and foundry's `[1]` citation markers become "bracket one". `toSpeakableText`
9
+ * strips the notation and keeps the prose.
10
+ *
11
+ * 2. **When to say it.** Waiting for the whole answer wastes the seconds the
12
+ * agent spends writing it. `SentenceStream` takes cumulative text and
13
+ * hands back complete sentences as they finish, so speech starts on the
14
+ * first full sentence.
15
+ *
16
+ * 3. **One at a time.** Voice Live rejects overlapping responses. The SDK's
17
+ * own `ResponseGate` serializes them, but it deliberately collapses
18
+ * everything queued during one response into a *single* follow-up — the
19
+ * right behaviour for turns, fatal for a sentence queue, where
20
+ * collapsing three pending sentences into one would drop two of them.
21
+ * `SpeechQueue` therefore keeps its own FIFO and releases the next
22
+ * utterance only once the previous response is done.
23
+ */
24
+ /** Longest run of text spoken without a sentence end, before flushing at a word boundary. */
25
+ const MAX_UTTERANCE_CHARS = 240;
26
+ /**
27
+ * Shortest utterance worth sending on its own.
28
+ *
29
+ * Every utterance is a separate synthesis: the player's queue drains to
30
+ * empty between them, and the seam is audible as a click — with an avatar,
31
+ * a visible restart too. Speaking each sentence the instant it lands turns a
32
+ * four-sentence answer into four seams. Complete sentences are therefore
33
+ * grouped until they are worth the seam, which still starts the audio well
34
+ * before the answer has finished streaming.
35
+ */
36
+ const MIN_UTTERANCE_CHARS = 140;
37
+ /** A sentence end: `.`, `!`, `?`, `…` (optionally closed by a quote/bracket) then whitespace. */
38
+ const SENTENCE_END = /([.!?…]["'”’)\]]*)(\s+)/;
39
+ const FENCED_CODE = /```[\s\S]*?(?:```|$)/g;
40
+ const IMAGE = /!\[([^\]]*)\]\([^)]*\)/g;
41
+ const LINK = /\[([^\]]*)\]\([^)]*\)/g;
42
+ /**
43
+ * foundry citation markers — `[1]`, `[12]`, `[1, 3]` — are for the eye only.
44
+ * The leading space goes with them: "net 30 [3]." must not be read as
45
+ * "net 30 <pause> ." once the marker is gone.
46
+ */
47
+ const CITATION = /[ \t]*\[\d+(?:\s*,\s*\d+)*\]/g;
48
+ /**
49
+ * A bracketed run at the very end of the text — "[", "[2", "[2, 3", "[label]".
50
+ *
51
+ * The closing "]" is optional on purpose. A finished "[label]" sitting at the
52
+ * end is not finished at all if the next character turns out to be "(", which
53
+ * makes it a link and reduces it to "label" — four characters shorter. Waiting
54
+ * for the next delta costs nothing, and speaking early costs a re-read.
55
+ *
56
+ * Anything still missing its "]" is unfinished by definition, since nothing
57
+ * has arrived after it yet. Dropping it keeps normalisation MONOTONIC: a
58
+ * half-arrived citation and the complete one reduce to the same text, so the
59
+ * closing bracket changes nothing. Were only the complete form stripped, the
60
+ * speakable text would SHORTEN as the raw answer grew, and any span already
61
+ * voiced across it would have to be read a second time.
62
+ *
63
+ * Text that later turns out not to be a citation — "[note]", a markdown link —
64
+ * simply reappears once it closes. Growth is harmless; only shrinkage forces
65
+ * a rewind.
66
+ */
67
+ const UNCLOSED_BRACKET = /[ \t]*\[[^\]]*\]?\s*$/;
68
+ /**
69
+ * A markdown link whose target is still arriving: "[label](http://ex" .
70
+ *
71
+ * The bracket is closed, so `UNCLOSED_BRACKET` does not apply, and `LINK`
72
+ * needs the ")" before it can reduce the whole thing to its label — so in
73
+ * between, the raw target leaks into the speakable text and then vanishes.
74
+ * That is a shrink, and a shrink after the span has been voiced is a re-read.
75
+ * Reduced to the label immediately instead, which is what it will become.
76
+ *
77
+ * The leading `!` is part of the match because an image is the same shape:
78
+ * `IMAGE` reduces the finished `![alt](url)` to `alt`, so an incomplete one
79
+ * that kept its `!` would shrink by that one character when the `)` landed.
80
+ * One character is enough — the rewind is by utterance, not by character.
81
+ */
82
+ const UNCLOSED_LINK = /!?\[([^\]]*)\]\([^)]*$/;
83
+ /** Matches nothing, so a rule can be switched off without branching the chain. */
84
+ const NOTHING = /(?!)/;
85
+ /**
86
+ * A bracketed run at the end of a SETTLED answer: keep the words, lose the
87
+ * brackets. Replaced with a LEADING SPACE, because the bracket may be doing
88
+ * the work of a separator — "array[index]" spoken as "arrayindex" is worse
89
+ * than the delimiters it was meant to remove. The whitespace collapse and
90
+ * `trim()` further down tidy up the doubled or leading space.
91
+ */
92
+ const TRAILING_BRACKETED = /\[([^\]]*)\]?\s*$/;
93
+ const HEADING = /^\s{0,3}#{1,6}\s+/gm;
94
+ const BLOCKQUOTE = /^\s{0,3}>\s?/gm;
95
+ const LIST_BULLET = /^\s{0,3}([-*+]|\d+[.)])\s+/gm;
96
+ const RULE = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/gm;
97
+ const EMPHASIS = /(\*\*|__|\*|_|~~)/g;
98
+ const INLINE_CODE = /`([^`]*)`/g;
99
+ const TABLE_PIPES = /^\s*\|(.+)\|\s*$/gm;
100
+ const TABLE_DIVIDER = /^\s*\|?[\s:|-]{3,}\|?\s*$/gm;
101
+ /**
102
+ * Markdown → text a voice can read.
103
+ *
104
+ * Deliberately lossy: code blocks and table rules are dropped rather than
105
+ * spelled out, because hearing punctuation read aloud is worse than not
106
+ * hearing it at all. What survives is the prose.
107
+ */
108
+ export function toSpeakableText(markdown, options = {}) {
109
+ // `final` means the answer has settled, so nothing can arrive to change what
110
+ // a trailing bracket turns out to be. Holding it back THEN does not defer
111
+ // speech, it drops it: "…and then choose [yes/no]" was spoken without the
112
+ // options, because `flush()` only ever sees this already-shortened text.
113
+ const settled = options.final === true;
114
+ return (markdown
115
+ .replace(FENCED_CODE, " ")
116
+ .replace(IMAGE, "$1")
117
+ .replace(LINK, "$1")
118
+ .replace(CITATION, "")
119
+ .replace(TABLE_DIVIDER, " ")
120
+ .replace(TABLE_PIPES, "$1")
121
+ .replace(RULE, " ")
122
+ .replace(HEADING, "")
123
+ .replace(BLOCKQUOTE, "")
124
+ .replace(LIST_BULLET, "")
125
+ .replace(INLINE_CODE, "$1")
126
+ // Whatever backtick is left is unmatched — an inline-code span whose
127
+ // closing tick has not arrived. Dropping it now means the arrival of that
128
+ // tick changes nothing, which is the same monotonicity argument as above.
129
+ .replace(/`/g, "")
130
+ .replace(EMPHASIS, "")
131
+ .replace(UNCLOSED_LINK, "$1")
132
+ .replace(settled ? NOTHING : UNCLOSED_BRACKET, "")
133
+ // Settled: keep the words, drop the delimiters. A voice reads "[yes/no]"
134
+ // as "open bracket yes slash no close bracket", which is the reason
135
+ // citations are stripped in the first place.
136
+ .replace(settled ? TRAILING_BRACKETED : NOTHING, " $1")
137
+ .replace(/[ \t]+/g, " ")
138
+ .replace(/\s*\n\s*/g, "\n")
139
+ .trim());
140
+ }
141
+ /**
142
+ * Cuts cumulative streaming text into speakable utterances.
143
+ *
144
+ * Fed the *whole* answer so far on each call (which is what assistant-ui
145
+ * exposes — parts carry cumulative text, not deltas) and returns only what
146
+ * has not been emitted yet.
147
+ */
148
+ export class SentenceStream {
149
+ constructor() {
150
+ /** How much of the answer has been turned into emitted utterances. */
151
+ this.consumed = 0;
152
+ /** Exactly that prefix, kept so a rewrite of it can be detected. */
153
+ this.consumedText = "";
154
+ /**
155
+ * Every position an utterance has ended at, 0 first.
156
+ *
157
+ * A rewrite inside already-spoken text can only be repaired at one of these.
158
+ * Rewinding to the raw point of divergence resumes mid-word — "…ly different
159
+ * answer" — because the divergence lands wherever the two strings happen to
160
+ * part, which is usually inside a word.
161
+ */
162
+ this.boundaries = [0];
163
+ /** The last cumulative answer seen, kept so `flush()` can finish it. */
164
+ this.text = "";
165
+ }
166
+ /**
167
+ * @param cumulative the full answer so far, already run through `toSpeakableText`
168
+ * @returns complete utterances that became available with this update
169
+ */
170
+ push(cumulative) {
171
+ this.text = cumulative;
172
+ // If the region already spoken has itself changed, rewind to where it
173
+ // stopped matching — no further. A wholesale replacement diverges at the
174
+ // start and rewinds to zero; a small edit re-speaks only from the edit.
175
+ let same = 0;
176
+ const limit = Math.min(this.consumedText.length, cumulative.length);
177
+ while (same < limit && this.consumedText[same] === cumulative[same])
178
+ same++;
179
+ if (same < this.consumed) {
180
+ // Back to the last utterance that ended at or before the divergence, so
181
+ // speech resumes on a seam the listener already heard end. A wholesale
182
+ // replacement parts company almost immediately and finds only 0, which
183
+ // is the whole answer again — correct, since none of it still stands.
184
+ let at = 0;
185
+ for (const b of this.boundaries)
186
+ if (b <= same)
187
+ at = b;
188
+ this.boundaries = this.boundaries.filter((b) => b <= at);
189
+ this.advanceTo(cumulative, at);
190
+ }
191
+ return this.take(segment(cumulative.slice(this.consumed), false), cumulative);
192
+ }
193
+ /** Emit whatever is left, complete sentence or not. Call when the answer ends. */
194
+ flush() {
195
+ return this.take(segment(this.text.slice(this.consumed), true), this.text);
196
+ }
197
+ reset() {
198
+ this.consumed = 0;
199
+ this.consumedText = "";
200
+ this.text = "";
201
+ this.boundaries = [0];
202
+ }
203
+ take({ utterances, ends }, source) {
204
+ if (ends.length === 0)
205
+ return utterances;
206
+ const base = this.consumed;
207
+ for (const end of ends)
208
+ this.boundaries.push(base + end);
209
+ this.advanceTo(source, base + ends[ends.length - 1]);
210
+ return utterances;
211
+ }
212
+ advanceTo(source, position) {
213
+ this.consumed = position;
214
+ this.consumedText = source.slice(0, position);
215
+ }
216
+ }
217
+ /**
218
+ * Split the UNSPOKEN remainder of an answer into utterances, reporting how
219
+ * much of it was used.
220
+ *
221
+ * The reporting is the point. An earlier version re-segmented the whole answer
222
+ * on every update and diffed the resulting lists, on the assumption that
223
+ * segmentation is prefix-stable. It is not, and a property test over 3,000
224
+ * randomised answers put the failure rate at roughly two in three for long
225
+ * unpunctuated text. The `MAX_UTTERANCE_CHARS` cut below is a *speculative*
226
+ * decision: it fires only because no sentence end is in sight yet. When the
227
+ * period finally arrives, that same span re-segments as one long sentence
228
+ * instead of the chunk that was already spoken, every later utterance shifts,
229
+ * and the whole answer is said again.
230
+ *
231
+ * So nothing already spoken is ever reconsidered. The caller advances a
232
+ * watermark by `consumed` and passes only what lies beyond it, which makes a
233
+ * speculative cut permanent the moment it is voiced — as it must be, the audio
234
+ * having already left.
235
+ *
236
+ * `final` means nothing more is coming, so the trailing fragment joins the last
237
+ * group rather than becoming an utterance of its own.
238
+ */
239
+ function segment(text, final) {
240
+ const utterances = [];
241
+ const ends = [];
242
+ let pos = 0;
243
+ // Complete sentences waiting to be grouped into one utterance.
244
+ let group = "";
245
+ const flushGroup = (end) => {
246
+ if (!group)
247
+ return;
248
+ utterances.push(group);
249
+ group = "";
250
+ ends.push(end);
251
+ };
252
+ for (;;) {
253
+ const rest = text.slice(pos);
254
+ const match = SENTENCE_END.exec(rest);
255
+ if (match) {
256
+ const end = match.index + match[1].length;
257
+ const utterance = rest.slice(0, end).trim();
258
+ const next = pos + end + match[2].length;
259
+ if (utterance) {
260
+ group = group ? `${group} ${utterance}` : utterance;
261
+ if (group.length >= MIN_UTTERANCE_CHARS)
262
+ flushGroup(next);
263
+ }
264
+ pos = next;
265
+ continue;
266
+ }
267
+ // No sentence end in sight. Once the remainder is long enough that waiting
268
+ // would be a noticeable silence, cut at the last word boundary — a long
269
+ // bulleted answer can run for a paragraph without a single period.
270
+ if (!final && text.length - pos > MAX_UTTERANCE_CHARS) {
271
+ const cut = text.lastIndexOf(" ", pos + MAX_UTTERANCE_CHARS);
272
+ if (cut <= pos)
273
+ break;
274
+ const utterance = text.slice(pos, cut).trim();
275
+ if (utterance) {
276
+ group = group ? `${group} ${utterance}` : utterance;
277
+ flushGroup(cut + 1);
278
+ }
279
+ pos = cut + 1;
280
+ continue;
281
+ }
282
+ break;
283
+ }
284
+ if (final) {
285
+ const tail = text.slice(pos).trim();
286
+ if (tail)
287
+ group = group ? `${group} ${tail}` : tail;
288
+ flushGroup(text.length);
289
+ }
290
+ return { utterances, ends };
291
+ }
292
+ /**
293
+ * A strict FIFO of utterances, one on the wire at a time.
294
+ *
295
+ * `send` puts one utterance on the wire. `onResponseDone` must be called
296
+ * when the service reports that response finished — that is the only thing
297
+ * that releases the next one.
298
+ */
299
+ export class SpeechQueue {
300
+ constructor(send) {
301
+ this.send = send;
302
+ this.pending = [];
303
+ this.speaking = false;
304
+ }
305
+ get isSpeaking() {
306
+ return this.speaking;
307
+ }
308
+ get pendingCount() {
309
+ return this.pending.length;
310
+ }
311
+ enqueue(text) {
312
+ const trimmed = text.trim();
313
+ if (!trimmed)
314
+ return;
315
+ this.pending.push(trimmed);
316
+ this.pump();
317
+ }
318
+ /** The in-flight response finished; release the next utterance. */
319
+ onResponseDone() {
320
+ this.speaking = false;
321
+ this.pump();
322
+ }
323
+ /**
324
+ * Barge-in or a new turn: forget everything still queued.
325
+ *
326
+ * `speaking` is cleared too. The caller cancels the in-flight response on
327
+ * the wire, and a cancelled response may never produce the `response.done`
328
+ * that would otherwise release the queue — leaving it wedged shut for the
329
+ * rest of the session.
330
+ */
331
+ cancel() {
332
+ this.pending.length = 0;
333
+ this.speaking = false;
334
+ }
335
+ pump() {
336
+ if (this.speaking)
337
+ return;
338
+ const next = this.pending.shift();
339
+ if (next === undefined)
340
+ return;
341
+ this.speaking = true;
342
+ this.send(next);
343
+ }
344
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Client-side tools, callable by voice.
3
+ *
4
+ * The tools the typed chat can call (`ui_navigate`, `ui_open_panel`, the
5
+ * page-scoped `page_*` set) live in `clientToolRegistry`. In `realtime` mode
6
+ * the Voice Live model is the one deciding to call them, so it needs both
7
+ * the schemas (see `session-config.ts`) and an executor.
8
+ *
9
+ * The SDK owns the wire half: an executor that returns a value has it sent
10
+ * as the `function_call_output` for that `call_id`, and a follow-up response
11
+ * triggered — serialized against any response already running. So all that
12
+ * is left here is dispatch, with the *same* failure semantics as
13
+ * `AGUIRunner`'s tool interception, so a tool that misbehaves reports
14
+ * identically whether it was called by voice or by typing.
15
+ */
16
+ import type { ToolRegistry } from "@iloveagents/foundry-agent";
17
+ import type { ToolExecutor } from "@iloveagents/foundry-voice-live-react";
18
+ /**
19
+ * A `ToolExecutor` that dispatches into the client tool registry.
20
+ *
21
+ * Always resolves to a JSON string, never rejects: an unanswered function
22
+ * call makes the model re-issue the tool and then report that it did not
23
+ * complete, so an error result is strictly better than no result.
24
+ */
25
+ export declare function createRegistryToolExecutor(registry: ToolRegistry): ToolExecutor;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Client-side tools, callable by voice.
3
+ *
4
+ * The tools the typed chat can call (`ui_navigate`, `ui_open_panel`, the
5
+ * page-scoped `page_*` set) live in `clientToolRegistry`. In `realtime` mode
6
+ * the Voice Live model is the one deciding to call them, so it needs both
7
+ * the schemas (see `session-config.ts`) and an executor.
8
+ *
9
+ * The SDK owns the wire half: an executor that returns a value has it sent
10
+ * as the `function_call_output` for that `call_id`, and a follow-up response
11
+ * triggered — serialized against any response already running. So all that
12
+ * is left here is dispatch, with the *same* failure semantics as
13
+ * `AGUIRunner`'s tool interception, so a tool that misbehaves reports
14
+ * identically whether it was called by voice or by typing.
15
+ */
16
+ /**
17
+ * A `ToolExecutor` that dispatches into the client tool registry.
18
+ *
19
+ * Always resolves to a JSON string, never rejects: an unanswered function
20
+ * call makes the model re-issue the tool and then report that it did not
21
+ * complete, so an error result is strictly better than no result.
22
+ */
23
+ export function createRegistryToolExecutor(registry) {
24
+ return async (name, args) => {
25
+ if (!registry.isRegistered(name)) {
26
+ return JSON.stringify({ error: `Unknown client tool: ${name}` });
27
+ }
28
+ try {
29
+ return await registry.executeTool(name, args);
30
+ }
31
+ catch (err) {
32
+ return JSON.stringify({
33
+ error: err instanceof Error ? err.message : "Client-side tool failed",
34
+ });
35
+ }
36
+ };
37
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Keeps the live session's tool catalogue in step with the page's.
3
+ *
4
+ * Two things this has to get right, and an inline callback got both wrong:
5
+ *
6
+ * - **Nothing may be dropped while the session is still connecting.** Page
7
+ * tools are registered and cleared on navigation, so the catalogue can
8
+ * change between mount and `session.created`. A callback that returns
9
+ * early when the session is not ready loses that change permanently —
10
+ * nothing replays it — and the model is left unable to act on the page
11
+ * the user is looking at, or calling a tool that no longer exists. The
12
+ * fix is not inside this class: the caller must `sync()` again the moment
13
+ * the session becomes ready, which the dedupe below makes free.
14
+ *
15
+ * - **Not every registry notification is a tool change.** `clientToolRegistry`
16
+ * is a zustand store, so subscribers fire on *any* state write. Sending a
17
+ * `session.update` for each one is pointless traffic on a live socket.
18
+ * Comparing the serialized catalogue collapses those to nothing.
19
+ */
20
+ import type { ToolRegistry } from "@iloveagents/foundry-agent";
21
+ import type { Tool } from "@iloveagents/foundry-voice-live-react";
22
+ export declare class ToolSync {
23
+ private readonly send;
24
+ /** Serialized catalogue last put on the wire, or null if none has been. */
25
+ private lastSent;
26
+ constructor(send: (tools: Tool[]) => void);
27
+ /**
28
+ * Push the session's current catalogue, unless it is already on the wire.
29
+ *
30
+ * `hostTools` is not optional decoration: `session.update` replaces the
31
+ * catalogue outright, so sending the registry's tools alone withdraws every
32
+ * MCP server and Foundry agent tool the host configured, and any host
33
+ * override of a registry tool. It has to be the same merge the session was
34
+ * built with, which is why both go through `sessionTools`.
35
+ */
36
+ sync(registry: ToolRegistry, hostTools: Tool[] | undefined): void;
37
+ /**
38
+ * Forget what was sent.
39
+ *
40
+ * A new session starts with whatever catalogue its own `session.update`
41
+ * carried, but this object cannot know that — so after a disconnect it
42
+ * must assume nothing, or the first sync of the next session is skipped
43
+ * as a duplicate and that session runs with a catalogue nobody sent.
44
+ */
45
+ reset(): void;
46
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Keeps the live session's tool catalogue in step with the page's.
3
+ *
4
+ * Two things this has to get right, and an inline callback got both wrong:
5
+ *
6
+ * - **Nothing may be dropped while the session is still connecting.** Page
7
+ * tools are registered and cleared on navigation, so the catalogue can
8
+ * change between mount and `session.created`. A callback that returns
9
+ * early when the session is not ready loses that change permanently —
10
+ * nothing replays it — and the model is left unable to act on the page
11
+ * the user is looking at, or calling a tool that no longer exists. The
12
+ * fix is not inside this class: the caller must `sync()` again the moment
13
+ * the session becomes ready, which the dedupe below makes free.
14
+ *
15
+ * - **Not every registry notification is a tool change.** `clientToolRegistry`
16
+ * is a zustand store, so subscribers fire on *any* state write. Sending a
17
+ * `session.update` for each one is pointless traffic on a live socket.
18
+ * Comparing the serialized catalogue collapses those to nothing.
19
+ */
20
+ import { sessionTools } from "./session-config.js";
21
+ export class ToolSync {
22
+ constructor(send) {
23
+ this.send = send;
24
+ /** Serialized catalogue last put on the wire, or null if none has been. */
25
+ this.lastSent = null;
26
+ }
27
+ /**
28
+ * Push the session's current catalogue, unless it is already on the wire.
29
+ *
30
+ * `hostTools` is not optional decoration: `session.update` replaces the
31
+ * catalogue outright, so sending the registry's tools alone withdraws every
32
+ * MCP server and Foundry agent tool the host configured, and any host
33
+ * override of a registry tool. It has to be the same merge the session was
34
+ * built with, which is why both go through `sessionTools`.
35
+ */
36
+ sync(registry, hostTools) {
37
+ const tools = sessionTools(registry, hostTools) ?? [];
38
+ const key = JSON.stringify(tools);
39
+ if (key === this.lastSent)
40
+ return;
41
+ this.lastSent = key;
42
+ this.send(tools);
43
+ }
44
+ /**
45
+ * Forget what was sent.
46
+ *
47
+ * A new session starts with whatever catalogue its own `session.update`
48
+ * carried, but this object cannot know that — so after a disconnect it
49
+ * must assume nothing, or the first sync of the next session is skipped
50
+ * as a duplicate and that session runs with a catalogue nobody sent.
51
+ */
52
+ reset() {
53
+ this.lastSent = null;
54
+ }
55
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Public configuration for the voice tier.
3
+ *
4
+ * Connection and session shapes are re-used from the Voice Live SDK rather
5
+ * than re-declared, so there is exactly one source of truth for what Azure
6
+ * accepts and a new SDK field needs no change here.
7
+ */
8
+ import type { ChromaKeyConfig, LogLevel, ReconnectOptions, VoiceLiveConnectionConfig, VoiceLiveSessionConfig } from "@iloveagents/foundry-voice-live-react";
9
+ /**
10
+ * Who answers a spoken question.
11
+ *
12
+ * - ``relay`` (default) — Voice Live is ears and mouth only. The final
13
+ * transcript is submitted to the composer, so the app's own AG-UI agent
14
+ * answers with its full tool/retrieval surface, and the answer is spoken
15
+ * back. Turns are ordinary thread turns: they render with tool cards and
16
+ * citations, and they persist through whatever history adapter the host
17
+ * configured.
18
+ *
19
+ * - ``realtime`` — the Voice Live model answers directly. Sub-second, and
20
+ * it can call the app's client-side tools, but it only knows what those
21
+ * tools tell it. Its turns are live-session-scoped: assistant-ui merges
22
+ * them into the thread while connected and drops them on disconnect
23
+ * (`BaseThreadRuntimeCore.disconnectVoice` clears `_voiceMessages`), so
24
+ * they never reach a history adapter. Use `onTranscript` to persist them
25
+ * yourself if you need to.
26
+ */
27
+ export type VoiceMode = "relay" | "realtime";
28
+ /** A transcript line as it arrives; `text` is cumulative for a given turn. */
29
+ export interface VoiceTranscript {
30
+ role: "user" | "assistant";
31
+ text: string;
32
+ isFinal: boolean;
33
+ }
34
+ export interface VoiceConfig {
35
+ /** @default "relay" */
36
+ mode?: VoiceMode;
37
+ /**
38
+ * How to reach Voice Live. In production this is `{ proxyUrl }`: a
39
+ * browser cannot set an `Authorization` header on a WebSocket, so the
40
+ * credential belongs in a proxy, never in the bundle.
41
+ */
42
+ connection: VoiceLiveConnectionConfig;
43
+ /**
44
+ * Session options — voice, avatar, instructions, VAD tuning. Merged over
45
+ * the defaults this package derives from `mode`; anything you set here
46
+ * wins, including the fields the mode would otherwise choose.
47
+ */
48
+ session?: VoiceLiveSessionConfig;
49
+ /**
50
+ * Expose the app's client-side tools (`ui_*`, `page_*`) to the voice
51
+ * model. `realtime` only — in `relay` mode the AG-UI agent owns tools and
52
+ * this is ignored.
53
+ * @default true
54
+ */
55
+ exposeClientTools?: boolean;
56
+ /**
57
+ * Mute the microphone while the assistant is speaking.
58
+ *
59
+ * On a laptop's speakers the assistant hears itself: its own voice trips
60
+ * the service's VAD, which commits a turn, which produces another reply —
61
+ * a conversation the user is no longer part of. Server echo cancellation
62
+ * removes most of it, not all, and an avatar's audio arrives over WebRTC
63
+ * where it helps least.
64
+ *
65
+ * The cost is barge-in: a muted microphone cannot interrupt. Leave it off
66
+ * when users wear headsets; turn it on for demos and speakerphone rooms.
67
+ *
68
+ * @default false
69
+ */
70
+ halfDuplex?: boolean;
71
+ /**
72
+ * Chroma-key tuning for the avatar's green-screen removal.
73
+ *
74
+ * The default is slightly softer-edged than the SDK's, because the stock
75
+ * values left a visible green fringe on the avatar's outline against
76
+ * light and dark surfaces alike. Raise `similarity` if green spill
77
+ * remains; lower it if the avatar's own dark clothing starts to key out.
78
+ */
79
+ chromaKey?: ChromaKeyConfig;
80
+ /** Console verbosity of the underlying SDK. @default "warn" */
81
+ logLevel?: LogLevel;
82
+ /** Auto-reconnect after an unexpected control-channel close. @default false */
83
+ reconnect?: boolean | Partial<ReconnectOptions>;
84
+ /**
85
+ * Every transcript line, both roles. The hook into persistence for
86
+ * `realtime` turns, which assistant-ui deliberately keeps ephemeral.
87
+ */
88
+ onTranscript?: (transcript: VoiceTranscript) => void;
89
+ /** Session-fatal errors, already surfaced to the runtime as an ended session. */
90
+ onError?: (message: string) => void;
91
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Public configuration for the voice tier.
3
+ *
4
+ * Connection and session shapes are re-used from the Voice Live SDK rather
5
+ * than re-declared, so there is exactly one source of truth for what Azure
6
+ * accepts and a new SDK field needs no change here.
7
+ */
8
+ export {};