@voicethere/agent 0.5.5 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.js +6 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +13 -1
- package/dist/runtime.js.map +1 -1
- package/dist/templates/echo/agent.js +3 -0
- package/dist/templates/echo-dc/agent.js +3 -0
- package/dist/templates/game-sync/agent.js +67 -31
- package/dist/templates/index.d.ts +2 -0
- package/dist/templates/index.d.ts.map +1 -1
- package/dist/templates/index.js +14 -0
- package/dist/templates/index.js.map +1 -1
- package/dist/templates/recording-consent/agent.js +1003 -0
- package/dist/templates/registry.d.ts.map +1 -1
- package/dist/templates/registry.js +12 -0
- package/dist/templates/registry.js.map +1 -1
- package/dist/templates/voice-showcase/agent.js +44 -19
- package/dist/templates/voice-starter/agent.js +3 -0
- package/dist/templates/webhooks/agent.js +3 -0
- package/dist/templates/webhooks-redis/agent.js +3 -0
- package/package.json +7 -1
- package/templates/README.md +12 -6
- package/templates/game-sync-world-layout.ts +45 -0
- package/templates/game-sync.ts +39 -24
- package/templates/recording-consent/agent.ts +162 -0
- package/templates/recording-consent/conversation.ts +300 -0
- package/templates/voice-showcase/agent.ts +21 -22
- package/templates/voice-showcase/delivery.ts +57 -0
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// dist/runtime.js
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
|
+
|
|
9
|
+
// dist/session-serial-queue.js
|
|
10
|
+
var SessionSerialQueueCancellationError = class extends Error {
|
|
11
|
+
sessionId;
|
|
12
|
+
generation;
|
|
13
|
+
code = "SESSION_SERIAL_QUEUE_CANCELLED";
|
|
14
|
+
constructor(sessionId, generation) {
|
|
15
|
+
super(`session serial queue cancelled (sessionId=${sessionId}, generation=${generation})`);
|
|
16
|
+
this.sessionId = sessionId;
|
|
17
|
+
this.generation = generation;
|
|
18
|
+
this.name = "SessionSerialQueueCancellationError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var SessionSerialQueue = class {
|
|
22
|
+
sessions = /* @__PURE__ */ new Map();
|
|
23
|
+
/** Monotonic token source — never reused across clear/enqueue cycles. */
|
|
24
|
+
nextGeneration = 1;
|
|
25
|
+
/**
|
|
26
|
+
* Enqueue work for `sessionId`. Returns a promise that settles when this
|
|
27
|
+
* task finishes, is skipped as stale, or is cancelled by {@link clear}.
|
|
28
|
+
* Fire-and-forget callers may ignore the return value (compat).
|
|
29
|
+
*/
|
|
30
|
+
enqueue(sessionId, task) {
|
|
31
|
+
const state = this.ensureState(sessionId);
|
|
32
|
+
const captured = state;
|
|
33
|
+
const generation = captured.generation;
|
|
34
|
+
captured.pending += 1;
|
|
35
|
+
const previous = captured.tail;
|
|
36
|
+
let taskAbort = null;
|
|
37
|
+
const settled = previous.catch(() => void 0).then(async () => {
|
|
38
|
+
if (this.sessions.get(sessionId) !== captured) {
|
|
39
|
+
return "cancelled";
|
|
40
|
+
}
|
|
41
|
+
const abort = new AbortController();
|
|
42
|
+
taskAbort = abort;
|
|
43
|
+
captured.abort = abort;
|
|
44
|
+
const context = {
|
|
45
|
+
sessionId,
|
|
46
|
+
generation,
|
|
47
|
+
signal: abort.signal
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
await Promise.resolve(task(abort.signal, context));
|
|
51
|
+
if (this.sessions.get(sessionId) !== captured) {
|
|
52
|
+
return "cancelled";
|
|
53
|
+
}
|
|
54
|
+
return "completed";
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (abort.signal.aborted || this.sessions.get(sessionId) !== captured || error instanceof SessionSerialQueueCancellationError) {
|
|
57
|
+
return "cancelled";
|
|
58
|
+
}
|
|
59
|
+
return "failed";
|
|
60
|
+
} finally {
|
|
61
|
+
if (captured.abort === abort) {
|
|
62
|
+
captured.abort = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}).finally(() => {
|
|
66
|
+
captured.pending = Math.max(0, captured.pending - 1);
|
|
67
|
+
if (taskAbort && captured.abort === taskAbort) {
|
|
68
|
+
captured.abort = null;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
captured.tail = settled;
|
|
72
|
+
return settled;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Invalidate queued + running work for `sessionId`. Aborts the live AbortSignal,
|
|
76
|
+
* drops the map entry, and leaves a fresh identity for the next enqueue.
|
|
77
|
+
* Old in-flight `finally` blocks only touch their captured state object.
|
|
78
|
+
*/
|
|
79
|
+
clear(sessionId) {
|
|
80
|
+
const old = this.sessions.get(sessionId);
|
|
81
|
+
if (!old)
|
|
82
|
+
return;
|
|
83
|
+
old.abort?.abort();
|
|
84
|
+
old.abort = null;
|
|
85
|
+
this.sessions.delete(sessionId);
|
|
86
|
+
}
|
|
87
|
+
clearAll() {
|
|
88
|
+
for (const sessionId of [...this.sessions.keys()]) {
|
|
89
|
+
this.clear(sessionId);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
hasPending(sessionId) {
|
|
93
|
+
return (this.sessions.get(sessionId)?.pending ?? 0) > 0;
|
|
94
|
+
}
|
|
95
|
+
/** Live generation token for a session, or `undefined` when cleared. */
|
|
96
|
+
generationOf(sessionId) {
|
|
97
|
+
return this.sessions.get(sessionId)?.generation;
|
|
98
|
+
}
|
|
99
|
+
/** True when `generation` is the live map entry for `sessionId`. */
|
|
100
|
+
isCurrentGeneration(sessionId, generation) {
|
|
101
|
+
return this.sessions.get(sessionId)?.generation === generation;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* True when a queue row exists for the session (registered since first
|
|
105
|
+
* enqueue, until {@link clear}). Not the same as {@link hasPending}.
|
|
106
|
+
*/
|
|
107
|
+
isLive(sessionId) {
|
|
108
|
+
return this.sessions.has(sessionId);
|
|
109
|
+
}
|
|
110
|
+
/** Registered session rows (must not grow without matching `clear` calls). */
|
|
111
|
+
get activeSessionCount() {
|
|
112
|
+
return this.sessions.size;
|
|
113
|
+
}
|
|
114
|
+
ensureState(sessionId) {
|
|
115
|
+
const existing = this.sessions.get(sessionId);
|
|
116
|
+
if (existing)
|
|
117
|
+
return existing;
|
|
118
|
+
const created = {
|
|
119
|
+
generation: this.nextGeneration++,
|
|
120
|
+
tail: Promise.resolve(),
|
|
121
|
+
abort: null,
|
|
122
|
+
pending: 0
|
|
123
|
+
};
|
|
124
|
+
this.sessions.set(sessionId, created);
|
|
125
|
+
return created;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// dist/runtime.js
|
|
130
|
+
var SESSION_START_INIT_DELAY_ENABLED_ENV = "AGENT_SESSION_START_INIT_DELAY_ENABLED";
|
|
131
|
+
var SESSION_START_INIT_DELAY_MS_ENV = "AGENT_SESSION_START_INIT_DELAY_MS";
|
|
132
|
+
var DEFAULT_SESSION_START_INIT_DELAY_MS = 500;
|
|
133
|
+
function isRecordingControlAckMessage(value) {
|
|
134
|
+
if (!value || typeof value !== "object")
|
|
135
|
+
return false;
|
|
136
|
+
const msg = value;
|
|
137
|
+
return msg.type === "recording_control_ack" && typeof msg.requestId === "string";
|
|
138
|
+
}
|
|
139
|
+
function isWebhookMessage(value) {
|
|
140
|
+
if (!value || typeof value !== "object")
|
|
141
|
+
return false;
|
|
142
|
+
const msg = value;
|
|
143
|
+
return msg.type === "webhook" && typeof msg.eventId === "string" && typeof msg.projectId === "string";
|
|
144
|
+
}
|
|
145
|
+
function coerceInboundBinary(value) {
|
|
146
|
+
if (!value)
|
|
147
|
+
return null;
|
|
148
|
+
if (Buffer.isBuffer(value))
|
|
149
|
+
return value;
|
|
150
|
+
if (value instanceof Uint8Array) {
|
|
151
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
152
|
+
}
|
|
153
|
+
if (value instanceof ArrayBuffer)
|
|
154
|
+
return Buffer.from(value);
|
|
155
|
+
if (ArrayBuffer.isView(value)) {
|
|
156
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
157
|
+
}
|
|
158
|
+
if (typeof value === "object") {
|
|
159
|
+
const maybeBufferLike = value;
|
|
160
|
+
if (maybeBufferLike.type === "Buffer" && Array.isArray(maybeBufferLike.data)) {
|
|
161
|
+
return Buffer.from(maybeBufferLike.data);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
function normalizeWebhookHeaders(value) {
|
|
167
|
+
if (!value || typeof value !== "object")
|
|
168
|
+
return {};
|
|
169
|
+
const out = {};
|
|
170
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
171
|
+
if (typeof raw === "string") {
|
|
172
|
+
out[key] = raw;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
function isParentMessage(value) {
|
|
178
|
+
if (!value || typeof value !== "object")
|
|
179
|
+
return false;
|
|
180
|
+
const msg = value;
|
|
181
|
+
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 === "webhook";
|
|
182
|
+
}
|
|
183
|
+
function isSessionScopedParentMessage(value) {
|
|
184
|
+
return isParentMessage(value) && !isWebhookMessage(value);
|
|
185
|
+
}
|
|
186
|
+
function parseDataChannelPayload(raw) {
|
|
187
|
+
try {
|
|
188
|
+
return JSON.parse(raw);
|
|
189
|
+
} catch {
|
|
190
|
+
return raw;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
var peerEnvBySessionId = /* @__PURE__ */ new Map();
|
|
194
|
+
var recordingAvailableBySessionId = /* @__PURE__ */ new Map();
|
|
195
|
+
var endedSessionIds = /* @__PURE__ */ new Set();
|
|
196
|
+
var RECORDING_CONTROL_ACK_TIMEOUT_MS = 5e3;
|
|
197
|
+
var pendingRecordingAcks = /* @__PURE__ */ new Map();
|
|
198
|
+
function handleRecordingControlAck(message) {
|
|
199
|
+
const pending = pendingRecordingAcks.get(message.requestId);
|
|
200
|
+
if (!pending)
|
|
201
|
+
return;
|
|
202
|
+
clearTimeout(pending.timer);
|
|
203
|
+
pendingRecordingAcks.delete(message.requestId);
|
|
204
|
+
pending.resolve({
|
|
205
|
+
ok: message.ok,
|
|
206
|
+
reason: message.reason,
|
|
207
|
+
requestId: message.requestId
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
function clearPendingRecordingAcksForSession(sessionId, reason) {
|
|
211
|
+
for (const [requestId, pending] of pendingRecordingAcks) {
|
|
212
|
+
if (pending.sessionId !== sessionId)
|
|
213
|
+
continue;
|
|
214
|
+
clearTimeout(pending.timer);
|
|
215
|
+
pendingRecordingAcks.delete(requestId);
|
|
216
|
+
pending.resolve({ ok: false, reason, requestId });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function isVoicethereAgentChild() {
|
|
220
|
+
return typeof process.send === "function" && process.connected !== false && typeof process.env.__CHILD_BUNDLE_PATH__ === "string" && process.env.__CHILD_BUNDLE_PATH__.trim().length > 0;
|
|
221
|
+
}
|
|
222
|
+
var sessionExecutionContext = new AsyncLocalStorage();
|
|
223
|
+
var agentLogSessionContext = new AsyncLocalStorage();
|
|
224
|
+
var inboundQueueAuthority = null;
|
|
225
|
+
var childUnhandledRejectionGuardInstalled = false;
|
|
226
|
+
function normalizeRejectionReason(reason) {
|
|
227
|
+
return reason instanceof Error ? reason : new Error(String(reason));
|
|
228
|
+
}
|
|
229
|
+
function installChildUnhandledRejectionGuard() {
|
|
230
|
+
if (childUnhandledRejectionGuardInstalled) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
childUnhandledRejectionGuardInstalled = true;
|
|
234
|
+
process.on("unhandledRejection", (reason) => {
|
|
235
|
+
const err = normalizeRejectionReason(reason);
|
|
236
|
+
const store = sessionExecutionContext.getStore();
|
|
237
|
+
const sessionId = store?.sessionId ?? agentLogSessionContext.getStore() ?? "";
|
|
238
|
+
if (!allowOutboundForSession(sessionId || void 0)) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
agentLog("error", `unhandledRejection: ${err.message}`, sessionId || void 0);
|
|
242
|
+
sendParentMessage({
|
|
243
|
+
type: "agent_error",
|
|
244
|
+
sessionId,
|
|
245
|
+
message: err.message,
|
|
246
|
+
stack: err.stack
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function allowOutboundForSession(sessionId) {
|
|
251
|
+
if (!sessionId) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
const store = sessionExecutionContext.getStore();
|
|
255
|
+
const queue = inboundQueueAuthority;
|
|
256
|
+
if (store && store.sessionId === sessionId) {
|
|
257
|
+
if (!queue)
|
|
258
|
+
return !endedSessionIds.has(sessionId);
|
|
259
|
+
return queue.isCurrentGeneration(sessionId, store.generation);
|
|
260
|
+
}
|
|
261
|
+
if (endedSessionIds.has(sessionId))
|
|
262
|
+
return false;
|
|
263
|
+
if (!queue)
|
|
264
|
+
return true;
|
|
265
|
+
return queue.isLive(sessionId);
|
|
266
|
+
}
|
|
267
|
+
function sendParentMessage(message) {
|
|
268
|
+
const sessionId = message && typeof message === "object" && "sessionId" in message && typeof message.sessionId === "string" ? message.sessionId : void 0;
|
|
269
|
+
if (!allowOutboundForSession(sessionId))
|
|
270
|
+
return;
|
|
271
|
+
process.send?.(message);
|
|
272
|
+
}
|
|
273
|
+
function parseBooleanEnv(value, defaultValue) {
|
|
274
|
+
if (value === void 0)
|
|
275
|
+
return defaultValue;
|
|
276
|
+
const normalized = value.trim().toLowerCase();
|
|
277
|
+
if (normalized === "")
|
|
278
|
+
return defaultValue;
|
|
279
|
+
if (["0", "false", "off", "no"].includes(normalized))
|
|
280
|
+
return false;
|
|
281
|
+
if (["1", "true", "on", "yes"].includes(normalized))
|
|
282
|
+
return true;
|
|
283
|
+
return defaultValue;
|
|
284
|
+
}
|
|
285
|
+
function parseNonNegativeIntegerEnv(value, defaultValue) {
|
|
286
|
+
if (value === void 0)
|
|
287
|
+
return defaultValue;
|
|
288
|
+
const parsed = Number(value);
|
|
289
|
+
if (!Number.isFinite(parsed) || parsed < 0)
|
|
290
|
+
return defaultValue;
|
|
291
|
+
return Math.floor(parsed);
|
|
292
|
+
}
|
|
293
|
+
function resolveSessionStartInitDelayMs() {
|
|
294
|
+
const enabled = parseBooleanEnv(process.env[SESSION_START_INIT_DELAY_ENABLED_ENV], true);
|
|
295
|
+
if (!enabled)
|
|
296
|
+
return 0;
|
|
297
|
+
return parseNonNegativeIntegerEnv(process.env[SESSION_START_INIT_DELAY_MS_ENV], DEFAULT_SESSION_START_INIT_DELAY_MS);
|
|
298
|
+
}
|
|
299
|
+
async function handleWebhookMessage(message, handlers) {
|
|
300
|
+
if (!handlers.onWebhook)
|
|
301
|
+
return;
|
|
302
|
+
const body = coerceInboundBinary(message.body);
|
|
303
|
+
if (!body) {
|
|
304
|
+
agentLog("warn", "webhook ipc dropped: body is not binary");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const ctx = {
|
|
308
|
+
eventId: message.eventId,
|
|
309
|
+
projectId: message.projectId,
|
|
310
|
+
method: typeof message.method === "string" ? message.method : "POST",
|
|
311
|
+
path: typeof message.path === "string" ? message.path : "",
|
|
312
|
+
headers: normalizeWebhookHeaders(message.headers),
|
|
313
|
+
body,
|
|
314
|
+
contentType: typeof message.contentType === "string" ? message.contentType : null,
|
|
315
|
+
receivedAt: typeof message.receivedAt === "string" ? message.receivedAt : "",
|
|
316
|
+
sessionIds: Array.isArray(message.sessionIds) ? message.sessionIds.filter((id) => typeof id === "string" && id.length > 0) : []
|
|
317
|
+
};
|
|
318
|
+
try {
|
|
319
|
+
const started = Date.now();
|
|
320
|
+
await handlers.onWebhook(ctx);
|
|
321
|
+
sendParentMessage({
|
|
322
|
+
type: "webhook_handled",
|
|
323
|
+
projectId: message.projectId,
|
|
324
|
+
eventId: message.eventId,
|
|
325
|
+
durationMs: Date.now() - started
|
|
326
|
+
});
|
|
327
|
+
} catch (error) {
|
|
328
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
329
|
+
await runErrorHook(handlers, {
|
|
330
|
+
sessionId: "",
|
|
331
|
+
projectId: message.projectId,
|
|
332
|
+
env: process.env,
|
|
333
|
+
error: err
|
|
334
|
+
});
|
|
335
|
+
sendParentMessage({
|
|
336
|
+
type: "agent_error",
|
|
337
|
+
sessionId: "",
|
|
338
|
+
message: err.message,
|
|
339
|
+
stack: err.stack
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async function handleParentMessage(message, handlers) {
|
|
344
|
+
switch (message.type) {
|
|
345
|
+
case "session_start":
|
|
346
|
+
endedSessionIds.delete(message.sessionId);
|
|
347
|
+
peerEnvBySessionId.set(message.sessionId, message.env);
|
|
348
|
+
recordingAvailableBySessionId.set(message.sessionId, message.recordingAvailable ?? false);
|
|
349
|
+
const sessionStartInitDelayMs = resolveSessionStartInitDelayMs();
|
|
350
|
+
if (sessionStartInitDelayMs > 0) {
|
|
351
|
+
await new Promise((resolve) => setTimeout(resolve, sessionStartInitDelayMs));
|
|
352
|
+
}
|
|
353
|
+
await (handlers.onClientJoin ?? handlers.onSessionStart)?.({
|
|
354
|
+
sessionId: message.sessionId,
|
|
355
|
+
env: message.env,
|
|
356
|
+
recordingAvailable: message.recordingAvailable ?? false
|
|
357
|
+
});
|
|
358
|
+
sendParentMessage({
|
|
359
|
+
type: "session_start_ack",
|
|
360
|
+
sessionId: message.sessionId
|
|
361
|
+
});
|
|
362
|
+
break;
|
|
363
|
+
case "speech_event":
|
|
364
|
+
await handlers.onSpeechEvent?.({ sessionId: message.sessionId }, message.event);
|
|
365
|
+
if (message.event.type === "user_speech_final" && typeof message.event.text === "string" && message.event.text.trim()) {
|
|
366
|
+
await handlers.onUserSpeechFinal?.({
|
|
367
|
+
sessionId: message.sessionId,
|
|
368
|
+
text: message.event.text.trim()
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
case "data_channel_message":
|
|
373
|
+
await handlers.onDataChannelMessage?.({
|
|
374
|
+
sessionId: message.sessionId,
|
|
375
|
+
message: parseDataChannelPayload(message.payload),
|
|
376
|
+
raw: message.payload,
|
|
377
|
+
rawBinary: null,
|
|
378
|
+
channel: "control"
|
|
379
|
+
});
|
|
380
|
+
break;
|
|
381
|
+
case "data_channel_binary":
|
|
382
|
+
await handlers.onDataChannelBinary?.({
|
|
383
|
+
sessionId: message.sessionId,
|
|
384
|
+
message: null,
|
|
385
|
+
raw: null,
|
|
386
|
+
rawBinary: message.data,
|
|
387
|
+
channel: message.channel ?? "sync"
|
|
388
|
+
});
|
|
389
|
+
break;
|
|
390
|
+
case "session_end":
|
|
391
|
+
clearPendingRecordingAcksForSession(message.sessionId, "session_ended");
|
|
392
|
+
peerEnvBySessionId.delete(message.sessionId);
|
|
393
|
+
recordingAvailableBySessionId.delete(message.sessionId);
|
|
394
|
+
await (handlers.onClientLeave ?? handlers.onSessionEnd)?.({
|
|
395
|
+
sessionId: message.sessionId
|
|
396
|
+
});
|
|
397
|
+
break;
|
|
398
|
+
case "idle_timeout":
|
|
399
|
+
await runIdleTimeoutHook(handlers, message);
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function defineAgent(handlers) {
|
|
404
|
+
installChildUnhandledRejectionGuard();
|
|
405
|
+
const inboundBySession = new SessionSerialQueue();
|
|
406
|
+
inboundQueueAuthority = inboundBySession;
|
|
407
|
+
const agentStartReady = runAgentStartHook(handlers);
|
|
408
|
+
process.on("message", (message) => {
|
|
409
|
+
if (isRecordingControlAckMessage(message)) {
|
|
410
|
+
handleRecordingControlAck(message);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (isWebhookMessage(message)) {
|
|
414
|
+
void agentStartReady.then(() => handleWebhookMessage(message, handlers));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (!isSessionScopedParentMessage(message))
|
|
418
|
+
return;
|
|
419
|
+
if (message.type === "session_end") {
|
|
420
|
+
endedSessionIds.add(message.sessionId);
|
|
421
|
+
clearPendingRecordingAcksForSession(message.sessionId, "session_ended");
|
|
422
|
+
inboundBySession.clear(message.sessionId);
|
|
423
|
+
}
|
|
424
|
+
if (message.type === "session_start") {
|
|
425
|
+
endedSessionIds.delete(message.sessionId);
|
|
426
|
+
inboundBySession.clear(message.sessionId);
|
|
427
|
+
}
|
|
428
|
+
inboundBySession.enqueue(message.sessionId, async (_signal, context) => {
|
|
429
|
+
await agentStartReady;
|
|
430
|
+
try {
|
|
431
|
+
await sessionExecutionContext.run({
|
|
432
|
+
sessionId: message.sessionId,
|
|
433
|
+
generation: context.generation
|
|
434
|
+
}, async () => agentLogSessionContext.run(message.sessionId, async () => {
|
|
435
|
+
try {
|
|
436
|
+
await handleParentMessage(message, handlers);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
439
|
+
const env = peerEnvBySessionId.get(message.sessionId) ?? buildIdleEnv(message.sessionId);
|
|
440
|
+
await runErrorHook(handlers, {
|
|
441
|
+
sessionId: message.sessionId,
|
|
442
|
+
projectId: env.PROJECT_ID,
|
|
443
|
+
buildId: env.BUILD_ID,
|
|
444
|
+
env,
|
|
445
|
+
error: err,
|
|
446
|
+
customerContext: parseCustomerContext(env.AGENT_CUSTOMER_CONTEXT)
|
|
447
|
+
});
|
|
448
|
+
sendParentMessage({
|
|
449
|
+
type: "agent_error",
|
|
450
|
+
sessionId: message.sessionId,
|
|
451
|
+
message: err.message,
|
|
452
|
+
stack: err.stack
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}));
|
|
456
|
+
} finally {
|
|
457
|
+
if (message.type === "session_end" && inboundBySession.isCurrentGeneration(message.sessionId, context.generation)) {
|
|
458
|
+
inboundBySession.clear(message.sessionId);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
async function runAgentStartHook(handlers) {
|
|
465
|
+
if (!handlers.onAgentStart)
|
|
466
|
+
return;
|
|
467
|
+
try {
|
|
468
|
+
await handlers.onAgentStart({
|
|
469
|
+
env: process.env
|
|
470
|
+
});
|
|
471
|
+
} catch (error) {
|
|
472
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
473
|
+
agentLog("error", `onAgentStart failed: ${err.message}`);
|
|
474
|
+
sendParentMessage({
|
|
475
|
+
type: "agent_error",
|
|
476
|
+
sessionId: "",
|
|
477
|
+
message: err.message,
|
|
478
|
+
stack: err.stack
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function parseCustomerContext(raw) {
|
|
483
|
+
if (!raw?.trim())
|
|
484
|
+
return void 0;
|
|
485
|
+
try {
|
|
486
|
+
const parsed = JSON.parse(raw);
|
|
487
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
488
|
+
return parsed;
|
|
489
|
+
}
|
|
490
|
+
} catch {
|
|
491
|
+
}
|
|
492
|
+
return void 0;
|
|
493
|
+
}
|
|
494
|
+
async function runErrorHook(handlers, ctx) {
|
|
495
|
+
if (!handlers.errorHook)
|
|
496
|
+
return;
|
|
497
|
+
try {
|
|
498
|
+
await handlers.errorHook(ctx);
|
|
499
|
+
} catch (hookError) {
|
|
500
|
+
const message = hookError instanceof Error ? hookError.message : String(hookError);
|
|
501
|
+
agentLog("error", `errorHook failed: ${message}`, ctx.sessionId);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
async function runIdleTimeoutHook(handlers, message) {
|
|
505
|
+
const onIdleTimeout = handlers.onIdleTimeout;
|
|
506
|
+
agentLog("info", `idle_timeout ipc received (maxGraceMs=${message.maxGraceMs}, onIdleTimeout=${typeof onIdleTimeout === "function"})`, message.sessionId);
|
|
507
|
+
if (!onIdleTimeout) {
|
|
508
|
+
sendParentMessage({
|
|
509
|
+
type: "idle_timeout_done",
|
|
510
|
+
sessionId: message.sessionId
|
|
511
|
+
});
|
|
512
|
+
agentLog("info", "idle_timeout_done ipc sent (no onIdleTimeout handler)", message.sessionId);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const env = peerEnvBySessionId.get(message.sessionId) ?? buildIdleEnv(message.sessionId);
|
|
516
|
+
const idleTimeoutSeconds = Number(env.IDLE_TIMEOUT_SEC) || 0;
|
|
517
|
+
const ctx = {
|
|
518
|
+
sessionId: message.sessionId,
|
|
519
|
+
projectId: env.PROJECT_ID,
|
|
520
|
+
buildId: env.BUILD_ID,
|
|
521
|
+
env,
|
|
522
|
+
idleTimeoutSeconds
|
|
523
|
+
};
|
|
524
|
+
let error;
|
|
525
|
+
try {
|
|
526
|
+
await onIdleTimeout(ctx);
|
|
527
|
+
} catch (hookError) {
|
|
528
|
+
error = hookError instanceof Error ? hookError.message : String(hookError);
|
|
529
|
+
agentLog("error", `onIdleTimeout failed: ${error}`, message.sessionId);
|
|
530
|
+
}
|
|
531
|
+
sendParentMessage({
|
|
532
|
+
type: "idle_timeout_done",
|
|
533
|
+
sessionId: message.sessionId,
|
|
534
|
+
error
|
|
535
|
+
});
|
|
536
|
+
agentLog("info", error ? `idle_timeout_done ipc sent (onIdleTimeout error: ${error})` : "idle_timeout_done ipc sent (onIdleTimeout completed)", message.sessionId);
|
|
537
|
+
}
|
|
538
|
+
function buildIdleEnv(sessionId) {
|
|
539
|
+
return {
|
|
540
|
+
SESSION_ID: sessionId,
|
|
541
|
+
...process.env.PROJECT_ID ? { PROJECT_ID: process.env.PROJECT_ID } : {},
|
|
542
|
+
...process.env.BUILD_ID ? { BUILD_ID: process.env.BUILD_ID } : {},
|
|
543
|
+
...process.env.IDLE_TIMEOUT_SEC ? { IDLE_TIMEOUT_SEC: process.env.IDLE_TIMEOUT_SEC } : {}
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
function speak(sessionId, text) {
|
|
547
|
+
sendParentMessage({ type: "speak", sessionId, text });
|
|
548
|
+
}
|
|
549
|
+
async function sendRecordingControl(sessionId, action) {
|
|
550
|
+
const requestId = randomUUID();
|
|
551
|
+
if (!allowOutboundForSession(sessionId)) {
|
|
552
|
+
return { ok: false, reason: "session_ended", requestId };
|
|
553
|
+
}
|
|
554
|
+
if (recordingAvailableBySessionId.has(sessionId) && recordingAvailableBySessionId.get(sessionId) === false && (action === "start" || action === "resume")) {
|
|
555
|
+
agentLog("warn", "Project conversation recording is disabled; the agent cannot turn recording on", sessionId);
|
|
556
|
+
return { ok: false, reason: "disabled", requestId };
|
|
557
|
+
}
|
|
558
|
+
if (!isVoicethereAgentChild()) {
|
|
559
|
+
return { ok: true, reason: "local_mock", requestId };
|
|
560
|
+
}
|
|
561
|
+
return new Promise((resolve) => {
|
|
562
|
+
const timer = setTimeout(() => {
|
|
563
|
+
pendingRecordingAcks.delete(requestId);
|
|
564
|
+
resolve({ ok: false, reason: "timeout", requestId });
|
|
565
|
+
}, RECORDING_CONTROL_ACK_TIMEOUT_MS);
|
|
566
|
+
pendingRecordingAcks.set(requestId, { sessionId, resolve, timer });
|
|
567
|
+
sendParentMessage({
|
|
568
|
+
type: "recording_control",
|
|
569
|
+
sessionId,
|
|
570
|
+
action,
|
|
571
|
+
requestId
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
function pauseRecording(sessionId) {
|
|
576
|
+
return sendRecordingControl(sessionId, "pause");
|
|
577
|
+
}
|
|
578
|
+
function resumeRecording(sessionId) {
|
|
579
|
+
return sendRecordingControl(sessionId, "resume");
|
|
580
|
+
}
|
|
581
|
+
function stopRecording(sessionId) {
|
|
582
|
+
return sendRecordingControl(sessionId, "stop");
|
|
583
|
+
}
|
|
584
|
+
function sendToClient(sessionId, payload) {
|
|
585
|
+
sendParentMessage({ type: "send_to_client", sessionId, payload });
|
|
586
|
+
}
|
|
587
|
+
var AGENT_LOG_MESSAGE_MAX_CHARS = 2048;
|
|
588
|
+
var AGENT_LOG_FIELDS_MAX_CHARS = 8192;
|
|
589
|
+
function truncateAgentLogMessage(message) {
|
|
590
|
+
if (message.length <= AGENT_LOG_MESSAGE_MAX_CHARS) {
|
|
591
|
+
return message;
|
|
592
|
+
}
|
|
593
|
+
const suffix = "\u2026[truncated]";
|
|
594
|
+
return message.slice(0, AGENT_LOG_MESSAGE_MAX_CHARS - suffix.length) + suffix;
|
|
595
|
+
}
|
|
596
|
+
function isPlainObject(value) {
|
|
597
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
598
|
+
}
|
|
599
|
+
function sanitizeAgentLogFields(fields) {
|
|
600
|
+
if (Object.keys(fields).length === 0) {
|
|
601
|
+
return void 0;
|
|
602
|
+
}
|
|
603
|
+
try {
|
|
604
|
+
const serialized = JSON.stringify(fields);
|
|
605
|
+
if (serialized.length <= AGENT_LOG_FIELDS_MAX_CHARS) {
|
|
606
|
+
return fields;
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
_agentLogFieldsTruncated: true,
|
|
610
|
+
_originalBytes: serialized.length,
|
|
611
|
+
_preview: serialized.slice(0, AGENT_LOG_FIELDS_MAX_CHARS - 80) + "\u2026[truncated]"
|
|
612
|
+
};
|
|
613
|
+
} catch {
|
|
614
|
+
return { _agentLogFieldsError: "not_serializable" };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
function buildAgentLogPayload(level, message, fields, sessionId) {
|
|
618
|
+
const resolvedSessionId = sessionId ?? sessionExecutionContext.getStore()?.sessionId ?? agentLogSessionContext.getStore();
|
|
619
|
+
const sanitizedFields = fields ? sanitizeAgentLogFields(fields) : void 0;
|
|
620
|
+
return {
|
|
621
|
+
type: "log",
|
|
622
|
+
level,
|
|
623
|
+
message: truncateAgentLogMessage(message),
|
|
624
|
+
ts: Date.now(),
|
|
625
|
+
...resolvedSessionId ? { sessionId: resolvedSessionId } : {},
|
|
626
|
+
...sanitizedFields ? { fields: sanitizedFields } : {}
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
function agentLog(level, message, fieldsOrSessionId, sessionId) {
|
|
630
|
+
let fields;
|
|
631
|
+
let resolvedSessionId;
|
|
632
|
+
if (typeof fieldsOrSessionId === "string") {
|
|
633
|
+
resolvedSessionId = fieldsOrSessionId;
|
|
634
|
+
} else if (isPlainObject(fieldsOrSessionId)) {
|
|
635
|
+
fields = fieldsOrSessionId;
|
|
636
|
+
resolvedSessionId = sessionId;
|
|
637
|
+
} else {
|
|
638
|
+
resolvedSessionId = sessionId;
|
|
639
|
+
}
|
|
640
|
+
sendParentMessage(buildAgentLogPayload(level, message, fields, resolvedSessionId));
|
|
641
|
+
}
|
|
642
|
+
function parseChatText(message) {
|
|
643
|
+
if (!message || typeof message !== "object")
|
|
644
|
+
return null;
|
|
645
|
+
const record = message;
|
|
646
|
+
if (record.type !== "chat" || typeof record.text !== "string")
|
|
647
|
+
return null;
|
|
648
|
+
const trimmed = record.text.trim();
|
|
649
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// templates/recording-consent/conversation.ts
|
|
653
|
+
var CONSENT_PROMPT = "This call may be recorded for quality purposes. Is that OK?";
|
|
654
|
+
var NAME_PROMPT = "May I have your name please?";
|
|
655
|
+
var BIRTHDATE_PROMPT = "And your date of birth?";
|
|
656
|
+
var RECORDING_DISABLED_SKIP_MESSAGE = "Conversation recording is not enabled for this project.";
|
|
657
|
+
function createInitialState(recordingAvailable) {
|
|
658
|
+
if (recordingAvailable) {
|
|
659
|
+
return {
|
|
660
|
+
phase: "awaitingConsent",
|
|
661
|
+
recordingAvailable,
|
|
662
|
+
consentSkipped: false
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
return {
|
|
666
|
+
phase: "awaitingName",
|
|
667
|
+
recordingAvailable,
|
|
668
|
+
consentSkipped: true
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
function speakAndChat(text) {
|
|
672
|
+
return {
|
|
673
|
+
speakLines: [text],
|
|
674
|
+
messages: [{ type: "chat_reply", text }]
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
function beginSession(recordingAvailable) {
|
|
678
|
+
const state = createInitialState(recordingAvailable);
|
|
679
|
+
if (recordingAvailable) {
|
|
680
|
+
const prompt = speakAndChat(CONSENT_PROMPT);
|
|
681
|
+
return {
|
|
682
|
+
state,
|
|
683
|
+
speakLines: prompt.speakLines,
|
|
684
|
+
messages: prompt.messages,
|
|
685
|
+
recordingAction: null
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
const skip = speakAndChat(RECORDING_DISABLED_SKIP_MESSAGE);
|
|
689
|
+
const name = speakAndChat(NAME_PROMPT);
|
|
690
|
+
return {
|
|
691
|
+
state,
|
|
692
|
+
speakLines: [...skip.speakLines, ...name.speakLines],
|
|
693
|
+
messages: [...skip.messages, ...name.messages],
|
|
694
|
+
recordingAction: null,
|
|
695
|
+
warnRecordingDisabled: true
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
function isConsentNo(utterance) {
|
|
699
|
+
const lower = utterance.toLowerCase().trim();
|
|
700
|
+
if (/\bnot\s+ok(?:ay)?\b/i.test(lower)) return true;
|
|
701
|
+
const noPhrases = [
|
|
702
|
+
"no",
|
|
703
|
+
"nope",
|
|
704
|
+
"nah",
|
|
705
|
+
"don't",
|
|
706
|
+
"do not",
|
|
707
|
+
"decline",
|
|
708
|
+
"refuse"
|
|
709
|
+
];
|
|
710
|
+
if (noPhrases.some((p) => lower === p || lower.startsWith(`${p} `))) {
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
return /\b(no|nope|nah)\b/i.test(utterance) && !/\bknow\b/i.test(utterance);
|
|
714
|
+
}
|
|
715
|
+
function isConsentYes(utterance) {
|
|
716
|
+
if (isConsentNo(utterance)) return false;
|
|
717
|
+
const lower = utterance.toLowerCase().trim();
|
|
718
|
+
const yesPhrases = [
|
|
719
|
+
"yes",
|
|
720
|
+
"yeah",
|
|
721
|
+
"yep",
|
|
722
|
+
"sure",
|
|
723
|
+
"ok",
|
|
724
|
+
"okay",
|
|
725
|
+
"that's fine",
|
|
726
|
+
"that is fine",
|
|
727
|
+
"go ahead",
|
|
728
|
+
"fine",
|
|
729
|
+
"absolutely"
|
|
730
|
+
];
|
|
731
|
+
if (yesPhrases.some((p) => lower === p || lower.startsWith(`${p} `))) {
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
734
|
+
return /\b(yes|yeah|yep|sure|ok|okay)\b/i.test(utterance);
|
|
735
|
+
}
|
|
736
|
+
function extractName(utterance) {
|
|
737
|
+
const trimmed = utterance.trim();
|
|
738
|
+
const patterns = [/(?:my name is|i'm|i am|call me)\s+(.+)/i];
|
|
739
|
+
for (const pattern of patterns) {
|
|
740
|
+
const match = trimmed.match(pattern);
|
|
741
|
+
if (match?.[1]) {
|
|
742
|
+
return sanitizeToken(match[1], 40);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (trimmed.length > 0 && trimmed.length <= 60) {
|
|
746
|
+
return sanitizeToken(trimmed, 40);
|
|
747
|
+
}
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
function extractBirthdate(utterance) {
|
|
751
|
+
const trimmed = utterance.trim();
|
|
752
|
+
const iso = trimmed.match(/\b(\d{4}-\d{2}-\d{2})\b/);
|
|
753
|
+
if (iso?.[1]) return iso[1];
|
|
754
|
+
const slash = trimmed.match(/\b(\d{1,2}\/\d{1,2}\/\d{2,4})\b/);
|
|
755
|
+
if (slash?.[1]) return slash[1];
|
|
756
|
+
const spoken = trimmed.match(
|
|
757
|
+
/\b(january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4}\b/i
|
|
758
|
+
);
|
|
759
|
+
if (spoken?.[0]) return spoken[0];
|
|
760
|
+
if (trimmed.length >= 4 && trimmed.length <= 40) {
|
|
761
|
+
return sanitizeToken(trimmed, 40);
|
|
762
|
+
}
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
function sanitizeToken(raw, maxLen) {
|
|
766
|
+
let value = raw.trim().replace(/[.,!?;:]+$/g, "").trim();
|
|
767
|
+
if (value.length > maxLen) {
|
|
768
|
+
value = value.slice(0, maxLen).trim();
|
|
769
|
+
}
|
|
770
|
+
return value;
|
|
771
|
+
}
|
|
772
|
+
function askNameAgain(state) {
|
|
773
|
+
const prompt = speakAndChat(
|
|
774
|
+
"Sorry, I didn't catch your name. May I have your name please?"
|
|
775
|
+
);
|
|
776
|
+
return {
|
|
777
|
+
state,
|
|
778
|
+
speakLines: prompt.speakLines,
|
|
779
|
+
messages: prompt.messages,
|
|
780
|
+
recordingAction: null
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
function askBirthdateAgain(state) {
|
|
784
|
+
const prompt = speakAndChat(
|
|
785
|
+
"Sorry, I didn't catch your date of birth. Could you repeat it?"
|
|
786
|
+
);
|
|
787
|
+
return {
|
|
788
|
+
state,
|
|
789
|
+
speakLines: prompt.speakLines,
|
|
790
|
+
messages: prompt.messages,
|
|
791
|
+
recordingAction: null
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
function finishAfterPii(state) {
|
|
795
|
+
const next = { ...state, phase: "complete" };
|
|
796
|
+
const thankYou = speakAndChat(
|
|
797
|
+
`Thank you, ${state.name}. We have your date of birth on file.`
|
|
798
|
+
);
|
|
799
|
+
let recordingAction = null;
|
|
800
|
+
if (state.consent === true && state.recordingAvailable) {
|
|
801
|
+
recordingAction = "resume";
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
state: next,
|
|
805
|
+
speakLines: thankYou.speakLines,
|
|
806
|
+
messages: thankYou.messages,
|
|
807
|
+
recordingAction
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
function handleUtterance(state, utterance) {
|
|
811
|
+
switch (state.phase) {
|
|
812
|
+
case "awaitingConsent": {
|
|
813
|
+
if (isConsentNo(utterance)) {
|
|
814
|
+
const next = {
|
|
815
|
+
...state,
|
|
816
|
+
phase: "awaitingName",
|
|
817
|
+
consent: false
|
|
818
|
+
};
|
|
819
|
+
const name = speakAndChat(NAME_PROMPT);
|
|
820
|
+
return {
|
|
821
|
+
state: next,
|
|
822
|
+
speakLines: name.speakLines,
|
|
823
|
+
messages: name.messages,
|
|
824
|
+
recordingAction: "stop"
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
if (isConsentYes(utterance)) {
|
|
828
|
+
const next = {
|
|
829
|
+
...state,
|
|
830
|
+
phase: "awaitingName",
|
|
831
|
+
consent: true
|
|
832
|
+
};
|
|
833
|
+
const name = speakAndChat(NAME_PROMPT);
|
|
834
|
+
return {
|
|
835
|
+
state: next,
|
|
836
|
+
speakLines: name.speakLines,
|
|
837
|
+
messages: name.messages,
|
|
838
|
+
recordingAction: "pause"
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
const retry = speakAndChat(
|
|
842
|
+
"Please say yes or no \u2014 may we record this conversation?"
|
|
843
|
+
);
|
|
844
|
+
return {
|
|
845
|
+
state,
|
|
846
|
+
speakLines: retry.speakLines,
|
|
847
|
+
messages: retry.messages,
|
|
848
|
+
recordingAction: null
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
case "awaitingName": {
|
|
852
|
+
const name = extractName(utterance);
|
|
853
|
+
if (!name) {
|
|
854
|
+
return askNameAgain(state);
|
|
855
|
+
}
|
|
856
|
+
const next = {
|
|
857
|
+
...state,
|
|
858
|
+
phase: "awaitingBirthdate",
|
|
859
|
+
name
|
|
860
|
+
};
|
|
861
|
+
const birthdate = speakAndChat(BIRTHDATE_PROMPT);
|
|
862
|
+
return {
|
|
863
|
+
state: next,
|
|
864
|
+
speakLines: birthdate.speakLines,
|
|
865
|
+
messages: birthdate.messages,
|
|
866
|
+
recordingAction: null
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
case "awaitingBirthdate": {
|
|
870
|
+
const birthdate = extractBirthdate(utterance);
|
|
871
|
+
if (!birthdate) {
|
|
872
|
+
return askBirthdateAgain(state);
|
|
873
|
+
}
|
|
874
|
+
return finishAfterPii({ ...state, birthdate });
|
|
875
|
+
}
|
|
876
|
+
case "complete": {
|
|
877
|
+
const done = speakAndChat("We are all set. How can I help you today?");
|
|
878
|
+
return {
|
|
879
|
+
state,
|
|
880
|
+
speakLines: done.speakLines,
|
|
881
|
+
messages: done.messages,
|
|
882
|
+
recordingAction: null
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// templates/recording-consent/agent.ts
|
|
889
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
890
|
+
var consentBySessionId = /* @__PURE__ */ new Map();
|
|
891
|
+
function getState(sessionId) {
|
|
892
|
+
const state = sessions.get(sessionId);
|
|
893
|
+
if (!state) {
|
|
894
|
+
throw new Error(
|
|
895
|
+
`recording-consent: missing session state for ${sessionId}`
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
return state;
|
|
899
|
+
}
|
|
900
|
+
function relaySpeechEvent(sessionId, event) {
|
|
901
|
+
sendToClient(sessionId, {
|
|
902
|
+
type: "agent_event",
|
|
903
|
+
event: event.type,
|
|
904
|
+
text: event.text,
|
|
905
|
+
raw: event
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
function deliverMessages(sessionId, messages) {
|
|
909
|
+
for (const message of messages) {
|
|
910
|
+
sendToClient(sessionId, message);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
function speakLines(sessionId, lines) {
|
|
914
|
+
for (const line of lines) {
|
|
915
|
+
speak(sessionId, line);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
async function applyRecordingAction(sessionId, action) {
|
|
919
|
+
if (!action) return;
|
|
920
|
+
switch (action) {
|
|
921
|
+
case "pause":
|
|
922
|
+
await pauseRecording(sessionId);
|
|
923
|
+
break;
|
|
924
|
+
case "stop":
|
|
925
|
+
await stopRecording(sessionId);
|
|
926
|
+
break;
|
|
927
|
+
case "start":
|
|
928
|
+
case "resume":
|
|
929
|
+
await resumeRecording(sessionId);
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
async function applyTurn(sessionId, result) {
|
|
934
|
+
sessions.set(sessionId, result.state);
|
|
935
|
+
if (result.state.consent !== void 0) {
|
|
936
|
+
consentBySessionId.set(sessionId, result.state.consent);
|
|
937
|
+
}
|
|
938
|
+
if (result.warnRecordingDisabled) {
|
|
939
|
+
agentLog(
|
|
940
|
+
"warn",
|
|
941
|
+
"Project conversation recording is disabled; skipping consent and will not call startRecording",
|
|
942
|
+
sessionId
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
speakLines(sessionId, result.speakLines);
|
|
946
|
+
deliverMessages(sessionId, result.messages);
|
|
947
|
+
await applyRecordingAction(sessionId, result.recordingAction);
|
|
948
|
+
}
|
|
949
|
+
async function onUserText(sessionId, text) {
|
|
950
|
+
const state = getState(sessionId);
|
|
951
|
+
const result = handleUtterance(state, text);
|
|
952
|
+
await applyTurn(sessionId, result);
|
|
953
|
+
}
|
|
954
|
+
defineAgent({
|
|
955
|
+
onAgentStart() {
|
|
956
|
+
sessions = /* @__PURE__ */ new Map();
|
|
957
|
+
consentBySessionId = /* @__PURE__ */ new Map();
|
|
958
|
+
},
|
|
959
|
+
onSessionStart({ sessionId, recordingAvailable }) {
|
|
960
|
+
const result = beginSession(recordingAvailable);
|
|
961
|
+
sessions.set(sessionId, result.state);
|
|
962
|
+
if (result.warnRecordingDisabled) {
|
|
963
|
+
agentLog(
|
|
964
|
+
"warn",
|
|
965
|
+
"Project conversation recording is disabled; skipping consent and will not call startRecording",
|
|
966
|
+
sessionId
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
speakLines(sessionId, result.speakLines);
|
|
970
|
+
deliverMessages(sessionId, result.messages);
|
|
971
|
+
sendToClient(sessionId, {
|
|
972
|
+
type: "agent_event",
|
|
973
|
+
event: "session_start",
|
|
974
|
+
sessionId
|
|
975
|
+
});
|
|
976
|
+
agentLog(
|
|
977
|
+
"info",
|
|
978
|
+
`recording-consent session_start ${sessionId} recordingAvailable=${recordingAvailable}`,
|
|
979
|
+
sessionId
|
|
980
|
+
);
|
|
981
|
+
},
|
|
982
|
+
onSpeechEvent({ sessionId }, event) {
|
|
983
|
+
relaySpeechEvent(sessionId, event);
|
|
984
|
+
},
|
|
985
|
+
onUserSpeechFinal({ sessionId, text }) {
|
|
986
|
+
void onUserText(sessionId, text);
|
|
987
|
+
},
|
|
988
|
+
onDataChannelMessage(ctx) {
|
|
989
|
+
const text = parseChatText(ctx.message);
|
|
990
|
+
if (!text) return;
|
|
991
|
+
void onUserText(ctx.sessionId, text);
|
|
992
|
+
},
|
|
993
|
+
onSessionEnd({ sessionId }) {
|
|
994
|
+
sessions.delete(sessionId);
|
|
995
|
+
consentBySessionId.delete(sessionId);
|
|
996
|
+
sendToClient(sessionId, {
|
|
997
|
+
type: "agent_event",
|
|
998
|
+
event: "session_end",
|
|
999
|
+
sessionId
|
|
1000
|
+
});
|
|
1001
|
+
agentLog("info", `recording-consent session_end ${sessionId}`, sessionId);
|
|
1002
|
+
}
|
|
1003
|
+
});
|