@stackbone/cli 0.6.3 → 0.7.1

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.
@@ -0,0 +1,2227 @@
1
+ // libs/runtime/voice-worker/src/main.ts
2
+ import { fileURLToPath } from "node:url";
3
+ import {
4
+ ServerOptions,
5
+ cli,
6
+ defineAgent,
7
+ inference,
8
+ telemetry as telemetry2,
9
+ voice as voice4
10
+ } from "@livekit/agents";
11
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
12
+
13
+ // libs/runtime/voice-worker/src/thinking-sound.ts
14
+ import { voice } from "@livekit/agents";
15
+ import { AudioFrame } from "@livekit/rtc-node";
16
+ var SAMPLE_RATE = 48e3;
17
+ var FRAME_MS = 20;
18
+ var SAMPLES_PER_FRAME = SAMPLE_RATE * FRAME_MS / 1e3;
19
+ var VOLUME = 0.12;
20
+ var PATTERN = [
21
+ [660, 140],
22
+ [0, 60],
23
+ [880, 160],
24
+ [0, 900]
25
+ ];
26
+ var renderPattern = () => {
27
+ const total = PATTERN.reduce((n, [, ms]) => n + SAMPLE_RATE * ms / 1e3, 0);
28
+ const pcm = new Int16Array(total);
29
+ let offset = 0;
30
+ for (const [hz, ms] of PATTERN) {
31
+ const length = SAMPLE_RATE * ms / 1e3;
32
+ for (let i = 0; hz > 0 && i < length; i++) {
33
+ const envelope = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / length);
34
+ const sample = Math.sin(2 * Math.PI * hz * i / SAMPLE_RATE) * envelope * VOLUME;
35
+ pcm[offset + i] = Math.round(sample * 32767);
36
+ }
37
+ offset += length;
38
+ }
39
+ return pcm;
40
+ };
41
+ var LOOP = renderPattern();
42
+ async function* thinkingCue() {
43
+ for (; ; ) {
44
+ for (let start = 0; start + SAMPLES_PER_FRAME <= LOOP.length; start += SAMPLES_PER_FRAME) {
45
+ yield new AudioFrame(
46
+ LOOP.slice(start, start + SAMPLES_PER_FRAME),
47
+ SAMPLE_RATE,
48
+ 1,
49
+ SAMPLES_PER_FRAME
50
+ );
51
+ }
52
+ }
53
+ }
54
+ var startThinkingCue = async ({
55
+ room,
56
+ session
57
+ }) => {
58
+ const player = new voice.BackgroundAudioPlayer();
59
+ await player.start({ room, agentSession: session });
60
+ let playing;
61
+ return {
62
+ start: () => {
63
+ playing ??= player.play(thinkingCue());
64
+ },
65
+ stop: () => {
66
+ playing?.stop();
67
+ playing = void 0;
68
+ }
69
+ };
70
+ };
71
+
72
+ // libs/runtime/voice-worker/src/vad.ts
73
+ import { createRequire } from "node:module";
74
+ import { dirname, join } from "node:path";
75
+ import * as silero from "@livekit/agents-plugin-silero";
76
+ var WINDOWS_MAX_PATH = 260;
77
+ var SILERO_DEFAULTS = {
78
+ minSpeechDuration: 50,
79
+ minSilenceDuration: 550,
80
+ prefixPaddingDuration: 500,
81
+ maxBufferedSpeech: 6e4,
82
+ activationThreshold: 0.5,
83
+ sampleRate: 16e3,
84
+ forceCPU: true
85
+ };
86
+ var loadVad = async () => {
87
+ const require2 = createRequire(import.meta.url);
88
+ const pluginEntry = require2.resolve("@livekit/agents-plugin-silero");
89
+ const model = join(dirname(pluginEntry), "silero_vad.onnx");
90
+ if (process.platform !== "win32" || model.length < WINDOWS_MAX_PATH) {
91
+ return await silero.VAD.load();
92
+ }
93
+ const { InferenceSession } = createRequire(pluginEntry)("onnxruntime-node");
94
+ const session = await InferenceSession.create(`\\\\?\\${model}`, {
95
+ interOpNumThreads: 1,
96
+ intraOpNumThreads: 1,
97
+ executionMode: "sequential",
98
+ executionProviders: [{ name: "cpu" }]
99
+ });
100
+ return new silero.VAD(session, SILERO_DEFAULTS);
101
+ };
102
+
103
+ // libs/runtime/voice-worker/src/worker.ts
104
+ import { log as log4, voice as voice3 } from "@livekit/agents";
105
+ import { BackgroundVoiceCancellation } from "@livekit/noise-cancellation-node";
106
+ import { ParticipantKind } from "@livekit/rtc-node";
107
+
108
+ // libs/shared/validators/src/lib/common/json-object.ts
109
+ import { z } from "zod";
110
+ var jsonObject = () => z.looseObject({});
111
+
112
+ // libs/shared/validators/src/lib/common/page.ts
113
+ import { z as z2 } from "zod";
114
+ var PAGE_LIMIT_MAX = 100;
115
+ var cursorParamSchema = z2.string().min(1);
116
+ var limitParamSchema = (max = PAGE_LIMIT_MAX) => z2.coerce.number().int().min(1).max(max);
117
+ var orderParamSchema = z2.enum(["asc", "desc"]);
118
+ var pageQuerySchema = z2.object({
119
+ cursor: cursorParamSchema.optional(),
120
+ limit: limitParamSchema().optional(),
121
+ order: orderParamSchema.optional()
122
+ });
123
+ var forwardPageQuerySchema = pageQuerySchema.omit({ order: true });
124
+ var pageCursorSchema = z2.string().nullable();
125
+ var pageResponseSchema = (item) => z2.object({ items: z2.array(item), nextCursor: pageCursorSchema, prevCursor: pageCursorSchema });
126
+ var forwardPageResponseSchema = (item) => z2.object({ items: z2.array(item), nextCursor: pageCursorSchema });
127
+
128
+ // libs/shared/validators/src/lib/agents/runs.ts
129
+ import { z as z4 } from "zod";
130
+
131
+ // libs/shared/validators/src/lib/common/system-entries.ts
132
+ import { z as z3 } from "zod";
133
+ var systemEntriesParamSchema = z3.enum(["include", "exclude"]);
134
+ var systemEntriesQuerySchema = z3.object({
135
+ system: systemEntriesParamSchema.optional()
136
+ });
137
+
138
+ // libs/shared/validators/src/lib/agents/runs.ts
139
+ var RUN_STATUSES = [
140
+ "running",
141
+ "done",
142
+ "failed",
143
+ "interrupted",
144
+ "waiting",
145
+ "cancelled"
146
+ ];
147
+ var runStatusSchema = z4.enum(RUN_STATUSES);
148
+ var RUN_TRIGGERS = [
149
+ "manual",
150
+ "webhook",
151
+ "cron",
152
+ "event",
153
+ "job",
154
+ "workflow",
155
+ "chat",
156
+ "eval",
157
+ "platform",
158
+ "unknown"
159
+ ];
160
+ var runTriggerSchema = z4.enum(RUN_TRIGGERS);
161
+ var runEntityKindSchema = z4.enum(["agent", "workflow"]);
162
+ var runTriggerLinkSchema = z4.object({
163
+ /** The `trigger_subscriptions.id`: the link the Studio trigger page routes to. */
164
+ subscriptionId: z4.string(),
165
+ /** The connector id, e.g. `gmail`. */
166
+ connector: z4.string(),
167
+ /** The trigger id within the connector, e.g. `gmail_new_email_received`. */
168
+ trigger: z4.string(),
169
+ outcome: z4.enum(["started", "resumed"]).optional(),
170
+ /** The connector's brand name, e.g. `Gmail`. */
171
+ connectorDisplayName: z4.string().optional(),
172
+ /** The trigger's name, e.g. `New Email`. */
173
+ triggerDisplayName: z4.string().optional(),
174
+ connectorLogoUrl: z4.string().optional()
175
+ });
176
+ var runSchema = z4.object({
177
+ id: z4.uuid(),
178
+ status: runStatusSchema,
179
+ trigger: runTriggerSchema,
180
+ createdAt: z4.iso.datetime(),
181
+ startedAt: z4.iso.datetime().nullable(),
182
+ finishedAt: z4.iso.datetime().nullable(),
183
+ durationMs: z4.number().int().nullable(),
184
+ traceId: z4.string().nullable(),
185
+ // True when the run was triggered from the Studio Playground (header
186
+ // `STACKBONE_PLAYGROUND_HEADER` on `POST /api/invoke`). Billing and the Trust
187
+ // Layer's verified metrics filter on this column to exclude stress-tests.
188
+ isPlayground: z4.boolean(),
189
+ // Name of the agent or workflow this run belongs to (migration 0020). Stamped
190
+ // at run creation: the agent name for `chat` runs, the workflow name for
191
+ // `workflow` runs. Combined with `trigger` the UI knows the kind. Null for
192
+ // classic/manual cloud runs (deep-agent chat is dev-only), so the Runs screen renders `—`.
193
+ entityName: z4.string().nullable(),
194
+ // Which KIND of entity `entityName` names (STA-426, migration 0037). Stamped at
195
+ // run creation next to the name, so an agent and a workflow that share a name stop
196
+ // sharing one row set. Null for a run written before the column existed (no
197
+ // backfill) — those stay matched by name alone.
198
+ //
199
+ // OPTIONAL as well as nullable, unlike `entityName`: the box and the control plane
200
+ // version independently and the proxy returns the box's body verbatim, so a new
201
+ // reader (the CLI, or the control plane's response validation) must tolerate an
202
+ // older box that does not send the field at all. Absent reads the same as null.
203
+ entityKind: runEntityKindSchema.nullable().optional(),
204
+ // WHICH Connect link started the run (PLA-1717, migration 0075): the
205
+ // `trigger_subscriptions.id`, stamped at start for `trigger: 'event'` and null
206
+ // for every other gate. Optional as well as nullable for the reason `entityKind`
207
+ // is: an older box does not send the field.
208
+ triggerRef: z4.string().nullable().optional(),
209
+ // The Connect link that started or resumed the run, with its names (PLA-1898).
210
+ // Null for a run no link started; optional for the reason `entityKind` is.
211
+ triggerLink: runTriggerLinkSchema.nullable().optional(),
212
+ input: jsonObject().nullable(),
213
+ output: jsonObject().nullable(),
214
+ error: z4.object({ message: z4.string(), stack: z4.string().optional() }).nullable(),
215
+ // Per-turn token usage for deep-agent CHAT runs (feature 68, A5): the chat
216
+ // recorder folds the turn's usage and stamps the four token counters onto the run.
217
+ // Null when the run had no model call; `totalTokens` is derived at read
218
+ // (`input + output`), never stored. No $ cost (D5).
219
+ inputTokens: z4.number().int().nullable(),
220
+ outputTokens: z4.number().int().nullable(),
221
+ cacheReadTokens: z4.number().int().nullable(),
222
+ cacheWriteTokens: z4.number().int().nullable(),
223
+ totalTokens: z4.number().int().nullable(),
224
+ /**
225
+ * The run's own `metadata` jsonb, verbatim (PLA-1272 slice 4).
226
+ *
227
+ * DECLARED AS A BAG rather than a shape, and the reason is the wire: the box
228
+ * merges stamps onto this column from several places at different times (an
229
+ * eval stamp, an output-schema violation) and versions independently of the
230
+ * control plane, so a reader that named today's keys would strip tomorrow's.
231
+ * It had no field at all until PLA-1272, which is why a violation the box
232
+ * recorded reached the browser and was thrown away here — zod strips what a
233
+ * schema does not name, silently, with the typecheck green.
234
+ *
235
+ * Read it through a NAMED reader, never by indexing: {@link runOutputViolation}
236
+ * is the one for the violation, and lives beside this so the runtime that
237
+ * writes the stamp and the screen that paints it share one declaration.
238
+ *
239
+ * Optional as well as nullable for the same reason `entityKind` is: an older
240
+ * box does not send the field.
241
+ */
242
+ metadata: jsonObject().nullable().optional()
243
+ });
244
+ var schemaIssueSchema = z4.object({
245
+ /** Path segments to the offending field (`[]` for the root value). */
246
+ path: z4.array(z4.union([z4.string(), z4.number()])),
247
+ message: z4.string()
248
+ });
249
+ var runOutputViolationSchema = z4.object({
250
+ /** The workflow whose declared `outputSchema` the return value broke. */
251
+ workflowName: z4.string(),
252
+ issues: z4.array(schemaIssueSchema),
253
+ /**
254
+ * When the projector observed it (ISO). Not the run's `finishedAt`: the run
255
+ * closed at that instant whether or not anything validated it.
256
+ */
257
+ recordedAt: z4.string()
258
+ });
259
+ var runIdParamSchema = z4.object({ runId: z4.uuid() });
260
+ var runTagKeySchema = z4.string().regex(/^[a-z][a-z0-9_.-]{0,63}$/);
261
+ var RUN_TAG_VALUE_MAX = 256;
262
+ var RUN_TAGS_MAX = 16;
263
+ var UNSTORABLE_TAG_CHAR = (
264
+ // eslint-disable-next-line no-control-regex
265
+ /[\u0000-\u001f\u007f]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/
266
+ );
267
+ var runTagValueSchema = z4.string().min(1).max(RUN_TAG_VALUE_MAX).refine((value) => !UNSTORABLE_TAG_CHAR.test(value), {
268
+ message: "A tag value holds no control character and no lone surrogate."
269
+ });
270
+ var splitRunTagFilter = (tag) => {
271
+ const colon = tag.indexOf(":");
272
+ return colon < 0 ? null : { key: tag.slice(0, colon), value: tag.slice(colon + 1) };
273
+ };
274
+ var runTagFilterSchema = z4.string().superRefine((tag, ctx) => {
275
+ const filter = splitRunTagFilter(tag);
276
+ if (!filter) {
277
+ ctx.addIssue({ code: "custom", message: "A tag filter is `key:value`." });
278
+ return;
279
+ }
280
+ if (!runTagKeySchema.safeParse(filter.key).success) {
281
+ ctx.addIssue({
282
+ code: "custom",
283
+ message: "A tag key is a lowercase slug that starts with a letter, up to 64 characters."
284
+ });
285
+ }
286
+ if (!runTagValueSchema.safeParse(filter.value).success) {
287
+ ctx.addIssue({
288
+ code: "custom",
289
+ message: `A tag value is 1 to ${RUN_TAG_VALUE_MAX} characters, with no control characters.`
290
+ });
291
+ }
292
+ });
293
+ var listRunsQuerySchema = pageQuerySchema.extend({
294
+ status: runStatusSchema.optional(),
295
+ trigger: runTriggerSchema.optional(),
296
+ from: z4.iso.datetime().optional(),
297
+ to: z4.iso.datetime().optional(),
298
+ // Feature 69 — the Sessions detail screen reads a conversation's turns with
299
+ // `GET /runs?session_id=<id>&order=asc`. `session_id` is the real
300
+ // `runs.session_id` join column (the deterministic UUIDv5 of the deep-agent
301
+ // session key), so it is exact-match on the indexed column.
302
+ session_id: z4.uuid().optional(),
303
+ // Migration 0020 — the Runs screen filters to one agent/workflow by its
304
+ // exact name (the value picked from the name dropdown, or deep-linked from
305
+ // the agent/workflow detail's "View runs"). Exact-match on `runs.entity_name`.
306
+ name: z4.string().min(1).optional(),
307
+ // STA-426 — narrow the name filter to ONE kind of entity, so "the runs of the
308
+ // workflow named sync" excludes the agent that shares the name. A row that
309
+ // DECLARES the other kind is always excluded; a row whose kind was never stamped
310
+ // (pre-0037, no backfill) is kept under the name that matches (STA-435), because
311
+ // dropping it would empty the history of every entry in a workspace that ran
312
+ // before the migration. Use the name-only filter to see every kind at once.
313
+ kind: runEntityKindSchema.optional(),
314
+ // PLA-1730 — the runs whose input has a top-level `input_key` field containing
315
+ // `input_value`, case-insensitive: `input_key=senderEmail&input_value=astex.com`
316
+ // finds every sender at that domain. The field is compared as text, so a number
317
+ // or boolean matches its JSON spelling (`3`, `true`). The ONE filter here that is
318
+ // not exact-match: it serves a search box, and a search box that needs the whole
319
+ // address typed without a typo is not one. Nested paths are out of scope.
320
+ input_key: z4.string().min(1).optional(),
321
+ input_value: z4.string().min(1).optional(),
322
+ // PLA-1785 — `exclude` leaves out the runs of the platform's own entries
323
+ // (`studio-copilot`, `mapping-suggest`, `rag-ingest`), decided by the box's
324
+ // `isSystemEntry` and applied before the cursor and the LIMIT, so a page stays
325
+ // full. Absent keeps every run, which is what `stackbone runs` lists.
326
+ system: systemEntriesParamSchema.optional(),
327
+ // FDE-62 S7 — the runs stamped with one `metadata.tags` entry (`channel:voice`
328
+ // keeps a call's turns). Exact match on the value, case-sensitive.
329
+ tag: runTagFilterSchema.optional()
330
+ }).refine((q) => !q.from || !q.to || q.from <= q.to, {
331
+ message: "`from` must be earlier than or equal to `to`",
332
+ path: ["from"]
333
+ }).refine((q) => q.input_key === void 0 === (q.input_value === void 0), {
334
+ message: "`input_key` and `input_value` go together",
335
+ path: ["input_key"]
336
+ }).refine((q) => q.input_key === void 0 || q.name !== void 0, {
337
+ message: "`input_key` needs `name`: the input filter searches one entry's runs",
338
+ path: ["input_key"]
339
+ });
340
+ var runListResponseSchema = pageResponseSchema(runSchema);
341
+ var runEntityEntrySchema = z4.object({
342
+ name: z4.string(),
343
+ kind: runEntityKindSchema.nullable()
344
+ });
345
+ var runEntityNamesResponseSchema = z4.object({
346
+ names: z4.array(z4.string()),
347
+ entries: z4.array(runEntityEntrySchema).optional()
348
+ });
349
+ var cancelRunResponseSchema = z4.object({
350
+ run: runSchema
351
+ });
352
+ var STACKBONE_SESSION_HEADER = "x-stackbone-session";
353
+ var RUN_TAGS_HEADER = "x-stackbone-run-tags";
354
+ var fixtureSchema = z4.object({
355
+ id: z4.string(),
356
+ name: z4.string(),
357
+ input: jsonObject().nullable(),
358
+ description: z4.string().nullable(),
359
+ createdAt: z4.iso.datetime()
360
+ });
361
+ var createFixtureRequestSchema = z4.object({
362
+ name: z4.string().min(1).max(120),
363
+ input: jsonObject(),
364
+ description: z4.string().max(2e3).optional().nullable()
365
+ });
366
+ var fixtureListResponseSchema = pageResponseSchema(fixtureSchema);
367
+ var fixtureIdParamSchema = z4.object({ fxtId: z4.uuid() });
368
+ var handlerSchemaSchema = z4.object({
369
+ input: jsonObject().nullable(),
370
+ output: jsonObject().nullable()
371
+ });
372
+ var schemaResponseSchema = z4.object({
373
+ input: jsonObject().nullable(),
374
+ output: jsonObject().nullable(),
375
+ handlers: jsonObject().nullable().optional()
376
+ });
377
+
378
+ // libs/shared/validators/src/lib/studio/studio-model-provider.ts
379
+ import { z as z5 } from "zod";
380
+ var modelProviderSlotSchema = z5.enum(["baseUrl", "apiKey"]);
381
+ var MODEL_PROVIDER_SLOTS = {
382
+ baseUrl: ["MODEL_PROVIDER_BASE_URL", "OPENROUTER_BASE_URL"],
383
+ apiKey: ["MODEL_PROVIDER_API_KEY", "OPENROUTER_API_KEY"]
384
+ };
385
+ var SLOT_NAMES = Object.keys(MODEL_PROVIDER_SLOTS);
386
+ var MODEL_PROVIDER_DEPRECATED_ENV_NAMES = SLOT_NAMES.map(
387
+ (slot) => MODEL_PROVIDER_SLOTS[slot][1]
388
+ );
389
+ var modelProviderSourceSchema = z5.enum(["environment", "stored", "none"]);
390
+ var isModelProviderValuePresent = (value) => typeof value === "string" && value.trim() !== "";
391
+ var storedOverridesSchema = z5.strictObject({
392
+ baseUrl: z5.boolean().optional(),
393
+ apiKey: z5.boolean().optional()
394
+ });
395
+ var modelProviderKindSchema = z5.enum(["openrouter", "ollama", "lmstudio", "generic"]);
396
+ var modelProviderGetResponseSchema = z5.object({
397
+ configured: z5.boolean(),
398
+ base_url: z5.string().nullable(),
399
+ api_key_masked: z5.string().nullable(),
400
+ source: modelProviderSourceSchema.optional(),
401
+ env_override: z5.array(z5.string()).optional(),
402
+ effective_base_url: z5.string().nullable().optional(),
403
+ // The gateway kind of the endpoint IN EFFECT (the one `effective_base_url`
404
+ // names), resolved by the box; `null` when nothing is configured. Optional for
405
+ // the same version-skew reason as `source`: a box that predates the field.
406
+ kind: modelProviderKindSchema.nullable().optional(),
407
+ // The slots where a Studio override beats an exported value (PLA-1825, ADR
408
+ // D8), by SLOT name, never the variable's (D4). Such a slot drops out of
409
+ // `env_override`, which keeps naming only the variables that actually win.
410
+ // A box that knows overrides always sends the list, empty or not; an absent
411
+ // list is a box that predates them, and would drop an `override` it cannot
412
+ // read, so Studio offers no Override against it.
413
+ overridden: z5.array(modelProviderSlotSchema).optional()
414
+ });
415
+ var modelProviderPutRequestSchema = z5.object({
416
+ base_url: z5.url(),
417
+ api_key: z5.string().optional().nullable(),
418
+ kind: modelProviderKindSchema.optional(),
419
+ override: z5.object({ baseUrl: z5.boolean().optional(), apiKey: z5.boolean().optional() }).optional()
420
+ }).refine((body) => body.override?.apiKey !== true || isModelProviderValuePresent(body.api_key), {
421
+ message: "Overriding the API key needs the key to use.",
422
+ path: ["api_key"]
423
+ });
424
+ var modelProviderAgentsOutcomeSchema = z5.object({
425
+ /** Agents that were degraded and are now serving. */
426
+ ready: z5.number().int().nonnegative(),
427
+ /** Agents that are still degraded (their reason was refreshed by the retry). */
428
+ degraded: z5.number().int().nonnegative()
429
+ });
430
+ var modelProviderPutResponseSchema = modelProviderGetResponseSchema.extend({
431
+ agents: modelProviderAgentsOutcomeSchema.optional()
432
+ });
433
+ var modelProviderFirstRunResponseSchema = z5.object({
434
+ completed: z5.boolean()
435
+ });
436
+ var modelProviderKeyCheckSchema = z5.enum(["verified", "unverified"]);
437
+ var modelProviderTestResponseSchema = z5.object({
438
+ ok: z5.boolean(),
439
+ model_count: z5.number().int().nonnegative().optional(),
440
+ error: z5.string().optional(),
441
+ key_checked: modelProviderKeyCheckSchema.optional()
442
+ });
443
+ var modelProviderModelTypeSchema = z5.enum(["chat", "image", "embedding", "other"]);
444
+ var modelProviderModelSchema = z5.object({
445
+ id: z5.string().min(1),
446
+ name: z5.string().optional(),
447
+ context_length: z5.number().int().positive().optional(),
448
+ pricing: z5.object({
449
+ prompt: z5.string().optional(),
450
+ completion: z5.string().optional()
451
+ }).optional(),
452
+ owned_by: z5.string().optional(),
453
+ type: modelProviderModelTypeSchema.optional(),
454
+ // What the gateway says the model can do (PLA-1605): `true`, `false`, or
455
+ // `null` for "this gateway does not say". The three values are distinct on
456
+ // purpose — Studio hides a model that reports no tool support and only WARNS
457
+ // about one that reports nothing, so collapsing `null` into `false` would hide
458
+ // every model on a custom OpenAI-compatible endpoint.
459
+ //
460
+ // Nullable AND optional: a box on an older runtime sends neither field, and
461
+ // that must keep validating, exactly as `type` already does.
462
+ tools: z5.boolean().nullable().optional(),
463
+ reasoning: z5.boolean().nullable().optional(),
464
+ // STA3-17: on a box with several providers, the one that serves this entry.
465
+ // `responding: false` marks an entry remembered from a provider that is down.
466
+ // Declared so the parse keeps it: an object schema strips what it does not name.
467
+ provider: z5.object({ id: z5.string(), name: z5.string(), responding: z5.boolean() }).optional()
468
+ });
469
+ var modelProviderModelsRequestSchema = z5.object({
470
+ base_url: z5.url().optional().nullable(),
471
+ api_key: z5.string().optional().nullable()
472
+ });
473
+ var modelProviderModelsResponseSchema = z5.object({
474
+ ok: z5.boolean(),
475
+ models: z5.array(modelProviderModelSchema).optional(),
476
+ error: z5.string().optional(),
477
+ warning: z5.string().optional()
478
+ });
479
+ var MODEL_PROVIDER_DEFAULT_ID = "default";
480
+ var MODEL_PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]{0,30}[a-z0-9]$/;
481
+ var modelProviderIdSchema = z5.string().regex(MODEL_PROVIDER_ID_PATTERN, {
482
+ message: "Lowercase letters, digits and dashes, 2 to 32, starting with a letter."
483
+ }).refine((id) => id !== MODEL_PROVIDER_DEFAULT_ID, {
484
+ message: `'${MODEL_PROVIDER_DEFAULT_ID}' is provider 1's id.`
485
+ });
486
+ var pickedModelProviderIdSchema = z5.string().regex(MODEL_PROVIDER_ID_PATTERN);
487
+ var modelProviderListItemSchema = z5.object({
488
+ id: z5.string(),
489
+ name: z5.string(),
490
+ kind: modelProviderKindSchema,
491
+ baseUrl: z5.string(),
492
+ apiKey: z5.string().nullable(),
493
+ source: z5.enum(["env", "local"]),
494
+ removable: z5.boolean(),
495
+ responding: z5.boolean().nullable(),
496
+ error: z5.string().optional()
497
+ });
498
+ var modelProviderListResponseSchema = z5.object({
499
+ providers: z5.array(modelProviderListItemSchema),
500
+ duplicatedIds: z5.array(z5.object({ modelId: z5.string(), providerIds: z5.array(z5.string()) }))
501
+ });
502
+ var providerFields = {
503
+ name: z5.string().trim().min(1),
504
+ kind: modelProviderKindSchema,
505
+ baseUrl: z5.url(),
506
+ apiKey: z5.string().optional()
507
+ };
508
+ var modelProviderCreateRequestSchema = z5.strictObject({
509
+ id: modelProviderIdSchema,
510
+ ...providerFields
511
+ });
512
+ var modelProviderUpdateRequestSchema = z5.strictObject(providerFields);
513
+
514
+ // libs/shared/validators/src/lib/studio/studio-voice.ts
515
+ import { z as z8 } from "zod";
516
+
517
+ // libs/shared/validators/src/lib/studio/memory-identities.ts
518
+ import { z as z6 } from "zod";
519
+ var identityKeySchema = z6.string().min(1).max(100);
520
+ var identityValueSchema = z6.string().min(1).max(500);
521
+ var integrationIdentitiesSchema = z6.unknown().superRefine((input, ctx) => {
522
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
523
+ ctx.addIssue({ code: "custom", message: "Identities must be a string map." });
524
+ return;
525
+ }
526
+ const entries = Object.entries(input);
527
+ if (entries.length > 32)
528
+ ctx.addIssue({ code: "custom", message: "At most 32 identity fields are allowed." });
529
+ for (const [key, value] of entries) {
530
+ if (!identityKeySchema.safeParse(key).success || !identityValueSchema.safeParse(value).success)
531
+ ctx.addIssue({
532
+ code: "custom",
533
+ path: [key],
534
+ message: "Identity keys must contain 1\u2013100 characters and values 1\u2013500 characters."
535
+ });
536
+ }
537
+ }).transform((input) => Object.fromEntries(Object.entries(input)));
538
+ var memoryIdentitySnapshotSchema = z6.union([
539
+ z6.strictObject({ key: z6.null(), value: z6.null() }),
540
+ z6.strictObject({ key: identityKeySchema, value: identityValueSchema.nullable() })
541
+ ]);
542
+ var memoryIdentityBindingSchema = z6.strictObject({
543
+ identityKey: identityKeySchema,
544
+ source: z6.enum(["code", "studio", "legacy"]),
545
+ fixedAt: z6.iso.datetime()
546
+ });
547
+
548
+ // libs/shared/validators/src/lib/studio/studio-rag.ts
549
+ import { z as z7 } from "zod";
550
+ var STUDIO_RAG_DOCS_LIMIT_DEFAULT = 50;
551
+ var STUDIO_RAG_DOCS_LIMIT_MAX = 200;
552
+ var STUDIO_RAG_TOPK_DEFAULT = 10;
553
+ var STUDIO_RAG_TOPK_MAX = 50;
554
+ var STUDIO_RAG_JOBS_LIMIT_DEFAULT = 50;
555
+ var STUDIO_RAG_JOBS_LIMIT_MAX = 200;
556
+ var STUDIO_RAG_COLLECTION_REGEX = /^[A-Za-z0-9][A-Za-z0-9._\-/:]{0,127}$/;
557
+ var ragCollectionNameParamSchema = z7.string().min(1).max(128).regex(STUDIO_RAG_COLLECTION_REGEX, "collection must be 1..128 ASCII identifier-safe chars");
558
+ var ragDistanceSchema = z7.enum(["cosine", "l2", "ip"]);
559
+ var ragIndexKindSchema = z7.enum(["hnsw", "exact"]);
560
+ var ragNumberFormatSchema = z7.enum(["eu", "us"]);
561
+ var ragCollectionSchema = z7.object({
562
+ name: z7.string(),
563
+ dimensions: z7.number().int().min(1),
564
+ // Optional for boxes that predate collection-owned embeddings.
565
+ embedding_provider: z7.string().min(1).optional(),
566
+ embedding_model: z7.string().min(1).optional(),
567
+ distance: ragDistanceSchema,
568
+ index_kind: ragIndexKindSchema,
569
+ chunk_count: z7.number().int().nonnegative(),
570
+ doc_count: z7.number().int().nonnegative(),
571
+ // Maximum `created_at` across the collection's documents. Null when the
572
+ // collection has zero documents (an empty collection row created before its
573
+ // first ingest).
574
+ last_ingested_at: z7.iso.datetime().nullable(),
575
+ // STA3-57. Optional so a list from a box older than hybrid search still parses.
576
+ hybrid_enabled: z7.boolean().optional(),
577
+ number_format: ragNumberFormatSchema.optional()
578
+ });
579
+ var ragCollectionListResponseSchema = z7.object({
580
+ items: z7.array(ragCollectionSchema)
581
+ });
582
+ var ragJobStatusSchema = z7.enum(["queued", "running", "succeeded", "failed", "cancelled"]);
583
+ var ragDocumentSchema = z7.object({
584
+ id: z7.string(),
585
+ namespace: z7.string(),
586
+ // Zero while the document is still ingesting (staged row, no chunks yet) or
587
+ // when its ingest run failed before writing any chunk. A ready document has
588
+ // at least one chunk.
589
+ chunks_count: z7.number().int().nonnegative(),
590
+ // Document-level metadata copied onto the chunks at ingest time. We surface
591
+ // the document's `metadata` so the UI can render filterable tags without
592
+ // paging through chunks.
593
+ metadata: jsonObject(),
594
+ // Truncated content of the first chunk. Full body is one click away via
595
+ // the docs drawer in the component.
596
+ preview: z7.string(),
597
+ first_seen_at: z7.iso.datetime(),
598
+ last_seen_at: z7.iso.datetime(),
599
+ // Ingest status of the run that produced this document, when the document
600
+ // was uploaded through Studio's managed ingest (which fires a durable
601
+ // `rag-ingest` run). `null` for documents the agent wrote directly via
602
+ // `client.rag.ingest()` (no run to track — they are ready once they have
603
+ // chunks). Optional so the cloud control plane, which doesn't project runs,
604
+ // can omit it without breaking the wire contract.
605
+ ingest_status: ragJobStatusSchema.nullable().optional(),
606
+ // Id of the ingest run that produced this document, for deep-linking to the
607
+ // run details screen (`studio/runs/:runId`). `null`/absent when there is no
608
+ // tracked run (direct SDK ingest, or the cloud surface).
609
+ run_id: z7.string().nullable().optional(),
610
+ // Error message copied off the ingest run when it failed, so the docs table
611
+ // can surface it inline without opening the run. `null`/absent otherwise.
612
+ ingest_error: z7.string().nullable().optional(),
613
+ // STA3-8. Every version of a document is its own row; the list shows the
614
+ // published one, so `id` is that version's id. `version_count` counts every
615
+ // version, published or not. `pending_version` is the newest version past the
616
+ // published one whose ingest finished, the "v2 ready to publish" on the row;
617
+ // `null` when there is none. Optional, like the ingest fields above, so a box
618
+ // from before versioning still parses.
619
+ version: z7.number().int().min(1).optional(),
620
+ version_count: z7.number().int().min(1).optional(),
621
+ pending_version: z7.number().int().min(1).nullable().optional()
622
+ });
623
+ var ragFilterSchema = jsonObject();
624
+ var ragDateBoundSchema = z7.string().length(10).regex(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/).refine((value) => {
625
+ const year = Number(value.slice(0, 4));
626
+ const month = Number(value.slice(5, 7));
627
+ const day = Number(value.slice(8, 10));
628
+ const date = /* @__PURE__ */ new Date(0);
629
+ date.setUTCFullYear(year, month - 1, day);
630
+ return year >= 1 && year <= 9999 && date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
631
+ }, "date must be a real YYYY-MM-DD day in years 0001\u20139999");
632
+ var ragRangeFieldSchema = z7.string().min(1).refine((field) => !field.includes("."), {
633
+ message: "field must be a literal top-level metadata key without dots"
634
+ });
635
+ var ragRangeSchema = z7.discriminatedUnion("type", [
636
+ z7.strictObject({
637
+ field: ragRangeFieldSchema,
638
+ type: z7.literal("number"),
639
+ gt: z7.number().finite().optional(),
640
+ gte: z7.number().finite().optional(),
641
+ lt: z7.number().finite().optional(),
642
+ lte: z7.number().finite().optional()
643
+ }),
644
+ z7.strictObject({
645
+ field: ragRangeFieldSchema,
646
+ type: z7.literal("date"),
647
+ gt: ragDateBoundSchema.optional(),
648
+ gte: ragDateBoundSchema.optional(),
649
+ lt: ragDateBoundSchema.optional(),
650
+ lte: ragDateBoundSchema.optional()
651
+ })
652
+ ]).superRefine((range, ctx) => {
653
+ const lower = range.gt ?? range.gte;
654
+ const upper = range.lt ?? range.lte;
655
+ if (lower === void 0 && upper === void 0) {
656
+ ctx.addIssue({ code: "custom", message: "range must have at least one bound" });
657
+ }
658
+ if (range.gt !== void 0 && range.gte !== void 0) {
659
+ ctx.addIssue({ code: "custom", message: "gt and gte cannot be combined", path: ["gte"] });
660
+ }
661
+ if (range.lt !== void 0 && range.lte !== void 0) {
662
+ ctx.addIssue({ code: "custom", message: "lt and lte cannot be combined", path: ["lte"] });
663
+ }
664
+ if (lower !== void 0 && upper !== void 0 && lower > upper) {
665
+ ctx.addIssue({ code: "custom", message: "lower bound must not exceed upper bound" });
666
+ }
667
+ });
668
+ var ragRangesSchema = z7.array(ragRangeSchema).max(10);
669
+ var listRagDocsQuerySchema = forwardPageQuerySchema.extend({
670
+ limit: limitParamSchema(STUDIO_RAG_DOCS_LIMIT_MAX).default(STUDIO_RAG_DOCS_LIMIT_DEFAULT),
671
+ // Filter is sent JSON-encoded inside a single query param so the UI can
672
+ // pass an arbitrarily nested object without colliding with the URL syntax.
673
+ // Empty/absent => no filter.
674
+ filter: z7.string().optional()
675
+ });
676
+ var ragDocumentListResponseSchema = forwardPageResponseSchema(ragDocumentSchema);
677
+ var ragDocumentVersionSchema = z7.object({
678
+ id: z7.string(),
679
+ version: z7.number().int().min(1),
680
+ is_active: z7.boolean(),
681
+ ready: z7.boolean(),
682
+ chunks_count: z7.number().int().nonnegative(),
683
+ ingest_status: ragJobStatusSchema.nullable(),
684
+ run_id: z7.string().nullable(),
685
+ ingest_error: z7.string().nullable(),
686
+ filename: z7.string().nullable(),
687
+ created_at: z7.iso.datetime()
688
+ });
689
+ var ragDocumentVersionListResponseSchema = z7.object({
690
+ items: z7.array(ragDocumentVersionSchema)
691
+ });
692
+ var ragDocumentVersionNumberSchema = z7.number().int().min(1).max(2147483647);
693
+ var ragDocumentVersionParamSchema = z7.string().regex(/^\d+$/, { message: "version must be a positive integer" }).transform((value) => Number.parseInt(value, 10)).pipe(ragDocumentVersionNumberSchema);
694
+ var activateRagDocumentVersionResponseSchema = z7.object({
695
+ id: z7.string(),
696
+ collection: z7.string(),
697
+ version: z7.number().int().min(1),
698
+ previous_version: z7.number().int().min(1).nullable(),
699
+ changed: z7.boolean()
700
+ });
701
+ var ragQueryRequestSchema = z7.object({
702
+ text: z7.string().min(1).optional(),
703
+ embedding: z7.array(z7.number().finite()).min(1).optional(),
704
+ model: z7.string().min(1).optional(),
705
+ topK: z7.coerce.number().int().min(1).max(STUDIO_RAG_TOPK_MAX).default(STUDIO_RAG_TOPK_DEFAULT),
706
+ filter: ragFilterSchema.optional(),
707
+ ranges: ragRangesSchema.optional(),
708
+ preview: z7.object({ docId: z7.string().min(1), version: ragDocumentVersionNumberSchema }).optional(),
709
+ hybrid: z7.boolean().optional()
710
+ }).refine((value) => value.embedding !== void 0 || value.text !== void 0, {
711
+ message: "either `embedding` or `text` must be provided",
712
+ path: ["embedding"]
713
+ });
714
+ var ragQueryHitSchema = z7.object({
715
+ id: z7.string(),
716
+ chunk_idx: z7.number().int().nonnegative(),
717
+ // Cosine similarity in [-1, 1]. The query handler emits `1 - (embedding <=>
718
+ // query)` over `stackbone_rag_chunks`, so higher is closer. We surface the
719
+ // raw value; the UI formats it.
720
+ score: z7.number(),
721
+ // The reciprocal rank fusion score a hybrid search ordered by (STA3-57 D10).
722
+ // Absent when only the search by meaning ran, and from a box older than it.
723
+ mixed_score: z7.number().optional(),
724
+ content: z7.string(),
725
+ metadata: jsonObject()
726
+ });
727
+ var ragQueryResponseSchema = z7.object({
728
+ items: z7.array(ragQueryHitSchema),
729
+ // Echoes the dimensions used at retrieval time so the UI can flag a
730
+ // dim-mismatch before it round-trips to a 4xx.
731
+ dimensions: z7.number().int().min(1),
732
+ // Provider-pinned model (`provider::model`) used to embed `text`. Null when
733
+ // the caller passed `embedding` directly.
734
+ embedded_with: z7.string().nullable()
735
+ });
736
+ var ragJobSchema = z7.object({
737
+ id: z7.string(),
738
+ collection: z7.string(),
739
+ status: ragJobStatusSchema,
740
+ attempts: z7.number().int().nonnegative(),
741
+ error: z7.string().nullable(),
742
+ created_at: z7.iso.datetime(),
743
+ finished_at: z7.iso.datetime().nullable()
744
+ });
745
+ var listRagJobsQuerySchema = forwardPageQuerySchema.extend({
746
+ collection: ragCollectionNameParamSchema.optional(),
747
+ limit: limitParamSchema(STUDIO_RAG_JOBS_LIMIT_MAX).default(STUDIO_RAG_JOBS_LIMIT_DEFAULT)
748
+ });
749
+ var ragJobListResponseSchema = forwardPageResponseSchema(ragJobSchema);
750
+ var ragJobIdParamSchema = z7.object({
751
+ jobId: z7.string().min(1).max(128)
752
+ });
753
+ var ragJobActionResponseSchema = z7.object({
754
+ id: z7.string(),
755
+ status: ragJobStatusSchema
756
+ });
757
+ var STUDIO_RAG_SUPPORTED_CONTENT_TYPES = [
758
+ "text/plain",
759
+ "text/markdown",
760
+ "application/pdf"
761
+ ];
762
+ var ragSupportedContentTypeSchema = z7.enum(STUDIO_RAG_SUPPORTED_CONTENT_TYPES);
763
+ var createRagCollectionRequestSchema = z7.object({
764
+ name: ragCollectionNameParamSchema,
765
+ metadata: jsonObject().optional(),
766
+ embedding_provider: pickedModelProviderIdSchema.optional(),
767
+ embedding_model: z7.string().trim().min(1).optional(),
768
+ // Omitted, the box creates the collection with hybrid search off and `eu`.
769
+ hybrid_enabled: z7.boolean().optional(),
770
+ number_format: ragNumberFormatSchema.optional()
771
+ }).refine(
772
+ (value) => value.embedding_provider === void 0 === (value.embedding_model === void 0),
773
+ {
774
+ message: "embedding_provider and embedding_model must be provided together",
775
+ path: ["embedding_model"]
776
+ }
777
+ );
778
+ var updateRagCollectionRequestSchema = z7.strictObject({
779
+ hybrid_enabled: z7.boolean().optional(),
780
+ number_format: ragNumberFormatSchema.optional()
781
+ }).refine((value) => value.hybrid_enabled !== void 0 || value.number_format !== void 0, {
782
+ message: "Send hybrid_enabled, number_format or both"
783
+ });
784
+ var updateRagCollectionResponseSchema = z7.object({
785
+ name: z7.string(),
786
+ hybrid_enabled: z7.boolean(),
787
+ number_format: ragNumberFormatSchema
788
+ });
789
+ var deleteRagCollectionResponseSchema = z7.object({
790
+ name: z7.string(),
791
+ deleted: z7.literal(true)
792
+ });
793
+ var uploadRagDocumentRequestSchema = z7.object({
794
+ collection: ragCollectionNameParamSchema,
795
+ filename: z7.string().min(1).max(255),
796
+ content_type: ragSupportedContentTypeSchema,
797
+ metadata: jsonObject().optional(),
798
+ replaces: z7.string().min(1).optional()
799
+ });
800
+ var uploadRagDocumentResponseSchema = z7.object({
801
+ job_id: z7.string(),
802
+ status: ragJobStatusSchema,
803
+ version: z7.number().int().min(1)
804
+ });
805
+ var deleteRagDocumentResponseSchema = z7.object({
806
+ id: z7.string(),
807
+ collection: z7.string(),
808
+ deleted: z7.literal(true)
809
+ });
810
+ var deleteRagDocumentVersionResponseSchema = z7.object({
811
+ id: z7.string(),
812
+ collection: z7.string(),
813
+ version: ragDocumentVersionNumberSchema,
814
+ deleted: z7.literal(true)
815
+ });
816
+ var downloadRagDocumentResponseSchema = z7.object({
817
+ url: z7.string(),
818
+ filename: z7.string(),
819
+ content_type: z7.string()
820
+ });
821
+ var changeRagEmbeddingRequestSchema = z7.object({
822
+ provider: pickedModelProviderIdSchema,
823
+ model: z7.string().trim().min(1)
824
+ });
825
+ var retryRagRebuildRequestSchema = z7.object({ id: z7.uuid() });
826
+ var ragEmbeddingIdentitySchema = z7.object({
827
+ provider: z7.string().min(1),
828
+ model: z7.string().min(1),
829
+ dimension: z7.number().int().positive()
830
+ });
831
+ var ragRebuildStatusSchema = z7.object({
832
+ id: z7.uuid(),
833
+ collection: z7.string(),
834
+ attempt: z7.number().int().positive(),
835
+ status: z7.enum(["queued", "running", "failed", "interrupted", "complete"]),
836
+ current: ragEmbeddingIdentitySchema,
837
+ target: ragEmbeddingIdentitySchema,
838
+ completed_versions: z7.number().int().nonnegative(),
839
+ total_versions: z7.number().int().nonnegative(),
840
+ run_id: z7.string().nullable(),
841
+ world_run_id: z7.string().nullable(),
842
+ error: z7.string().nullable()
843
+ });
844
+ var ragRebuildStatusResponseSchema = z7.object({
845
+ rebuild: ragRebuildStatusSchema.nullable()
846
+ });
847
+
848
+ // libs/shared/validators/src/lib/studio/studio-voice.ts
849
+ var VOICE_STT_MODELS = [
850
+ "deepgram/flux-general-multi",
851
+ "deepgram/nova-3",
852
+ "assemblyai/universal-3-6-pro"
853
+ ];
854
+ var VOICE_TTS_MODELS = [
855
+ "cartesia/sonic-3.6",
856
+ "deepgram/aura-2",
857
+ "gradium/default"
858
+ ];
859
+ var isCartesiaTtsModel = (model) => model.startsWith("cartesia/") && VOICE_TTS_MODELS.includes(model);
860
+ var thresholdSchema = z8.number().min(0).max(1);
861
+ var millisecondsSchema = z8.int().min(0).max(1e4);
862
+ var keytermsSchema = z8.array(z8.string().trim().min(1).max(64)).max(50);
863
+ var vadSchema = z8.strictObject({
864
+ activationThreshold: thresholdSchema,
865
+ minSpeechMs: millisecondsSchema,
866
+ minSilenceMs: millisecondsSchema
867
+ });
868
+ var turnSchema = z8.strictObject({
869
+ mode: z8.enum(["stt", "livekit"]),
870
+ minEndpointingDelayMs: millisecondsSchema,
871
+ maxEndpointingDelayMs: millisecondsSchema,
872
+ detectorThreshold: thresholdSchema
873
+ });
874
+ var interruptionsSchema = z8.strictObject({
875
+ enabled: z8.boolean(),
876
+ minDurationMs: millisecondsSchema,
877
+ minWords: z8.int().min(0).max(20)
878
+ });
879
+ var ragSchema = z8.strictObject({
880
+ collection: ragCollectionNameParamSchema.nullable(),
881
+ minScore: z8.number().min(0).max(1),
882
+ minWords: z8.int().min(0).max(20),
883
+ topK: z8.int().min(1).max(10),
884
+ timeoutMs: z8.int().min(200).max(1e4)
885
+ });
886
+ var effectiveVoiceSettingsObjectSchema = z8.strictObject({
887
+ /** BCP-47-ish tag the STT is hinted with and the TTS speaks (`es`, `en-US`), or `multi`. */
888
+ language: z8.string().max(16).regex(/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/),
889
+ sttModel: z8.enum(VOICE_STT_MODELS),
890
+ keyterms: keytermsSchema.default(() => []),
891
+ vad: vadSchema,
892
+ turn: turnSchema,
893
+ interruptions: interruptionsSchema,
894
+ ttsModel: z8.enum(VOICE_TTS_MODELS),
895
+ /**
896
+ * The provider's own voice id (a Cartesia UUID, an ElevenLabs id, a Deepgram name),
897
+ * so only its outline is checked: no surrounding whitespace, no control characters.
898
+ */
899
+ ttsVoice: z8.string().max(128).regex(/^[^\s\p{Cc}](?:[^\p{Cc}]*[^\s\p{Cc}])?$/u),
900
+ /** Cartesia only (D18): another model keeps its own pace, so it takes only 1. */
901
+ ttsSpeed: z8.number().min(0.5).max(2),
902
+ /**
903
+ * What the agent says when the call connects. `null`: it waits for the caller. A
904
+ * blank string is refused rather than read as either: it would be an empty
905
+ * utterance, not a wait.
906
+ */
907
+ greeting: z8.string().trim().min(1).max(500).nullable(),
908
+ /** Band-pass the agent's voice to 300-3400 Hz so it sounds like a phone line (D5). */
909
+ phoneLine: z8.boolean(),
910
+ /** LiveKit expressive mode: the turn gets Cartesia's markup guide (D5). Cartesia only. */
911
+ expressive: z8.boolean(),
912
+ /**
913
+ * Whether the box records the call to its bucket (D30). Off, no egress starts: a
914
+ * box may not be allowed to keep a caller's audio. The box alone reads it, so the
915
+ * dispatch to the worker leaves it out (`callVoiceSettingsSchema`).
916
+ */
917
+ recording: z8.boolean(),
918
+ /**
919
+ * Ask OpenRouter to route this agent's model calls to the provider with the
920
+ * highest throughput (`provider: { sort: 'throughput' }`) instead of its default
921
+ * price routing. Gemma 4 31B on price routing landed on a 13 tok/s provider (5 s
922
+ * a turn); throughput routing answered in about 1 s. A request field, not a
923
+ * `:nitro` model suffix, so the model id the box checks and prices stays the
924
+ * one written. The box strips it off any other gateway. The SDK applies it when
925
+ * it builds the graph, so the worker never reads it.
926
+ */
927
+ fastModelRouting: z8.boolean(),
928
+ /** The box runs the search before the model's turn, so the worker never reads it. */
929
+ rag: ragSchema,
930
+ /**
931
+ * What the agent says when an input guardrail stops the caller's sentence (FDE-66):
932
+ * a policy message written for a chat reader would leak the rule if a TTS read it.
933
+ * The box answers the turn itself, so the worker never reads it. Not nullable: a
934
+ * blocked turn always says something.
935
+ */
936
+ refusalPhrase: z8.string().trim().min(1).max(300)
937
+ });
938
+ var checkCrossFieldRules = (value, ctx) => {
939
+ const min = value.turn?.minEndpointingDelayMs;
940
+ const max = value.turn?.maxEndpointingDelayMs;
941
+ if (min !== void 0 && max !== void 0 && min > max) {
942
+ ctx.addIssue({
943
+ code: "custom",
944
+ path: ["turn", "minEndpointingDelayMs"],
945
+ message: "turn.minEndpointingDelayMs must not exceed turn.maxEndpointingDelayMs"
946
+ });
947
+ }
948
+ if (value.expressive === true && value.ttsModel !== void 0 && !isCartesiaTtsModel(value.ttsModel)) {
949
+ ctx.addIssue({
950
+ code: "custom",
951
+ path: ["expressive"],
952
+ message: "expressive needs a Cartesia ttsModel"
953
+ });
954
+ }
955
+ if (value.ttsSpeed !== void 0 && value.ttsSpeed !== 1 && value.ttsModel !== void 0 && !isCartesiaTtsModel(value.ttsModel)) {
956
+ ctx.addIssue({
957
+ code: "custom",
958
+ path: ["ttsSpeed"],
959
+ message: "ttsSpeed other than 1 needs a Cartesia ttsModel"
960
+ });
961
+ }
962
+ };
963
+ var effectiveVoiceSettingsSchema = effectiveVoiceSettingsObjectSchema.superRefine(checkCrossFieldRules);
964
+ var callVoiceSettingsObjectSchema = effectiveVoiceSettingsObjectSchema.omit({
965
+ recording: true,
966
+ fastModelRouting: true,
967
+ rag: true,
968
+ refusalPhrase: true
969
+ });
970
+ var callVoiceSettingsSchema = callVoiceSettingsObjectSchema.superRefine(checkCrossFieldRules);
971
+ var VOICE_SETTINGS_LEAF_PATHS = [
972
+ "language",
973
+ "sttModel",
974
+ "keyterms",
975
+ "vad.activationThreshold",
976
+ "vad.minSpeechMs",
977
+ "vad.minSilenceMs",
978
+ "turn.mode",
979
+ "turn.minEndpointingDelayMs",
980
+ "turn.maxEndpointingDelayMs",
981
+ "turn.detectorThreshold",
982
+ "interruptions.enabled",
983
+ "interruptions.minDurationMs",
984
+ "interruptions.minWords",
985
+ "ttsModel",
986
+ "ttsVoice",
987
+ "ttsSpeed",
988
+ "greeting",
989
+ "phoneLine",
990
+ "expressive",
991
+ "recording",
992
+ "fastModelRouting",
993
+ "rag.collection",
994
+ "rag.minScore",
995
+ "rag.minWords",
996
+ "rag.topK",
997
+ "rag.timeoutMs",
998
+ "refusalPhrase"
999
+ ];
1000
+ var voiceSettingOriginSchema = z8.enum(["studio", "code", "default"]);
1001
+ var effectiveVoiceSettingsViewSchema = z8.strictObject({
1002
+ value: effectiveVoiceSettingsObjectSchema,
1003
+ origins: z8.strictObject(
1004
+ Object.fromEntries(
1005
+ VOICE_SETTINGS_LEAF_PATHS.map((path) => [path, voiceSettingOriginSchema])
1006
+ )
1007
+ )
1008
+ });
1009
+ var checkTtsPair = (value, ctx) => {
1010
+ if (value.ttsModel !== void 0 && value.ttsVoice === void 0) {
1011
+ ctx.addIssue({
1012
+ code: "custom",
1013
+ path: ["ttsVoice"],
1014
+ message: "ttsModel needs a ttsVoice of that provider beside it"
1015
+ });
1016
+ }
1017
+ };
1018
+ var voiceSettingsSchema = effectiveVoiceSettingsObjectSchema.extend({
1019
+ keyterms: keytermsSchema,
1020
+ vad: vadSchema.partial(),
1021
+ turn: turnSchema.partial(),
1022
+ interruptions: interruptionsSchema.partial(),
1023
+ rag: ragSchema.partial()
1024
+ }).partial().superRefine(checkCrossFieldRules).superRefine(checkTtsPair);
1025
+ var checkStudioTtsPair = (value, ctx) => {
1026
+ if (value.ttsVoice !== void 0 && value.ttsModel === void 0) {
1027
+ ctx.addIssue({
1028
+ code: "custom",
1029
+ path: ["ttsModel"],
1030
+ message: "ttsVoice needs the ttsModel it was picked for beside it"
1031
+ });
1032
+ }
1033
+ };
1034
+ var studioVoiceSettingsPatchSchema = voiceSettingsSchema.superRefine(checkStudioTtsPair);
1035
+ var voiceSettingsResponseSchema = effectiveVoiceSettingsViewSchema.extend({
1036
+ issue: z8.string().nullable()
1037
+ });
1038
+ var deepAgentNameSchema = z8.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/);
1039
+ var voiceCallIdSchema = z8.string().min(1).max(RUN_TAG_VALUE_MAX);
1040
+ var voiceTagsSchema = z8.record(runTagKeySchema, runTagValueSchema).refine((tags) => Object.keys(tags).length <= RUN_TAGS_MAX, {
1041
+ message: `A call carries at most ${RUN_TAGS_MAX} tags.`
1042
+ });
1043
+ var VOICE_TTS_MARKUP_CONTEXT = "voice-tts-markup";
1044
+ var isVoiceBoxUrl = (raw) => {
1045
+ if (!/^https?:\/\//i.test(raw) || /[\s?#]/.test(raw)) return false;
1046
+ try {
1047
+ const url = new URL(raw);
1048
+ return url.hostname !== "" && url.username === "" && url.password === "";
1049
+ } catch {
1050
+ return false;
1051
+ }
1052
+ };
1053
+ var VOICE_BOX_URL_MAX = 2048;
1054
+ var voiceBoxUrlSchema = z8.string().max(VOICE_BOX_URL_MAX).refine(isVoiceBoxUrl, {
1055
+ message: "boxUrl must be an absolute http(s) URL with no user part, query or fragment."
1056
+ });
1057
+ var VOICE_TRACES_PATH = "/voice/v1/traces";
1058
+ var VOICE_WORKER_AGENT_NAME = "stackbone-voice";
1059
+ var VOICE_WORKER_AGENT_NAME_ENV = "STACKBONE_VOICE_AGENT_NAME";
1060
+ var VOICE_WORKER_PORT_ENV = "STACKBONE_VOICE_WORKER_PORT";
1061
+ var VOICE_CALL_TOKEN_MAX = 2048;
1062
+ var voiceDispatchSchema = z8.strictObject({
1063
+ agent: deepAgentNameSchema,
1064
+ /**
1065
+ * The merged value (Studio > code > box default), cross-field rules included,
1066
+ * without the box-only leaves (`callVoiceSettingsSchema`).
1067
+ */
1068
+ voiceSettings: callVoiceSettingsSchema,
1069
+ sessionKey: voiceCallIdSchema,
1070
+ tags: voiceTagsSchema,
1071
+ boxUrl: voiceBoxUrlSchema,
1072
+ /** The call token: the bearer on every AG-UI request of this call, for this agent and session only. */
1073
+ callToken: z8.string().max(VOICE_CALL_TOKEN_MAX).regex(/^\S+$/, { message: "callToken must be a non-empty token with no whitespace." }),
1074
+ /**
1075
+ * Set only when the box exports traces to an OTLP endpoint of its own (FDE-62 S11):
1076
+ * the worker then sends each call's spans to `<boxUrl>` + {@link VOICE_TRACES_PATH} with the
1077
+ * call token. Off is the absent key, never `false`.
1078
+ */
1079
+ traces: z8.literal(true).optional()
1080
+ });
1081
+ var voiceRoomRequestSchema = z8.strictObject({
1082
+ agent: deepAgentNameSchema,
1083
+ origin: runTagKeySchema.default("studio"),
1084
+ identities: integrationIdentitiesSchema.optional()
1085
+ });
1086
+ var voiceRoomResponseSchema = z8.strictObject({
1087
+ url: z8.string().min(1),
1088
+ token: z8.string().min(1),
1089
+ room: voiceCallIdSchema,
1090
+ agent: deepAgentNameSchema,
1091
+ sessionKey: voiceCallIdSchema,
1092
+ voiceSettings: callVoiceSettingsObjectSchema
1093
+ });
1094
+ var VOICE_RECORDING_STATUSES = ["recording", "ready", "failed"];
1095
+ var voiceRecordingQuerySchema = z8.object({ session_id: z8.uuid() });
1096
+ var voiceRecordingResponseSchema = z8.strictObject({
1097
+ room: voiceCallIdSchema,
1098
+ status: z8.enum(VOICE_RECORDING_STATUSES),
1099
+ url: z8.string().nullable(),
1100
+ expires_at: z8.iso.datetime().nullable(),
1101
+ started_at: z8.iso.datetime(),
1102
+ ended_at: z8.iso.datetime().nullable()
1103
+ });
1104
+ var voiceStatusResponseSchema = z8.strictObject({
1105
+ configured: z8.literal(true),
1106
+ reachable: z8.boolean(),
1107
+ checkedAt: z8.iso.datetime()
1108
+ });
1109
+ var HEARD_TEXT_MAX = 4e3;
1110
+ var aguiHeardSchema = z8.strictObject({
1111
+ text: z8.string().min(1).max(HEARD_TEXT_MAX)
1112
+ });
1113
+ var VOICE_CALLS_PATH = "/voice/v1/calls";
1114
+ var voiceCallBootstrapSchema = z8.strictObject({
1115
+ identities: integrationIdentitiesSchema.optional()
1116
+ });
1117
+ var voiceCallTranscriptSchema = z8.array(
1118
+ z8.strictObject({
1119
+ role: z8.enum(["user", "assistant"]),
1120
+ text: z8.string().trim().min(1).max(16e3)
1121
+ })
1122
+ ).max(1e3).refine((items) => items.reduce((size, item) => size + item.text.length, 0) <= 2e5, {
1123
+ message: "A call transcript may contain at most 200000 characters."
1124
+ });
1125
+ var voiceCallCompleteSchema = z8.strictObject({
1126
+ transcript: voiceCallTranscriptSchema,
1127
+ outcome: z8.enum(["completed", "disconnected", "failed"])
1128
+ });
1129
+ var voiceCallLifecycleResponseSchema = z8.strictObject({ ok: z8.literal(true) });
1130
+
1131
+ // libs/runtime/voice-worker/src/box-llm.ts
1132
+ import { randomUUID } from "node:crypto";
1133
+ import { DEFAULT_API_CONNECT_OPTIONS, llm, log } from "@livekit/agents";
1134
+
1135
+ // libs/shared/llm-wire/src/lib/sse/read-sse-data.ts
1136
+ async function* readSseData(body) {
1137
+ const reader = body.getReader();
1138
+ const decoder = new TextDecoder();
1139
+ let buffered = "";
1140
+ let data = [];
1141
+ try {
1142
+ for (; ; ) {
1143
+ const { done, value } = await reader.read();
1144
+ if (!done) buffered += decoder.decode(value, { stream: true });
1145
+ const end = !done && buffered.endsWith("\r") ? buffered.length - 1 : buffered.length;
1146
+ const lines = buffered.slice(0, end).split(/\r\n|\r|\n/);
1147
+ buffered = (lines.pop() ?? "") + buffered.slice(end);
1148
+ for (const line of lines) {
1149
+ if (line === "") {
1150
+ if (data.length > 0) yield data.join("\n");
1151
+ data = [];
1152
+ } else if (line.startsWith("data:")) {
1153
+ const field = line.slice(5);
1154
+ data.push(field.startsWith(" ") ? field.slice(1) : field);
1155
+ }
1156
+ }
1157
+ if (done) return;
1158
+ }
1159
+ } finally {
1160
+ await reader.cancel().catch(() => void 0);
1161
+ }
1162
+ }
1163
+
1164
+ // libs/runtime/voice-worker/src/box-wire.ts
1165
+ var BODY_EXCERPT_CHARS = 300;
1166
+ var TURN_IDLE_TIMEOUT_MS = 3e4;
1167
+ var NON_ASCII = /[\u007f-\uffff]/g;
1168
+ var runErrorOf = ({ message, code }) => new Error(`box run failed${code ? ` (${code})` : ""}: ${message}`);
1169
+ var BoxTurnError = class extends Error {
1170
+ constructor(agent, status, body) {
1171
+ super(`box refused the AG-UI turn of ${agent}: HTTP ${status} ${body}`);
1172
+ this.name = "BoxTurnError";
1173
+ this.status = status;
1174
+ this.body = body;
1175
+ }
1176
+ };
1177
+ var boxEndpoint = (boxUrl, path) => `${boxUrl.replace(/\/+$/, "")}${path}`;
1178
+ async function* aguiTurn(opts) {
1179
+ const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1180
+ const heard = "toolResult" in opts ? void 0 : opts.heard;
1181
+ const message = "toolResult" in opts ? {
1182
+ id: `tool-${opts.toolResult.toolCallId}`,
1183
+ role: "tool",
1184
+ toolCallId: opts.toolResult.toolCallId,
1185
+ content: opts.toolResult.content
1186
+ } : { id: `user-${runId}`, role: "user", content: opts.text };
1187
+ const res = await fetch(
1188
+ boxEndpoint(opts.boxUrl, `/agui/v1/agents/${encodeURIComponent(opts.agent)}`),
1189
+ {
1190
+ method: "POST",
1191
+ headers: {
1192
+ "content-type": "application/json",
1193
+ accept: "text/event-stream",
1194
+ authorization: `Bearer ${opts.token}`,
1195
+ [STACKBONE_SESSION_HEADER]: opts.sessionKey,
1196
+ [RUN_TAGS_HEADER]: tagsHeader(opts.tags)
1197
+ },
1198
+ body: JSON.stringify({
1199
+ threadId: opts.sessionKey,
1200
+ runId,
1201
+ state: {},
1202
+ messages: [message],
1203
+ tools: opts.tools ?? [],
1204
+ context: opts.context ?? [],
1205
+ forwardedProps: heard ? { heard } : {}
1206
+ }),
1207
+ ...opts.signal ? { signal: opts.signal } : {}
1208
+ }
1209
+ );
1210
+ if (!res.ok || !res.body) throw new BoxTurnError(opts.agent, res.status, await readExcerpt(res));
1211
+ let inStep = false;
1212
+ for await (const data of readSseData(res.body)) {
1213
+ const parsed = JSON.parse(data);
1214
+ const ev = typeof parsed === "object" && parsed !== null ? parsed : {};
1215
+ const wire = toWireEvent(ev);
1216
+ if (wire.kind === "other" && wire.type === "STEP_STARTED") inStep = true;
1217
+ if (wire.kind === "other" && wire.type === "STEP_FINISHED") inStep = false;
1218
+ yield wire.kind === "text" && inStep ? { kind: "other", type: "TEXT_MESSAGE_CONTENT" } : wire;
1219
+ }
1220
+ }
1221
+ var tagsHeader = (tags) => JSON.stringify(tags).replace(
1222
+ NON_ASCII,
1223
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`
1224
+ );
1225
+ var readExcerpt = async (res) => {
1226
+ if (!res.body) return "";
1227
+ const reader = res.body.getReader();
1228
+ const decoder = new TextDecoder();
1229
+ let text = "";
1230
+ try {
1231
+ while (text.length < BODY_EXCERPT_CHARS) {
1232
+ const { done, value } = await reader.read();
1233
+ if (done) break;
1234
+ text += decoder.decode(value, { stream: true });
1235
+ }
1236
+ } finally {
1237
+ await reader.cancel().catch(() => void 0);
1238
+ }
1239
+ return text.slice(0, BODY_EXCERPT_CHARS);
1240
+ };
1241
+ var stringField = (ev, key) => {
1242
+ const value = ev[key];
1243
+ return typeof value === "string" ? value : void 0;
1244
+ };
1245
+ var toWireEvent = (ev) => {
1246
+ const type = stringField(ev, "type") ?? "";
1247
+ const id = stringField(ev, "toolCallId");
1248
+ switch (type) {
1249
+ case "TEXT_MESSAGE_CONTENT": {
1250
+ const delta = stringField(ev, "delta");
1251
+ return delta ? { kind: "text", text: delta } : { kind: "other", type };
1252
+ }
1253
+ case "TOOL_CALL_START":
1254
+ return id ? { kind: "tool_call_start", id, name: stringField(ev, "toolCallName") ?? "" } : { kind: "other", type };
1255
+ case "TOOL_CALL_RESULT":
1256
+ return id ? { kind: "tool_call_result", id } : { kind: "other", type };
1257
+ case "RUN_FINISHED": {
1258
+ const outcome = ev["outcome"];
1259
+ const paused = typeof outcome === "object" && outcome !== null && outcome["type"] === "interrupt";
1260
+ return { kind: "finish", outcome: paused ? "interrupt" : "success" };
1261
+ }
1262
+ case "RUN_ERROR": {
1263
+ const code = stringField(ev, "code");
1264
+ return {
1265
+ kind: "error",
1266
+ message: stringField(ev, "message") ?? "run error",
1267
+ ...code ? { code } : {}
1268
+ };
1269
+ }
1270
+ default:
1271
+ return { kind: "other", type };
1272
+ }
1273
+ };
1274
+ var voiceCallRequest = async (wire, operation, payload) => {
1275
+ const response = await fetch(
1276
+ boxEndpoint(
1277
+ wire.boxUrl,
1278
+ `${VOICE_CALLS_PATH}/${encodeURIComponent(wire.sessionKey)}/${operation}`
1279
+ ),
1280
+ {
1281
+ method: "POST",
1282
+ headers: { "content-type": "application/json", authorization: `Bearer ${wire.token}` },
1283
+ signal: AbortSignal.timeout(TURN_IDLE_TIMEOUT_MS),
1284
+ body: JSON.stringify(payload)
1285
+ }
1286
+ );
1287
+ if (!response.ok)
1288
+ throw new BoxTurnError(wire.agent, response.status, await readExcerpt(response));
1289
+ voiceCallLifecycleResponseSchema.parse(await response.json());
1290
+ };
1291
+
1292
+ // libs/runtime/voice-worker/src/heard.ts
1293
+ var VOICE_RESPONSE_MARKER = "stackboneVoiceResponse";
1294
+ var WORD_CHAR = /[\p{L}\p{N}]/u;
1295
+ var SPACE = /\s/u;
1296
+ var TAG = /^<[\p{L}/][^>]*>/u;
1297
+ var charAt = (text, index) => String.fromCodePoint(text.codePointAt(index) ?? 0);
1298
+ var heardPrefixOf = (boxText, heardText) => {
1299
+ let b = 0;
1300
+ let h = 0;
1301
+ while (h < heardText.length) {
1302
+ const heardChar = charAt(heardText, h);
1303
+ if (b >= boxText.length) {
1304
+ if (!SPACE.test(heardChar)) return void 0;
1305
+ h += heardChar.length;
1306
+ continue;
1307
+ }
1308
+ const boxChar = charAt(boxText, b);
1309
+ const tag = boxChar === "<" ? TAG.exec(boxText.slice(b)) : null;
1310
+ if (boxChar === heardChar) {
1311
+ b += boxChar.length;
1312
+ h += heardChar.length;
1313
+ } else if (tag) {
1314
+ b += tag[0].length;
1315
+ } else if (!WORD_CHAR.test(boxChar)) {
1316
+ b += boxChar.length;
1317
+ } else if (SPACE.test(heardChar)) {
1318
+ h += heardChar.length;
1319
+ } else {
1320
+ return void 0;
1321
+ }
1322
+ }
1323
+ return boxText.slice(0, b).trimEnd() || void 0;
1324
+ };
1325
+ var TurnReply = class {
1326
+ /** LiveKit's chat items when the turn started: the reply a barge-in cut is a later one. */
1327
+ #seen;
1328
+ /** Every text delta queued to LiveKit this turn. */
1329
+ #text = "";
1330
+ /** Where the current model call's text starts in `#text`. */
1331
+ #callStart = 0;
1332
+ /** A tool result came after the current call: the next text is a new call's. */
1333
+ #callClosed = false;
1334
+ /** The current call is the turn's first: no tool result came before it. */
1335
+ #firstCall = true;
1336
+ /** The current call asked for tools: the box writes it before it runs them. */
1337
+ #callToolCalls = false;
1338
+ /** `RUN_FINISHED` arrived: the box wrote every call of the run. */
1339
+ #finished = false;
1340
+ constructor(chatCtx) {
1341
+ this.#seen = new Set(chatCtx.items.map((item) => item.id));
1342
+ }
1343
+ observe(ev) {
1344
+ switch (ev.kind) {
1345
+ case "text":
1346
+ this.#openCallAfterResult();
1347
+ this.#text += ev.text;
1348
+ break;
1349
+ case "tool_call_start":
1350
+ this.#openCallAfterResult();
1351
+ this.#callToolCalls = true;
1352
+ break;
1353
+ case "tool_call_result":
1354
+ this.#callClosed = true;
1355
+ break;
1356
+ case "finish":
1357
+ this.#finished = true;
1358
+ break;
1359
+ }
1360
+ }
1361
+ #openCallAfterResult() {
1362
+ if (!this.#callClosed) return;
1363
+ this.#callStart = this.#text.length;
1364
+ this.#callClosed = false;
1365
+ this.#firstCall = false;
1366
+ this.#callToolCalls = false;
1367
+ }
1368
+ /**
1369
+ * Whether the box's last AI message after the turn's user message is the current
1370
+ * call's, or there is none (the box then appends `heard` as the reply):
1371
+ * - the run finished: the current call's, unless a tool result closed it, since the
1372
+ * last model call then said nothing (an empty message);
1373
+ * - cut while the box still sent: the current call's once the box wrote it, which a
1374
+ * tool call or a tool result proves; none for the turn's first call; for a later
1375
+ * call torn down mid-text, an EARLIER call's, which the caller's words do not fit.
1376
+ */
1377
+ #boxHoldsCurrentCall() {
1378
+ if (this.#finished) return !this.#callClosed;
1379
+ return this.#callClosed || this.#callToolCalls || this.#firstCall;
1380
+ }
1381
+ finalPrefix(playedText) {
1382
+ const prefix = heardPrefixOf(this.#text, playedText);
1383
+ if (prefix === void 0) return void 0;
1384
+ const remainder = this.#text.slice(prefix.length).replace(/<[\p{L}/][^>]*>/gu, "");
1385
+ return WORD_CHAR.test(remainder) ? prefix : void 0;
1386
+ }
1387
+ /**
1388
+ * What the caller heard of this reply's last model call, from the next turn's chat
1389
+ * context: only when a barge-in cut it there, and never the whole call's text.
1390
+ * `undefined` when no barge-in cut it; otherwise the heard part or why there is none.
1391
+ */
1392
+ heardIn(chatCtx) {
1393
+ const played = lastNewAssistantMessage(chatCtx, this.#seen);
1394
+ if (!played?.interrupted) return void 0;
1395
+ if (!this.#boxHoldsCurrentCall()) return { skipped: "not_box_held" };
1396
+ const prefix = heardPrefixOf(this.#text, played.textContent ?? "");
1397
+ if (prefix === void 0) return { skipped: "unaligned" };
1398
+ if (prefix.length <= this.#callStart) return { skipped: "earlier_call" };
1399
+ const text = prefix.slice(this.#callStart);
1400
+ const whole = this.#text.slice(this.#callStart).trimEnd();
1401
+ if (text.length >= whole.length) return { skipped: "whole" };
1402
+ return text.length <= HEARD_TEXT_MAX ? { heard: { text } } : { skipped: "over_max" };
1403
+ }
1404
+ };
1405
+ var HeardTracker = class {
1406
+ #last;
1407
+ #responses = /* @__PURE__ */ new Map();
1408
+ /**
1409
+ * Starts a turn that is about to send: what the caller heard of the previous turn's
1410
+ * reply (or why a cut reply sends nothing), and this turn's reply. The previous
1411
+ * reply is read once.
1412
+ */
1413
+ openTurn(chatCtx, responseId) {
1414
+ const read = this.#last?.heardIn(chatCtx);
1415
+ const reply = new TurnReply(chatCtx);
1416
+ this.#last = reply;
1417
+ if (responseId) this.#responses.set(responseId, reply);
1418
+ return {
1419
+ heard: read && "heard" in read ? read.heard : void 0,
1420
+ skipped: read && "skipped" in read ? read.skipped : void 0,
1421
+ reply
1422
+ };
1423
+ }
1424
+ transcript(history, onUncertain) {
1425
+ const transcript = [];
1426
+ for (const item of history.items) {
1427
+ if (item.type !== "message" || item.role !== "user" && item.role !== "assistant") continue;
1428
+ let text = item.textContent?.trim();
1429
+ if (!text) continue;
1430
+ if (item.role === "assistant" && item.interrupted) {
1431
+ const responseId = item.extra[VOICE_RESPONSE_MARKER];
1432
+ const response = typeof responseId === "string" ? this.#responses.get(responseId) : void 0;
1433
+ text = response?.finalPrefix(text)?.trim();
1434
+ if (!text) {
1435
+ onUncertain(response ? "unverified_interrupted_prefix" : "unowned_interruption");
1436
+ continue;
1437
+ }
1438
+ }
1439
+ transcript.push({ role: item.role, text });
1440
+ }
1441
+ return transcript;
1442
+ }
1443
+ /** A failed turn: the box keeps none of its reply, so the next turn sends nothing for it. */
1444
+ dropTurn(reply) {
1445
+ if (this.#last === reply) this.#last = void 0;
1446
+ for (const [id, response] of this.#responses)
1447
+ if (response === reply) this.#responses.delete(id);
1448
+ }
1449
+ };
1450
+ var lastNewAssistantMessage = (chatCtx, seen) => {
1451
+ for (let i = chatCtx.items.length - 1; i >= 0; i--) {
1452
+ const item = chatCtx.items[i];
1453
+ if (item === void 0 || seen.has(item.id)) continue;
1454
+ if (item.type === "message" && item.role === "assistant") return item;
1455
+ }
1456
+ return void 0;
1457
+ };
1458
+
1459
+ // libs/runtime/voice-worker/src/box-llm.ts
1460
+ var BoxLLM = class extends llm.LLM {
1461
+ #opts;
1462
+ #replies = new HeardTracker();
1463
+ #pending = /* @__PURE__ */ new Set();
1464
+ async settled() {
1465
+ await Promise.all(this.#pending);
1466
+ }
1467
+ constructor(opts) {
1468
+ super();
1469
+ this.#opts = opts;
1470
+ }
1471
+ heardTranscript(history) {
1472
+ return this.#replies.transcript(
1473
+ history,
1474
+ (reason) => log().warn(
1475
+ { agent: this.#opts.agent, sessionKey: this.#opts.sessionKey, reason },
1476
+ "voice worker: omitted uncertain interrupted output"
1477
+ )
1478
+ );
1479
+ }
1480
+ label() {
1481
+ return "stackbone.BoxLLM";
1482
+ }
1483
+ get model() {
1484
+ return this.#opts.agent;
1485
+ }
1486
+ get provider() {
1487
+ return "stackbone";
1488
+ }
1489
+ chat({
1490
+ chatCtx,
1491
+ connOptions
1492
+ }) {
1493
+ let done = () => void 0;
1494
+ const pending = new Promise((resolve) => {
1495
+ done = resolve;
1496
+ });
1497
+ this.#pending.add(pending);
1498
+ return new BoxLLMStream(
1499
+ this,
1500
+ this.#opts,
1501
+ this.#replies,
1502
+ () => {
1503
+ this.#pending.delete(pending);
1504
+ done();
1505
+ },
1506
+ {
1507
+ chatCtx,
1508
+ connOptions: { ...connOptions ?? DEFAULT_API_CONNECT_OPTIONS, maxRetry: 0 }
1509
+ }
1510
+ );
1511
+ }
1512
+ };
1513
+ var refusedHeard = (err) => {
1514
+ if (!(err instanceof BoxTurnError) || err.status !== 400) return false;
1515
+ try {
1516
+ return JSON.parse(err.body)?.code === "invalid_heard";
1517
+ } catch {
1518
+ return false;
1519
+ }
1520
+ };
1521
+ async function* textTurnEvents({
1522
+ heard,
1523
+ ...turn
1524
+ }) {
1525
+ if (heard) {
1526
+ try {
1527
+ yield* aguiTurn({ ...turn, heard });
1528
+ return;
1529
+ } catch (err) {
1530
+ if (!refusedHeard(err)) throw err;
1531
+ log().warn(
1532
+ { agent: turn.agent, sessionKey: turn.sessionKey, status: err.status, error: err.message },
1533
+ "voice worker: the box refused heard; the turn goes on without it"
1534
+ );
1535
+ }
1536
+ }
1537
+ yield* aguiTurn(turn);
1538
+ }
1539
+ var HEARD_SKIP_LEVEL = {
1540
+ not_box_held: "debug",
1541
+ earlier_call: "debug",
1542
+ whole: "debug",
1543
+ unaligned: "warn",
1544
+ over_max: "warn"
1545
+ };
1546
+ var logHeardSkip = (skipped, turn) => log()[HEARD_SKIP_LEVEL[skipped]](
1547
+ { reason: skipped, agent: turn.agent, sessionKey: turn.sessionKey },
1548
+ "voice worker: no heard for the reply cut"
1549
+ );
1550
+ var lastUserText = (chatCtx) => {
1551
+ for (let i = chatCtx.items.length - 1; i >= 0; i--) {
1552
+ const item = chatCtx.items[i];
1553
+ if (item?.type === "message" && item.role === "user") return item.textContent ?? "";
1554
+ }
1555
+ return "";
1556
+ };
1557
+ var BoxLLMStream = class extends llm.LLMStream {
1558
+ #opts;
1559
+ #replies;
1560
+ #settle;
1561
+ constructor(owner, opts, replies, settle, init) {
1562
+ super(owner, init);
1563
+ this.#opts = opts;
1564
+ this.#replies = replies;
1565
+ this.#settle = settle;
1566
+ }
1567
+ async run() {
1568
+ try {
1569
+ await this.runTurn();
1570
+ } finally {
1571
+ this.#settle();
1572
+ }
1573
+ }
1574
+ async runTurn() {
1575
+ const {
1576
+ onToolCall,
1577
+ onTurnError,
1578
+ onRunFinished,
1579
+ onClientToolCall,
1580
+ pendingResume,
1581
+ isEnding,
1582
+ clientTools = [],
1583
+ turnIdleTimeoutMs = TURN_IDLE_TIMEOUT_MS,
1584
+ ...turn
1585
+ } = this.#opts;
1586
+ const id = randomUUID();
1587
+ const { signal } = this.abortController;
1588
+ await pendingResume?.();
1589
+ if (signal.aborted || isEnding?.()) return;
1590
+ const { heard, skipped, reply } = this.#replies.openTurn(this.chatCtx, id);
1591
+ if (skipped) logHeardSkip(skipped, turn);
1592
+ const clientToolNames = new Set(clientTools.map((tool) => tool.name));
1593
+ const clientToolCalls = /* @__PURE__ */ new Map();
1594
+ const idle = new AbortController();
1595
+ let timer;
1596
+ const restartIdle = () => {
1597
+ clearTimeout(timer);
1598
+ timer = setTimeout(() => idle.abort(), turnIdleTimeoutMs);
1599
+ };
1600
+ const openToolCalls = /* @__PURE__ */ new Set();
1601
+ let finished;
1602
+ let failure;
1603
+ try {
1604
+ restartIdle();
1605
+ const turnSignal = AbortSignal.any([signal, idle.signal]);
1606
+ for await (const ev of textTurnEvents({
1607
+ ...turn,
1608
+ tools: clientTools,
1609
+ text: lastUserText(this.chatCtx),
1610
+ ...heard ? { heard } : {},
1611
+ signal: turnSignal
1612
+ })) {
1613
+ restartIdle();
1614
+ reply.observe(ev);
1615
+ switch (ev.kind) {
1616
+ case "text":
1617
+ this.queue.put({
1618
+ id,
1619
+ delta: {
1620
+ role: "assistant",
1621
+ content: ev.text,
1622
+ extra: { [VOICE_RESPONSE_MARKER]: id }
1623
+ }
1624
+ });
1625
+ break;
1626
+ case "tool_call_start":
1627
+ if (clientToolNames.has(ev.name)) {
1628
+ clientToolCalls.set(ev.id, { id: ev.id, name: ev.name });
1629
+ } else if (!openToolCalls.has(ev.id)) {
1630
+ openToolCalls.add(ev.id);
1631
+ onToolCall?.("start", ev.id);
1632
+ }
1633
+ break;
1634
+ case "tool_call_result":
1635
+ clientToolCalls.delete(ev.id);
1636
+ if (openToolCalls.delete(ev.id)) onToolCall?.("end", ev.id);
1637
+ break;
1638
+ case "finish":
1639
+ finished = ev.outcome;
1640
+ break;
1641
+ case "error":
1642
+ throw runErrorOf(ev);
1643
+ }
1644
+ }
1645
+ if (!finished) throw new Error("box closed the turn before RUN_FINISHED");
1646
+ } catch (err) {
1647
+ if (finished) {
1648
+ } else if (signal.aborted) {
1649
+ } else if (idle.signal.aborted) {
1650
+ failure = new Error(`box sent no event for ${turnIdleTimeoutMs} ms`);
1651
+ } else {
1652
+ failure = err instanceof Error ? err : new Error(String(err));
1653
+ }
1654
+ } finally {
1655
+ clearTimeout(timer);
1656
+ }
1657
+ for (const callId of openToolCalls) onToolCall?.("end", callId);
1658
+ if (failure) {
1659
+ this.#replies.dropTurn(reply);
1660
+ onTurnError?.(failure);
1661
+ return;
1662
+ }
1663
+ if (finished === "success")
1664
+ for (const call of clientToolCalls.values()) onClientToolCall?.(call);
1665
+ if (finished) onRunFinished?.(finished);
1666
+ }
1667
+ };
1668
+
1669
+ // libs/runtime/voice-worker/src/dispatch.ts
1670
+ var readDispatch = (metadata) => {
1671
+ if (!metadata) return null;
1672
+ let value;
1673
+ try {
1674
+ value = JSON.parse(metadata);
1675
+ } catch {
1676
+ return null;
1677
+ }
1678
+ const parsed = voiceDispatchSchema.safeParse(value);
1679
+ return parsed.success ? parsed.data : null;
1680
+ };
1681
+
1682
+ // libs/runtime/voice-worker/src/hang-up.ts
1683
+ import { log as log2 } from "@livekit/agents";
1684
+ var HANG_UP_TOOL = Object.freeze({
1685
+ name: "hang_up",
1686
+ description: "End the phone call. Say goodbye first, in the same reply, then call it.",
1687
+ parameters: Object.freeze({ type: "object", properties: {}, additionalProperties: false })
1688
+ });
1689
+ var HANG_UP_RESULT = "Done: the call ends after your goodbye.";
1690
+ var HANG_UP_PLAYOUT_CAP_MS = 6e4;
1691
+ var untilGoodbyePlayed = (session, capMs = HANG_UP_PLAYOUT_CAP_MS) => {
1692
+ let cap;
1693
+ const capped = new Promise((resolve) => {
1694
+ cap = setTimeout(() => {
1695
+ log2().warn({ capMs }, "voice worker: the goodbye did not play out; hanging up anyway");
1696
+ resolve();
1697
+ }, capMs);
1698
+ });
1699
+ const idle = session.waitForIdle().then(
1700
+ () => void 0,
1701
+ () => void 0
1702
+ );
1703
+ return Promise.race([idle, capped]).finally(() => clearTimeout(cap));
1704
+ };
1705
+ var createHangUp = ({
1706
+ wire,
1707
+ untilPlayed,
1708
+ hangUpAfter,
1709
+ isEnding,
1710
+ onResumeFailed
1711
+ }) => {
1712
+ let resumeInFlight;
1713
+ const resume = async (toolCallId) => {
1714
+ let finished = false;
1715
+ try {
1716
+ for await (const ev of aguiTurn({
1717
+ ...wire,
1718
+ tools: [HANG_UP_TOOL],
1719
+ toolResult: { toolCallId, content: HANG_UP_RESULT },
1720
+ // No turn timer watches it: a box that hangs on it must not hold the call open.
1721
+ signal: AbortSignal.timeout(TURN_IDLE_TIMEOUT_MS)
1722
+ })) {
1723
+ if (ev.kind === "error") throw runErrorOf(ev);
1724
+ if (ev.kind === "finish") finished = true;
1725
+ }
1726
+ if (!finished) throw new Error("box closed the resume before RUN_FINISHED");
1727
+ } catch (err) {
1728
+ if (!finished) onResumeFailed(err);
1729
+ }
1730
+ };
1731
+ return {
1732
+ onClientToolCall: ({ id }) => {
1733
+ if (isEnding()) return;
1734
+ const resumed = resume(id).finally(() => {
1735
+ resumeInFlight = void 0;
1736
+ });
1737
+ resumeInFlight = resumed;
1738
+ hangUpAfter(() => Promise.allSettled([untilPlayed(), resumed]), "agent hung up");
1739
+ },
1740
+ pendingResume: () => resumeInFlight
1741
+ };
1742
+ };
1743
+
1744
+ // libs/runtime/voice-worker/src/session-options.ts
1745
+ import { tts, voice as voice2 } from "@livekit/agents";
1746
+
1747
+ // libs/runtime/voice-worker/src/number-text.ts
1748
+ import { TransformStream } from "node:stream/web";
1749
+ var THOUSANDS_COMMA = /(\d),(?=\d{3}(?!\d))/g;
1750
+ var TRAILING_NUMBER = /[\d.,]+$/;
1751
+ var rewrite = (text) => text.replace(THOUSANDS_COMMA, "$1");
1752
+ var dropThousandsComma = (input) => {
1753
+ let pending = "";
1754
+ return input.pipeThrough(
1755
+ new TransformStream({
1756
+ transform(chunk, controller) {
1757
+ const text = pending + chunk;
1758
+ const tail = TRAILING_NUMBER.exec(text)?.[0] ?? "";
1759
+ pending = tail;
1760
+ const ready = text.slice(0, text.length - tail.length);
1761
+ if (ready) controller.enqueue(rewrite(ready));
1762
+ },
1763
+ flush(controller) {
1764
+ if (pending) controller.enqueue(rewrite(pending));
1765
+ }
1766
+ })
1767
+ );
1768
+ };
1769
+
1770
+ // libs/runtime/voice-worker/src/phone-voice.ts
1771
+ import { AudioFrame as AudioFrame2 } from "@livekit/rtc-node";
1772
+ import { TransformStream as TransformStream2 } from "node:stream/web";
1773
+ var LOW_CUT_HZ = 300;
1774
+ var HIGH_CUT_HZ = 3400;
1775
+ var Q = Math.SQRT1_2;
1776
+ var DRIVE = 1.8;
1777
+ var biquad = (kind, hz, sampleRate) => {
1778
+ const w0 = 2 * Math.PI * hz / sampleRate;
1779
+ const alpha = Math.sin(w0) / (2 * Q);
1780
+ const cos = Math.cos(w0);
1781
+ const a0 = 1 + alpha;
1782
+ const [b0, b1, b2] = kind === "lowpass" ? [(1 - cos) / 2, 1 - cos, (1 - cos) / 2] : [(1 + cos) / 2, -(1 + cos), (1 + cos) / 2];
1783
+ return {
1784
+ b0: b0 / a0,
1785
+ b1: b1 / a0,
1786
+ b2: b2 / a0,
1787
+ a1: -2 * cos / a0,
1788
+ a2: (1 - alpha) / a0,
1789
+ x1: 0,
1790
+ x2: 0,
1791
+ y1: 0,
1792
+ y2: 0
1793
+ };
1794
+ };
1795
+ var run = (f, x) => {
1796
+ const y = f.b0 * x + f.b1 * f.x1 + f.b2 * f.x2 - f.a1 * f.y1 - f.a2 * f.y2;
1797
+ f.x2 = f.x1;
1798
+ f.x1 = x;
1799
+ f.y2 = f.y1;
1800
+ f.y1 = y;
1801
+ return y;
1802
+ };
1803
+ var phoneLine = (audio) => {
1804
+ let chain;
1805
+ let chainRate = 0;
1806
+ const norm = Math.tanh(DRIVE);
1807
+ return audio.pipeThrough(
1808
+ new TransformStream2({
1809
+ transform(frame, controller) {
1810
+ if (!chain || chainRate !== frame.sampleRate) {
1811
+ chainRate = frame.sampleRate;
1812
+ chain = [
1813
+ biquad("highpass", LOW_CUT_HZ, chainRate),
1814
+ biquad("lowpass", HIGH_CUT_HZ, chainRate),
1815
+ biquad("lowpass", HIGH_CUT_HZ, chainRate)
1816
+ ];
1817
+ }
1818
+ const out = new Int16Array(frame.data.length);
1819
+ for (let i = 0; i < frame.data.length; i++) {
1820
+ let y = frame.data[i] / 32768;
1821
+ for (const f of chain) y = run(f, y);
1822
+ y = Math.tanh(y * DRIVE) / norm;
1823
+ out[i] = Math.max(-32768, Math.min(32767, Math.round(y * 32767)));
1824
+ }
1825
+ controller.enqueue(
1826
+ new AudioFrame2(out, frame.sampleRate, frame.channels, frame.samplesPerChannel)
1827
+ );
1828
+ }
1829
+ })
1830
+ );
1831
+ };
1832
+
1833
+ // libs/runtime/voice-worker/src/session-options.ts
1834
+ var sttOptions = ({ sttModel, language, keyterms }) => {
1835
+ const terms = keyterms.length > 0 ? [...keyterms] : void 0;
1836
+ const keyterm = terms ? { keyterm: terms } : void 0;
1837
+ if (sttModel.startsWith("deepgram/flux")) {
1838
+ return { model: sttModel, modelOptions: { language_hint: language, ...keyterm } };
1839
+ }
1840
+ if (sttModel.startsWith("assemblyai/")) {
1841
+ return {
1842
+ model: sttModel,
1843
+ language,
1844
+ modelOptions: { max_turn_silence: 1e3, ...terms ? { keyterms_prompt: terms } : {} }
1845
+ };
1846
+ }
1847
+ return { model: sttModel, language, ...keyterm ? { modelOptions: keyterm } : {} };
1848
+ };
1849
+ var ttsOptions = ({ ttsModel, ttsVoice, ttsSpeed, language }) => {
1850
+ const options = { model: ttsModel, voice: ttsVoice, language };
1851
+ if (ttsSpeed === 1 || !isCartesiaTtsModel(ttsModel)) return options;
1852
+ return { ...options, modelOptions: { speed: ttsSpeed } };
1853
+ };
1854
+ var markupContext = (expressive, speech) => {
1855
+ if (!expressive) return [];
1856
+ const guide = speech.markup.llmInstructions({
1857
+ speechSteering: tts.DEFAULT_SPEECH_STEERING_OPTIONS
1858
+ });
1859
+ return guide === void 0 ? [] : [{ description: VOICE_TTS_MARKUP_CONTEXT, value: guide }];
1860
+ };
1861
+ var PhoneLineAgent = class extends voice2.Agent {
1862
+ async ttsNode(text, modelSettings) {
1863
+ const audio = await super.ttsNode(text, modelSettings);
1864
+ return audio && phoneLine(audio);
1865
+ }
1866
+ };
1867
+ var voiceSessionOptions = (settings, pieces) => {
1868
+ const { turn, vad, interruptions } = settings;
1869
+ pieces.vad.updateOptions({
1870
+ activationThreshold: vad.activationThreshold,
1871
+ minSpeechDuration: vad.minSpeechMs,
1872
+ minSilenceDuration: vad.minSilenceMs
1873
+ });
1874
+ const speech = pieces.createTts(ttsOptions(settings));
1875
+ return {
1876
+ session: {
1877
+ vad: pieces.vad,
1878
+ stt: pieces.createStt(sttOptions(settings)),
1879
+ tts: speech,
1880
+ // The prompt asks for numbers in words; the comma filter is the net for digits.
1881
+ ttsTextTransforms: ["filter_markdown", "filter_emoji", dropThousandsComma],
1882
+ // LiveKit's TTS only normalizes the model's markup, converts it to Cartesia's
1883
+ // and drops bracket cues when the session is expressive: without it the caller
1884
+ // hears the tags. The guide LiveKit then injects into its own chat context never
1885
+ // reaches the box (`BoxLLM` sends the last user text); `boxContext` carries it.
1886
+ expressive: settings.expressive,
1887
+ // LiveKit 1.9.0 feeds the STT silence for the first 3 s the agent speaks (its AEC
1888
+ // warmup), so a caller who talks over the greeting lost the start of the sentence
1889
+ // ("Hola Andrés…" reached the box as "Andrés…"). The browser and the phone network
1890
+ // already cancel echo; LiveKit itself disables it for outbound SIP.
1891
+ aecWarmupDuration: null,
1892
+ turnHandling: {
1893
+ // `stt`: an STT that ends turns itself (Flux) decides; `livekit`: LiveKit's detector.
1894
+ turnDetection: turn.mode === "stt" ? "stt" : pieces.createTurnDetector({ unlikelyThreshold: turn.detectorThreshold }),
1895
+ endpointing: { minDelay: turn.minEndpointingDelayMs, maxDelay: turn.maxEndpointingDelayMs },
1896
+ // LiveKit 1.9.0 reads `enabled` as the session's `allowInterruptions`, the
1897
+ // default of every speech; the old top-level option is deprecated.
1898
+ // `adaptive` explicitly: with no mode, LiveKit 1.9.0 (`resolveInterruptionDetector`)
1899
+ // turns adaptive interruption off unless the worker runs on LiveKit Cloud or in its
1900
+ // deprecated `dev` mode, and `stackbone dev` runs `start` since S15, so a "sí", a
1901
+ // "vale" or a cough cut the agent off. LiveKit still skips it, with a warning,
1902
+ // when interruptions are off or the STT gives no word-aligned transcript.
1903
+ interruption: {
1904
+ mode: "adaptive",
1905
+ enabled: interruptions.enabled,
1906
+ minDuration: interruptions.minDurationMs,
1907
+ minWords: interruptions.minWords
1908
+ },
1909
+ // A preemptive reply is a box turn: a durable run, with tools, started on a
1910
+ // guess at what the caller said. Off explicitly, whatever LiveKit's default.
1911
+ preemptiveGeneration: { enabled: false, preemptiveTts: false }
1912
+ }
1913
+ },
1914
+ // The box holds the prompt: LiveKit's own chat context stays prompt-less.
1915
+ agent: settings.phoneLine ? new PhoneLineAgent({ instructions: "" }) : new voice2.Agent({ instructions: "" }),
1916
+ boxContext: markupContext(settings.expressive, speech)
1917
+ };
1918
+ };
1919
+
1920
+ // libs/runtime/voice-worker/src/telemetry/call-traces.ts
1921
+ import { log as log3, telemetry } from "@livekit/agents";
1922
+ import { ExportResultCode } from "@opentelemetry/core";
1923
+ import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources";
1924
+ import {
1925
+ BatchSpanProcessor,
1926
+ NodeTracerProvider
1927
+ } from "@opentelemetry/sdk-trace-node";
1928
+ var SERVICE_NAME = "stackbone-voice-worker";
1929
+ var warnOnRefusedBatch = (exporter, sessionKey) => ({
1930
+ export: (spans, done) => exporter.export(spans, (result) => {
1931
+ try {
1932
+ if (result.code !== ExportResultCode.SUCCESS) {
1933
+ log3().warn(
1934
+ { sessionKey, error: result.error?.message ?? "unknown" },
1935
+ "voice worker: a batch of call spans failed to export to the box"
1936
+ );
1937
+ }
1938
+ } finally {
1939
+ done(result);
1940
+ }
1941
+ }),
1942
+ shutdown: () => exporter.shutdown(),
1943
+ ...exporter.forceFlush ? { forceFlush: exporter.forceFlush.bind(exporter) } : {}
1944
+ });
1945
+ var installCallTraces = (dispatch, job, deps) => {
1946
+ if (dispatch.traces !== true) return;
1947
+ const exporter = warnOnRefusedBatch(
1948
+ deps.createExporter({
1949
+ url: boxEndpoint(dispatch.boxUrl, VOICE_TRACES_PATH),
1950
+ headers: { authorization: `Bearer ${dispatch.callToken}` }
1951
+ }),
1952
+ dispatch.sessionKey
1953
+ );
1954
+ const fanout = new telemetry.FanoutSpanProcessor();
1955
+ const provider = new NodeTracerProvider({
1956
+ resource: defaultResource().merge(resourceFromAttributes({ "service.name": SERVICE_NAME })),
1957
+ spanProcessors: [fanout, new BatchSpanProcessor(exporter)]
1958
+ });
1959
+ provider.register();
1960
+ deps.setTracerProvider(provider, {
1961
+ metadata: {
1962
+ "stackbone.voice.session_key": dispatch.sessionKey,
1963
+ ...job.room === void 0 ? {} : { "livekit.room": job.room },
1964
+ "livekit.job_id": job.jobId
1965
+ },
1966
+ registerSpanProcessor: (processor) => fanout.add(processor),
1967
+ allowPii: false
1968
+ });
1969
+ job.addShutdownCallback(() => provider.shutdown());
1970
+ };
1971
+
1972
+ // libs/runtime/voice-worker/src/worker.ts
1973
+ var resolveWorkerAgentName = (env) => env[VOICE_WORKER_AGENT_NAME_ENV]?.trim() || VOICE_WORKER_AGENT_NAME;
1974
+ var resolveWorkerHealthServer = (env) => {
1975
+ const port = Number(env[VOICE_WORKER_PORT_ENV]);
1976
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return {};
1977
+ return { host: "127.0.0.1", port, numIdleProcesses: 1 };
1978
+ };
1979
+ var TURN_FALLBACK_PHRASE = Object.freeze({
1980
+ es: "Perdona, no he podido responderte. \xBFMe lo repites?",
1981
+ en: "Sorry, I couldn't answer that. Could you say it again?"
1982
+ });
1983
+ var PAUSED_RUN_PHRASE = Object.freeze({
1984
+ es: "Esto lo tiene que revisar una persona. Te contactaremos en cuanto est\xE9. Hasta luego.",
1985
+ en: "Someone needs to review this. We'll contact you once it's done. Goodbye."
1986
+ });
1987
+ var CALL_CLOSING_PHRASE = Object.freeze({
1988
+ es: "Lo siento, ahora mismo no puedo atenderte. Por favor, vuelve a llamar m\xE1s tarde.",
1989
+ en: "Sorry, I can't help you right now. Please call again later."
1990
+ });
1991
+ var MAX_FAILED_TURNS_IN_A_ROW = 3;
1992
+ var inCallLanguage = (phrase, language) => language.split("-")[0]?.toLowerCase() === "es" ? phrase.es : phrase.en;
1993
+ var toolCallCue = () => {
1994
+ let cue;
1995
+ let open = 0;
1996
+ return {
1997
+ // A call can open before the cue's track is up.
1998
+ attach: (ready) => {
1999
+ cue = ready;
2000
+ if (open > 0) cue.start();
2001
+ },
2002
+ onToolCall: (phase) => {
2003
+ if (phase === "start") {
2004
+ open += 1;
2005
+ if (open === 1) cue?.start();
2006
+ } else if (open > 0) {
2007
+ open -= 1;
2008
+ if (open === 0) cue?.stop();
2009
+ }
2010
+ }
2011
+ };
2012
+ };
2013
+ var messageOf = (err) => err instanceof Error ? err.message : String(err);
2014
+ var callerNoiseCancellation = () => {
2015
+ try {
2016
+ return BackgroundVoiceCancellation();
2017
+ } catch (err) {
2018
+ log4().warn({ error: messageOf(err) }, "voice worker: no noise cancellation on this call");
2019
+ return void 0;
2020
+ }
2021
+ };
2022
+ var runVoiceJob = async (ctx, deps) => {
2023
+ const dispatch = readDispatch(ctx.job.metadata);
2024
+ if (!dispatch) {
2025
+ log4().warn(
2026
+ { metadataChars: ctx.job.metadata?.length ?? 0 },
2027
+ "voice worker: refused a job without a valid dispatch"
2028
+ );
2029
+ ctx.shutdown("invalid dispatch");
2030
+ return;
2031
+ }
2032
+ await ctx.connect();
2033
+ const caller = await ctx.waitForParticipant().catch((err) => {
2034
+ log4().info(
2035
+ { reason: messageOf(err) },
2036
+ "voice worker: the room closed before the caller joined"
2037
+ );
2038
+ return void 0;
2039
+ });
2040
+ if (caller === void 0) {
2041
+ ctx.shutdown("caller left");
2042
+ return;
2043
+ }
2044
+ installCallTraces(
2045
+ dispatch,
2046
+ {
2047
+ jobId: ctx.job.id,
2048
+ room: ctx.room.name,
2049
+ addShutdownCallback: (callback) => ctx.addShutdownCallback(callback)
2050
+ },
2051
+ deps
2052
+ );
2053
+ const settings = dispatch.voiceSettings;
2054
+ const cue = toolCallCue();
2055
+ const fallback = inCallLanguage(TURN_FALLBACK_PHRASE, settings.language);
2056
+ let failedInARow = 0;
2057
+ let ending = false;
2058
+ const sayIfOpen = (text, what) => {
2059
+ try {
2060
+ session.say(text);
2061
+ } catch (err) {
2062
+ log4().info({ error: messageOf(err) }, `voice worker: the ${what} was not spoken`);
2063
+ }
2064
+ };
2065
+ const hangUpAfter = (played, reason) => {
2066
+ ending = true;
2067
+ session.input.setAudioEnabled(false);
2068
+ void Promise.resolve().then(played).catch((err) => {
2069
+ log4().info({ error: messageOf(err) }, "voice worker: the closing phrase was not spoken");
2070
+ }).then(() => ctx.deleteRoom()).catch((err) => {
2071
+ log4().warn({ error: messageOf(err) }, "voice worker: the room was not deleted");
2072
+ }).then(
2073
+ () => finishCall(
2074
+ reason === "turns kept failing" || reason === "box refused the call token" ? "failed" : "completed"
2075
+ )
2076
+ ).catch(() => {
2077
+ log4().warn(
2078
+ { agent: dispatch.agent, sessionKey: dispatch.sessionKey, reason: "completion_failed" },
2079
+ "voice worker: call finalization failed"
2080
+ );
2081
+ }).finally(() => ctx.shutdown(reason));
2082
+ };
2083
+ const endCall = (phrase, reason) => hangUpAfter(
2084
+ () => session.say(inCallLanguage(phrase, settings.language), { allowInterruptions: false }).waitForPlayout(),
2085
+ reason
2086
+ );
2087
+ const boxFailure = (err) => ({
2088
+ agent: dispatch.agent,
2089
+ sessionKey: dispatch.sessionKey,
2090
+ room: ctx.room.name,
2091
+ // A refusal carries only its status.
2092
+ ...err instanceof BoxTurnError ? { status: err.status } : {},
2093
+ error: messageOf(err)
2094
+ });
2095
+ const onTurnError = (err) => {
2096
+ if (err instanceof BoxTurnError && err.status === 401) {
2097
+ log4().error(boxFailure(err), "voice worker: the box refused the call token (401)");
2098
+ if (!ending) endCall(CALL_CLOSING_PHRASE, "box refused the call token");
2099
+ return;
2100
+ }
2101
+ failedInARow += 1;
2102
+ log4().warn({ ...boxFailure(err), failedInARow }, "voice worker: a turn failed");
2103
+ if (ending) return;
2104
+ if (failedInARow >= MAX_FAILED_TURNS_IN_A_ROW) {
2105
+ endCall(CALL_CLOSING_PHRASE, "turns kept failing");
2106
+ return;
2107
+ }
2108
+ sayIfOpen(fallback, "fallback");
2109
+ };
2110
+ const onRunFinished = (outcome) => {
2111
+ failedInARow = 0;
2112
+ if (outcome === "interrupt" && !ending) endCall(PAUSED_RUN_PHRASE, "run paused for review");
2113
+ };
2114
+ const plan = voiceSessionOptions(settings, { ...deps, vad: ctx.proc.userData.vad });
2115
+ const wire = {
2116
+ boxUrl: dispatch.boxUrl,
2117
+ token: dispatch.callToken,
2118
+ agent: dispatch.agent,
2119
+ sessionKey: dispatch.sessionKey,
2120
+ tags: dispatch.tags,
2121
+ // With `expressive`, the TTS markup guide: LiveKit gives it only to its own chat context.
2122
+ context: plan.boxContext
2123
+ };
2124
+ const participant = caller ?? {};
2125
+ const attributes = participant.attributes;
2126
+ const phoneNumber = participant.kind === ParticipantKind.SIP && attributes && Object.hasOwn(attributes, "sip.phoneNumber") ? attributes["sip.phoneNumber"] : void 0;
2127
+ await voiceCallRequest(wire, "bootstrap", {
2128
+ identities: typeof phoneNumber === "string" && phoneNumber.length > 0 ? { phoneNumber } : {}
2129
+ });
2130
+ const hangUp = createHangUp({
2131
+ wire,
2132
+ untilPlayed: () => untilGoodbyePlayed(session),
2133
+ hangUpAfter,
2134
+ isEnding: () => ending,
2135
+ onResumeFailed: (err) => {
2136
+ log4().warn(boxFailure(err), "voice worker: the hang_up resume failed");
2137
+ }
2138
+ });
2139
+ const brain = new BoxLLM({
2140
+ ...wire,
2141
+ clientTools: [HANG_UP_TOOL],
2142
+ onClientToolCall: hangUp.onClientToolCall,
2143
+ pendingResume: hangUp.pendingResume,
2144
+ isEnding: () => ending,
2145
+ onToolCall: cue.onToolCall,
2146
+ onTurnError,
2147
+ onRunFinished
2148
+ });
2149
+ const session = deps.createSession({ ...plan.session, llm: brain });
2150
+ let completion;
2151
+ const finishCall = (outcome = "disconnected") => {
2152
+ if (completion) return completion;
2153
+ ending = true;
2154
+ completion = Promise.resolve().then(async () => {
2155
+ await hangUp.pendingResume();
2156
+ await session.close();
2157
+ await brain.settled();
2158
+ await voiceCallRequest(
2159
+ wire,
2160
+ "complete",
2161
+ voiceCallCompleteSchema.parse({
2162
+ transcript: brain.heardTranscript(session.history),
2163
+ outcome
2164
+ })
2165
+ );
2166
+ }).catch((error) => {
2167
+ log4().warn(
2168
+ { agent: dispatch.agent, sessionKey: dispatch.sessionKey, reason: "completion_failed" },
2169
+ "voice worker: call finalization failed"
2170
+ );
2171
+ throw error;
2172
+ });
2173
+ return completion;
2174
+ };
2175
+ ctx.addShutdownCallback(() => finishCall());
2176
+ session.on(voice3.AgentSessionEventTypes.Close, () => {
2177
+ if (completion || ending) return;
2178
+ void finishCall().catch(() => void 0).finally(() => ctx.shutdown("caller left"));
2179
+ });
2180
+ const noiseCancellation = callerNoiseCancellation();
2181
+ await session.start({
2182
+ agent: plan.agent,
2183
+ room: ctx.room,
2184
+ inputOptions: noiseCancellation ? { noiseCancellation } : {}
2185
+ });
2186
+ if (settings.greeting !== null) sayIfOpen(settings.greeting, "greeting");
2187
+ await deps.createCue({ room: ctx.room, session }).then(cue.attach, (err) => {
2188
+ log4().warn({ error: messageOf(err) }, "voice worker: the thinking cue did not start");
2189
+ });
2190
+ };
2191
+
2192
+ // libs/runtime/voice-worker/src/main.ts
2193
+ var liveDeps = () => ({
2194
+ createSession: (options) => new voice4.AgentSession(options),
2195
+ createStt: (options) => new inference.STT(options),
2196
+ createTts: (options) => new inference.TTS(options),
2197
+ createTurnDetector: (options) => new inference.TurnDetector(options),
2198
+ createCue: startThinkingCue,
2199
+ // OTLP/HTTP JSON, uncompressed: the encoding the box relay takes and forwards as is.
2200
+ // `compression` is set so an `OTEL_EXPORTER_OTLP_COMPRESSION=gzip` in the worker's
2201
+ // environment cannot turn every export into a body the relay refuses.
2202
+ createExporter: ({ url, headers }) => new OTLPTraceExporter({
2203
+ url,
2204
+ headers: { ...headers },
2205
+ compression: "none"
2206
+ }),
2207
+ setTracerProvider: telemetry2.setTracerProvider
2208
+ });
2209
+ var main_default = defineAgent({
2210
+ prewarm: async (proc) => {
2211
+ proc.userData.vad = await loadVad();
2212
+ },
2213
+ entry: (ctx) => runVoiceJob(ctx, liveDeps())
2214
+ });
2215
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
2216
+ cli.runApp(
2217
+ new ServerOptions({
2218
+ agent: fileURLToPath(import.meta.url),
2219
+ agentName: resolveWorkerAgentName(process.env),
2220
+ ...resolveWorkerHealthServer(process.env)
2221
+ })
2222
+ );
2223
+ }
2224
+ export {
2225
+ main_default as default
2226
+ };
2227
+ //# sourceMappingURL=main.js.map