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