@diegoaltoworks/talker 0.1.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.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +299 -0
  3. package/dist/adapters/twilio.d.ts +12 -0
  4. package/dist/adapters/twilio.d.ts.map +1 -0
  5. package/dist/core/chat.d.ts +14 -0
  6. package/dist/core/chat.d.ts.map +1 -0
  7. package/dist/core/chatbot/client.d.ts +12 -0
  8. package/dist/core/chatbot/client.d.ts.map +1 -0
  9. package/dist/core/chatbot/conversations.d.ts +14 -0
  10. package/dist/core/chatbot/conversations.d.ts.map +1 -0
  11. package/dist/core/chatbot/index.d.ts +9 -0
  12. package/dist/core/chatbot/index.d.ts.map +1 -0
  13. package/dist/core/chatbot/types.d.ts +25 -0
  14. package/dist/core/chatbot/types.d.ts.map +1 -0
  15. package/dist/core/context.d.ts +61 -0
  16. package/dist/core/context.d.ts.map +1 -0
  17. package/dist/core/context.test.d.ts +2 -0
  18. package/dist/core/context.test.d.ts.map +1 -0
  19. package/dist/core/errors.d.ts +8 -0
  20. package/dist/core/errors.d.ts.map +1 -0
  21. package/dist/core/errors.test.d.ts +2 -0
  22. package/dist/core/errors.test.d.ts.map +1 -0
  23. package/dist/core/logger.d.ts +11 -0
  24. package/dist/core/logger.d.ts.map +1 -0
  25. package/dist/core/phrases.d.ts +30 -0
  26. package/dist/core/phrases.d.ts.map +1 -0
  27. package/dist/core/phrases.test.d.ts +2 -0
  28. package/dist/core/phrases.test.d.ts.map +1 -0
  29. package/dist/core/processing/incoming.d.ts +12 -0
  30. package/dist/core/processing/incoming.d.ts.map +1 -0
  31. package/dist/core/processing/index.d.ts +10 -0
  32. package/dist/core/processing/index.d.ts.map +1 -0
  33. package/dist/core/processing/openai.d.ts +15 -0
  34. package/dist/core/processing/openai.d.ts.map +1 -0
  35. package/dist/core/processing/outgoing.d.ts +12 -0
  36. package/dist/core/processing/outgoing.d.ts.map +1 -0
  37. package/dist/core/processing/prompts.d.ts +14 -0
  38. package/dist/core/processing/prompts.d.ts.map +1 -0
  39. package/dist/core/twiml.d.ts +31 -0
  40. package/dist/core/twiml.d.ts.map +1 -0
  41. package/dist/core/twiml.test.d.ts +2 -0
  42. package/dist/core/twiml.test.d.ts.map +1 -0
  43. package/dist/core/voice.d.ts +16 -0
  44. package/dist/core/voice.d.ts.map +1 -0
  45. package/dist/core/voice.test.d.ts +2 -0
  46. package/dist/core/voice.test.d.ts.map +1 -0
  47. package/dist/core/xml.d.ts +8 -0
  48. package/dist/core/xml.d.ts.map +1 -0
  49. package/dist/core/xml.test.d.ts +2 -0
  50. package/dist/core/xml.test.d.ts.map +1 -0
  51. package/dist/db/client.d.ts +25 -0
  52. package/dist/db/client.d.ts.map +1 -0
  53. package/dist/db/index.d.ts +12 -0
  54. package/dist/db/index.d.ts.map +1 -0
  55. package/dist/db/migrate.d.ts +8 -0
  56. package/dist/db/migrate.d.ts.map +1 -0
  57. package/dist/db/persist.d.ts +18 -0
  58. package/dist/db/persist.d.ts.map +1 -0
  59. package/dist/db/sessions.d.ts +57 -0
  60. package/dist/db/sessions.d.ts.map +1 -0
  61. package/dist/flows/index.d.ts +12 -0
  62. package/dist/flows/index.d.ts.map +1 -0
  63. package/dist/flows/intent.d.ts +11 -0
  64. package/dist/flows/intent.d.ts.map +1 -0
  65. package/dist/flows/loader.d.ts +12 -0
  66. package/dist/flows/loader.d.ts.map +1 -0
  67. package/dist/flows/manager.d.ts +14 -0
  68. package/dist/flows/manager.d.ts.map +1 -0
  69. package/dist/flows/params.d.ts +12 -0
  70. package/dist/flows/params.d.ts.map +1 -0
  71. package/dist/flows/registry.d.ts +35 -0
  72. package/dist/flows/registry.d.ts.map +1 -0
  73. package/dist/flows/utils.d.ts +12 -0
  74. package/dist/flows/utils.d.ts.map +1 -0
  75. package/dist/flows/utils.test.d.ts +2 -0
  76. package/dist/flows/utils.test.d.ts.map +1 -0
  77. package/dist/index.d.ts +62 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +1840 -0
  80. package/dist/index.mjs +1771 -0
  81. package/dist/plugin.d.ts +31 -0
  82. package/dist/plugin.d.ts.map +1 -0
  83. package/dist/routes/call/handle-answer.d.ts +10 -0
  84. package/dist/routes/call/handle-answer.d.ts.map +1 -0
  85. package/dist/routes/call/handle-initial.d.ts +10 -0
  86. package/dist/routes/call/handle-initial.d.ts.map +1 -0
  87. package/dist/routes/call/handle-nospeech.d.ts +10 -0
  88. package/dist/routes/call/handle-nospeech.d.ts.map +1 -0
  89. package/dist/routes/call/handle-respond.d.ts +11 -0
  90. package/dist/routes/call/handle-respond.d.ts.map +1 -0
  91. package/dist/routes/call/handle-status.d.ts +9 -0
  92. package/dist/routes/call/handle-status.d.ts.map +1 -0
  93. package/dist/routes/call/index.d.ts +14 -0
  94. package/dist/routes/call/index.d.ts.map +1 -0
  95. package/dist/routes/call/pending.d.ts +20 -0
  96. package/dist/routes/call/pending.d.ts.map +1 -0
  97. package/dist/routes/call/processor.d.ts +13 -0
  98. package/dist/routes/call/processor.d.ts.map +1 -0
  99. package/dist/routes/sms/handle-incoming.d.ts +10 -0
  100. package/dist/routes/sms/handle-incoming.d.ts.map +1 -0
  101. package/dist/routes/sms/index.d.ts +13 -0
  102. package/dist/routes/sms/index.d.ts.map +1 -0
  103. package/dist/routes/sms/processor.d.ts +12 -0
  104. package/dist/routes/sms/processor.d.ts.map +1 -0
  105. package/dist/standalone.d.ts +44 -0
  106. package/dist/standalone.d.ts.map +1 -0
  107. package/dist/types.d.ts +240 -0
  108. package/dist/types.d.ts.map +1 -0
  109. package/language/de.json +27 -0
  110. package/language/en.json +27 -0
  111. package/language/es.json +27 -0
  112. package/language/fr.json +27 -0
  113. package/language/nl.json +27 -0
  114. package/language/pt.json +27 -0
  115. package/package.json +103 -0
  116. package/prompts/incoming.md +88 -0
  117. package/prompts/outgoing.md +58 -0
