@voicethere/agent 0.5.5 → 0.5.6

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.
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/templates/registry.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,KAAK,CAAC;AAE7C,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,MAAM,CAAC;IACX,6EAA6E;IAC7E,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,YAAY,CAAC;IACnB,mFAAmF;IACnF,YAAY,EAAE,OAAO,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,yEAAyE;IACzE,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,eAAO,MAAM,eAAe,EAAE,SAAS,uBAAuB,EAgHpD,CAAC;AAEX,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC;AAMrE,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,EAAE,IAAI,eAAe,CAEnE;AAED,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,uBAAuB,CAMnE;AAED,wBAAgB,yBAAyB,IAAI,uBAAuB,EAAE,CAErE"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/templates/registry.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,KAAK,CAAC;AAE7C,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,MAAM,CAAC;IACX,6EAA6E;IAC7E,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,YAAY,CAAC;IACnB,mFAAmF;IACnF,YAAY,EAAE,OAAO,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,yEAAyE;IACzE,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,eAAO,MAAM,eAAe,EAAE,SAAS,uBAAuB,EA4HpD,CAAC;AAEX,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC;AAMrE,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,EAAE,IAAI,eAAe,CAEnE;AAED,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,uBAAuB,CAMnE;AAED,wBAAgB,yBAAyB,IAAI,uBAAuB,EAAE,CAErE"}
@@ -51,6 +51,17 @@ export const AGENT_TEMPLATES = [
51
51
  "voice-showcase/fun-facts.ts",
52
52
  ],
53
53
  },
