@voicethere/agent 0.7.9 → 0.7.11

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,1808 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+
4
+
5
+ // dist/protocol.js
6
+ var MIX_REQUIRES_VOICE_PLUS_DATA = "Mix APIs require sessionMode 'voice+data' (voice tracks and a sync data channel)";
7
+ var TTS_POSE_REQUIRES_VOICE = "TTS pose APIs require voice or voice+data (not data-only)";
8
+
9
+ // dist/runtime.js
10
+ import { randomUUID } from "node:crypto";
11
+ import { AsyncLocalStorage } from "node:async_hooks";
12
+
13
+ // dist/session-serial-queue.js
14
+ var SessionSerialQueueCancellationError = class extends Error {
15
+ sessionId;
16
+ generation;
17
+ code = "SESSION_SERIAL_QUEUE_CANCELLED";
18
+ constructor(sessionId, generation) {
19
+ super(`session serial queue cancelled (sessionId=${sessionId}, generation=${generation})`);
20
+ this.sessionId = sessionId;
21
+ this.generation = generation;
22
+ this.name = "SessionSerialQueueCancellationError";
23
+ }
24
+ };
25
+ var SessionSerialQueue = class {
26
+ sessions = /* @__PURE__ */ new Map();
27
+ /** Monotonic token source — never reused across clear/enqueue cycles. */
28
+ nextGeneration = 1;
29
+ /**
30
+ * Enqueue work for `sessionId`. Returns a promise that settles when this
31
+ * task finishes, is skipped as stale, or is cancelled by {@link clear}.
32
+ * Fire-and-forget callers may ignore the return value (compat).
33
+ */
34
+ enqueue(sessionId, task) {
35
+ const state = this.ensureState(sessionId);
36
+ const captured = state;
37
+ const generation = captured.generation;
38
+ captured.pending += 1;
39
+ const previous = captured.tail;
40
+ let taskAbort = null;
41
+ const settled = previous.catch(() => void 0).then(async () => {
42
+ if (this.sessions.get(sessionId) !== captured) {
43
+ return "cancelled";
44
+ }
45
+ const abort = new AbortController();
46
+ taskAbort = abort;
47
+ captured.abort = abort;
48
+ const context = {
49
+ sessionId,
50
+ generation,
51
+ signal: abort.signal
52
+ };
53
+ try {
54
+ await Promise.resolve(task(abort.signal, context));
55
+ if (this.sessions.get(sessionId) !== captured) {
56
+ return "cancelled";
57
+ }
58
+ return "completed";
59
+ } catch (error) {
60
+ if (abort.signal.aborted || this.sessions.get(sessionId) !== captured || error instanceof SessionSerialQueueCancellationError) {
61
+ return "cancelled";
62
+ }
63
+ return "failed";
64
+ } finally {
65
+ if (captured.abort === abort) {
66
+ captured.abort = null;
67
+ }
68
+ }
69
+ }).finally(() => {
70
+ captured.pending = Math.max(0, captured.pending - 1);
71
+ if (taskAbort && captured.abort === taskAbort) {
72
+ captured.abort = null;
73
+ }
74
+ });
75
+ captured.tail = settled;
76
+ return settled;
77
+ }
78
+ /**
79
+ * Invalidate queued + running work for `sessionId`. Aborts the live AbortSignal,
80
+ * drops the map entry, and leaves a fresh identity for the next enqueue.
81
+ * Old in-flight `finally` blocks only touch their captured state object.
82
+ */
83
+ clear(sessionId) {
84
+ const old = this.sessions.get(sessionId);
85
+ if (!old)
86
+ return;
87
+ old.abort?.abort();
88
+ old.abort = null;
89
+ this.sessions.delete(sessionId);
90
+ }
91
+ clearAll() {
92
+ for (const sessionId of [...this.sessions.keys()]) {
93
+ this.clear(sessionId);
94
+ }
95
+ }
96
+ hasPending(sessionId) {
97
+ return (this.sessions.get(sessionId)?.pending ?? 0) > 0;
98
+ }
99
+ /** Live generation token for a session, or `undefined` when cleared. */
100
+ generationOf(sessionId) {
101
+ return this.sessions.get(sessionId)?.generation;
102
+ }
103
+ /** True when `generation` is the live map entry for `sessionId`. */
104
+ isCurrentGeneration(sessionId, generation) {
105
+ return this.sessions.get(sessionId)?.generation === generation;
106
+ }
107
+ /**
108
+ * True when a queue row exists for the session (registered since first
109
+ * enqueue, until {@link clear}). Not the same as {@link hasPending}.
110
+ */
111
+ isLive(sessionId) {
112
+ return this.sessions.has(sessionId);
113
+ }
114
+ /** Registered session rows (must not grow without matching `clear` calls). */
115
+ get activeSessionCount() {
116
+ return this.sessions.size;
117
+ }
118
+ ensureState(sessionId) {
119
+ const existing = this.sessions.get(sessionId);
120
+ if (existing)
121
+ return existing;
122
+ const created = {
123
+ generation: this.nextGeneration++,
124
+ tail: Promise.resolve(),
125
+ abort: null,
126
+ pending: 0
127
+ };
128
+ this.sessions.set(sessionId, created);
129
+ return created;
130
+ }
131
+ };
132
+
133
+ // dist/runtime.js
134
+ var SESSION_START_INIT_DELAY_ENABLED_ENV = "AGENT_SESSION_START_INIT_DELAY_ENABLED";
135
+ var SESSION_START_INIT_DELAY_MS_ENV = "AGENT_SESSION_START_INIT_DELAY_MS";
136
+ var DEFAULT_SESSION_START_INIT_DELAY_MS = 500;
137
+ function isPlayAckMessage(value) {
138
+ if (!value || typeof value !== "object")
139
+ return false;
140
+ const msg = value;
141
+ return msg.type === "play_ack" && typeof msg.requestId === "string";
142
+ }
143
+ function isPlayStatusAckMessage(value) {
144
+ if (!value || typeof value !== "object")
145
+ return false;
146
+ const msg = value;
147
+ return msg.type === "play_status_ack" && typeof msg.requestId === "string";
148
+ }
149
+ function isPlayStopAckMessage(value) {
150
+ if (!value || typeof value !== "object")
151
+ return false;
152
+ const msg = value;
153
+ return msg.type === "play_stop_ack" && typeof msg.requestId === "string";
154
+ }
155
+ function isRecordingControlAckMessage(value) {
156
+ if (!value || typeof value !== "object")
157
+ return false;
158
+ const msg = value;
159
+ return msg.type === "recording_control_ack" && typeof msg.requestId === "string";
160
+ }
161
+ function isMixControlAckMessage(value) {
162
+ if (!value || typeof value !== "object")
163
+ return false;
164
+ const msg = value;
165
+ return msg.type === "mix_control_ack" && typeof msg.requestId === "string";
166
+ }
167
+ function isSttControlAckMessage(value) {
168
+ if (!value || typeof value !== "object")
169
+ return false;
170
+ const msg = value;
171
+ return msg.type === "stt_control_ack" && typeof msg.requestId === "string";
172
+ }
173
+ function isWebhookMessage(value) {
174
+ if (!value || typeof value !== "object")
175
+ return false;
176
+ const msg = value;
177
+ return msg.type === "webhook" && typeof msg.eventId === "string" && typeof msg.projectId === "string";
178
+ }
179
+ function coerceInboundBinary(value) {
180
+ if (!value)
181
+ return null;
182
+ if (Buffer.isBuffer(value))
183
+ return value;
184
+ if (value instanceof Uint8Array) {
185
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
186
+ }
187
+ if (value instanceof ArrayBuffer)
188
+ return Buffer.from(value);
189
+ if (ArrayBuffer.isView(value)) {
190
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
191
+ }
192
+ if (typeof value === "object") {
193
+ const maybeBufferLike = value;
194
+ if (maybeBufferLike.type === "Buffer" && Array.isArray(maybeBufferLike.data)) {
195
+ return Buffer.from(maybeBufferLike.data);
196
+ }
197
+ }
198
+ return null;
199
+ }
200
+ function normalizeWebhookHeaders(value) {
201
+ if (!value || typeof value !== "object")
202
+ return {};
203
+ const out = {};
204
+ for (const [key, raw] of Object.entries(value)) {
205
+ if (typeof raw === "string") {
206
+ out[key] = raw;
207
+ }
208
+ }
209
+ return out;
210
+ }
211
+ function isParentMessage(value) {
212
+ if (!value || typeof value !== "object")
213
+ return false;
214
+ const msg = value;
215
+ return msg.type === "session_start" || msg.type === "speech_event" || msg.type === "session_end" || msg.type === "data_channel_message" || msg.type === "data_channel_binary" || msg.type === "idle_timeout" || msg.type === "recording_control_ack" || msg.type === "play_ack" || msg.type === "play_status_ack" || msg.type === "play_stop_ack" || msg.type === "mix_control_ack" || msg.type === "stt_control_ack" || msg.type === "webhook";
216
+ }
217
+ function isSessionScopedParentMessage(value) {
218
+ return isParentMessage(value) && !isWebhookMessage(value);
219
+ }
220
+ function parseDataChannelPayload(raw) {
221
+ try {
222
+ return JSON.parse(raw);
223
+ } catch {
224
+ return raw;
225
+ }
226
+ }
227
+ var peerEnvBySessionId = /* @__PURE__ */ new Map();
228
+ var recordingAvailableBySessionId = /* @__PURE__ */ new Map();
229
+ var mixAvailableBySessionId = /* @__PURE__ */ new Map();
230
+ var ttsPoseAvailableBySessionId = /* @__PURE__ */ new Map();
231
+ var endedSessionIds = /* @__PURE__ */ new Set();
232
+ var PLAY_CONTROL_ACK_TIMEOUT_MS = 5e3;
233
+ var PLAY_BYTES_MAX_DECODED_LENGTH = 64 * 1024;
234
+ var pendingRecordingAcks = /* @__PURE__ */ new Map();
235
+ var pendingPlayAcks = /* @__PURE__ */ new Map();
236
+ var pendingPlayStatusAcks = /* @__PURE__ */ new Map();
237
+ var pendingPlayStopAcks = /* @__PURE__ */ new Map();
238
+ var MIX_CONTROL_ACK_TIMEOUT_MS = 5e3;
239
+ var pendingMixAcks = /* @__PURE__ */ new Map();
240
+ var pendingSttAcks = /* @__PURE__ */ new Map();
241
+ function handleRecordingControlAck(message) {
242
+ const pending = pendingRecordingAcks.get(message.requestId);
243
+ if (!pending)
244
+ return;
245
+ clearTimeout(pending.timer);
246
+ pendingRecordingAcks.delete(message.requestId);
247
+ pending.resolve({
248
+ ok: message.ok,
249
+ reason: message.reason,
250
+ requestId: message.requestId
251
+ });
252
+ }
253
+ function handlePlayAck(message) {
254
+ const pending = pendingPlayAcks.get(message.requestId);
255
+ if (!pending)
256
+ return;
257
+ clearTimeout(pending.timer);
258
+ pendingPlayAcks.delete(message.requestId);
259
+ pending.resolve({
260
+ ok: message.ok,
261
+ ...message.playId ? { playId: message.playId } : {},
262
+ reason: message.reason,
263
+ requestId: message.requestId
264
+ });
265
+ }
266
+ function handlePlayStatusAck(message) {
267
+ const pending = pendingPlayStatusAcks.get(message.requestId);
268
+ if (!pending)
269
+ return;
270
+ clearTimeout(pending.timer);
271
+ pendingPlayStatusAcks.delete(message.requestId);
272
+ pending.resolve({
273
+ ok: message.ok,
274
+ playId: message.playId,
275
+ ...message.status ? { status: message.status } : {},
276
+ reason: message.reason,
277
+ requestId: message.requestId
278
+ });
279
+ }
280
+ function handlePlayStopAck(message) {
281
+ const pending = pendingPlayStopAcks.get(message.requestId);
282
+ if (!pending)
283
+ return;
284
+ clearTimeout(pending.timer);
285
+ pendingPlayStopAcks.delete(message.requestId);
286
+ pending.resolve({
287
+ ok: message.ok,
288
+ playId: message.playId,
289
+ reason: message.reason,
290
+ requestId: message.requestId
291
+ });
292
+ }
293
+ function handleMixControlAck(message) {
294
+ const pending = pendingMixAcks.get(message.requestId);
295
+ if (!pending)
296
+ return;
297
+ clearTimeout(pending.timer);
298
+ pendingMixAcks.delete(message.requestId);
299
+ pending.resolve({
300
+ ok: message.ok,
301
+ reason: message.reason,
302
+ requestId: message.requestId,
303
+ ...message.statuses ? { statuses: message.statuses } : {}
304
+ });
305
+ }
306
+ function handleSttControlAck(message) {
307
+ const pending = pendingSttAcks.get(message.requestId);
308
+ if (!pending)
309
+ return;
310
+ clearTimeout(pending.timer);
311
+ pendingSttAcks.delete(message.requestId);
312
+ pending.resolve({
313
+ ok: message.ok,
314
+ reason: message.reason,
315
+ requestId: message.requestId
316
+ });
317
+ }
318
+ function isMixAvailableInProcess() {
319
+ for (const available of mixAvailableBySessionId.values()) {
320
+ if (available)
321
+ return true;
322
+ }
323
+ return false;
324
+ }
325
+ function assertMixAvailable() {
326
+ if (!isMixAvailableInProcess()) {
327
+ throw new Error(MIX_REQUIRES_VOICE_PLUS_DATA);
328
+ }
329
+ }
330
+ function isTtsPoseAvailableInProcess() {
331
+ for (const available of ttsPoseAvailableBySessionId.values()) {
332
+ if (available)
333
+ return true;
334
+ }
335
+ return false;
336
+ }
337
+ function assertTtsPoseAvailable() {
338
+ if (!isTtsPoseAvailableInProcess()) {
339
+ throw new Error(TTS_POSE_REQUIRES_VOICE);
340
+ }
341
+ }
342
+ function clearPendingRecordingAcksForSession(sessionId, reason) {
343
+ for (const [requestId, pending] of pendingRecordingAcks) {
344
+ if (pending.sessionId !== sessionId)
345
+ continue;
346
+ clearTimeout(pending.timer);
347
+ pendingRecordingAcks.delete(requestId);
348
+ pending.resolve({ ok: false, reason, requestId });
349
+ }
350
+ }
351
+ function isVoicethereAgentChild() {
352
+ return typeof process.send === "function" && process.connected !== false && typeof process.env.__CHILD_BUNDLE_PATH__ === "string" && process.env.__CHILD_BUNDLE_PATH__.trim().length > 0;
353
+ }
354
+ var sessionExecutionContext = new AsyncLocalStorage();
355
+ var agentLogSessionContext = new AsyncLocalStorage();
356
+ var inboundQueueAuthority = null;
357
+ var childUnhandledRejectionGuardInstalled = false;
358
+ function normalizeRejectionReason(reason) {
359
+ return reason instanceof Error ? reason : new Error(String(reason));
360
+ }
361
+ function installChildUnhandledRejectionGuard() {
362
+ if (childUnhandledRejectionGuardInstalled) {
363
+ return;
364
+ }
365
+ childUnhandledRejectionGuardInstalled = true;
366
+ process.on("unhandledRejection", (reason) => {
367
+ const err = normalizeRejectionReason(reason);
368
+ const store = sessionExecutionContext.getStore();
369
+ const sessionId = store?.sessionId ?? agentLogSessionContext.getStore() ?? "";
370
+ if (!allowOutboundForSession(sessionId || void 0)) {
371
+ return;
372
+ }
373
+ agentLog("error", `unhandledRejection: ${err.message}`, sessionId || void 0);
374
+ sendParentMessage({
375
+ type: "agent_error",
376
+ sessionId,
377
+ message: err.message,
378
+ stack: err.stack
379
+ });
380
+ });
381
+ }
382
+ function allowOutboundForSession(sessionId) {
383
+ if (!sessionId) {
384
+ return true;
385
+ }
386
+ const store = sessionExecutionContext.getStore();
387
+ const queue = inboundQueueAuthority;
388
+ if (store && store.sessionId === sessionId) {
389
+ if (!queue)
390
+ return !endedSessionIds.has(sessionId);
391
+ return queue.isCurrentGeneration(sessionId, store.generation);
392
+ }
393
+ if (endedSessionIds.has(sessionId))
394
+ return false;
395
+ if (!queue)
396
+ return true;
397
+ return queue.isLive(sessionId);
398
+ }
399
+ function sendParentMessage(message) {
400
+ const msgType = message && typeof message === "object" && "type" in message ? message.type : void 0;
401
+ if (msgType === "mix_control" || msgType === "stt_control") {
402
+ process.send?.(message);
403
+ return;
404
+ }
405
+ if (msgType === "play" || msgType === "play_status" || msgType === "play_stop") {
406
+ process.send?.(message);
407
+ return;
408
+ }
409
+ const sessionId = message && typeof message === "object" && "sessionId" in message && typeof message.sessionId === "string" ? message.sessionId : void 0;
410
+ if (!allowOutboundForSession(sessionId))
411
+ return;
412
+ process.send?.(message);
413
+ }
414
+ function parseBooleanEnv(value, defaultValue) {
415
+ if (value === void 0)
416
+ return defaultValue;
417
+ const normalized = value.trim().toLowerCase();
418
+ if (normalized === "")
419
+ return defaultValue;
420
+ if (["0", "false", "off", "no"].includes(normalized))
421
+ return false;
422
+ if (["1", "true", "on", "yes"].includes(normalized))
423
+ return true;
424
+ return defaultValue;
425
+ }
426
+ function parseNonNegativeIntegerEnv(value, defaultValue) {
427
+ if (value === void 0)
428
+ return defaultValue;
429
+ const parsed = Number(value);
430
+ if (!Number.isFinite(parsed) || parsed < 0)
431
+ return defaultValue;
432
+ return Math.floor(parsed);
433
+ }
434
+ function resolveSessionStartInitDelayMs() {
435
+ const enabled = parseBooleanEnv(process.env[SESSION_START_INIT_DELAY_ENABLED_ENV], true);
436
+ if (!enabled)
437
+ return 0;
438
+ return parseNonNegativeIntegerEnv(process.env[SESSION_START_INIT_DELAY_MS_ENV], DEFAULT_SESSION_START_INIT_DELAY_MS);
439
+ }
440
+ async function handleWebhookMessage(message, handlers) {
441
+ if (!handlers.onWebhook)
442
+ return;
443
+ const body = coerceInboundBinary(message.body);
444
+ if (!body) {
445
+ agentLog("warn", "webhook ipc dropped: body is not binary");
446
+ return;
447
+ }
448
+ const ctx = {
449
+ eventId: message.eventId,
450
+ projectId: message.projectId,
451
+ method: typeof message.method === "string" ? message.method : "POST",
452
+ path: typeof message.path === "string" ? message.path : "",
453
+ headers: normalizeWebhookHeaders(message.headers),
454
+ body,
455
+ contentType: typeof message.contentType === "string" ? message.contentType : null,
456
+ receivedAt: typeof message.receivedAt === "string" ? message.receivedAt : "",
457
+ sessionIds: Array.isArray(message.sessionIds) ? message.sessionIds.filter((id) => typeof id === "string" && id.length > 0) : []
458
+ };
459
+ try {
460
+ const started = Date.now();
461
+ await handlers.onWebhook(ctx);
462
+ sendParentMessage({
463
+ type: "webhook_handled",
464
+ projectId: message.projectId,
465
+ eventId: message.eventId,
466
+ durationMs: Date.now() - started
467
+ });
468
+ } catch (error) {
469
+ const err = error instanceof Error ? error : new Error(String(error));
470
+ await runErrorHook(handlers, {
471
+ sessionId: "",
472
+ projectId: message.projectId,
473
+ env: process.env,
474
+ error: err
475
+ });
476
+ sendParentMessage({
477
+ type: "agent_error",
478
+ sessionId: "",
479
+ message: err.message,
480
+ stack: err.stack
481
+ });
482
+ }
483
+ }
484
+ function resolveUserLanguageCode(event) {
485
+ const withLanguage = event;
486
+ const candidates = [withLanguage.language, event.text];
487
+ for (const value of candidates) {
488
+ if (typeof value === "string" && value.trim()) {
489
+ return value.trim();
490
+ }
491
+ }
492
+ return void 0;
493
+ }
494
+ async function handleParentMessage(message, handlers) {
495
+ switch (message.type) {
496
+ case "session_start":
497
+ endedSessionIds.delete(message.sessionId);
498
+ peerEnvBySessionId.set(message.sessionId, message.env);
499
+ recordingAvailableBySessionId.set(message.sessionId, message.recordingAvailable ?? false);
500
+ mixAvailableBySessionId.set(message.sessionId, message.mixAvailable ?? false);
501
+ ttsPoseAvailableBySessionId.set(message.sessionId, message.ttsPoseAvailable ?? false);
502
+ const sessionStartInitDelayMs = resolveSessionStartInitDelayMs();
503
+ if (sessionStartInitDelayMs > 0) {
504
+ await new Promise((resolve) => setTimeout(resolve, sessionStartInitDelayMs));
505
+ }
506
+ await (handlers.onClientJoin ?? handlers.onSessionStart)?.({
507
+ sessionId: message.sessionId,
508
+ env: message.env,
509
+ recordingAvailable: message.recordingAvailable ?? false,
510
+ mixAvailable: message.mixAvailable ?? false,
511
+ ttsPoseAvailable: message.ttsPoseAvailable ?? false
512
+ });
513
+ sendParentMessage({
514
+ type: "session_start_ack",
515
+ sessionId: message.sessionId
516
+ });
517
+ break;
518
+ case "speech_event":
519
+ await handlers.onSpeechEvent?.({ sessionId: message.sessionId }, message.event);
520
+ if (message.event.type === "user_speech_final" && typeof message.event.text === "string" && message.event.text.trim()) {
521
+ await handlers.onUserSpeechFinal?.({
522
+ sessionId: message.sessionId,
523
+ text: message.event.text.trim()
524
+ });
525
+ }
526
+ if (message.event.type === "user_language") {
527
+ const language = resolveUserLanguageCode(message.event);
528
+ if (language) {
529
+ await handlers.onUserLanguage?.({
530
+ sessionId: message.sessionId,
531
+ language
532
+ });
533
+ }
534
+ }
535
+ break;
536
+ case "data_channel_message":
537
+ await handlers.onDataChannelMessage?.({
538
+ sessionId: message.sessionId,
539
+ message: parseDataChannelPayload(message.payload),
540
+ raw: message.payload,
541
+ rawBinary: null,
542
+ channel: "control"
543
+ });
544
+ break;
545
+ case "data_channel_binary":
546
+ await handlers.onDataChannelBinary?.({
547
+ sessionId: message.sessionId,
548
+ message: null,
549
+ raw: null,
550
+ rawBinary: message.data,
551
+ channel: message.channel ?? "sync"
552
+ });
553
+ break;
554
+ case "session_end":
555
+ clearPendingRecordingAcksForSession(message.sessionId, "session_ended");
556
+ peerEnvBySessionId.delete(message.sessionId);
557
+ recordingAvailableBySessionId.delete(message.sessionId);
558
+ mixAvailableBySessionId.delete(message.sessionId);
559
+ ttsPoseAvailableBySessionId.delete(message.sessionId);
560
+ await (handlers.onClientLeave ?? handlers.onSessionEnd)?.({
561
+ sessionId: message.sessionId
562
+ });
563
+ break;
564
+ case "idle_timeout":
565
+ await runIdleTimeoutHook(handlers, message);
566
+ break;
567
+ }
568
+ }
569
+ function defineAgent(handlers) {
570
+ installChildUnhandledRejectionGuard();
571
+ const inboundBySession = new SessionSerialQueue();
572
+ inboundQueueAuthority = inboundBySession;
573
+ const agentStartReady = runAgentStartHook(handlers);
574
+ process.on("message", (message) => {
575
+ if (isRecordingControlAckMessage(message)) {
576
+ handleRecordingControlAck(message);
577
+ return;
578
+ }
579
+ if (isPlayAckMessage(message)) {
580
+ handlePlayAck(message);
581
+ return;
582
+ }
583
+ if (isPlayStatusAckMessage(message)) {
584
+ handlePlayStatusAck(message);
585
+ return;
586
+ }
587
+ if (isPlayStopAckMessage(message)) {
588
+ handlePlayStopAck(message);
589
+ return;
590
+ }
591
+ if (isMixControlAckMessage(message)) {
592
+ handleMixControlAck(message);
593
+ return;
594
+ }
595
+ if (isSttControlAckMessage(message)) {
596
+ handleSttControlAck(message);
597
+ return;
598
+ }
599
+ if (isWebhookMessage(message)) {
600
+ void agentStartReady.then(() => handleWebhookMessage(message, handlers));
601
+ return;
602
+ }
603
+ if (!isSessionScopedParentMessage(message))
604
+ return;
605
+ if (message.type === "session_end") {
606
+ endedSessionIds.add(message.sessionId);
607
+ clearPendingRecordingAcksForSession(message.sessionId, "session_ended");
608
+ inboundBySession.clear(message.sessionId);
609
+ }
610
+ if (message.type === "session_start") {
611
+ endedSessionIds.delete(message.sessionId);
612
+ inboundBySession.clear(message.sessionId);
613
+ }
614
+ inboundBySession.enqueue(message.sessionId, async (_signal, context) => {
615
+ await agentStartReady;
616
+ try {
617
+ await sessionExecutionContext.run({
618
+ sessionId: message.sessionId,
619
+ generation: context.generation
620
+ }, async () => agentLogSessionContext.run(message.sessionId, async () => {
621
+ try {
622
+ await handleParentMessage(message, handlers);
623
+ } catch (error) {
624
+ const err = error instanceof Error ? error : new Error(String(error));
625
+ const env = peerEnvBySessionId.get(message.sessionId) ?? buildIdleEnv(message.sessionId);
626
+ await runErrorHook(handlers, {
627
+ sessionId: message.sessionId,
628
+ projectId: env.PROJECT_ID,
629
+ buildId: env.BUILD_ID,
630
+ env,
631
+ error: err,
632
+ customerContext: parseCustomerContext(env.AGENT_CUSTOMER_CONTEXT)
633
+ });
634
+ sendParentMessage({
635
+ type: "agent_error",
636
+ sessionId: message.sessionId,
637
+ message: err.message,
638
+ stack: err.stack
639
+ });
640
+ }
641
+ }));
642
+ } finally {
643
+ if (message.type === "session_end" && inboundBySession.isCurrentGeneration(message.sessionId, context.generation)) {
644
+ inboundBySession.clear(message.sessionId);
645
+ }
646
+ }
647
+ });
648
+ });
649
+ }
650
+ async function runAgentStartHook(handlers) {
651
+ if (!handlers.onAgentStart)
652
+ return;
653
+ try {
654
+ await handlers.onAgentStart({
655
+ env: process.env
656
+ });
657
+ } catch (error) {
658
+ const err = error instanceof Error ? error : new Error(String(error));
659
+ agentLog("error", `onAgentStart failed: ${err.message}`);
660
+ sendParentMessage({
661
+ type: "agent_error",
662
+ sessionId: "",
663
+ message: err.message,
664
+ stack: err.stack
665
+ });
666
+ }
667
+ }
668
+ function parseCustomerContext(raw) {
669
+ if (!raw?.trim())
670
+ return void 0;
671
+ try {
672
+ const parsed = JSON.parse(raw);
673
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
674
+ return parsed;
675
+ }
676
+ } catch {
677
+ }
678
+ return void 0;
679
+ }
680
+ async function runErrorHook(handlers, ctx) {
681
+ if (!handlers.errorHook)
682
+ return;
683
+ try {
684
+ await handlers.errorHook(ctx);
685
+ } catch (hookError) {
686
+ const message = hookError instanceof Error ? hookError.message : String(hookError);
687
+ agentLog("error", `errorHook failed: ${message}`, ctx.sessionId);
688
+ }
689
+ }
690
+ async function runIdleTimeoutHook(handlers, message) {
691
+ const onIdleTimeout = handlers.onIdleTimeout;
692
+ agentLog("info", `idle_timeout ipc received (maxGraceMs=${message.maxGraceMs}, onIdleTimeout=${typeof onIdleTimeout === "function"})`, message.sessionId);
693
+ if (!onIdleTimeout) {
694
+ sendParentMessage({
695
+ type: "idle_timeout_done",
696
+ sessionId: message.sessionId
697
+ });
698
+ agentLog("info", "idle_timeout_done ipc sent (no onIdleTimeout handler)", message.sessionId);
699
+ return;
700
+ }
701
+ const env = peerEnvBySessionId.get(message.sessionId) ?? buildIdleEnv(message.sessionId);
702
+ const idleTimeoutSeconds = Number(env.IDLE_TIMEOUT_SEC) || 0;
703
+ const ctx = {
704
+ sessionId: message.sessionId,
705
+ projectId: env.PROJECT_ID,
706
+ buildId: env.BUILD_ID,
707
+ env,
708
+ idleTimeoutSeconds
709
+ };
710
+ let error;
711
+ try {
712
+ await onIdleTimeout(ctx);
713
+ } catch (hookError) {
714
+ error = hookError instanceof Error ? hookError.message : String(hookError);
715
+ agentLog("error", `onIdleTimeout failed: ${error}`, message.sessionId);
716
+ }
717
+ sendParentMessage({
718
+ type: "idle_timeout_done",
719
+ sessionId: message.sessionId,
720
+ error
721
+ });
722
+ agentLog("info", error ? `idle_timeout_done ipc sent (onIdleTimeout error: ${error})` : "idle_timeout_done ipc sent (onIdleTimeout completed)", message.sessionId);
723
+ }
724
+ function buildIdleEnv(sessionId) {
725
+ return {
726
+ SESSION_ID: sessionId,
727
+ ...process.env.PROJECT_ID ? { PROJECT_ID: process.env.PROJECT_ID } : {},
728
+ ...process.env.BUILD_ID ? { BUILD_ID: process.env.BUILD_ID } : {},
729
+ ...process.env.IDLE_TIMEOUT_SEC ? { IDLE_TIMEOUT_SEC: process.env.IDLE_TIMEOUT_SEC } : {}
730
+ };
731
+ }
732
+ function speak(sessionId, text) {
733
+ sendParentMessage({ type: "speak", sessionId, text });
734
+ }
735
+ function isMixAvailable(ctx) {
736
+ return ctx.mixAvailable;
737
+ }
738
+ function isTtsPoseAvailable(ctx) {
739
+ return ctx.ttsPoseAvailable;
740
+ }
741
+ async function sendMixControl(action, payload) {
742
+ assertMixAvailable();
743
+ const requestId = randomUUID();
744
+ if (!isVoicethereAgentChild()) {
745
+ return { ok: true, reason: "local_mock", requestId };
746
+ }
747
+ return new Promise((resolve) => {
748
+ const timer = setTimeout(() => {
749
+ pendingMixAcks.delete(requestId);
750
+ resolve({ ok: false, reason: "timeout", requestId });
751
+ }, MIX_CONTROL_ACK_TIMEOUT_MS);
752
+ pendingMixAcks.set(requestId, { resolve, timer });
753
+ sendParentMessage({
754
+ type: "mix_control",
755
+ action,
756
+ requestId,
757
+ ...payload
758
+ });
759
+ });
760
+ }
761
+ async function sendTtsPoseControl(action, payload) {
762
+ assertTtsPoseAvailable();
763
+ const requestId = randomUUID();
764
+ if (!isVoicethereAgentChild()) {
765
+ return { ok: true, reason: "local_mock", requestId };
766
+ }
767
+ return new Promise((resolve) => {
768
+ const timer = setTimeout(() => {
769
+ pendingMixAcks.delete(requestId);
770
+ resolve({ ok: false, reason: "timeout", requestId });
771
+ }, MIX_CONTROL_ACK_TIMEOUT_MS);
772
+ pendingMixAcks.set(requestId, { resolve, timer });
773
+ sendParentMessage({
774
+ type: "mix_control",
775
+ action,
776
+ requestId,
777
+ ...payload
778
+ });
779
+ });
780
+ }
781
+ function createMixGroup(options) {
782
+ return sendMixControl("create_group", {
783
+ groupId: options.id,
784
+ clientIds: [...options.clientIds]
785
+ });
786
+ }
787
+ function addClientToMix(groupId, clientId) {
788
+ return sendMixControl("add_client", { groupId, clientId });
789
+ }
790
+ function removeClientFromMix(groupId, clientId) {
791
+ return sendMixControl("remove_client", { groupId, clientId });
792
+ }
793
+ function setClientPose(clientId, pose) {
794
+ return sendTtsPoseControl("set_pose", { clientId, pose });
795
+ }
796
+ function setPositionalMixing(enabled) {
797
+ return sendTtsPoseControl("set_positional", { enabled });
798
+ }
799
+ function setTtsPose(sessionId, pose) {
800
+ return sendTtsPoseControl("set_tts_pose", { clientId: sessionId, pose });
801
+ }
802
+ function clearTtsPose(sessionId) {
803
+ return sendTtsPoseControl("clear_tts_pose", { clientId: sessionId });
804
+ }
805
+ function setListenerMute(options) {
806
+ return sendMixControl("set_listener_mute", {
807
+ clientId: options.targetId,
808
+ listenerId: options.listenerId,
809
+ muted: options.muted
810
+ });
811
+ }
812
+ function validatePlayOptions(options) {
813
+ const requestId = randomUUID();
814
+ const url = options.url?.trim();
815
+ if (!url) {
816
+ return { ok: false, reason: "invalid_payload", requestId };
817
+ }
818
+ if (options.placement != null && options.pose != null) {
819
+ return { ok: false, reason: "invalid_payload", requestId };
820
+ }
821
+ if (options.bytes !== void 0) {
822
+ try {
823
+ const decoded = Buffer.from(options.bytes, "base64");
824
+ if (decoded.length > PLAY_BYTES_MAX_DECODED_LENGTH) {
825
+ return { ok: false, reason: "invalid_payload", requestId };
826
+ }
827
+ } catch {
828
+ return { ok: false, reason: "invalid_payload", requestId };
829
+ }
830
+ }
831
+ return null;
832
+ }
833
+ async function sendPlayControl(options) {
834
+ const invalid = validatePlayOptions(options);
835
+ if (invalid)
836
+ return invalid;
837
+ const requestId = randomUUID();
838
+ if (!isVoicethereAgentChild()) {
839
+ return {
840
+ ok: true,
841
+ playId: `local-mock-${requestId}`,
842
+ reason: "local_mock",
843
+ requestId
844
+ };
845
+ }
846
+ return new Promise((resolve) => {
847
+ const timer = setTimeout(() => {
848
+ pendingPlayAcks.delete(requestId);
849
+ resolve({ ok: false, reason: "timeout", requestId });
850
+ }, PLAY_CONTROL_ACK_TIMEOUT_MS);
851
+ pendingPlayAcks.set(requestId, { resolve, timer });
852
+ sendParentMessage({
853
+ type: "play",
854
+ requestId,
855
+ url: options.url.trim(),
856
+ ...options.sessionIds?.length ? { sessionIds: [...options.sessionIds] } : {},
857
+ ...options.volume !== void 0 ? { volume: options.volume } : {},
858
+ ...options.bytes !== void 0 ? { bytes: options.bytes } : {},
859
+ ...options.placement !== void 0 ? { placement: options.placement } : {},
860
+ ...options.pose !== void 0 ? { pose: options.pose } : {}
861
+ });
862
+ });
863
+ }
864
+ async function sendPlayStatus(playId) {
865
+ const requestId = randomUUID();
866
+ if (!playId?.trim()) {
867
+ return {
868
+ ok: false,
869
+ playId: playId ?? "",
870
+ reason: "invalid_payload",
871
+ requestId
872
+ };
873
+ }
874
+ if (!isVoicethereAgentChild()) {
875
+ return {
876
+ ok: true,
877
+ playId,
878
+ status: "ended",
879
+ reason: "local_mock",
880
+ requestId
881
+ };
882
+ }
883
+ return new Promise((resolve) => {
884
+ const timer = setTimeout(() => {
885
+ pendingPlayStatusAcks.delete(requestId);
886
+ resolve({ ok: false, playId, reason: "timeout", requestId });
887
+ }, PLAY_CONTROL_ACK_TIMEOUT_MS);
888
+ pendingPlayStatusAcks.set(requestId, { playId, resolve, timer });
889
+ sendParentMessage({
890
+ type: "play_status",
891
+ requestId,
892
+ playId
893
+ });
894
+ });
895
+ }
896
+ async function sendPlayStop(playId) {
897
+ const requestId = randomUUID();
898
+ if (!playId?.trim()) {
899
+ return {
900
+ ok: false,
901
+ playId: playId ?? "",
902
+ reason: "invalid_payload",
903
+ requestId
904
+ };
905
+ }
906
+ if (!isVoicethereAgentChild()) {
907
+ return { ok: true, playId, reason: "local_mock", requestId };
908
+ }
909
+ return new Promise((resolve) => {
910
+ const timer = setTimeout(() => {
911
+ pendingPlayStopAcks.delete(requestId);
912
+ resolve({ ok: false, playId, reason: "timeout", requestId });
913
+ }, PLAY_CONTROL_ACK_TIMEOUT_MS);
914
+ pendingPlayStopAcks.set(requestId, { playId, resolve, timer });
915
+ sendParentMessage({
916
+ type: "play_stop",
917
+ requestId,
918
+ playId
919
+ });
920
+ });
921
+ }
922
+ function play(options) {
923
+ return sendPlayControl(options);
924
+ }
925
+ function getPlay(playId) {
926
+ return sendPlayStatus(playId);
927
+ }
928
+ function stopPlay(playId) {
929
+ return sendPlayStop(playId);
930
+ }
931
+ function sendToClient(sessionId, payload) {
932
+ sendParentMessage({ type: "send_to_client", sessionId, payload });
933
+ }
934
+ var AGENT_LOG_MESSAGE_MAX_CHARS = 2048;
935
+ var AGENT_LOG_FIELDS_MAX_CHARS = 8192;
936
+ function truncateAgentLogMessage(message) {
937
+ if (message.length <= AGENT_LOG_MESSAGE_MAX_CHARS) {
938
+ return message;
939
+ }
940
+ const suffix = "\u2026[truncated]";
941
+ return message.slice(0, AGENT_LOG_MESSAGE_MAX_CHARS - suffix.length) + suffix;
942
+ }
943
+ function isPlainObject(value) {
944
+ return typeof value === "object" && value !== null && !Array.isArray(value);
945
+ }
946
+ function sanitizeAgentLogFields(fields) {
947
+ if (Object.keys(fields).length === 0) {
948
+ return void 0;
949
+ }
950
+ try {
951
+ const serialized = JSON.stringify(fields);
952
+ if (serialized.length <= AGENT_LOG_FIELDS_MAX_CHARS) {
953
+ return fields;
954
+ }
955
+ return {
956
+ _agentLogFieldsTruncated: true,
957
+ _originalBytes: serialized.length,
958
+ _preview: serialized.slice(0, AGENT_LOG_FIELDS_MAX_CHARS - 80) + "\u2026[truncated]"
959
+ };
960
+ } catch {
961
+ return { _agentLogFieldsError: "not_serializable" };
962
+ }
963
+ }
964
+ function buildAgentLogPayload(level, message, fields, sessionId) {
965
+ const resolvedSessionId = sessionId ?? sessionExecutionContext.getStore()?.sessionId ?? agentLogSessionContext.getStore();
966
+ const sanitizedFields = fields ? sanitizeAgentLogFields(fields) : void 0;
967
+ return {
968
+ type: "log",
969
+ level,
970
+ message: truncateAgentLogMessage(message),
971
+ ts: Date.now(),
972
+ ...resolvedSessionId ? { sessionId: resolvedSessionId } : {},
973
+ ...sanitizedFields ? { fields: sanitizedFields } : {}
974
+ };
975
+ }
976
+ function agentLog(level, message, fieldsOrSessionId, sessionId) {
977
+ let fields;
978
+ let resolvedSessionId;
979
+ if (typeof fieldsOrSessionId === "string") {
980
+ resolvedSessionId = fieldsOrSessionId;
981
+ } else if (isPlainObject(fieldsOrSessionId)) {
982
+ fields = fieldsOrSessionId;
983
+ resolvedSessionId = sessionId;
984
+ } else {
985
+ resolvedSessionId = sessionId;
986
+ }
987
+ sendParentMessage(buildAgentLogPayload(level, message, fields, resolvedSessionId));
988
+ }
989
+
990
+ // templates/spatial-showcase/orbit.ts
991
+ function orbitTtsPose(elapsedSec, options = {}) {
992
+ const radius = options.radius ?? 2;
993
+ const periodSec = options.periodSec ?? 2 * Math.PI;
994
+ const elevation = options.elevation ?? 0;
995
+ const angle = 2 * Math.PI * elapsedSec / periodSec;
996
+ const x = Math.cos(angle) * radius;
997
+ const z = Math.sin(angle) * radius;
998
+ return {
999
+ position: { x, y: elevation, z },
1000
+ orientation: { x: 0, y: 0, z: 0, w: 1 }
1001
+ };
1002
+ }
1003
+
1004
+ // templates/spatial-showcase/orbit-session.ts
1005
+ function stopOrbitInterval(state) {
1006
+ if (state.orbitTimer) {
1007
+ clearInterval(state.orbitTimer);
1008
+ state.orbitTimer = void 0;
1009
+ }
1010
+ }
1011
+ function resetOrbitSession(state) {
1012
+ stopOrbitInterval(state);
1013
+ state.orbitStartMs = void 0;
1014
+ state.lastOrbitPoseEmitMs = void 0;
1015
+ }
1016
+ function beginOrbitClock(state) {
1017
+ state.orbitStartMs = Date.now();
1018
+ state.lastOrbitPoseEmitMs = void 0;
1019
+ }
1020
+
1021
+ // templates/spatial-showcase/sounds.ts
1022
+ var SHOWCASE_SOUND_FILES = {
1023
+ chime: { file: "chime.wav", loop: false, defaultVolume: 0.8 },
1024
+ bell: { file: "bell.wav", loop: false, defaultVolume: 0.8 },
1025
+ laser: { file: "laser.wav", loop: false, defaultVolume: 0.7 },
1026
+ impact: { file: "impact.wav", loop: false, defaultVolume: 0.85 },
1027
+ footsteps: { file: "footsteps.wav", loop: false, defaultVolume: 0.75 },
1028
+ "rain-loop": { file: "rain-loop.wav", loop: true, defaultVolume: 0.5 },
1029
+ "cafe-loop": { file: "cafe-loop.wav", loop: true, defaultVolume: 0.5 },
1030
+ jingle: { file: "jingle.wav", loop: false, defaultVolume: 0.8 }
1031
+ };
1032
+ var SHOWCASE_ASSET_ORIGINS = [
1033
+ "https://app.voicethere.io",
1034
+ "https://app.voicethere.dev",
1035
+ "http://localhost:3000"
1036
+ ];
1037
+ var CLIP_IDS = new Set(Object.keys(SHOWCASE_SOUND_FILES));
1038
+ function isShowcaseClipId(clipId) {
1039
+ return CLIP_IDS.has(clipId);
1040
+ }
1041
+ function normalizeOrigin(origin) {
1042
+ return origin.replace(/\/+$/, "");
1043
+ }
1044
+ function isAllowedOrigin(origin) {
1045
+ const normalized = normalizeOrigin(origin);
1046
+ return SHOWCASE_ASSET_ORIGINS.some(
1047
+ (allowed) => normalizeOrigin(allowed) === normalized
1048
+ );
1049
+ }
1050
+ function resolveClipUrl(assetOrigin, clipId) {
1051
+ if (!isAllowedOrigin(assetOrigin) || !isShowcaseClipId(clipId)) {
1052
+ return null;
1053
+ }
1054
+ const { file } = SHOWCASE_SOUND_FILES[clipId];
1055
+ return `${normalizeOrigin(assetOrigin)}/showcase/sounds/${file}`;
1056
+ }
1057
+
1058
+ // templates/spatial-showcase/protocol.ts
1059
+ var SHOWCASE_DEMOS = /* @__PURE__ */ new Set(["orbit", "soundboard", "proximity"]);
1060
+ var MAX_SAY_TEXT_LENGTH = 200;
1061
+ var MAX_XZ = 5;
1062
+ function isFiniteNumber(value) {
1063
+ return typeof value === "number" && Number.isFinite(value);
1064
+ }
1065
+ function clampVolume(value) {
1066
+ return Math.min(1, Math.max(0, value));
1067
+ }
1068
+ function isInXzRange(value) {
1069
+ return value >= -MAX_XZ && value <= MAX_XZ;
1070
+ }
1071
+ function isMixPlacement(value) {
1072
+ return value === "center" || value === "left" || value === "right" || value === "front" || value === "behind" || value === "below" || value === "above";
1073
+ }
1074
+ function isNonEmptyString(value) {
1075
+ return typeof value === "string" && value.trim().length > 0;
1076
+ }
1077
+ function isShowcaseDemo(value) {
1078
+ return typeof value === "string" && SHOWCASE_DEMOS.has(value);
1079
+ }
1080
+ function parseShowcaseMessage(message) {
1081
+ if (!message || typeof message !== "object") {
1082
+ return null;
1083
+ }
1084
+ const record = message;
1085
+ if (typeof record.type !== "string") {
1086
+ return null;
1087
+ }
1088
+ switch (record.type) {
1089
+ case "join": {
1090
+ const join = message;
1091
+ if (!isShowcaseDemo(join.demo)) {
1092
+ return null;
1093
+ }
1094
+ if (join.assetOrigin !== void 0 && typeof join.assetOrigin !== "string") {
1095
+ return null;
1096
+ }
1097
+ return {
1098
+ type: "join",
1099
+ demo: join.demo,
1100
+ ...typeof join.assetOrigin === "string" ? { assetOrigin: join.assetOrigin } : {}
1101
+ };
1102
+ }
1103
+ case "orbit": {
1104
+ const orbit = message;
1105
+ if (orbit.action === "set") {
1106
+ const set = message;
1107
+ if (set.radius !== void 0 && (!isFiniteNumber(set.radius) || set.radius <= 0)) {
1108
+ return null;
1109
+ }
1110
+ if (set.periodSec !== void 0 && (!isFiniteNumber(set.periodSec) || set.periodSec <= 0)) {
1111
+ return null;
1112
+ }
1113
+ if (set.elevation !== void 0 && !isFiniteNumber(set.elevation)) {
1114
+ return null;
1115
+ }
1116
+ if (set.paused !== void 0 && typeof set.paused !== "boolean") {
1117
+ return null;
1118
+ }
1119
+ return {
1120
+ type: "orbit",
1121
+ action: "set",
1122
+ ...set.radius !== void 0 ? { radius: set.radius } : {},
1123
+ ...set.periodSec !== void 0 ? { periodSec: set.periodSec } : {},
1124
+ ...set.elevation !== void 0 ? { elevation: set.elevation } : {},
1125
+ ...set.paused !== void 0 ? { paused: set.paused } : {}
1126
+ };
1127
+ }
1128
+ if (orbit.action === "say") {
1129
+ const say = message;
1130
+ if (typeof say.text !== "string" || say.text.length === 0 || say.text.length > MAX_SAY_TEXT_LENGTH) {
1131
+ return null;
1132
+ }
1133
+ return { type: "orbit", action: "say", text: say.text };
1134
+ }
1135
+ return null;
1136
+ }
1137
+ case "pad": {
1138
+ const pad = message;
1139
+ if (!isShowcaseClipId(pad.clipId)) {
1140
+ return null;
1141
+ }
1142
+ if (!isFiniteNumber(pad.x) || !isInXzRange(pad.x)) {
1143
+ return null;
1144
+ }
1145
+ if (!isFiniteNumber(pad.z) || !isInXzRange(pad.z)) {
1146
+ return null;
1147
+ }
1148
+ if (pad.volume !== void 0) {
1149
+ if (!isFiniteNumber(pad.volume)) {
1150
+ return null;
1151
+ }
1152
+ }
1153
+ if (pad.placement !== void 0 && !isMixPlacement(pad.placement)) {
1154
+ return null;
1155
+ }
1156
+ return {
1157
+ type: "pad",
1158
+ clipId: pad.clipId,
1159
+ x: pad.x,
1160
+ z: pad.z,
1161
+ ...pad.volume !== void 0 ? { volume: clampVolume(pad.volume) } : {},
1162
+ ...pad.placement !== void 0 ? { placement: pad.placement } : {}
1163
+ };
1164
+ }
1165
+ case "pad_stop": {
1166
+ const stop = message;
1167
+ if (!isNonEmptyString(stop.playId)) {
1168
+ return null;
1169
+ }
1170
+ return { type: "pad_stop", playId: stop.playId.trim() };
1171
+ }
1172
+ case "pad_status": {
1173
+ const status = message;
1174
+ if (!isNonEmptyString(status.playId)) {
1175
+ return null;
1176
+ }
1177
+ return { type: "pad_status", playId: status.playId.trim() };
1178
+ }
1179
+ case "move": {
1180
+ const move = message;
1181
+ if (!isFiniteNumber(move.x) || !isInXzRange(move.x)) {
1182
+ return null;
1183
+ }
1184
+ if (!isFiniteNumber(move.z) || !isInXzRange(move.z)) {
1185
+ return null;
1186
+ }
1187
+ if (move.yawDeg !== void 0 && !isFiniteNumber(move.yawDeg)) {
1188
+ return null;
1189
+ }
1190
+ return {
1191
+ type: "move",
1192
+ x: move.x,
1193
+ z: move.z,
1194
+ ...move.yawDeg !== void 0 ? { yawDeg: move.yawDeg } : {}
1195
+ };
1196
+ }
1197
+ case "mute_peer": {
1198
+ const mute = message;
1199
+ if (!isNonEmptyString(mute.peerId) || typeof mute.muted !== "boolean") {
1200
+ return null;
1201
+ }
1202
+ return {
1203
+ type: "mute_peer",
1204
+ peerId: mute.peerId.trim(),
1205
+ muted: mute.muted
1206
+ };
1207
+ }
1208
+ case "leave":
1209
+ return { type: "leave" };
1210
+ case "ping":
1211
+ return { type: "ping" };
1212
+ default:
1213
+ return null;
1214
+ }
1215
+ }
1216
+ function poseAt(x, z, yawDeg = 0) {
1217
+ const yawRad = yawDeg * Math.PI / 180;
1218
+ const half = yawRad / 2;
1219
+ return {
1220
+ position: { x, y: 0, z },
1221
+ orientation: {
1222
+ x: 0,
1223
+ y: Math.sin(half),
1224
+ z: 0,
1225
+ w: Math.cos(half)
1226
+ }
1227
+ };
1228
+ }
1229
+
1230
+ // templates/spatial-showcase/room.ts
1231
+ var MAX_ROOM_PEERS = 8;
1232
+ var ProximityRoom = class {
1233
+ members = /* @__PURE__ */ new Map();
1234
+ listenerMutes = /* @__PURE__ */ new Map();
1235
+ join(sessionId) {
1236
+ if (this.members.has(sessionId)) {
1237
+ return "ok";
1238
+ }
1239
+ if (this.members.size >= MAX_ROOM_PEERS) {
1240
+ return "full";
1241
+ }
1242
+ this.members.set(sessionId, { x: 0, z: 0, yawDeg: 0 });
1243
+ return "ok";
1244
+ }
1245
+ leave(sessionId) {
1246
+ this.members.delete(sessionId);
1247
+ this.listenerMutes.delete(sessionId);
1248
+ for (const targets of this.listenerMutes.values()) {
1249
+ targets.delete(sessionId);
1250
+ }
1251
+ }
1252
+ setPose(sessionId, x, z, yawDeg) {
1253
+ const peer = this.members.get(sessionId);
1254
+ if (!peer) {
1255
+ return;
1256
+ }
1257
+ peer.x = x;
1258
+ peer.z = z;
1259
+ peer.yawDeg = yawDeg;
1260
+ }
1261
+ memberIds() {
1262
+ return [...this.members.keys()];
1263
+ }
1264
+ /** Per-listener view — `muted` is whether this listener muted the peer (never true for self). */
1265
+ snapshotFor(listenerId) {
1266
+ const mutesForListener = this.listenerMutes.get(listenerId);
1267
+ return [...this.members.entries()].map(([id, pose]) => ({
1268
+ id,
1269
+ x: pose.x,
1270
+ z: pose.z,
1271
+ yawDeg: pose.yawDeg,
1272
+ muted: id !== listenerId && mutesForListener?.get(id) === true
1273
+ }));
1274
+ }
1275
+ setPeerMuted(listenerId, targetId, muted) {
1276
+ let targets = this.listenerMutes.get(listenerId);
1277
+ if (!targets) {
1278
+ targets = /* @__PURE__ */ new Map();
1279
+ this.listenerMutes.set(listenerId, targets);
1280
+ }
1281
+ targets.set(targetId, muted);
1282
+ }
1283
+ clear() {
1284
+ this.members.clear();
1285
+ this.listenerMutes.clear();
1286
+ }
1287
+ };
1288
+
1289
+ // templates/spatial-showcase/agent.ts
1290
+ var ORBIT_TICK_MS = 50;
1291
+ var ORBIT_POSE_EMIT_MS = 100;
1292
+ var ROOM_STATE_TICK_MS = 100;
1293
+ var LOOP_PAD_POLL_MS = 250;
1294
+ var MAX_ACTIVE_PLAYS = 4;
1295
+ var SHOWCASE_ROOM_GROUP_ID = "showcase-room";
1296
+ var sessions = /* @__PURE__ */ new Map();
1297
+ var proximityRoom = new ProximityRoom();
1298
+ var showcaseRoomGroupReady = null;
1299
+ var lastRoomSnapshotByListener = /* @__PURE__ */ new Map();
1300
+ function ensureShowcaseRoomGroup() {
1301
+ if (!showcaseRoomGroupReady) {
1302
+ showcaseRoomGroupReady = createMixGroup({
1303
+ id: SHOWCASE_ROOM_GROUP_ID,
1304
+ clientIds: []
1305
+ }).then((result) => result.ok).catch(() => false);
1306
+ }
1307
+ return showcaseRoomGroupReady;
1308
+ }
1309
+ function ack(sessionId, action, extra) {
1310
+ sendToClient(sessionId, {
1311
+ type: "showcase_ack",
1312
+ action,
1313
+ ok: true,
1314
+ ...extra
1315
+ });
1316
+ }
1317
+ function ackError(sessionId, action, error) {
1318
+ sendToClient(sessionId, {
1319
+ type: "showcase_ack",
1320
+ action,
1321
+ ok: false,
1322
+ error
1323
+ });
1324
+ }
1325
+ async function safeMixCall(sessionId, label, fn) {
1326
+ try {
1327
+ const result = await fn();
1328
+ if (!result.ok) {
1329
+ agentLog(
1330
+ "warn",
1331
+ `spatial-showcase ${label} failed: ${result.reason}`,
1332
+ sessionId
1333
+ );
1334
+ return false;
1335
+ }
1336
+ return true;
1337
+ } catch (err) {
1338
+ agentLog(
1339
+ "warn",
1340
+ `spatial-showcase ${label} error: ${String(err)}`,
1341
+ sessionId
1342
+ );
1343
+ return false;
1344
+ }
1345
+ }
1346
+ function defaultSessionState(demo, assetOrigin) {
1347
+ return {
1348
+ demo,
1349
+ assetOrigin,
1350
+ orbit: { radius: 2, periodSec: 2 * Math.PI, elevation: 0, paused: false },
1351
+ playIds: /* @__PURE__ */ new Set(),
1352
+ loopPads: /* @__PURE__ */ new Map(),
1353
+ loopPollTimers: /* @__PURE__ */ new Map(),
1354
+ inProximityRoom: false
1355
+ };
1356
+ }
1357
+ function clearRoomStateTimer(state) {
1358
+ if (state.roomStateTimer) {
1359
+ clearInterval(state.roomStateTimer);
1360
+ state.roomStateTimer = void 0;
1361
+ }
1362
+ }
1363
+ function clearLoopPad(sessionId, state, playId) {
1364
+ const timer = state.loopPollTimers.get(playId);
1365
+ if (timer) {
1366
+ clearInterval(timer);
1367
+ state.loopPollTimers.delete(playId);
1368
+ }
1369
+ state.loopPads.delete(playId);
1370
+ state.playIds.delete(playId);
1371
+ }
1372
+ function clearAllPlays(sessionId, state) {
1373
+ for (const playId of [...state.playIds]) {
1374
+ state.loopPads.delete(playId);
1375
+ const timer = state.loopPollTimers.get(playId);
1376
+ if (timer) {
1377
+ clearInterval(timer);
1378
+ state.loopPollTimers.delete(playId);
1379
+ }
1380
+ void stopPlay(playId);
1381
+ }
1382
+ state.playIds.clear();
1383
+ state.loopPads.clear();
1384
+ state.loopPollTimers.clear();
1385
+ }
1386
+ function emitOrbitPose(sessionId, state) {
1387
+ if (!state.orbitStartMs) {
1388
+ return;
1389
+ }
1390
+ const now = Date.now();
1391
+ if (state.lastOrbitPoseEmitMs !== void 0 && now - state.lastOrbitPoseEmitMs < ORBIT_POSE_EMIT_MS) {
1392
+ return;
1393
+ }
1394
+ state.lastOrbitPoseEmitMs = now;
1395
+ const elapsedSec = (now - state.orbitStartMs) / 1e3;
1396
+ const pose = orbitTtsPose(elapsedSec, state.orbit);
1397
+ const angleRad = 2 * Math.PI * elapsedSec / (state.orbit.periodSec ?? 2 * Math.PI);
1398
+ sendToClient(sessionId, {
1399
+ type: "orbit_pose",
1400
+ angleDeg: angleRad * 180 / Math.PI,
1401
+ x: pose.position.x,
1402
+ z: pose.position.z
1403
+ });
1404
+ }
1405
+ function startOrbitDemo(sessionId, state) {
1406
+ stopOrbitInterval(state);
1407
+ beginOrbitClock(state);
1408
+ state.orbitTimer = setInterval(() => {
1409
+ if (state.orbit.paused || !state.orbitStartMs) {
1410
+ return;
1411
+ }
1412
+ const elapsedSec = (Date.now() - state.orbitStartMs) / 1e3;
1413
+ void safeMixCall(
1414
+ sessionId,
1415
+ "setTtsPose",
1416
+ () => setTtsPose(sessionId, orbitTtsPose(elapsedSec, state.orbit))
1417
+ );
1418
+ emitOrbitPose(sessionId, state);
1419
+ }, ORBIT_TICK_MS);
1420
+ emitOrbitPose(sessionId, state);
1421
+ speak(sessionId, "I'll circle around you.");
1422
+ }
1423
+ function broadcastRoomStateIfChanged() {
1424
+ const memberIds = proximityRoom.memberIds();
1425
+ if (memberIds.length === 0) {
1426
+ lastRoomSnapshotByListener.clear();
1427
+ return;
1428
+ }
1429
+ for (const listenerId of memberIds) {
1430
+ const peers = proximityRoom.snapshotFor(listenerId);
1431
+ const payload = JSON.stringify(peers);
1432
+ if (lastRoomSnapshotByListener.get(listenerId) === payload) {
1433
+ continue;
1434
+ }
1435
+ lastRoomSnapshotByListener.set(listenerId, payload);
1436
+ sendToClient(listenerId, { type: "room_state", peers });
1437
+ }
1438
+ for (const listenerId of [...lastRoomSnapshotByListener.keys()]) {
1439
+ if (!memberIds.includes(listenerId)) {
1440
+ lastRoomSnapshotByListener.delete(listenerId);
1441
+ }
1442
+ }
1443
+ }
1444
+ function startProximityRoomBroadcast(sessionId, state) {
1445
+ clearRoomStateTimer(state);
1446
+ state.roomStateTimer = setInterval(() => {
1447
+ broadcastRoomStateIfChanged();
1448
+ }, ROOM_STATE_TICK_MS);
1449
+ lastRoomSnapshotByListener.clear();
1450
+ broadcastRoomStateIfChanged();
1451
+ }
1452
+ async function startLoopPadPoll(sessionId, state, playId, pad) {
1453
+ const assetOrigin = state.assetOrigin;
1454
+ if (!assetOrigin) {
1455
+ return;
1456
+ }
1457
+ const timer = setInterval(() => {
1458
+ void (async () => {
1459
+ const loopState = state.loopPads.get(playId);
1460
+ if (!loopState) {
1461
+ clearInterval(timer);
1462
+ return;
1463
+ }
1464
+ const status = await getPlay(playId);
1465
+ if (!status.ok) {
1466
+ return;
1467
+ }
1468
+ if (status.status === "ended") {
1469
+ const url = resolveClipUrl(assetOrigin, pad.clipId);
1470
+ if (!url) {
1471
+ ackError(sessionId, "pad", "invalid_asset_origin");
1472
+ return;
1473
+ }
1474
+ const options = {
1475
+ url,
1476
+ sessionIds: [sessionId],
1477
+ volume: pad.volume,
1478
+ ...pad.placement ? { placement: pad.placement } : { pose: poseAt(pad.x, pad.z) }
1479
+ };
1480
+ const replay = await play(options);
1481
+ if (replay.ok && replay.playId) {
1482
+ clearLoopPad(sessionId, state, playId);
1483
+ state.playIds.add(replay.playId);
1484
+ state.loopPads.set(replay.playId, pad);
1485
+ void startLoopPadPoll(sessionId, state, replay.playId, pad);
1486
+ }
1487
+ } else if (status.status) {
1488
+ sendToClient(sessionId, {
1489
+ type: "pad_progress",
1490
+ playId,
1491
+ status: status.status
1492
+ });
1493
+ }
1494
+ })();
1495
+ }, LOOP_PAD_POLL_MS);
1496
+ state.loopPollTimers.set(playId, timer);
1497
+ }
1498
+ async function handlePadPlay(sessionId, state, clipId, x, z, volume, placement) {
1499
+ if (state.playIds.size >= MAX_ACTIVE_PLAYS) {
1500
+ ackError(sessionId, "pad", "busy");
1501
+ return;
1502
+ }
1503
+ const assetOrigin = state.assetOrigin;
1504
+ if (!assetOrigin) {
1505
+ ackError(sessionId, "pad", "missing_asset_origin");
1506
+ return;
1507
+ }
1508
+ const url = resolveClipUrl(assetOrigin, clipId);
1509
+ if (!url) {
1510
+ ackError(sessionId, "pad", "invalid_asset_origin");
1511
+ return;
1512
+ }
1513
+ const sound = SHOWCASE_SOUND_FILES[clipId];
1514
+ const playVolume = volume ?? sound.defaultVolume;
1515
+ const options = {
1516
+ url,
1517
+ sessionIds: [sessionId],
1518
+ volume: playVolume,
1519
+ ...placement ? { placement } : { pose: poseAt(x, z) }
1520
+ };
1521
+ const result = await play(options);
1522
+ if (!result.ok || !result.playId) {
1523
+ ackError(sessionId, "pad", result.reason ?? "play_failed");
1524
+ return;
1525
+ }
1526
+ state.playIds.add(result.playId);
1527
+ ack(sessionId, "pad", { playId: result.playId });
1528
+ if (sound.loop) {
1529
+ const pad = {
1530
+ clipId,
1531
+ volume: playVolume,
1532
+ x,
1533
+ z,
1534
+ placement,
1535
+ loop: true
1536
+ };
1537
+ state.loopPads.set(result.playId, pad);
1538
+ void startLoopPadPoll(sessionId, state, result.playId, pad);
1539
+ }
1540
+ }
1541
+ async function handleJoin(sessionId, demo, assetOrigin) {
1542
+ teardownSession(sessionId);
1543
+ const state = defaultSessionState(demo, assetOrigin);
1544
+ sessions.set(sessionId, state);
1545
+ const mixOk = await safeMixCall(
1546
+ sessionId,
1547
+ "setPositionalMixing",
1548
+ () => setPositionalMixing(true)
1549
+ );
1550
+ if (!mixOk) {
1551
+ ackError(sessionId, "join", "positional_mixing_unavailable");
1552
+ return;
1553
+ }
1554
+ ack(sessionId, "join");
1555
+ switch (demo) {
1556
+ case "orbit":
1557
+ startOrbitDemo(sessionId, state);
1558
+ break;
1559
+ case "soundboard":
1560
+ break;
1561
+ case "proximity": {
1562
+ const groupOk = await ensureShowcaseRoomGroup();
1563
+ if (!groupOk) {
1564
+ ackError(sessionId, "join", "mix_group_unavailable");
1565
+ return;
1566
+ }
1567
+ const joined = proximityRoom.join(sessionId);
1568
+ if (joined === "full") {
1569
+ sendToClient(sessionId, { type: "room_full" });
1570
+ sessions.delete(sessionId);
1571
+ return;
1572
+ }
1573
+ const added = await safeMixCall(
1574
+ sessionId,
1575
+ "addClientToMix",
1576
+ () => addClientToMix(SHOWCASE_ROOM_GROUP_ID, sessionId)
1577
+ );
1578
+ if (!added) {
1579
+ proximityRoom.leave(sessionId);
1580
+ sessions.delete(sessionId);
1581
+ ackError(sessionId, "join", "add_to_mix_failed");
1582
+ return;
1583
+ }
1584
+ state.inProximityRoom = true;
1585
+ startProximityRoomBroadcast(sessionId, state);
1586
+ speak(sessionId, "Welcome to the proximity room.");
1587
+ await safeMixCall(
1588
+ sessionId,
1589
+ "setTtsPose",
1590
+ () => setTtsPose(sessionId, poseAt(0, 0))
1591
+ );
1592
+ break;
1593
+ }
1594
+ }
1595
+ }
1596
+ function teardownSession(sessionId) {
1597
+ const state = sessions.get(sessionId);
1598
+ if (!state) {
1599
+ return;
1600
+ }
1601
+ resetOrbitSession(state);
1602
+ clearRoomStateTimer(state);
1603
+ clearAllPlays(sessionId, state);
1604
+ void safeMixCall(sessionId, "clearTtsPose", () => clearTtsPose(sessionId));
1605
+ if (state.inProximityRoom) {
1606
+ proximityRoom.leave(sessionId);
1607
+ void safeMixCall(
1608
+ sessionId,
1609
+ "removeClientFromMix",
1610
+ () => removeClientFromMix(SHOWCASE_ROOM_GROUP_ID, sessionId)
1611
+ );
1612
+ state.inProximityRoom = false;
1613
+ broadcastRoomStateIfChanged();
1614
+ }
1615
+ sessions.delete(sessionId);
1616
+ }
1617
+ async function dispatchMessage(sessionId, message) {
1618
+ const state = sessions.get(sessionId);
1619
+ switch (message.type) {
1620
+ case "join":
1621
+ await handleJoin(sessionId, message.demo, message.assetOrigin);
1622
+ return;
1623
+ case "ping":
1624
+ return;
1625
+ case "leave":
1626
+ teardownSession(sessionId);
1627
+ ack(sessionId, "leave");
1628
+ return;
1629
+ default:
1630
+ if (!state) {
1631
+ ackError(sessionId, message.type, "join_required");
1632
+ return;
1633
+ }
1634
+ }
1635
+ if (!state) {
1636
+ return;
1637
+ }
1638
+ switch (message.type) {
1639
+ case "orbit": {
1640
+ if (state.demo !== "orbit") {
1641
+ ackError(sessionId, "orbit", "wrong_demo");
1642
+ return;
1643
+ }
1644
+ if (message.action === "set") {
1645
+ if (message.radius !== void 0) {
1646
+ state.orbit.radius = message.radius;
1647
+ }
1648
+ if (message.periodSec !== void 0) {
1649
+ state.orbit.periodSec = message.periodSec;
1650
+ }
1651
+ if (message.elevation !== void 0) {
1652
+ state.orbit.elevation = message.elevation;
1653
+ }
1654
+ if (message.paused !== void 0) {
1655
+ state.orbit.paused = message.paused;
1656
+ }
1657
+ ack(sessionId, "orbit_set");
1658
+ return;
1659
+ }
1660
+ speak(sessionId, message.text);
1661
+ ack(sessionId, "orbit_say");
1662
+ return;
1663
+ }
1664
+ case "pad": {
1665
+ if (state.demo !== "soundboard") {
1666
+ ackError(sessionId, "pad", "wrong_demo");
1667
+ return;
1668
+ }
1669
+ await handlePadPlay(
1670
+ sessionId,
1671
+ state,
1672
+ message.clipId,
1673
+ message.x,
1674
+ message.z,
1675
+ message.volume,
1676
+ message.placement
1677
+ );
1678
+ return;
1679
+ }
1680
+ case "pad_stop": {
1681
+ const loopPad = state.loopPads.get(message.playId);
1682
+ if (loopPad) {
1683
+ clearLoopPad(sessionId, state, message.playId);
1684
+ } else {
1685
+ state.playIds.delete(message.playId);
1686
+ }
1687
+ const stopped = await stopPlay(message.playId);
1688
+ if (!stopped.ok) {
1689
+ ackError(sessionId, "pad_stop", stopped.reason ?? "stop_failed");
1690
+ return;
1691
+ }
1692
+ ack(sessionId, "pad_stop");
1693
+ return;
1694
+ }
1695
+ case "pad_status": {
1696
+ const status = await getPlay(message.playId);
1697
+ if (!status.ok) {
1698
+ ackError(sessionId, "pad_status", status.reason ?? "status_failed");
1699
+ return;
1700
+ }
1701
+ if (status.status) {
1702
+ sendToClient(sessionId, {
1703
+ type: "pad_progress",
1704
+ playId: message.playId,
1705
+ status: status.status
1706
+ });
1707
+ } else {
1708
+ ack(sessionId, "pad_status");
1709
+ }
1710
+ return;
1711
+ }
1712
+ case "move": {
1713
+ if (state.demo !== "proximity") {
1714
+ ackError(sessionId, "move", "wrong_demo");
1715
+ return;
1716
+ }
1717
+ const yawDeg = message.yawDeg ?? 0;
1718
+ proximityRoom.setPose(sessionId, message.x, message.z, yawDeg);
1719
+ const poseOk = await safeMixCall(
1720
+ sessionId,
1721
+ "setClientPose",
1722
+ () => setClientPose(sessionId, poseAt(message.x, message.z, yawDeg))
1723
+ );
1724
+ if (!poseOk) {
1725
+ ackError(sessionId, "move", "set_pose_failed");
1726
+ return;
1727
+ }
1728
+ lastRoomSnapshotByListener.clear();
1729
+ broadcastRoomStateIfChanged();
1730
+ ack(sessionId, "move");
1731
+ return;
1732
+ }
1733
+ case "mute_peer": {
1734
+ if (state.demo !== "proximity") {
1735
+ ackError(sessionId, "mute_peer", "wrong_demo");
1736
+ return;
1737
+ }
1738
+ proximityRoom.setPeerMuted(sessionId, message.peerId, message.muted);
1739
+ const muteOk = await safeMixCall(
1740
+ sessionId,
1741
+ "setListenerMute",
1742
+ () => setListenerMute({
1743
+ listenerId: sessionId,
1744
+ targetId: message.peerId,
1745
+ muted: message.muted
1746
+ })
1747
+ );
1748
+ if (!muteOk) {
1749
+ ackError(sessionId, "mute_peer", "mute_failed");
1750
+ return;
1751
+ }
1752
+ lastRoomSnapshotByListener.clear();
1753
+ broadcastRoomStateIfChanged();
1754
+ ack(sessionId, "mute_peer");
1755
+ return;
1756
+ }
1757
+ default:
1758
+ return;
1759
+ }
1760
+ }
1761
+ defineAgent({
1762
+ onAgentStart() {
1763
+ for (const sessionId of [...sessions.keys()]) {
1764
+ teardownSession(sessionId);
1765
+ }
1766
+ sessions.clear();
1767
+ proximityRoom.clear();
1768
+ showcaseRoomGroupReady = null;
1769
+ lastRoomSnapshotByListener.clear();
1770
+ },
1771
+ onSessionStart(ctx) {
1772
+ const { sessionId } = ctx;
1773
+ if (!isMixAvailable(ctx) && !isTtsPoseAvailable(ctx)) {
1774
+ agentLog(
1775
+ "warn",
1776
+ "spatial-showcase requires voice or Voice+Data \u2014 mix/TTS pose APIs unavailable",
1777
+ sessionId
1778
+ );
1779
+ return;
1780
+ }
1781
+ sendToClient(sessionId, {
1782
+ type: "showcase_ack",
1783
+ action: "hello",
1784
+ ok: true
1785
+ });
1786
+ },
1787
+ onUserSpeechFinal({ sessionId, text }) {
1788
+ const state = sessions.get(sessionId);
1789
+ if (!state || state.demo !== "orbit") {
1790
+ return;
1791
+ }
1792
+ speak(sessionId, `You said: ${text}`);
1793
+ },
1794
+ onDataChannelMessage(ctx) {
1795
+ const message = parseShowcaseMessage(ctx.message);
1796
+ if (!message) {
1797
+ return;
1798
+ }
1799
+ void dispatchMessage(ctx.sessionId, message);
1800
+ },
1801
+ onSessionEnd({ sessionId }) {
1802
+ teardownSession(sessionId);
1803
+ agentLog("info", `spatial-showcase session_end ${sessionId}`, sessionId);
1804
+ }
1805
+ });
1806
+ export {
1807
+ handlePadPlay
1808
+ };