@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.
@@ -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
+ }
@@ -19,8 +19,13 @@ import {
19
19
  handleUtterance,
20
20
  resolveWeatherTurn,
21
21
  type ConversationState,
22
- type OutboundMessage,
22
+ type ConversationTurnResult,
23
23
  } from "./conversation.js";
24
+ import {
25
+ applyOutboundOps,
26
+ greetingOps,
27
+ spokenThenPlayOps,
28
+ } from "./delivery.js";
24
29
 
25
30
  const sessions = new Map<string, ConversationState>();
26
31
 
@@ -42,25 +47,23 @@ function relaySpeechEvent(sessionId: string, event: SpeechEvent): void {
42
47
  });
43
48
  }
44
49
 
45
- function deliverMessages(sessionId: string, messages: OutboundMessage[]): void {
46
- for (const message of messages) {
47
- sendToClient(sessionId, message);
48
- }
49
- }
50
-
51
- function speakLines(sessionId: string, lines: string[]): void {
52
- for (const line of lines) {
53
- speak(sessionId, line);
54
- }
50
+ function deliverSpokenThenPlay(
51
+ sessionId: string,
52
+ result: Pick<ConversationTurnResult, "messages" | "speakLines">,
53
+ ): void {
54
+ applyOutboundOps(
55
+ sessionId,
56
+ spokenThenPlayOps(result.messages, result.speakLines),
57
+ { sendToClient, speak },
58
+ );
55
59
  }
56
60
 
57
61
  async function applyTurn(
58
62
  sessionId: string,
59
- result: Awaited<ReturnType<typeof handleUtterance>>,
63
+ result: ConversationTurnResult,
60
64
  ): Promise<void> {
61
65
  sessions.set(sessionId, result.state);
62
- speakLines(sessionId, result.speakLines);
63
- deliverMessages(sessionId, result.messages);
66
+ deliverSpokenThenPlay(sessionId, result);
64
67
 
65
68
  if (result.pendingWeather) {
66
69
  const weatherResult = await resolveWeatherTurn(
@@ -69,8 +72,7 @@ async function applyTurn(
69
72
  result.pendingWeather.country,
70
73
  );
71
74
  sessions.set(sessionId, weatherResult.state);
72
- speakLines(sessionId, weatherResult.speakLines);
73
- deliverMessages(sessionId, weatherResult.messages);
75
+ deliverSpokenThenPlay(sessionId, weatherResult);
74
76
  }
75
77
  }
76
78
 
@@ -83,13 +85,10 @@ async function onUserText(sessionId: string, text: string): Promise<void> {
83
85
  defineAgent({
84
86
  onSessionStart({ sessionId }) {
85
87
  sessions.set(sessionId, createInitialState());
86
- sendToClient(sessionId, {
87
- type: "agent_event",
88
- event: "session_start",
89
- sessionId,
88
+ applyOutboundOps(sessionId, greetingOps(sessionId, GREETING), {
89
+ sendToClient,
90
+ speak,
90
91
  });
91
- speak(sessionId, GREETING);
92
- sendToClient(sessionId, { type: "chat_reply", text: GREETING });
93
92
  agentLog("info", `voice-showcase session_start ${sessionId}`);
94
93
  },
95
94
 
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Voice-showcase outbound order: send spoken TTS text to the client first,
3
+ * then trigger TTS play. Parent IPC preserves this order.
4
+ */
5
+
6
+ import type { OutboundMessage } from "./conversation.js";
7
+
8
+ export type OutboundOp =
9
+ | { kind: "send"; message: OutboundMessage }
10
+ | { kind: "play"; text: string };
11
+
12
+ export interface OutboundDeps {
13
+ sendToClient: (sessionId: string, payload: unknown) => void;
14
+ speak: (sessionId: string, text: string) => void;
15
+ }
16
+
17
+ /** All DataChannel payloads, then TTS play commands. */
18
+ export function spokenThenPlayOps(
19
+ messages: OutboundMessage[],
20
+ speakLines: string[],
21
+ ): OutboundOp[] {
22
+ const ops: OutboundOp[] = [];
23
+ for (const message of messages) {
24
+ ops.push({ kind: "send", message });
25
+ }
26
+ for (const text of speakLines) {
27
+ ops.push({ kind: "play", text });
28
+ }
29
+ return ops;
30
+ }
31
+
32
+ export function greetingOps(
33
+ sessionId: string,
34
+ greeting: string,
35
+ ): OutboundOp[] {
36
+ return spokenThenPlayOps(
37
+ [
38
+ { type: "agent_event", event: "session_start", sessionId },
39
+ { type: "chat_reply", text: greeting },
40
+ ],
41
+ [greeting],
42
+ );
43
+ }
44
+
45
+ export function applyOutboundOps(
46
+ sessionId: string,
47
+ ops: OutboundOp[],
48
+ deps: OutboundDeps,
49
+ ): void {
50
+ for (const op of ops) {
51
+ if (op.kind === "send") {
52
+ deps.sendToClient(sessionId, op.message);
53
+ } else {
54
+ deps.speak(sessionId, op.text);
55
+ }
56
+ }
57
+ }