54
+ {
55
+ id: "recording-consent",
56
+ entry: "recording-consent/agent.ts",
57
+ kind: "product",
58
+ seedOnCreate: true,
59
+ description: "Recording consent flow — ask consent, pause capture for PII, resume when allowed.",
60
+ sourceFiles: [
61
+ "recording-consent/agent.ts",
62
+ "recording-consent/conversation.ts",
63
+ ],
64
+ },
54
65
  {
55
66
  id: "webhooks",
56
67
  entry: "webhooks.ts",
@@ -1 +1 @@
1
- {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/templates/registry.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/templates/registry.ts"],"names":[],"mappings":"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"}
@@ -191,6 +191,7 @@ function parseDataChannelPayload(raw) {
191
191
  }
192
192
  }
193
193
  var peerEnvBySessionId = /* @__PURE__ */ new Map();
194
+ var recordingAvailableBySessionId = /* @__PURE__ */ new Map();
194
195
  var endedSessionIds = /* @__PURE__ */ new Set();
195
196
  var pendingRecordingAcks = /* @__PURE__ */ new Map();
196
197
  function handleRecordingControlAck(message) {
@@ -340,6 +341,7 @@ async function handleParentMessage(message, handlers) {
340
341
  case "session_start":
341
342
  endedSessionIds.delete(message.sessionId);
342
343
  peerEnvBySessionId.set(message.sessionId, message.env);
344
+ recordingAvailableBySessionId.set(message.sessionId, message.recordingAvailable ?? false);
343
345
  const sessionStartInitDelayMs = resolveSessionStartInitDelayMs();
344
346
  if (sessionStartInitDelayMs > 0) {
345
347
  await new Promise((resolve) => setTimeout(resolve, sessionStartInitDelayMs));
@@ -384,6 +386,7 @@ async function handleParentMessage(message, handlers) {
384
386
  case "session_end":
385
387
  clearPendingRecordingAcksForSession(message.sessionId, "session_ended");
386
388
  peerEnvBySessionId.delete(message.sessionId);
389
+ recordingAvailableBySessionId.delete(message.sessionId);
387
390
  await (handlers.onClientLeave ?? handlers.onSessionEnd)?.({
388
391
  sessionId: message.sessionId
389
392
  });
@@ -191,6 +191,7 @@ function parseDataChannelPayload(raw) {
191
191
  }
192
192
  }
193
193
  var peerEnvBySessionId = /* @__PURE__ */ new Map();
194
+ var recordingAvailableBySessionId = /* @__PURE__ */ new Map();
194
195
  var endedSessionIds = /* @__PURE__ */ new Set();
195
196
  var pendingRecordingAcks = /* @__PURE__ */ new Map();
196
197
  function handleRecordingControlAck(message) {
@@ -340,6 +341,7 @@ async function handleParentMessage(message, handlers) {
340
341
  case "session_start":
341
342
  endedSessionIds.delete(message.sessionId);
342
343
  peerEnvBySessionId.set(message.sessionId, message.env);
344
+ recordingAvailableBySessionId.set(message.sessionId, message.recordingAvailable ?? false);
343
345
  const sessionStartInitDelayMs = resolveSessionStartInitDelayMs();
344
346
  if (sessionStartInitDelayMs > 0) {
345
347
  await new Promise((resolve) => setTimeout(resolve, sessionStartInitDelayMs));
@@ -384,6 +386,7 @@ async function handleParentMessage(message, handlers) {
384
386
  case "session_end":
385
387
  clearPendingRecordingAcksForSession(message.sessionId, "session_ended");
386
388
  peerEnvBySessionId.delete(message.sessionId);
389
+ recordingAvailableBySessionId.delete(message.sessionId);
387
390
  await (handlers.onClientLeave ?? handlers.onSessionEnd)?.({
388
391
  sessionId: message.sessionId
389
392
  });
@@ -194,6 +194,7 @@ function parseDataChannelPayload(raw) {
194
194
  }
195
195
  }
196
196
  var peerEnvBySessionId = /* @__PURE__ */ new Map();
197
+ var recordingAvailableBySessionId = /* @__PURE__ */ new Map();
197
198
  var endedSessionIds = /* @__PURE__ */ new Set();
198
199
  var pendingRecordingAcks = /* @__PURE__ */ new Map();
199
200
  function handleRecordingControlAck(message) {
@@ -343,6 +344,7 @@ async function handleParentMessage(message, handlers) {
343
344
  case "session_start":
344
345
  endedSessionIds.delete(message.sessionId);
345
346
  peerEnvBySessionId.set(message.sessionId, message.env);
347
+ recordingAvailableBySessionId.set(message.sessionId, message.recordingAvailable ?? false);
346
348
  const sessionStartInitDelayMs = resolveSessionStartInitDelayMs();
347
349
  if (sessionStartInitDelayMs > 0) {
348
350
  await new Promise((resolve) => setTimeout(resolve, sessionStartInitDelayMs));
@@ -387,6 +389,7 @@ async function handleParentMessage(message, handlers) {
387
389
  case "session_end":
388
390
  clearPendingRecordingAcksForSession(message.sessionId, "session_ended");
389
391
  peerEnvBySessionId.delete(message.sessionId);
392
+ recordingAvailableBySessionId.delete(message.sessionId);
390
393
  await (handlers.onClientLeave ?? handlers.onSessionEnd)?.({
391
394
  sessionId: message.sessionId
392
395
  });
@@ -10946,6 +10946,7 @@ function parseDataChannelPayload(raw) {
10946
10946
  }
10947
10947
  }
10948
10948
  var peerEnvBySessionId = /* @__PURE__ */ new Map();
10949
+ var recordingAvailableBySessionId = /* @__PURE__ */ new Map();
10949
10950
  var endedSessionIds = /* @__PURE__ */ new Set();
10950
10951
  var pendingRecordingAcks = /* @__PURE__ */ new Map();
10951
10952
  function handleRecordingControlAck(message) {
@@ -11095,6 +11096,7 @@ async function handleParentMessage(message, handlers) {
11095
11096
  case "session_start":
11096
11097
  endedSessionIds.delete(message.sessionId);
11097
11098
  peerEnvBySessionId.set(message.sessionId, message.env);
11099
+ recordingAvailableBySessionId.set(message.sessionId, message.recordingAvailable ?? false);
11098
11100
  const sessionStartInitDelayMs = resolveSessionStartInitDelayMs();
11099
11101
  if (sessionStartInitDelayMs > 0) {
11100
11102
  await new Promise((resolve) => setTimeout(resolve, sessionStartInitDelayMs));
@@ -11139,6 +11141,7 @@ async function handleParentMessage(message, handlers) {
11139
11141
  case "session_end":
11140
11142
  clearPendingRecordingAcksForSession(message.sessionId, "session_ended");
11141
11143
  peerEnvBySessionId.delete(message.sessionId);
11144
+ recordingAvailableBySessionId.delete(message.sessionId);
11142
11145
  await (handlers.onClientLeave ?? handlers.onSessionEnd)?.({
11143
11146
  sessionId: message.sessionId
11144
11147
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voicethere/agent",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "VoiceThere customer agent SDK — IPC types and runtime helpers for sandboxed child bundles",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,10 +15,10 @@ import {
15
15
 
16
16
  ## Product vs e2e
17
17
 
18
- | Kind | Dashboard create | Prebuilt seed bundle | Typical consumer |
19
- | ----------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------- |
20
- | **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `game-sync`, `voice-showcase`, `webhooks`, `webhooks-redis`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
21
- | **e2e** | No | No — build from sources at test time | `voicethere/e2e` smokes |
18
+ | Kind | Dashboard create | Prebuilt seed bundle | Typical consumer |
19
+ | ----------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------- |
20
+ | **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `game-sync`, `voice-showcase`, `recording-consent`, `webhooks`, `webhooks-redis`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
21
+ | **e2e** | No | No — build from sources at test time | `voicethere/e2e` smokes |
22
22
 
23
23
  Product templates always set `seedOnCreate: true`. CI fails if a product template is missing its prebuilt bundle after `npm run build`.
24
24
 
@@ -67,6 +67,12 @@ Conversational voice demo for landing and dashboard previews — greets the user
67
67
 
68
68
  Sources: `voice-showcase/agent.ts` (defineAgent wiring), `conversation.ts` (pure state machine), `weather.ts`, `recipes.ts`, `fun-facts.ts`.
69
69
 
70
+ ### `recording-consent/` (`recording-consent`)
71
+
72
+ Demonstrates conversation recording consent on connect: asks whether recording is OK when the project has recording enabled, pauses capture while collecting name and date of birth, then resumes only if the customer consented. When project recording is off, logs a warning and never calls `startRecording`. Voice finals and typed chat share the same handler.
73
+
74
+ Sources: `recording-consent/agent.ts` (defineAgent wiring), `conversation.ts` (pure state machine).
75
+
70
76
  ### `webhooks.ts` (`webhooks`)
71
77
 
72
78
  Inbound HTTP webhook sample — verifies `x-agent-webhook-signature` HMAC on the **raw body** with `AGENT_WEBHOOK_SIGNING_SECRET`, then `JSON.parse` and fans out to connected sessions via DataChannel + `speak`.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Recording consent template — ask consent, pause for PII, resume when allowed.
3
+ *
4
+ * Build:
5
+ * npx @voicethere/agent build --entry templates/recording-consent/agent.ts
6
+ */
7
+ import {
8
+ agentLog,
9
+ defineAgent,
10
+ parseChatText,
11
+ pauseRecording,
12
+ resumeRecording,
13
+ sendToClient,
14
+ speak,
15
+ stopRecording,
16
+ type SpeechEvent,
17
+ } from "@voicethere/agent";
18
+
19
+ import {
20
+ beginSession,
21
+ handleUtterance,
22
+ type ConversationState,
23
+ type ConversationTurnResult,
24
+ type OutboundMessage,
25
+ type RecordingAction,
26
+ } from "./conversation.js";
27
+
28
+ let sessions = new Map<string, ConversationState>();
29
+ /** Per-session consent flag (shared-child safe — not one process-wide boolean). */
30
+ let consentBySessionId = new Map<string, boolean>();
31
+
32
+ function getState(sessionId: string): ConversationState {
33
+ const state = sessions.get(sessionId);
34
+ if (!state) {
35
+ throw new Error(
36
+ `recording-consent: missing session state for ${sessionId}`,
37
+ );
38
+ }
39
+ return state;
40
+ }
41
+
42
+ function relaySpeechEvent(sessionId: string, event: SpeechEvent): void {
43
+ sendToClient(sessionId, {
44
+ type: "agent_event",
45
+ event: event.type,
46
+ text: event.text,
47
+ raw: event,
48
+ });
49
+ }
50
+
51
+ function deliverMessages(sessionId: string, messages: OutboundMessage[]): void {
52
+ for (const message of messages) {
53
+ sendToClient(sessionId, message);
54
+ }
55
+ }
56
+
57
+ function speakLines(sessionId: string, lines: string[]): void {
58
+ for (const line of lines) {
59
+ speak(sessionId, line);
60
+ }
61
+ }
62
+
63
+ async function applyRecordingAction(
64
+ sessionId: string,
65
+ action: RecordingAction,
66
+ ): Promise<void> {
67
+ if (!action) return;
68
+ switch (action) {
69
+ case "pause":
70
+ await pauseRecording(sessionId);
71
+ break;
72
+ case "stop":
73
+ await stopRecording(sessionId);
74
+ break;
75
+ case "start":
76
+ case "resume":
77
+ await resumeRecording(sessionId);
78
+ break;
79
+ }
80
+ }
81
+
82
+ async function applyTurn(
83
+ sessionId: string,
84
+ result: ConversationTurnResult,
85
+ ): Promise<void> {
86
+ sessions.set(sessionId, result.state);
87
+ if (result.state.consent !== undefined) {
88
+ consentBySessionId.set(sessionId, result.state.consent);
89
+ }
90
+ if (result.warnRecordingDisabled) {
91
+ agentLog(
92
+ "warn",
93
+ "Project conversation recording is disabled; skipping consent and will not call startRecording",
94
+ sessionId,
95
+ );
96
+ }
97
+ speakLines(sessionId, result.speakLines);
98
+ deliverMessages(sessionId, result.messages);
99
+ await applyRecordingAction(sessionId, result.recordingAction);
100
+ }
101
+
102
+ async function onUserText(sessionId: string, text: string): Promise<void> {
103
+ const state = getState(sessionId);
104
+ const result = handleUtterance(state, text);
105
+ await applyTurn(sessionId, result);
106
+ }
107
+
108
+ defineAgent({
109
+ onAgentStart() {
110
+ sessions = new Map();
111
+ consentBySessionId = new Map();
112
+ },
113
+
114
+ onSessionStart({ sessionId, recordingAvailable }) {
115
+ const result = beginSession(recordingAvailable);
116
+ sessions.set(sessionId, result.state);
117
+ if (result.warnRecordingDisabled) {
118
+ agentLog(
119
+ "warn",
120
+ "Project conversation recording is disabled; skipping consent and will not call startRecording",
121
+ sessionId,
122
+ );
123
+ }
124
+ speakLines(sessionId, result.speakLines);
125
+ deliverMessages(sessionId, result.messages);
126
+ sendToClient(sessionId, {
127
+ type: "agent_event",
128
+ event: "session_start",
129
+ sessionId,
130
+ });
131
+ agentLog(
132
+ "info",
133
+ `recording-consent session_start ${sessionId} recordingAvailable=${recordingAvailable}`,
134
+ sessionId,
135
+ );
136
+ },
137
+
138
+ onSpeechEvent({ sessionId }, event: SpeechEvent) {
139
+ relaySpeechEvent(sessionId, event);
140
+ },
141
+
142
+ onUserSpeechFinal({ sessionId, text }) {
143
+ void onUserText(sessionId, text);
144
+ },
145
+
146
+ onDataChannelMessage(ctx) {
147
+ const text = parseChatText(ctx.message);
148
+ if (!text) return;
149
+ void onUserText(ctx.sessionId, text);
150
+ },
151
+
152
+ onSessionEnd({ sessionId }) {
153
+ sessions.delete(sessionId);
154
+ consentBySessionId.delete(sessionId);
155
+ sendToClient(sessionId, {
156
+ type: "agent_event",
157
+ event: "session_end",
158
+ sessionId,
159
+ });
160
+ agentLog("info", `recording-consent session_end ${sessionId}`, sessionId);
161
+ },
162
+ });
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Pure conversation state machine for the recording-consent template.
3
+ * Tests import this module directly — no defineAgent dependency.
4
+ */
5
+
6
+ export const CONSENT_PROMPT =
7
+ "This call may be recorded for quality purposes. Is that OK?";
8
+
9
+ export const NAME_PROMPT = "May I have your name please?";
10
+
11
+ export const BIRTHDATE_PROMPT = "And your date of birth?";
12
+
13
+ export const RECORDING_DISABLED_SKIP_MESSAGE =
14
+ "Conversation recording is not enabled for this project.";
15
+
16
+ export type ConversationPhase =
17
+ "awaitingConsent" | "awaitingName" | "awaitingBirthdate" | "complete";
18
+
19
+ export type RecordingAction = "pause" | "stop" | "start" | "resume" | null;
20
+
21
+ export interface ConversationState {
22
+ phase: ConversationPhase;
23
+ recordingAvailable: boolean;
24
+ consent?: boolean;
25
+ /** True when consent was skipped because project recording is off. */
26
+ consentSkipped: boolean;
27
+ name?: string;
28
+ birthdate?: string;
29
+ }
30
+
31
+ export interface OutboundMessage {
32
+ type: "chat_reply" | "agent_event";
33
+ text?: string;
34
+ event?: string;
35
+ sessionId?: string;
36
+ }
37
+
38
+ export interface ConversationTurnResult {
39
+ state: ConversationState;
40
+ speakLines: string[];
41
+ messages: OutboundMessage[];
42
+ recordingAction: RecordingAction;
43
+ /** When true, agent should warn that project recording is disabled. */
44
+ warnRecordingDisabled?: boolean;
45
+ }
46
+
47
+ export function createInitialState(
48
+ recordingAvailable: boolean,
49
+ ): ConversationState {
50
+ if (recordingAvailable) {
51
+ return {
52
+ phase: "awaitingConsent",
53
+ recordingAvailable,
54
+ consentSkipped: false,
55
+ };
56
+ }
57
+ return {
58
+ phase: "awaitingName",
59
+ recordingAvailable,
60
+ consentSkipped: true,
61
+ };
62
+ }
63
+
64
+ function speakAndChat(text: string): {
65
+ speakLines: string[];
66
+ messages: OutboundMessage[];
67
+ } {
68
+ return {
69
+ speakLines: [text],
70
+ messages: [{ type: "chat_reply", text }],
71
+ };
72
+ }
73
+
74
+ export function beginSession(
75
+ recordingAvailable: boolean,
76
+ ): ConversationTurnResult {
77
+ const state = createInitialState(recordingAvailable);
78
+ if (recordingAvailable) {
79
+ const prompt = speakAndChat(CONSENT_PROMPT);
80
+ return {
81
+ state,
82
+ speakLines: prompt.speakLines,
83
+ messages: prompt.messages,
84
+ recordingAction: null,
85
+ };
86
+ }
87
+ const skip = speakAndChat(RECORDING_DISABLED_SKIP_MESSAGE);
88
+ const name = speakAndChat(NAME_PROMPT);
89
+ return {
90
+ state,
91
+ speakLines: [...skip.speakLines, ...name.speakLines],
92
+ messages: [...skip.messages, ...name.messages],
93
+ recordingAction: null,
94
+ warnRecordingDisabled: true,
95
+ };
96
+ }
97
+
98
+ export function isConsentNo(utterance: string): boolean {
99
+ const lower = utterance.toLowerCase().trim();
100
+ if (/\bnot\s+ok(?:ay)?\b/i.test(lower)) return true;
101
+ const noPhrases = [
102
+ "no",
103
+ "nope",
104
+ "nah",
105
+ "don't",
106
+ "do not",
107
+ "decline",
108
+ "refuse",
109
+ ];
110
+ if (noPhrases.some((p) => lower === p || lower.startsWith(`${p} `))) {
111
+ return true;
112
+ }
113
+ return /\b(no|nope|nah)\b/i.test(utterance) && !/\bknow\b/i.test(utterance);
114
+ }
115
+
116
+ export function isConsentYes(utterance: string): boolean {
117
+ if (isConsentNo(utterance)) return false;
118
+ const lower = utterance.toLowerCase().trim();
119
+ const yesPhrases = [
120
+ "yes",
121
+ "yeah",
122
+ "yep",
123
+ "sure",
124
+ "ok",
125
+ "okay",
126
+ "that's fine",
127
+ "that is fine",
128
+ "go ahead",
129
+ "fine",
130
+ "absolutely",
131
+ ];
132
+ if (yesPhrases.some((p) => lower === p || lower.startsWith(`${p} `))) {
133
+ return true;
134
+ }
135
+ return /\b(yes|yeah|yep|sure|ok|okay)\b/i.test(utterance);
136
+ }
137
+
138
+ export function extractName(utterance: string): string | null {
139
+ const trimmed = utterance.trim();
140
+ const patterns = [/(?:my name is|i'm|i am|call me)\s+(.+)/i];
141
+ for (const pattern of patterns) {
142
+ const match = trimmed.match(pattern);
143
+ if (match?.[1]) {
144
+ return sanitizeToken(match[1], 40);
145
+ }
146
+ }
147
+ if (trimmed.length > 0 && trimmed.length <= 60) {
148
+ return sanitizeToken(trimmed, 40);
149
+ }
150
+ return null;
151
+ }
152
+
153
+ export function extractBirthdate(utterance: string): string | null {
154
+ const trimmed = utterance.trim();
155
+ const iso = trimmed.match(/\b(\d{4}-\d{2}-\d{2})\b/);
156
+ if (iso?.[1]) return iso[1];
157
+ const slash = trimmed.match(/\b(\d{1,2}\/\d{1,2}\/\d{2,4})\b/);
158
+ if (slash?.[1]) return slash[1];
159
+ const spoken = trimmed.match(
160
+ /\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,
161
+ );
162
+ if (spoken?.[0]) return spoken[0];
163
+ if (trimmed.length >= 4 && trimmed.length <= 40) {
164
+ return sanitizeToken(trimmed, 40);
165
+ }
166
+ return null;
167
+ }
168
+
169
+ function sanitizeToken(raw: string, maxLen: number): string {
170
+ let value = raw
171
+ .trim()
172
+ .replace(/[.,!?;:]+$/g, "")
173
+ .trim();
174
+ if (value.length > maxLen) {
175
+ value = value.slice(0, maxLen).trim();
176
+ }
177
+ return value;
178
+ }
179
+
180
+ function askNameAgain(state: ConversationState): ConversationTurnResult {
181
+ const prompt = speakAndChat(
182
+ "Sorry, I didn't catch your name. May I have your name please?",
183
+ );
184
+ return {
185
+ state,
186
+ speakLines: prompt.speakLines,
187
+ messages: prompt.messages,
188
+ recordingAction: null,
189
+ };
190
+ }
191
+
192
+ function askBirthdateAgain(state: ConversationState): ConversationTurnResult {
193
+ const prompt = speakAndChat(
194
+ "Sorry, I didn't catch your date of birth. Could you repeat it?",
195
+ );
196
+ return {
197
+ state,
198
+ speakLines: prompt.speakLines,
199
+ messages: prompt.messages,
200
+ recordingAction: null,
201
+ };
202
+ }
203
+
204
+ function finishAfterPii(state: ConversationState): ConversationTurnResult {
205
+ const next: ConversationState = { ...state, phase: "complete" };
206
+ const thankYou = speakAndChat(
207
+ `Thank you, ${state.name}. We have your date of birth on file.`,
208
+ );
209
+ let recordingAction: RecordingAction = null;
210
+ if (state.consent === true && state.recordingAvailable) {
211
+ recordingAction = "resume";
212
+ }
213
+ return {
214
+ state: next,
215
+ speakLines: thankYou.speakLines,
216
+ messages: thankYou.messages,
217
+ recordingAction,
218
+ };
219
+ }
220
+
221
+ export function handleUtterance(
222
+ state: ConversationState,
223
+ utterance: string,
224
+ ): ConversationTurnResult {
225
+ switch (state.phase) {
226
+ case "awaitingConsent": {
227
+ if (isConsentNo(utterance)) {
228
+ const next: ConversationState = {
229
+ ...state,
230
+ phase: "awaitingName",
231
+ consent: false,
232
+ };
233
+ const name = speakAndChat(NAME_PROMPT);
234
+ return {
235
+ state: next,
236
+ speakLines: name.speakLines,
237
+ messages: name.messages,
238
+ recordingAction: "stop",
239
+ };
240
+ }
241
+ if (isConsentYes(utterance)) {
242
+ const next: ConversationState = {
243
+ ...state,
244
+ phase: "awaitingName",
245
+ consent: true,
246
+ };
247
+ const name = speakAndChat(NAME_PROMPT);
248
+ return {
249
+ state: next,
250
+ speakLines: name.speakLines,
251
+ messages: name.messages,
252
+ recordingAction: "pause",
253
+ };
254
+ }
255
+ const retry = speakAndChat(
256
+ "Please say yes or no — may we record this conversation?",
257
+ );
258
+ return {
259
+ state,
260
+ speakLines: retry.speakLines,
261
+ messages: retry.messages,
262
+ recordingAction: null,
263
+ };
264
+ }
265
+ case "awaitingName": {
266
+ const name = extractName(utterance);
267
+ if (!name) {
268
+ return askNameAgain(state);
269
+ }
270
+ const next: ConversationState = {
271
+ ...state,
272
+ phase: "awaitingBirthdate",
273
+ name,
274
+ };
275
+ const birthdate = speakAndChat(BIRTHDATE_PROMPT);
276
+ return {
277
+ state: next,
278
+ speakLines: birthdate.speakLines,
279
+ messages: birthdate.messages,
280
+ recordingAction: null,
281
+ };
282
+ }
283
+ case "awaitingBirthdate": {
284
+ const birthdate = extractBirthdate(utterance);
285
+ if (!birthdate) {
286
+ return askBirthdateAgain(state);
287
+ }
288
+ return finishAfterPii({ ...state, birthdate });
289
+ }
290
+ case "complete": {
291
+ const done = speakAndChat("We are all set. How can I help you today?");
292
+ return {
293
+ state,
294
+ speakLines: done.speakLines,
295
+ messages: done.messages,
296
+ recordingAction: null,
297
+ };
298
+ }
299
+ }
300
+ }