package/dist/index.js ADDED
@@ -0,0 +1,1840 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __esm = (fn, res) => function __init() {
9
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
+ };
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
15
+ var __copyProps = (to, from, except, desc) => {
16
+ if (from && typeof from === "object" || typeof from === "function") {
17
+ for (let key of __getOwnPropNames(from))
18
+ if (!__hasOwnProp.call(to, key) && key !== except)
19
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
20
+ }
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
+ // If the importer is in node compatibility mode or this is not an ESM
25
+ // file that has been converted to a CommonJS file using a Babel-
26
+ // compatible transform (i.e. "__esModule" has not been set), then set
27
+ // "default" to the CommonJS "module.exports" for node compatibility.
28
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
+ mod
30
+ ));
31
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
+
33
+ // src/routes/call/pending.ts
34
+ var pending_exports = {};
35
+ __export(pending_exports, {
36
+ deletePending: () => deletePending,
37
+ getPending: () => getPending,
38
+ setPending: () => setPending
39
+ });
40
+ function setPending(phoneNumber, query) {
41
+ pendingQueries.set(phoneNumber, query);
42
+ }
43
+ function getPending(phoneNumber) {
44
+ return pendingQueries.get(phoneNumber);
45
+ }
46
+ function deletePending(phoneNumber) {
47
+ pendingQueries.delete(phoneNumber);
48
+ }
49
+ var pendingQueries;
50
+ var init_pending = __esm({
51
+ "src/routes/call/pending.ts"() {
52
+ "use strict";
53
+ pendingQueries = /* @__PURE__ */ new Map();
54
+ }
55
+ });
56
+
57
+ // src/index.ts
58
+ var index_exports = {};
59
+ __export(index_exports, {
60
+ FlowRegistry: () => FlowRegistry,
61
+ acknowledgmentTwiml: () => acknowledgmentTwiml,
62
+ addMessage: () => addMessage,
63
+ callRoutes: () => callRoutes,
64
+ clearActiveFlow: () => clearActiveFlow,
65
+ clearAllContexts: () => clearAllContexts,
66
+ clearContext: () => clearContext,
67
+ closeDbClient: () => closeDbClient,
68
+ createStandaloneServer: () => createStandaloneServer,
69
+ createTelephonyRoutes: () => createTelephonyRoutes,
70
+ escapeXml: () => escapeXml,
71
+ farewellTwiml: () => farewellTwiml,
72
+ gatherTwiml: () => gatherTwiml,
73
+ getActiveFlow: () => getActiveFlow,
74
+ getContext: () => getContext,
75
+ getDbClient: () => getDbClient,
76
+ getDefaultVoices: () => getDefaultVoices,
77
+ getDetectedLanguage: () => getDetectedLanguage,
78
+ getExitMessage: () => getExitMessage,
79
+ getFarewellPhrase: () => getFarewellPhrase,
80
+ getFlowPhrase: () => getFlowPhrase,
81
+ getLastPrompt: () => getLastPrompt,
82
+ getMessageHistory: () => getMessageHistory,
83
+ getOrCreateContext: () => getOrCreateContext,
84
+ getPhrase: () => getPhrase,
85
+ getSmsPhrase: () => getSmsPhrase,
86
+ getVoiceConfig: () => getVoiceConfig,
87
+ incrementNoSpeechRetries: () => incrementNoSpeechRetries,
88
+ initDbClient: () => initDbClient,
89
+ loadFlowsFromDirectory: () => loadFlowsFromDirectory,
90
+ loadPhrases: () => loadPhrases,
91
+ logger: () => logger,
92
+ messageTwiml: () => messageTwiml,
93
+ persistFinalSession: () => persistFinalSession,
94
+ persistSession: () => persistSession,
95
+ processFlow: () => processFlow,
96
+ processIncoming: () => processIncoming,
97
+ processOutgoing: () => processOutgoing,
98
+ resetNoSpeechRetries: () => resetNoSpeechRetries,
99
+ runMigrations: () => runMigrations,
100
+ sayTwiml: () => sayTwiml,
101
+ sendSMS: () => sendSMS,
102
+ setActiveFlow: () => setActiveFlow,
103
+ setDetectedLanguage: () => setDetectedLanguage,
104
+ setLastPrompt: () => setLastPrompt,
105
+ shouldExitFlow: () => shouldExitFlow,
106
+ smsRoutes: () => smsRoutes,
107
+ startCleanup: () => startCleanup,
108
+ stopCleanup: () => stopCleanup,
109
+ transferTwiml: () => transferTwiml,
110
+ updateFlowParams: () => updateFlowParams
111
+ });
112
+ module.exports = __toCommonJS(index_exports);
113
+
114
+ // src/core/logger.ts
115
+ var isTestEnv = process.env.NODE_ENV === "test" || typeof Bun !== "undefined" && process.argv.some((arg) => arg.includes("test"));
116
+ var isDebug = process.env.DEBUG === "true";
117
+ var isSilent = isTestEnv && !isDebug;
118
+ var timestamp = () => (/* @__PURE__ */ new Date()).toISOString();
119
+ var log = (level, message, data) => {
120
+ if (isSilent) return;
121
+ const entry = {
122
+ timestamp: timestamp(),
123
+ level,
124
+ message,
125
+ ...data
126
+ };
127
+ console.log(JSON.stringify(entry));
128
+ };
129
+ var logger = {
130
+ info: (message, data) => log("info", message, data),
131
+ warn: (message, data) => log("warn", message, data),
132
+ error: (message, data) => log("error", message, data)
133
+ };
134
+
135
+ // src/core/context.ts
136
+ var contexts = /* @__PURE__ */ new Map();
137
+ var cleanupTimer = null;
138
+ function startCleanup(ttlMs, intervalMs) {
139
+ if (cleanupTimer) return;
140
+ cleanupTimer = setInterval(() => {
141
+ const now = Date.now();
142
+ for (const [phoneNumber, context] of contexts) {
143
+ if (now - context.lastActivity > ttlMs) {
144
+ contexts.delete(phoneNumber);
145
+ logger.info(`context expired for ${phoneNumber}`);
146
+ }
147
+ }
148
+ }, intervalMs);
149
+ }
150
+ function stopCleanup() {
151
+ if (cleanupTimer) {
152
+ clearInterval(cleanupTimer);
153
+ cleanupTimer = null;
154
+ }
155
+ }
156
+ function getOrCreateContext(phoneNumber, channel = "call") {
157
+ let context = contexts.get(phoneNumber);
158
+ if (!context) {
159
+ context = {
160
+ phoneNumber,
161
+ channel,
162
+ detectedLanguage: null,
163
+ messageHistory: [],
164
+ activeFlow: null,
165
+ noSpeechRetries: 0,
166
+ lastPrompt: null,
167
+ createdAt: Date.now(),
168
+ lastActivity: Date.now()
169
+ };
170
+ contexts.set(phoneNumber, context);
171
+ logger.info(`context created for ${phoneNumber} (${channel})`);
172
+ }
173
+ context.lastActivity = Date.now();
174
+ return context;
175
+ }
176
+ function getContext(phoneNumber) {
177
+ return contexts.get(phoneNumber);
178
+ }
179
+ function setDetectedLanguage(phoneNumber, language) {
180
+ const context = getOrCreateContext(phoneNumber);
181
+ if (!context.detectedLanguage) {
182
+ context.detectedLanguage = language;
183
+ logger.info(`detected language for ${phoneNumber}: ${language}`);
184
+ }
185
+ }
186
+ function getDetectedLanguage(phoneNumber) {
187
+ return contexts.get(phoneNumber)?.detectedLanguage || null;
188
+ }
189
+ function addMessage(phoneNumber, role, content, channel = "call") {
190
+ const context = getOrCreateContext(phoneNumber, channel);
191
+ context.messageHistory.push({ role, content, timestamp: Date.now() });
192
+ if (context.messageHistory.length > 10) {
193
+ context.messageHistory = context.messageHistory.slice(-10);
194
+ }
195
+ }
196
+ function getMessageHistory(phoneNumber) {
197
+ return contexts.get(phoneNumber)?.messageHistory || [];
198
+ }
199
+ function clearContext(phoneNumber) {
200
+ contexts.delete(phoneNumber);
201
+ logger.info("context cleared", { phoneNumber });
202
+ }
203
+ function setActiveFlow(phoneNumber, flowName, params = {}) {
204
+ const context = contexts.get(phoneNumber);
205
+ if (!context) return;
206
+ context.activeFlow = {
207
+ flowName,
208
+ params,
209
+ attempts: 0,
210
+ startedAt: Date.now()
211
+ };
212
+ logger.info(`flow activated for ${phoneNumber}: ${flowName}`);
213
+ }
214
+ function getActiveFlow(phoneNumber) {
215
+ return contexts.get(phoneNumber)?.activeFlow || null;
216
+ }
217
+ function updateFlowParams(phoneNumber, params) {
218
+ const context = contexts.get(phoneNumber);
219
+ if (!context || !context.activeFlow) return;
220
+ context.activeFlow.params = { ...context.activeFlow.params, ...params };
221
+ context.activeFlow.attempts += 1;
222
+ }
223
+ function clearActiveFlow(phoneNumber) {
224
+ const context = contexts.get(phoneNumber);
225
+ if (!context) return;
226
+ logger.info(`flow cleared for ${phoneNumber}: ${context.activeFlow?.flowName}`);
227
+ context.activeFlow = null;
228
+ }
229
+ function incrementNoSpeechRetries(phoneNumber) {
230
+ const context = contexts.get(phoneNumber);
231
+ if (!context) return 0;
232
+ context.noSpeechRetries += 1;
233
+ return context.noSpeechRetries;
234
+ }
235
+ function resetNoSpeechRetries(phoneNumber) {
236
+ const context = contexts.get(phoneNumber);
237
+ if (context && context.noSpeechRetries > 0) {
238
+ context.noSpeechRetries = 0;
239
+ }
240
+ }
241
+ function setLastPrompt(phoneNumber, prompt) {
242
+ const context = getOrCreateContext(phoneNumber);
243
+ context.lastPrompt = prompt;
244
+ }
245
+ function getLastPrompt(phoneNumber) {
246
+ return contexts.get(phoneNumber)?.lastPrompt || null;
247
+ }
248
+ function clearAllContexts() {
249
+ contexts.clear();
250
+ }
251
+
252
+ // src/db/client.ts
253
+ var import_client = require("@libsql/client");
254
+ var client = null;
255
+ function initDbClient(url, authToken) {
256
+ if (client) return;
257
+ try {
258
+ client = (0, import_client.createClient)({ url, authToken });
259
+ logger.info("database client initialized", {
260
+ url: url.replace(/:[^:]*@/, ":***@")
261
+ });
262
+ } catch (error) {
263
+ logger.error("failed to initialize database client", {
264
+ error: error instanceof Error ? error.message : "Unknown"
265
+ });
266
+ }
267
+ }
268
+ function getDbClient() {
269
+ return client;
270
+ }
271
+ async function closeDbClient() {
272
+ if (client) {
273
+ try {
274
+ client.close();
275
+ client = null;
276
+ logger.info("database client closed");
277
+ } catch (error) {
278
+ logger.error("error closing database client", {
279
+ error: error instanceof Error ? error.message : "Unknown"
280
+ });
281
+ }
282
+ }
283
+ }
284
+
285
+ // src/db/migrate.ts
286
+ var import_node_fs = require("node:fs");
287
+ var import_node_path = require("node:path");
288
+ async function runMigrations() {
289
+ const client2 = getDbClient();
290
+ if (!client2) {
291
+ logger.warn("cannot run migrations - database not configured");
292
+ return;
293
+ }
294
+ try {
295
+ const schemaPath = (0, import_node_path.join)(__dirname, "schema.sql");
296
+ const schema = (0, import_node_fs.readFileSync)(schemaPath, "utf-8");
297
+ const statements = schema.split("\n").filter((line) => !line.trim().startsWith("--")).join("\n").split(";").map((s) => s.trim()).filter((s) => s.length > 0);
298
+ logger.info("running database migrations", { statementCount: statements.length });
299
+ for (const statement of statements) {
300
+ await client2.execute(statement);
301
+ }
302
+ logger.info("database migrations completed");
303
+ } catch (error) {
304
+ logger.error("database migration failed", {
305
+ error: error instanceof Error ? error.message : "Unknown"
306
+ });
307
+ throw error;
308
+ }
309
+ }
310
+
311
+ // src/flows/registry.ts
312
+ var import_node_fs3 = require("node:fs");
313
+
314
+ // src/flows/intent.ts
315
+ var OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
316
+ async function detectIntent(deps, phoneNumber, userMessage, flows, conversationContext) {
317
+ const contextStr = conversationContext && conversationContext.length > 0 ? `
318
+
319
+ Recent conversation:
320
+ ${conversationContext.join("\n")}` : "";
321
+ const flowDescriptions = Array.from(flows.entries()).map(([id, flow], index) => {
322
+ const keywords = flow.definition.triggerKeywords.join(", ");
323
+ return `${index + 1}. ${id} -- ${flow.definition.description}
324
+ - Keywords: ${keywords}`;
325
+ }).join("\n\n");
326
+ const systemPrompt = `You are an intent detector for a voice assistant.
327
+
328
+ Detect ONE category. Use these EXACT ids (they must match flow ids):
329
+
330
+ ${flowDescriptions}
331
+
332
+ ${flows.size + 1}. chatbot -- All other questions (general fallback)
333
+ ${contextStr}
334
+
335
+ Return JSON with:
336
+ - "intent": one of the ids above (exact string)
337
+ - "confidence": 0.0-1.0 (how certain you are)
338
+ - "reasoning": brief explanation
339
+
340
+ Rules:
341
+ - If uncertain, always return "chatbot" with low confidence`;
342
+ const requestBody = {
343
+ model: deps.openaiModel,
344
+ messages: [
345
+ { role: "system", content: systemPrompt },
346
+ { role: "user", content: userMessage }
347
+ ],
348
+ temperature: 0.1,
349
+ response_format: { type: "json_object" }
350
+ };
351
+ logger.info("detecting intent", {
352
+ phoneNumber,
353
+ msg: userMessage.substring(0, 160),
354
+ hasContext: !!conversationContext && conversationContext.length > 0
355
+ });
356
+ try {
357
+ const response = await fetch(OPENAI_API_URL, {
358
+ method: "POST",
359
+ headers: {
360
+ Authorization: `Bearer ${deps.openaiApiKey}`,
361
+ "Content-Type": "application/json"
362
+ },
363
+ body: JSON.stringify(requestBody)
364
+ });
365
+ if (!response.ok) {
366
+ const error = await response.text();
367
+ logger.error("intent detection error", { phoneNumber, status: response.status, error });
368
+ return { intent: "chatbot", confidence: 0, reasoning: "API error, defaulting to chatbot" };
369
+ }
370
+ const data = await response.json();
371
+ const content = data.choices[0]?.message?.content || "{}";
372
+ const raw = JSON.parse(content);
373
+ const validFlowIds = new Set(flows.keys());
374
+ const normalizedIntent = validFlowIds.has(raw.intent) ? raw.intent : "chatbot";
375
+ const result = {
376
+ intent: normalizedIntent,
377
+ confidence: raw.confidence,
378
+ reasoning: raw.reasoning
379
+ };
380
+ logger.info("intent detected", {
381
+ phoneNumber,
382
+ intent: result.intent,
383
+ confidence: result.confidence,
384
+ reasoning: result.reasoning
385
+ });
386
+ return result;
387
+ } catch (error) {
388
+ logger.error("intent detection exception", {
389
+ phoneNumber,
390
+ error: error instanceof Error ? error.message : String(error)
391
+ });
392
+ return {
393
+ intent: "chatbot",
394
+ confidence: 0,
395
+ reasoning: "Exception occurred, defaulting to chatbot"
396
+ };
397
+ }
398
+ }
399
+
400
+ // src/flows/loader.ts
401
+ var import_node_fs2 = require("node:fs");
402
+ var import_node_path2 = require("node:path");
403
+ async function loadFlowsFromDirectory(flowsDir) {
404
+ const flows = /* @__PURE__ */ new Map();
405
+ if (!(0, import_node_fs2.existsSync)(flowsDir)) {
406
+ logger.warn("flows directory does not exist", { flowsDir });
407
+ return flows;
408
+ }
409
+ const flowDirs = (0, import_node_fs2.readdirSync)(flowsDir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory()).filter((dirent) => ["lib", "tests", "registry"].indexOf(dirent.name) === -1).map((dirent) => dirent.name);
410
+ logger.info("loading flows", { count: flowDirs.length, flowDirs });
411
+ for (const flowName of flowDirs) {
412
+ try {
413
+ const flow = await loadFlow(flowsDir, flowName);
414
+ flows.set(flowName, flow);
415
+ logger.info("flow loaded", { flowName });
416
+ } catch (error) {
417
+ logger.error("failed to load flow", {
418
+ flowName,
419
+ error: error instanceof Error ? error.message : String(error)
420
+ });
421
+ }
422
+ }
423
+ logger.info("flows loaded", { count: flows.size, flows: Array.from(flows.keys()) });
424
+ return flows;
425
+ }
426
+ async function loadFlow(flowsDir, flowName) {
427
+ const flowPath = (0, import_node_path2.join)(flowsDir, flowName);
428
+ const definitionPath = (0, import_node_path2.join)(flowPath, "flow.json");
429
+ const instructionsPath = (0, import_node_path2.join)(flowPath, "instructions.md");
430
+ const handlerPath = (0, import_node_path2.join)(flowPath, "handler.ts");
431
+ if (!(0, import_node_fs2.existsSync)(definitionPath)) {
432
+ throw new Error(`flow.json not found for ${flowName}`);
433
+ }
434
+ if (!(0, import_node_fs2.existsSync)(instructionsPath)) {
435
+ throw new Error(`instructions.md not found for ${flowName}`);
436
+ }
437
+ if (!(0, import_node_fs2.existsSync)(handlerPath)) {
438
+ throw new Error(`handler.ts not found for ${flowName}`);
439
+ }
440
+ const definitionContent = (0, import_node_fs2.readFileSync)(definitionPath, "utf-8");
441
+ const definition = JSON.parse(definitionContent);
442
+ if (definition.id !== flowName) {
443
+ throw new Error(`Flow id mismatch: ${definition.id} !== ${flowName}`);
444
+ }
445
+ if (!definition.triggerKeywords || definition.triggerKeywords.length === 0) {
446
+ throw new Error(`Flow ${flowName} has no trigger keywords`);
447
+ }
448
+ if (!definition.schema || Object.keys(definition.schema).length === 0) {
449
+ throw new Error(`Flow ${flowName} has no schema`);
450
+ }
451
+ const handlerModule = await import(handlerPath);
452
+ const handler = handlerModule.execute;
453
+ if (typeof handler !== "function") {
454
+ throw new Error(`Flow ${flowName} handler.ts must export an 'execute' function`);
455
+ }
456
+ const prefillPath = (0, import_node_path2.join)(flowPath, "prefill.ts");
457
+ let prefill;
458
+ if ((0, import_node_fs2.existsSync)(prefillPath)) {
459
+ const prefillModule = await import(prefillPath);
460
+ prefill = prefillModule.prefillFromContext;
461
+ if (typeof prefill !== "function") {
462
+ logger.warn("flow prefill.ts does not export prefillFromContext function", { flowName });
463
+ prefill = void 0;
464
+ }
465
+ }
466
+ return {
467
+ definition,
468
+ handler,
469
+ instructionsPath,
470
+ prefill
471
+ };
472
+ }
473
+
474
+ // src/flows/registry.ts
475
+ var CRITICAL_KEYWORDS = ["human", "person", "agent", "representative", "operator"];
476
+ var FlowRegistry = class {
477
+ flows = /* @__PURE__ */ new Map();
478
+ flowsDir;
479
+ constructor(flowsDir) {
480
+ this.flowsDir = flowsDir;
481
+ }
482
+ /**
483
+ * Load all flows from the flows directory
484
+ */
485
+ async loadFlows() {
486
+ this.flows = await loadFlowsFromDirectory(this.flowsDir);
487
+ }
488
+ /**
489
+ * Get a flow by name
490
+ */
491
+ getFlow(name) {
492
+ return this.flows.get(name);
493
+ }
494
+ /**
495
+ * Match user message to a flow using hybrid approach:
496
+ * 1. Critical keyword detection (immediate)
497
+ * 2. LLM intent classification
498
+ */
499
+ async matchFlow(deps, phoneNumber, message, conversationContext) {
500
+ const lowerMessage = message.toLowerCase();
501
+ for (const keyword of CRITICAL_KEYWORDS) {
502
+ if (lowerMessage.includes(keyword)) {
503
+ const transferFlow = this.flows.get("transfer");
504
+ if (transferFlow) {
505
+ logger.info("flow triggered (critical keyword)", {
506
+ flowId: transferFlow.definition.id,
507
+ keyword
508
+ });
509
+ return transferFlow;
510
+ }
511
+ }
512
+ }
513
+ if (this.flows.size === 0) return void 0;
514
+ const detection = process.env.NODE_ENV === "test" ? testModeDetectIntent(message) : await detectIntent(deps, phoneNumber, message, this.flows, conversationContext);
515
+ if (detection.confidence >= 0.7) {
516
+ const flow = this.flows.get(detection.intent);
517
+ if (flow) {
518
+ logger.info("flow triggered (LLM detection)", {
519
+ flowId: flow.definition.id,
520
+ intent: detection.intent,
521
+ confidence: detection.confidence
522
+ });
523
+ return flow;
524
+ }
525
+ }
526
+ return void 0;
527
+ }
528
+ /**
529
+ * Get all loaded flows
530
+ */
531
+ getAllFlows() {
532
+ return Array.from(this.flows.values());
533
+ }
534
+ /**
535
+ * Get flow instructions content
536
+ */
537
+ getInstructions(flowName) {
538
+ const flow = this.flows.get(flowName);
539
+ if (!flow) {
540
+ throw new Error(`Flow ${flowName} not found`);
541
+ }
542
+ return (0, import_node_fs3.readFileSync)(flow.instructionsPath, "utf-8");
543
+ }
544
+ };
545
+ function testModeDetectIntent(message) {
546
+ const m = message.toLowerCase();
547
+ if (m.match(/\b(add|sum|plus)\b/)) {
548
+ return { intent: "addNumbers", confidence: 0.99, reasoning: "test-mode mapping" };
549
+ }
550
+ return { intent: "chatbot", confidence: 0.4, reasoning: "test-mode default" };
551
+ }
552
+
553
+ // src/routes/call/index.ts
554
+ var import_hono = require("hono");
555
+
556
+ // src/core/errors.ts
557
+ function getErrorMessage(error) {
558
+ if (error instanceof Error) {
559
+ return error.message;
560
+ }
561
+ if (typeof error === "string") {
562
+ return error;
563
+ }
564
+ return "Unknown error";
565
+ }
566
+
567
+ // src/core/phrases.ts
568
+ var import_node_fs4 = require("node:fs");
569
+ var import_node_path3 = require("node:path");
570
+ var phrasesCache = {};
571
+ function loadPhrases(language, languageDir) {
572
+ const cacheKey = `${languageDir || "default"}:${language}`;
573
+ if (phrasesCache[cacheKey]) {
574
+ return phrasesCache[cacheKey];
575
+ }
576
+ const dirs = [languageDir, (0, import_node_path3.join)(__dirname, "../../language")].filter(Boolean);
577
+ for (const dir of dirs) {
578
+ const filePath = (0, import_node_path3.join)(dir, `${language}.json`);
579
+ if ((0, import_node_fs4.existsSync)(filePath)) {
580
+ try {
581
+ const content = (0, import_node_fs4.readFileSync)(filePath, "utf-8");
582
+ phrasesCache[cacheKey] = JSON.parse(content);
583
+ return phrasesCache[cacheKey];
584
+ } catch {
585
+ }
586
+ }
587
+ }
588
+ if (language !== "en") {
589
+ return loadPhrases("en", languageDir);
590
+ }
591
+ throw new Error("English phrase file not found in any search path");
592
+ }
593
+ function getPhrase(language, key, languageDir) {
594
+ const phrases = loadPhrases(language, languageDir);
595
+ return phrases[key];
596
+ }
597
+ function getFarewellPhrase(language, languageDir) {
598
+ const phrases = loadPhrases(language, languageDir);
599
+ const hour = (/* @__PURE__ */ new Date()).getHours();
600
+ if (hour < 12) {
601
+ return phrases.farewell.morning;
602
+ }
603
+ if (hour < 18) {
604
+ return phrases.farewell.afternoon;
605
+ }
606
+ return phrases.farewell.evening;
607
+ }
608
+ function getFlowPhrase(language, key, languageDir) {
609
+ const phrases = loadPhrases(language, languageDir);
610
+ return phrases.flow[key];
611
+ }
612
+ function getSmsPhrase(language, key, languageDir) {
613
+ const phrases = loadPhrases(language, languageDir);
614
+ return phrases.sms[key];
615
+ }
616
+
617
+ // src/core/voice.ts
618
+ var DEFAULT_VOICES = {
619
+ en: { voice: "Polly.Brian", language: "en-GB" },
620
+ fr: { voice: "Polly.Mathieu", language: "fr-FR" },
621
+ nl: { voice: "Polly.Ruben", language: "nl-NL" },
622
+ de: { voice: "Polly.Hans", language: "de-DE" },
623
+ es: { voice: "Polly.Enrique", language: "es-ES" },
624
+ pt: { voice: "Polly.Ricardo", language: "pt-BR" }
625
+ };
626
+ var DEFAULT_VOICE = DEFAULT_VOICES.en;
627
+ function getVoiceConfig(language, customVoices) {
628
+ if (customVoices?.[language]) {
629
+ return customVoices[language];
630
+ }
631
+ return DEFAULT_VOICES[language] || DEFAULT_VOICE;
632
+ }
633
+ function getDefaultVoices() {
634
+ return { ...DEFAULT_VOICES };
635
+ }
636
+
637
+ // src/core/xml.ts
638
+ function escapeXml(text) {
639
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
640
+ }
641
+
642
+ // src/core/twiml.ts
643
+ function gatherTwiml(prompt, language, config, phoneNumber) {
644
+ const { voice, language: lang } = getVoiceConfig(language, config.voices);
645
+ const prefix = config.routePrefix || "";
646
+ if (phoneNumber) {
647
+ setLastPrompt(phoneNumber, prompt);
648
+ }
649
+ return `<?xml version="1.0" encoding="UTF-8"?>
650
+ <Response>
651
+ <Say voice="${voice}" language="${lang}">${prompt}</Say>
652
+ <Gather input="speech" action="${prefix}/call/respond" method="POST" speechTimeout="auto" language="${lang}">
653
+ </Gather>
654
+ <Redirect method="POST">${prefix}/call/no-speech</Redirect>
655
+ </Response>`;
656
+ }
657
+ function sayTwiml(message, language, config) {
658
+ const { voice, language: lang } = getVoiceConfig(language, config.voices);
659
+ return `<?xml version="1.0" encoding="UTF-8"?>
660
+ <Response>
661
+ <Say voice="${voice}" language="${lang}">${message}</Say>
662
+ </Response>`;
663
+ }
664
+ function transferTwiml(language, config) {
665
+ const { voice, language: lang } = getVoiceConfig(language, config.voices);
666
+ const message = getPhrase(language, "transfer", config.languageDir);
667
+ const transferNumber = config.transferNumber || "";
668
+ return `<?xml version="1.0" encoding="UTF-8"?>
669
+ <Response>
670
+ <Say voice="${voice}" language="${lang}">${message}</Say>
671
+ <Dial>${transferNumber}</Dial>
672
+ </Response>`;
673
+ }
674
+ function acknowledgmentTwiml(language, config) {
675
+ const { voice, language: lang } = getVoiceConfig(language, config.voices);
676
+ const message = getPhrase(language, "acknowledgment", config.languageDir);
677
+ const prefix = config.routePrefix || "";
678
+ return `<?xml version="1.0" encoding="UTF-8"?>
679
+ <Response>
680
+ <Say voice="${voice}" language="${lang}">${message}</Say>
681
+ <Redirect method="POST">${prefix}/call/answer</Redirect>
682
+ </Response>`;
683
+ }
684
+ function farewellTwiml(language, config) {
685
+ const { voice, language: lang } = getVoiceConfig(language, config.voices);
686
+ const message = getFarewellPhrase(language, config.languageDir);
687
+ return `<?xml version="1.0" encoding="UTF-8"?>
688
+ <Response>
689
+ <Say voice="${voice}" language="${lang}">${message}</Say>
690
+ <Hangup/>
691
+ </Response>`;
692
+ }
693
+ function messageTwiml(message) {
694
+ const escapedMessage = escapeXml(message);
695
+ return `<?xml version="1.0" encoding="UTF-8"?>
696
+ <Response>
697
+ <Message>${escapedMessage}</Message>
698
+ </Response>`;
699
+ }
700
+
701
+ // src/routes/call/handle-answer.ts
702
+ init_pending();
703
+ async function handleAnswer(c, config) {
704
+ const body = await c.req.parseBody();
705
+ const phoneNumber = (body.From || "unknown").trim();
706
+ const pending = getPending(phoneNumber);
707
+ if (!pending) {
708
+ logger.warn("no pending query found", { phoneNumber });
709
+ const twiml = gatherTwiml(
710
+ getPhrase("en", "lostQuestion", config.languageDir),
711
+ "en",
712
+ config,
713
+ phoneNumber
714
+ );
715
+ return c.text(twiml, 200, { "Content-Type": "text/xml" });
716
+ }
717
+ try {
718
+ const timeoutPromise = new Promise((_, reject) => {
719
+ setTimeout(() => reject(new Error("Processing timeout")), 3e4);
720
+ });
721
+ const result = await Promise.race([pending.promise, timeoutPromise]);
722
+ deletePending(phoneNumber);
723
+ return c.text(result.twiml, 200, { "Content-Type": "text/xml" });
724
+ } catch (error) {
725
+ logger.error("answer error", { phoneNumber, error: getErrorMessage(error) });
726
+ deletePending(phoneNumber);
727
+ const twiml = sayTwiml(getPhrase("en", "timeout", config.languageDir), "en", config);
728
+ return c.text(twiml, 200, { "Content-Type": "text/xml" });
729
+ }
730
+ }
731
+
732
+ // src/routes/call/handle-initial.ts
733
+ async function handleInitialCall(c, config) {
734
+ const body = await c.req.parseBody();
735
+ const phoneNumber = (body.From || "unknown").trim();
736
+ logger.info("call started", { phoneNumber });
737
+ clearContext(phoneNumber);
738
+ const { voice, language: lang } = getVoiceConfig("en", config.voices);
739
+ const greeting = getPhrase("en", "greeting", config.languageDir);
740
+ const didNotHear = getPhrase("en", "didNotHear", config.languageDir);
741
+ const prefix = config.routePrefix || "";
742
+ const twiml = `<?xml version="1.0" encoding="UTF-8"?>
743
+ <Response>
744
+ <Say voice="${voice}" language="${lang}">${greeting}</Say>
745
+ <Gather input="speech" action="${prefix}/call/respond" method="POST" speechTimeout="auto" language="${lang}">
746
+ </Gather>
747
+ <Say voice="${voice}" language="${lang}">${didNotHear}</Say>
748
+ </Response>`;
749
+ return c.text(twiml, 200, { "Content-Type": "text/xml" });
750
+ }
751
+
752
+ // src/routes/call/handle-nospeech.ts
753
+ async function handleNoSpeech(c, config) {
754
+ const body = await c.req.parseBody();
755
+ const phoneNumber = (body.From || "unknown").trim();
756
+ const maxRetries = config.maxNoSpeechRetries ?? 3;
757
+ const retryCount = incrementNoSpeechRetries(phoneNumber);
758
+ const language = getDetectedLanguage(phoneNumber) || "en";
759
+ if (retryCount > maxRetries) {
760
+ logger.info("max retries reached, ending call", { phoneNumber, retryCount });
761
+ const finalMessage = getPhrase(language, "didNotHearFinal", config.languageDir);
762
+ return c.text(sayTwiml(finalMessage, language, config), 200, { "Content-Type": "text/xml" });
763
+ }
764
+ logger.info("retrying speech gather", { phoneNumber, retryCount, maxRetries });
765
+ const retryMessage = getPhrase(language, "didNotHearRetry", config.languageDir);
766
+ const lastPrompt = getLastPrompt(phoneNumber);
767
+ let prompt;
768
+ if (retryCount === 1) {
769
+ prompt = lastPrompt ? `${retryMessage} ${lastPrompt}` : retryMessage;
770
+ } else {
771
+ prompt = lastPrompt || retryMessage;
772
+ }
773
+ return c.text(gatherTwiml(prompt, language, config, phoneNumber), 200, {
774
+ "Content-Type": "text/xml"
775
+ });
776
+ }
777
+
778
+ // src/db/sessions.ts
779
+ function generateSessionId(phoneNumber, startTime) {
780
+ const sanitized = phoneNumber.replace(/[^0-9]/g, "");
781
+ return `${sanitized}-${startTime}`;
782
+ }
783
+ async function upsertSession(session) {
784
+ const client2 = getDbClient();
785
+ if (!client2) return false;
786
+ try {
787
+ await client2.execute({
788
+ sql: `
789
+ INSERT INTO talker_sessions (
790
+ id, phone_number, channel, reason, language,
791
+ started_at, ended_at, duration_ms, transfer_reason, conversation_id, updated_at
792
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
793
+ ON CONFLICT(id) DO UPDATE SET
794
+ channel = excluded.channel,
795
+ reason = excluded.reason,
796
+ language = excluded.language,
797
+ ended_at = excluded.ended_at,
798
+ duration_ms = excluded.duration_ms,
799
+ transfer_reason = excluded.transfer_reason,
800
+ conversation_id = excluded.conversation_id,
801
+ updated_at = excluded.updated_at
802
+ `,
803
+ args: [
804
+ session.id,
805
+ session.phoneNumber,
806
+ session.channel,
807
+ session.reason,
808
+ session.language,
809
+ session.startedAt,
810
+ session.endedAt,
811
+ session.durationMs,
812
+ session.transferReason || null,
813
+ session.conversationId || null,
814
+ Date.now()
815
+ ]
816
+ });
817
+ logger.info("session upserted", {
818
+ sessionId: session.id,
819
+ phoneNumber: session.phoneNumber,
820
+ channel: session.channel
821
+ });
822
+ return true;
823
+ } catch (error) {
824
+ logger.error("failed to upsert session", {
825
+ sessionId: session.id,
826
+ error: error instanceof Error ? error.message : "Unknown"
827
+ });
828
+ return false;
829
+ }
830
+ }
831
+ async function insertMessage(message) {
832
+ const client2 = getDbClient();
833
+ if (!client2) return false;
834
+ try {
835
+ await client2.execute({
836
+ sql: `INSERT OR IGNORE INTO talker_messages (id, session_id, role, content, timestamp)
837
+ VALUES (?, ?, ?, ?, ?)`,
838
+ args: [message.id, message.sessionId, message.role, message.content, message.timestamp]
839
+ });
840
+ return true;
841
+ } catch (error) {
842
+ logger.error("failed to insert message", {
843
+ messageId: message.id,
844
+ error: error instanceof Error ? error.message : "Unknown"
845
+ });
846
+ return false;
847
+ }
848
+ }
849
+
850
+ // src/db/persist.ts
851
+ function persistSession(phoneNumber, channel, conversationId) {
852
+ const context = getContext(phoneNumber);
853
+ const language = getDetectedLanguage(phoneNumber) || "en";
854
+ const messages = getMessageHistory(phoneNumber);
855
+ if (!context || messages.length === 0) return;
856
+ if (!getDbClient()) return;
857
+ const sanitizedPhone = phoneNumber.replace(/[^0-9]/g, "");
858
+ const sessionId = generateSessionId(phoneNumber, context.createdAt);
859
+ const now = Date.now();
860
+ upsertSession({
861
+ id: sessionId,
862
+ phoneNumber: sanitizedPhone,
863
+ channel,
864
+ reason: "ended",
865
+ language,
866
+ startedAt: context.createdAt,
867
+ endedAt: now,
868
+ durationMs: now - context.createdAt,
869
+ conversationId
870
+ }).catch((err) => {
871
+ logger.error("session persistence failed", { phoneNumber, error: getErrorMessage(err) });
872
+ });
873
+ for (const msg of messages) {
874
+ const messageId = `${sessionId}-${msg.timestamp}-${msg.role}`;
875
+ insertMessage({
876
+ id: messageId,
877
+ sessionId,
878
+ role: msg.role,
879
+ content: msg.content,
880
+ timestamp: msg.timestamp
881
+ }).catch((err) => {
882
+ logger.error("message persistence failed", { phoneNumber, error: getErrorMessage(err) });
883
+ });
884
+ }
885
+ }
886
+ function persistFinalSession(phoneNumber, channel, reason, transferReason) {
887
+ const context = getContext(phoneNumber);
888
+ const language = getDetectedLanguage(phoneNumber) || "en";
889
+ if (!context) return;
890
+ if (!getDbClient()) return;
891
+ const sanitizedPhone = phoneNumber.replace(/[^0-9]/g, "");
892
+ const sessionId = generateSessionId(phoneNumber, context.createdAt);
893
+ const now = Date.now();
894
+ upsertSession({
895
+ id: sessionId,
896
+ phoneNumber: sanitizedPhone,
897
+ channel,
898
+ reason,
899
+ language,
900
+ startedAt: context.createdAt,
901
+ endedAt: now,
902
+ durationMs: now - context.createdAt,
903
+ transferReason
904
+ }).catch((err) => {
905
+ logger.error("final session persistence failed", {
906
+ phoneNumber,
907
+ error: getErrorMessage(err)
908
+ });
909
+ });
910
+ }
911
+
912
+ // src/routes/call/handle-respond.ts
913
+ init_pending();
914
+
915
+ // src/core/chatbot/conversations.ts
916
+ var conversations = /* @__PURE__ */ new Map();
917
+ function getOrCreateConversation(phoneNumber, systemMessage) {
918
+ const existing = conversations.get(phoneNumber);
919
+ if (existing) {
920
+ existing.lastActivityAt = Date.now();
921
+ return existing;
922
+ }
923
+ const conversation = {
924
+ conversationId: crypto.randomUUID(),
925
+ chatHistory: systemMessage ? [{ role: "system", content: systemMessage }] : [],
926
+ createdAt: Date.now(),
927
+ lastActivityAt: Date.now()
928
+ };
929
+ conversations.set(phoneNumber, conversation);
930
+ logger.info("chatbot conversation created", {
931
+ phoneNumber,
932
+ conversationId: conversation.conversationId
933
+ });
934
+ return conversation;
935
+ }
936
+ function addUserMessage(phoneNumber, content, systemMessage) {
937
+ const conversation = getOrCreateConversation(phoneNumber, systemMessage);
938
+ conversation.chatHistory.push({ role: "user", content });
939
+ conversation.lastActivityAt = Date.now();
940
+ }
941
+ function addBotMessage(phoneNumber, content, systemMessage) {
942
+ const conversation = getOrCreateConversation(phoneNumber, systemMessage);
943
+ conversation.chatHistory.push({ role: "assistant", content });
944
+ conversation.lastActivityAt = Date.now();
945
+ }
946
+
947
+ // src/core/chatbot/client.ts
948
+ var DEFAULT_SYSTEM_MESSAGE = "You are a voice assistant. Always refer to the subject in third person.";
949
+ async function chatViaHTTP(config, phoneNumber, message) {
950
+ const systemMessage = config.systemMessage || DEFAULT_SYSTEM_MESSAGE;
951
+ addUserMessage(phoneNumber, message, systemMessage);
952
+ const conversation = getOrCreateConversation(phoneNumber, systemMessage);
953
+ const headers = {
954
+ "Content-Type": "application/json"
955
+ };
956
+ if (config.apiKey) {
957
+ headers["x-api-key"] = config.apiKey;
958
+ }
959
+ const body = JSON.stringify({
960
+ messages: conversation.chatHistory
961
+ });
962
+ logger.info("chatbot request", {
963
+ phoneNumber,
964
+ conversationId: conversation.conversationId,
965
+ message: message.substring(0, 100),
966
+ historyLength: conversation.chatHistory.length
967
+ });
968
+ try {
969
+ const response = await fetch(config.url, {
970
+ method: "POST",
971
+ headers,
972
+ body
973
+ });
974
+ if (!response.ok) {
975
+ const errorText = await response.text();
976
+ logger.error("chatbot error", {
977
+ status: response.status,
978
+ statusText: response.statusText,
979
+ error: errorText
980
+ });
981
+ throw new Error(`Chatbot API error: ${response.status} - ${errorText}`);
982
+ }
983
+ const data = await response.json();
984
+ logger.info("chatbot response", {
985
+ phoneNumber,
986
+ conversationId: conversation.conversationId,
987
+ reply: data.reply.substring(0, 200)
988
+ });
989
+ const answer = data.reply || "Sorry, I could not process your request.";
990
+ addBotMessage(phoneNumber, answer, systemMessage);
991
+ return answer;
992
+ } catch (error) {
993
+ logger.error("chatbot request failed", {
994
+ phoneNumber,
995
+ error: getErrorMessage(error)
996
+ });
997
+ return "Sorry, I encountered an error processing your question.";
998
+ }
999
+ }
1000
+
1001
+ // src/core/chat.ts
1002
+ async function chat(deps, phoneNumber, message) {
1003
+ if (deps.config.chatFn) {
1004
+ return deps.config.chatFn(phoneNumber, message);
1005
+ }
1006
+ if (deps.config.chatbot?.url) {
1007
+ return chatViaHTTP(deps.config.chatbot, phoneNumber, message);
1008
+ }
1009
+ try {
1010
+ const { completeOnce } = await import("@diegoaltoworks/chatter");
1011
+ const { client: client2, store, prompts } = deps.chatter;
1012
+ const ragContext = await store.query(message, 6, ["base", "public"]);
1013
+ const system = [
1014
+ prompts.baseSystemRules,
1015
+ prompts.publicPersona,
1016
+ `Context:
1017
+ ${ragContext.join("\n\n")}`
1018
+ ].join("\n\n");
1019
+ const result = await completeOnce({
1020
+ client: client2,
1021
+ system,
1022
+ messages: [{ role: "user", content: message }]
1023
+ });
1024
+ return result.content;
1025
+ } catch (error) {
1026
+ logger.error("chat error", { phoneNumber, error: getErrorMessage(error) });
1027
+ return "Sorry, I encountered an error processing your question.";
1028
+ }
1029
+ }
1030
+
1031
+ // src/core/processing/openai.ts
1032
+ var OPENAI_API_URL2 = "https://api.openai.com/v1/chat/completions";
1033
+ async function callOpenAI(deps, systemPrompt, userMessage, context) {
1034
+ const headers = {
1035
+ Authorization: `Bearer ${deps.openaiApiKey}`,
1036
+ "Content-Type": "application/json"
1037
+ };
1038
+ const requestBody = {
1039
+ model: deps.openaiModel,
1040
+ messages: [
1041
+ { role: "system", content: systemPrompt },
1042
+ { role: "user", content: userMessage }
1043
+ ],
1044
+ temperature: 0.3
1045
+ };
1046
+ logger.info(`${context.stage} request`, {
1047
+ phoneNumber: context.phoneNumber,
1048
+ stage: context.stage,
1049
+ input: userMessage.substring(0, 160) + (userMessage.length > 160 ? "..." : "")
1050
+ });
1051
+ const response = await fetch(OPENAI_API_URL2, {
1052
+ method: "POST",
1053
+ headers,
1054
+ body: JSON.stringify(requestBody)
1055
+ });
1056
+ if (!response.ok) {
1057
+ const error = await response.text();
1058
+ logger.error(`${context.stage} openai error`, {
1059
+ phoneNumber: context.phoneNumber,
1060
+ status: response.status,
1061
+ error
1062
+ });
1063
+ throw new Error(`OpenAI API error: ${response.status}`);
1064
+ }
1065
+ const data = await response.json();
1066
+ const content = data.choices[0]?.message?.content || "";
1067
+ logger.info(`${context.stage} response`, {
1068
+ phoneNumber: context.phoneNumber,
1069
+ stage: context.stage,
1070
+ output: content.substring(0, 160) + (content.length > 160 ? "..." : ""),
1071
+ tokens: data.usage?.total_tokens
1072
+ });
1073
+ return content;
1074
+ }
1075
+
1076
+ // src/core/processing/prompts.ts
1077
+ var import_node_fs5 = require("node:fs");
1078
+ var import_node_path4 = require("node:path");
1079
+ var incomingPrompt = null;
1080
+ var outgoingPrompt = null;
1081
+ function loadPromptFile(filename, customPath) {
1082
+ if (customPath && (0, import_node_fs5.existsSync)(customPath)) {
1083
+ return (0, import_node_fs5.readFileSync)(customPath, "utf-8");
1084
+ }
1085
+ const builtinPath = (0, import_node_path4.join)(__dirname, "../../../prompts", filename);
1086
+ if ((0, import_node_fs5.existsSync)(builtinPath)) {
1087
+ return (0, import_node_fs5.readFileSync)(builtinPath, "utf-8");
1088
+ }
1089
+ throw new Error(`Prompt file not found: ${filename}`);
1090
+ }
1091
+ function getIncomingPrompt(deps) {
1092
+ if (!incomingPrompt) {
1093
+ try {
1094
+ incomingPrompt = loadPromptFile("incoming.md", deps.config.processing?.incomingPromptPath);
1095
+ logger.info("incoming prompt loaded");
1096
+ } catch (error) {
1097
+ logger.error("failed to load incoming prompt", { error: getErrorMessage(error) });
1098
+ incomingPrompt = "Return JSON with shouldTransfer (boolean), shouldEndCall (boolean), detectedLanguage (string), processedMessage (string)";
1099
+ }
1100
+ }
1101
+ return incomingPrompt;
1102
+ }
1103
+ function getOutgoingPrompt(deps) {
1104
+ if (!outgoingPrompt) {
1105
+ try {
1106
+ outgoingPrompt = loadPromptFile("outgoing.md", deps.config.processing?.outgoingPromptPath);
1107
+ logger.info("outgoing prompt loaded");
1108
+ } catch (error) {
1109
+ logger.error("failed to load outgoing prompt", { error: getErrorMessage(error) });
1110
+ outgoingPrompt = "Make this response phone-friendly and brief.";
1111
+ }
1112
+ }
1113
+ return outgoingPrompt;
1114
+ }
1115
+
1116
+ // src/core/processing/incoming.ts
1117
+ async function processIncoming(deps, phoneNumber, userMessage, channel = "call") {
1118
+ try {
1119
+ getOrCreateContext(phoneNumber, channel);
1120
+ const history = getMessageHistory(phoneNumber);
1121
+ addMessage(phoneNumber, "user", userMessage, channel);
1122
+ let contextualMessage = userMessage;
1123
+ if (history.length > 0) {
1124
+ const recentHistory = history.slice(-4);
1125
+ const historyText = recentHistory.map((m) => `${m.role === "user" ? "Customer" : "Bot"}: ${m.content}`).join("\n");
1126
+ contextualMessage = `CONVERSATION HISTORY:
1127
+ ${historyText}
1128
+
1129
+ CURRENT MESSAGE:
1130
+ ${userMessage}`;
1131
+ }
1132
+ const result = await callOpenAI(deps, getIncomingPrompt(deps), contextualMessage, {
1133
+ phoneNumber,
1134
+ stage: "incoming"
1135
+ });
1136
+ const cleanedResult = result.replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/\s*```$/i, "").trim();
1137
+ const parsed = JSON.parse(cleanedResult);
1138
+ const detectedLang = parsed.detectedLanguage || "en";
1139
+ setDetectedLanguage(phoneNumber, detectedLang);
1140
+ const storedLanguage = getDetectedLanguage(phoneNumber) || "en";
1141
+ logger.info("INCOMING", {
1142
+ phoneNumber,
1143
+ channel,
1144
+ in: userMessage,
1145
+ out: parsed.processedMessage,
1146
+ lang: storedLanguage,
1147
+ ...parsed.shouldTransfer && { transfer: true },
1148
+ ...parsed.shouldEndCall && { endCall: true }
1149
+ });
1150
+ return {
1151
+ shouldTransfer: parsed.shouldTransfer ?? false,
1152
+ shouldEndCall: parsed.shouldEndCall ?? false,
1153
+ detectedLanguage: storedLanguage,
1154
+ processedMessage: parsed.processedMessage || userMessage
1155
+ };
1156
+ } catch (error) {
1157
+ logger.error("incoming processing error", {
1158
+ phoneNumber,
1159
+ error: getErrorMessage(error)
1160
+ });
1161
+ return {
1162
+ shouldTransfer: false,
1163
+ shouldEndCall: false,
1164
+ detectedLanguage: getDetectedLanguage(phoneNumber) || "en",
1165
+ processedMessage: userMessage
1166
+ };
1167
+ }
1168
+ }
1169
+
1170
+ // src/core/processing/outgoing.ts
1171
+ async function processOutgoing(deps, phoneNumber, botResponse, channel = "call") {
1172
+ try {
1173
+ const language = getDetectedLanguage(phoneNumber) || "en";
1174
+ const prompt = getOutgoingPrompt(deps);
1175
+ const promptWithContext = `${prompt}
1176
+
1177
+ ---
1178
+ Channel: ${channel}
1179
+ Respond in: ${language}`;
1180
+ const result = await callOpenAI(deps, promptWithContext, botResponse, {
1181
+ phoneNumber,
1182
+ stage: "outgoing"
1183
+ });
1184
+ addMessage(phoneNumber, "assistant", result || botResponse, channel);
1185
+ logger.info("OUTGOING", {
1186
+ phoneNumber,
1187
+ channel,
1188
+ in: botResponse,
1189
+ out: result,
1190
+ lang: language
1191
+ });
1192
+ return result || botResponse;
1193
+ } catch (error) {
1194
+ logger.error("outgoing processing error", {
1195
+ phoneNumber,
1196
+ channel,
1197
+ error: getErrorMessage(error)
1198
+ });
1199
+ return botResponse;
1200
+ }
1201
+ }
1202
+
1203
+ // src/flows/params.ts
1204
+ var import_node_fs6 = require("node:fs");
1205
+ var OPENAI_API_URL3 = "https://api.openai.com/v1/chat/completions";
1206
+ async function extractParameters(deps, flow, phoneNumber, userMessage, existingParams) {
1207
+ const instructions = (0, import_node_fs6.readFileSync)(flow.instructionsPath, "utf-8");
1208
+ const schema = flow.definition.schema;
1209
+ const properties = schema.properties || {};
1210
+ const required = schema.required || [];
1211
+ const schemaDescription = Object.entries(properties).map(
1212
+ ([key, field]) => `- ${key} (${field.type}${required.includes(key) ? ", required" : ""}): ${field.description || ""}`
1213
+ ).join("\n");
1214
+ const existingParamsStr = Object.keys(existingParams).length > 0 ? `
1215
+
1216
+ Already collected:
1217
+ ${JSON.stringify(existingParams, null, 2)}` : "";
1218
+ const missingFields = required.filter((key) => !existingParams[key]);
1219
+ const currentlyAsking = missingFields.length > 0 ? missingFields[0] : null;
1220
+ const now = /* @__PURE__ */ new Date();
1221
+ const todayStr = now.toISOString().split("T")[0];
1222
+ const currentYear = now.getFullYear();
1223
+ const currentMonth = now.getMonth() + 1;
1224
+ const nextYear = currentYear + 1;
1225
+ const tomorrow = new Date(now);
1226
+ tomorrow.setDate(tomorrow.getDate() + 1);
1227
+ const tomorrowStr = tomorrow.toISOString().split("T")[0];
1228
+ const dateContext = `
1229
+
1230
+ ## CRITICAL DATE CONTEXT
1231
+ TODAY IS: ${todayStr} (year ${currentYear}, month ${currentMonth})
1232
+
1233
+ **DATE EXTRACTION RULES:**
1234
+ - "tomorrow" = ${tomorrowStr}
1235
+ - Months 1-${currentMonth} = year ${nextYear}
1236
+ - Months ${currentMonth + 1}-12 = year ${currentYear}
1237
+ - NEVER output a date before ${todayStr}`;
1238
+ const currentFieldHint = currentlyAsking ? `
1239
+
1240
+ ## IMPORTANT CONTEXT
1241
+ We just asked the user for: "${currentlyAsking}"
1242
+ If the user's response is a simple value, interpret it as the value for the "${currentlyAsking}" parameter.` : "";
1243
+ const systemPrompt = `${instructions}
1244
+ ${dateContext}
1245
+
1246
+ ## Schema
1247
+ ${schemaDescription}
1248
+ ${existingParamsStr}
1249
+ ${currentFieldHint}
1250
+
1251
+ ## Task
1252
+ Extract any parameters from the user's message that match the schema above.
1253
+ Return JSON with:
1254
+ - "extractedParams": object with any NEW parameters found
1255
+ - "allParamsFilled": boolean indicating if ALL required params are now complete
1256
+
1257
+ If not all params are filled, do NOT include "nextPrompt" - we'll generate that separately.`;
1258
+ const requestBody = {
1259
+ model: deps.openaiModel,
1260
+ messages: [
1261
+ { role: "system", content: systemPrompt },
1262
+ { role: "user", content: userMessage }
1263
+ ],
1264
+ temperature: 0.2,
1265
+ response_format: { type: "json_object" }
1266
+ };
1267
+ logger.info("flow extracting params", {
1268
+ phoneNumber,
1269
+ flow: flow.definition.id,
1270
+ msg: userMessage.substring(0, 160),
1271
+ existing: Object.keys(existingParams).length > 0 ? existingParams : void 0,
1272
+ askingFor: currentlyAsking || "none"
1273
+ });
1274
+ const response = await fetch(OPENAI_API_URL3, {
1275
+ method: "POST",
1276
+ headers: {
1277
+ Authorization: `Bearer ${deps.openaiApiKey}`,
1278
+ "Content-Type": "application/json"
1279
+ },
1280
+ body: JSON.stringify(requestBody)
1281
+ });
1282
+ if (!response.ok) {
1283
+ const error = await response.text();
1284
+ logger.error("flow parameter extraction error", {
1285
+ phoneNumber,
1286
+ flowName: flow.definition.id,
1287
+ status: response.status,
1288
+ error
1289
+ });
1290
+ throw new Error(`OpenAI API error: ${response.status}`);
1291
+ }
1292
+ const data = await response.json();
1293
+ const content = data.choices[0]?.message?.content || "{}";
1294
+ const result = JSON.parse(content);
1295
+ const mergedParams = { ...existingParams, ...result.extractedParams || {} };
1296
+ const requiredFields = schema.required || [];
1297
+ const allRequiredFilled = requiredFields.every(
1298
+ (key) => mergedParams[key] !== void 0 && mergedParams[key] !== null
1299
+ );
1300
+ logger.info("flow params extracted", {
1301
+ phoneNumber,
1302
+ flow: flow.definition.id,
1303
+ extracted: result.extractedParams,
1304
+ complete: allRequiredFilled
1305
+ });
1306
+ let nextPrompt;
1307
+ if (!allRequiredFilled) {
1308
+ const missingKey = requiredFields.find(
1309
+ (key) => mergedParams[key] === void 0 || mergedParams[key] === null
1310
+ );
1311
+ if (missingKey) {
1312
+ nextPrompt = `What is the ${missingKey.replace(/([A-Z])/g, " $1").toLowerCase()}?`;
1313
+ }
1314
+ }
1315
+ return {
1316
+ extractedParams: result.extractedParams || {},
1317
+ allParamsFilled: allRequiredFilled,
1318
+ nextPrompt
1319
+ };
1320
+ }
1321
+
1322
+ // src/flows/utils.ts
1323
+ var CANCELLATION_KEYWORDS = ["cancel", "nevermind", "stop", "forget it", "quit"];
1324
+ function shouldExitFlow(message) {
1325
+ const lowerMessage = message.toLowerCase();
1326
+ return CANCELLATION_KEYWORDS.some((keyword) => lowerMessage.includes(keyword));
1327
+ }
1328
+ function getExitMessage(language, languageDir) {
1329
+ return getFlowPhrase(language, "cancelled", languageDir);
1330
+ }
1331
+
1332
+ // src/flows/manager.ts
1333
+ async function processFlow(deps, registry, phoneNumber, userMessage, channel) {
1334
+ const activeFlow = getActiveFlow(phoneNumber);
1335
+ if (activeFlow && shouldExitFlow(userMessage)) {
1336
+ logger.info("flow cancelled", { phoneNumber, flow: activeFlow.flowName });
1337
+ clearActiveFlow(phoneNumber);
1338
+ const language = getDetectedLanguage(phoneNumber) || "en";
1339
+ return {
1340
+ isFlowActive: false,
1341
+ response: getExitMessage(language, deps.config.languageDir),
1342
+ flowCompleted: false
1343
+ };
1344
+ }
1345
+ if (activeFlow) {
1346
+ const flow = registry.getFlow(activeFlow.flowName);
1347
+ if (!flow) {
1348
+ logger.error("active flow not found in registry", {
1349
+ phoneNumber,
1350
+ flowName: activeFlow.flowName
1351
+ });
1352
+ clearActiveFlow(phoneNumber);
1353
+ return { isFlowActive: false, response: "", flowCompleted: false };
1354
+ }
1355
+ try {
1356
+ const extraction = await extractParameters(
1357
+ deps,
1358
+ flow,
1359
+ phoneNumber,
1360
+ userMessage,
1361
+ activeFlow.params
1362
+ );
1363
+ updateFlowParams(phoneNumber, extraction.extractedParams);
1364
+ if (extraction.allParamsFilled) {
1365
+ logger.info("flow executing", {
1366
+ phoneNumber,
1367
+ flow: activeFlow.flowName,
1368
+ params: { ...activeFlow.params, ...extraction.extractedParams }
1369
+ });
1370
+ const result = await flow.handler(
1371
+ { ...activeFlow.params, ...extraction.extractedParams },
1372
+ { phoneNumber, channel }
1373
+ );
1374
+ clearActiveFlow(phoneNumber);
1375
+ return {
1376
+ isFlowActive: false,
1377
+ response: result.say,
1378
+ flowCompleted: true,
1379
+ smsContent: result.sms,
1380
+ flowSuccess: result.success
1381
+ };
1382
+ }
1383
+ return {
1384
+ isFlowActive: true,
1385
+ response: extraction.nextPrompt || "Could you provide more details?",
1386
+ flowCompleted: false
1387
+ };
1388
+ } catch (error) {
1389
+ logger.error("flow error", {
1390
+ phoneNumber,
1391
+ flow: activeFlow.flowName,
1392
+ error: getErrorMessage(error)
1393
+ });
1394
+ clearActiveFlow(phoneNumber);
1395
+ return {
1396
+ isFlowActive: false,
1397
+ response: "Sorry, I encountered an error. Let's start over.",
1398
+ flowCompleted: false
1399
+ };
1400
+ }
1401
+ }
1402
+ const conversationHistory = getMessageHistory(phoneNumber);
1403
+ const conversationContext = conversationHistory.slice(-5).map((msg) => `${msg.role === "assistant" ? "Bot" : "User"}: ${msg.content}`);
1404
+ const matchedFlow = await registry.matchFlow(deps, phoneNumber, userMessage, conversationContext);
1405
+ if (matchedFlow) {
1406
+ const globalParams = matchedFlow.prefill ? matchedFlow.prefill(phoneNumber, {}) : {};
1407
+ logger.info("flow started", {
1408
+ phoneNumber,
1409
+ flow: matchedFlow.definition.id,
1410
+ msg: userMessage.substring(0, 160)
1411
+ });
1412
+ try {
1413
+ const extraction = await extractParameters(
1414
+ deps,
1415
+ matchedFlow,
1416
+ phoneNumber,
1417
+ userMessage,
1418
+ globalParams
1419
+ );
1420
+ const mergedParams = { ...globalParams, ...extraction.extractedParams };
1421
+ setActiveFlow(phoneNumber, matchedFlow.definition.id, mergedParams);
1422
+ if (extraction.allParamsFilled) {
1423
+ logger.info("flow instant complete", {
1424
+ phoneNumber,
1425
+ flow: matchedFlow.definition.id,
1426
+ params: mergedParams
1427
+ });
1428
+ const result = await matchedFlow.handler(mergedParams, { phoneNumber, channel });
1429
+ clearActiveFlow(phoneNumber);
1430
+ return {
1431
+ isFlowActive: false,
1432
+ response: result.say,
1433
+ flowCompleted: true,
1434
+ smsContent: result.sms,
1435
+ flowSuccess: result.success
1436
+ };
1437
+ }
1438
+ return {
1439
+ isFlowActive: true,
1440
+ response: extraction.nextPrompt || "Could you provide more details?",
1441
+ flowCompleted: false
1442
+ };
1443
+ } catch (error) {
1444
+ logger.error("flow init error", {
1445
+ phoneNumber,
1446
+ flow: matchedFlow.definition.id,
1447
+ error: getErrorMessage(error)
1448
+ });
1449
+ return { isFlowActive: false, response: "", flowCompleted: false };
1450
+ }
1451
+ }
1452
+ return { isFlowActive: false, response: "", flowCompleted: false };
1453
+ }
1454
+
1455
+ // src/routes/call/processor.ts
1456
+ async function processCall(deps, registry, phoneNumber, speechResult) {
1457
+ const incoming = await processIncoming(deps, phoneNumber, speechResult, "call");
1458
+ if (incoming.shouldTransfer) {
1459
+ logger.info("transferring to human", { phoneNumber, language: incoming.detectedLanguage });
1460
+ persistSession(phoneNumber, "call");
1461
+ persistFinalSession(phoneNumber, "call", "redirected", incoming.processedMessage);
1462
+ return transferTwiml(incoming.detectedLanguage, deps.config);
1463
+ }
1464
+ if (incoming.shouldEndCall) {
1465
+ logger.info("ending call - user done", { phoneNumber, language: incoming.detectedLanguage });
1466
+ persistSession(phoneNumber, "call");
1467
+ persistFinalSession(phoneNumber, "call", "ended");
1468
+ clearContext(phoneNumber);
1469
+ return farewellTwiml(incoming.detectedLanguage, deps.config);
1470
+ }
1471
+ const flowResult = await processFlow(
1472
+ deps,
1473
+ registry,
1474
+ phoneNumber,
1475
+ incoming.processedMessage,
1476
+ "call"
1477
+ );
1478
+ if (flowResult.isFlowActive || flowResult.flowCompleted) {
1479
+ logger.info("FLOW RESULT", {
1480
+ phoneNumber,
1481
+ active: flowResult.isFlowActive,
1482
+ done: flowResult.flowCompleted,
1483
+ success: flowResult.flowSuccess
1484
+ });
1485
+ if (flowResult.flowCompleted && flowResult.flowSuccess === false) {
1486
+ const { voice, language: lang } = getVoiceConfig(
1487
+ incoming.detectedLanguage,
1488
+ deps.config.voices
1489
+ );
1490
+ const transferNumber = deps.config.transferNumber || "";
1491
+ return `<?xml version="1.0" encoding="UTF-8"?>
1492
+ <Response>
1493
+ <Say voice="${voice}" language="${lang}">${flowResult.response}</Say>
1494
+ <Dial>${transferNumber}</Dial>
1495
+ </Response>`;
1496
+ }
1497
+ const escapedResponse2 = escapeXml(flowResult.response);
1498
+ return gatherTwiml(escapedResponse2, incoming.detectedLanguage, deps.config, phoneNumber);
1499
+ }
1500
+ const botResponse = await chat(deps, phoneNumber, incoming.processedMessage);
1501
+ const phoneResponse = await processOutgoing(deps, phoneNumber, botResponse, "call");
1502
+ const escapedResponse = escapeXml(phoneResponse);
1503
+ return gatherTwiml(escapedResponse, incoming.detectedLanguage, deps.config, phoneNumber);
1504
+ }
1505
+
1506
+ // src/routes/call/handle-respond.ts
1507
+ async function handleRespond(c, deps, registry) {
1508
+ const body = await c.req.parseBody();
1509
+ const phoneNumber = (body.From || "unknown").trim();
1510
+ const speechResult = body.SpeechResult;
1511
+ const config = deps.config;
1512
+ logger.info("speech received", { phoneNumber, speechResult });
1513
+ if (!speechResult) {
1514
+ const twiml = gatherTwiml(
1515
+ getPhrase("en", "didNotCatch", config.languageDir),
1516
+ "en",
1517
+ config,
1518
+ phoneNumber
1519
+ );
1520
+ return c.text(twiml, 200, { "Content-Type": "text/xml" });
1521
+ }
1522
+ resetNoSpeechRetries(phoneNumber);
1523
+ const messageHistory = getMessageHistory(phoneNumber);
1524
+ const isFirstMessage = messageHistory.filter((m) => m.role === "user").length === 0;
1525
+ const ackEnabled = config.features?.thinkingAcknowledgmentEnabled ?? false;
1526
+ if (ackEnabled && isFirstMessage) {
1527
+ let resolveQuery;
1528
+ const promise = new Promise((resolve) => {
1529
+ resolveQuery = resolve;
1530
+ });
1531
+ setPending(phoneNumber, {
1532
+ speechResult,
1533
+ promise,
1534
+ resolve: resolveQuery
1535
+ });
1536
+ processCall(deps, registry, phoneNumber, speechResult).then((twiml) => {
1537
+ const pending = getPendingForResolve(phoneNumber);
1538
+ if (pending) pending.resolve({ twiml });
1539
+ persistSession(phoneNumber, "call");
1540
+ }).catch((error) => {
1541
+ logger.error("background processing error", {
1542
+ phoneNumber,
1543
+ error: getErrorMessage(error)
1544
+ });
1545
+ const pending = getPendingForResolve(phoneNumber);
1546
+ if (pending) {
1547
+ pending.resolve({
1548
+ twiml: sayTwiml(getPhrase("en", "error", config.languageDir), "en", config)
1549
+ });
1550
+ }
1551
+ });
1552
+ return c.text(acknowledgmentTwiml("en", config), 200, { "Content-Type": "text/xml" });
1553
+ }
1554
+ try {
1555
+ const twiml = await processCall(deps, registry, phoneNumber, speechResult);
1556
+ persistSession(phoneNumber, "call");
1557
+ return c.text(twiml, 200, { "Content-Type": "text/xml" });
1558
+ } catch (error) {
1559
+ logger.error("call processing error", { error: getErrorMessage(error) });
1560
+ const twiml = sayTwiml(getPhrase("en", "error", config.languageDir), "en", config);
1561
+ return c.text(twiml, 200, { "Content-Type": "text/xml" });
1562
+ }
1563
+ }
1564
+ function getPendingForResolve(phoneNumber) {
1565
+ const { getPending: getPending2 } = (init_pending(), __toCommonJS(pending_exports));
1566
+ return getPending2(phoneNumber);
1567
+ }
1568
+
1569
+ // src/routes/call/handle-status.ts
1570
+ async function handleStatus(c) {
1571
+ const body = await c.req.parseBody();
1572
+ const phoneNumber = (body.From || "unknown").trim();
1573
+ const callStatus = body.CallStatus;
1574
+ logger.info("call status update", { phoneNumber, callStatus });
1575
+ if (callStatus === "completed") {
1576
+ persistSession(phoneNumber, "call");
1577
+ persistFinalSession(phoneNumber, "call", "ended");
1578
+ clearContext(phoneNumber);
1579
+ }
1580
+ return c.text("", 200);
1581
+ }
1582
+
1583
+ // src/routes/call/index.ts
1584
+ function callRoutes(deps, registry) {
1585
+ const app = new import_hono.Hono();
1586
+ app.post("/call", (c) => handleInitialCall(c, deps.config));
1587
+ app.post("/call/respond", (c) => handleRespond(c, deps, registry));
1588
+ app.post("/call/answer", (c) => handleAnswer(c, deps.config));
1589
+ app.post("/call/no-speech", (c) => handleNoSpeech(c, deps.config));
1590
+ app.post("/call/status", (c) => handleStatus(c));
1591
+ return app;
1592
+ }
1593
+
1594
+ // src/routes/sms/index.ts
1595
+ var import_hono2 = require("hono");
1596
+
1597
+ // src/routes/sms/processor.ts
1598
+ async function processSms(deps, registry, phoneNumber, messageBody) {
1599
+ const incoming = await processIncoming(deps, phoneNumber, messageBody, "sms");
1600
+ if (incoming.shouldTransfer) {
1601
+ const message = getSmsPhrase(incoming.detectedLanguage, "callForHelp", deps.config.languageDir);
1602
+ return messageTwiml(message);
1603
+ }
1604
+ const flowResult = await processFlow(
1605
+ deps,
1606
+ registry,
1607
+ phoneNumber,
1608
+ incoming.processedMessage,
1609
+ "sms"
1610
+ );
1611
+ if (flowResult.isFlowActive || flowResult.flowCompleted) {
1612
+ if (flowResult.flowCompleted && flowResult.flowSuccess === false) {
1613
+ const message = getSmsPhrase(
1614
+ incoming.detectedLanguage,
1615
+ "processingError",
1616
+ deps.config.languageDir
1617
+ );
1618
+ return messageTwiml(message);
1619
+ }
1620
+ return messageTwiml(flowResult.response);
1621
+ }
1622
+ const botResponse = await chat(deps, phoneNumber, incoming.processedMessage);
1623
+ const smsResponse = await processOutgoing(deps, phoneNumber, botResponse, "sms");
1624
+ return messageTwiml(smsResponse);
1625
+ }
1626
+
1627
+ // src/routes/sms/handle-incoming.ts
1628
+ async function handleIncomingSMS(c, deps, registry) {
1629
+ const body = await c.req.parseBody();
1630
+ const phoneNumber = (body.From || "unknown").trim();
1631
+ const messageBody = body.Body || "";
1632
+ logger.info("sms received", { phoneNumber, messageBody });
1633
+ if (!messageBody.trim()) {
1634
+ return c.text(messageTwiml(getSmsPhrase("en", "greeting", deps.config.languageDir)), 200, {
1635
+ "Content-Type": "text/xml"
1636
+ });
1637
+ }
1638
+ try {
1639
+ const twiml = await processSms(deps, registry, phoneNumber, messageBody);
1640
+ persistSession(phoneNumber, "sms");
1641
+ return c.text(twiml, 200, { "Content-Type": "text/xml" });
1642
+ } catch (error) {
1643
+ logger.error("sms processing error", {
1644
+ phoneNumber,
1645
+ error: getErrorMessage(error)
1646
+ });
1647
+ return c.text(messageTwiml(getSmsPhrase("en", "genericError", deps.config.languageDir)), 200, {
1648
+ "Content-Type": "text/xml"
1649
+ });
1650
+ }
1651
+ }
1652
+
1653
+ // src/routes/sms/index.ts
1654
+ function smsRoutes(deps, registry) {
1655
+ const app = new import_hono2.Hono();
1656
+ app.post("/sms", (c) => handleIncomingSMS(c, deps, registry));
1657
+ app.get("/sms", (c) => c.text("SMS endpoint active"));
1658
+ return app;
1659
+ }
1660
+
1661
+ // src/plugin.ts
1662
+ var DEFAULT_CONTEXT_TTL_MS = 30 * 60 * 1e3;
1663
+ var DEFAULT_CLEANUP_INTERVAL_MS = 5 * 60 * 1e3;
1664
+ var DEFAULT_MODEL = "gpt-4o-mini";
1665
+ async function createTelephonyRoutes(app, chatterDeps, config) {
1666
+ logger.info("initializing telephony routes");
1667
+ const openaiApiKey = config.openaiApiKey || chatterDeps.config.openai.apiKey;
1668
+ if (!openaiApiKey) {
1669
+ throw new Error("OpenAI API key required for talker");
1670
+ }
1671
+ const deps = {
1672
+ chatter: chatterDeps,
1673
+ config,
1674
+ openaiApiKey,
1675
+ openaiModel: config.processing?.model || DEFAULT_MODEL
1676
+ };
1677
+ const dbUrl = config.database?.url || chatterDeps.config.database?.url;
1678
+ const dbToken = config.database?.authToken || chatterDeps.config.database?.authToken;
1679
+ if (dbUrl && dbToken) {
1680
+ initDbClient(dbUrl, dbToken);
1681
+ await runMigrations();
1682
+ }
1683
+ const registry = new FlowRegistry(config.flowsDir || "");
1684
+ if (config.flowsDir) {
1685
+ await registry.loadFlows();
1686
+ }
1687
+ startCleanup(
1688
+ config.contextTtlMs ?? DEFAULT_CONTEXT_TTL_MS,
1689
+ config.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS
1690
+ );
1691
+ const prefix = config.routePrefix || "";
1692
+ app.route(prefix, callRoutes(deps, registry));
1693
+ app.route(prefix, smsRoutes(deps, registry));
1694
+ logger.info("telephony routes mounted", {
1695
+ prefix: prefix || "/",
1696
+ hasFlows: !!config.flowsDir,
1697
+ flowCount: registry.getAllFlows().length,
1698
+ hasTransferNumber: !!config.transferNumber
1699
+ });
1700
+ }
1701
+
1702
+ // src/standalone.ts
1703
+ var import_hono3 = require("hono");
1704
+ var DEFAULT_CONTEXT_TTL_MS2 = 30 * 60 * 1e3;
1705
+ var DEFAULT_CLEANUP_INTERVAL_MS2 = 5 * 60 * 1e3;
1706
+ var DEFAULT_MODEL2 = "gpt-4o-mini";
1707
+ async function createStandaloneServer(config) {
1708
+ logger.info("initializing standalone talker server");
1709
+ if (!config.openaiApiKey) {
1710
+ throw new Error("openaiApiKey is required for standalone mode");
1711
+ }
1712
+ const stubChatterDeps = {
1713
+ config: { openai: { apiKey: config.openaiApiKey } }
1714
+ };
1715
+ const deps = {
1716
+ chatter: stubChatterDeps,
1717
+ config,
1718
+ openaiApiKey: config.openaiApiKey,
1719
+ openaiModel: config.processing?.model || DEFAULT_MODEL2
1720
+ };
1721
+ if (config.database?.url && config.database?.authToken) {
1722
+ initDbClient(config.database.url, config.database.authToken);
1723
+ await runMigrations();
1724
+ }
1725
+ const registry = new FlowRegistry(config.flowsDir || "");
1726
+ if (config.flowsDir) {
1727
+ await registry.loadFlows();
1728
+ }
1729
+ startCleanup(
1730
+ config.contextTtlMs ?? DEFAULT_CONTEXT_TTL_MS2,
1731
+ config.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS2
1732
+ );
1733
+ const app = new import_hono3.Hono();
1734
+ app.get("/healthz", (c) => c.text("ok"));
1735
+ const prefix = config.routePrefix || "";
1736
+ app.route(prefix, callRoutes(deps, registry));
1737
+ app.route(prefix, smsRoutes(deps, registry));
1738
+ logger.info("standalone talker server ready", {
1739
+ prefix: prefix || "/",
1740
+ hasFlows: !!config.flowsDir,
1741
+ flowCount: registry.getAllFlows().length,
1742
+ hasChatFn: !!config.chatFn,
1743
+ hasTransferNumber: !!config.transferNumber
1744
+ });
1745
+ return app;
1746
+ }
1747
+
1748
+ // src/adapters/twilio.ts
1749
+ async function sendSMS(config, to, message) {
1750
+ if (!config.accountSid || !config.authToken || !config.phoneNumber) {
1751
+ logger.warn("Twilio credentials not configured, skipping SMS send", { to });
1752
+ return false;
1753
+ }
1754
+ try {
1755
+ const auth = Buffer.from(`${config.accountSid}:${config.authToken}`).toString("base64");
1756
+ const response = await fetch(
1757
+ `https://api.twilio.com/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
1758
+ {
1759
+ method: "POST",
1760
+ headers: {
1761
+ Authorization: `Basic ${auth}`,
1762
+ "Content-Type": "application/x-www-form-urlencoded"
1763
+ },
1764
+ body: new URLSearchParams({
1765
+ From: config.phoneNumber,
1766
+ To: to,
1767
+ Body: message
1768
+ })
1769
+ }
1770
+ );
1771
+ if (!response.ok) {
1772
+ const error = await response.text();
1773
+ logger.error("SMS send failed", { to, status: response.status, error });
1774
+ return false;
1775
+ }
1776
+ const data = await response.json();
1777
+ logger.info("SMS sent successfully", { to, messageSid: data.sid });
1778
+ return true;
1779
+ } catch (error) {
1780
+ logger.error("SMS send error", {
1781
+ to,
1782
+ error: error instanceof Error ? error.message : "Unknown"
1783
+ });
1784
+ return false;
1785
+ }
1786
+ }
1787
+ // Annotate the CommonJS export names for ESM import in node:
1788
+ 0 && (module.exports = {
1789
+ FlowRegistry,
1790
+ acknowledgmentTwiml,
1791
+ addMessage,
1792
+ callRoutes,
1793
+ clearActiveFlow,
1794
+ clearAllContexts,
1795
+ clearContext,
1796
+ closeDbClient,
1797
+ createStandaloneServer,
1798
+ createTelephonyRoutes,
1799
+ escapeXml,
1800
+ farewellTwiml,
1801
+ gatherTwiml,
1802
+ getActiveFlow,
1803
+ getContext,
1804
+ getDbClient,
1805
+ getDefaultVoices,
1806
+ getDetectedLanguage,
1807
+ getExitMessage,
1808
+ getFarewellPhrase,
1809
+ getFlowPhrase,
1810
+ getLastPrompt,
1811
+ getMessageHistory,
1812
+ getOrCreateContext,
1813
+ getPhrase,
1814
+ getSmsPhrase,
1815
+ getVoiceConfig,
1816
+ incrementNoSpeechRetries,
1817
+ initDbClient,
1818
+ loadFlowsFromDirectory,
1819
+ loadPhrases,
1820
+ logger,
1821
+ messageTwiml,
1822
+ persistFinalSession,
1823
+ persistSession,
1824
+ processFlow,
1825
+ processIncoming,
1826
+ processOutgoing,
1827
+ resetNoSpeechRetries,
1828
+ runMigrations,
1829
+ sayTwiml,
1830
+ sendSMS,
1831
+ setActiveFlow,
1832
+ setDetectedLanguage,
1833
+ setLastPrompt,
1834
+ shouldExitFlow,
1835
+ smsRoutes,
1836
+ startCleanup,
1837
+ stopCleanup,
1838
+ transferTwiml,
1839
+ updateFlowParams
1840
+ });