@conversionpros/aiva 1.0.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 (152) hide show
  1. package/README.md +148 -0
  2. package/auto-deploy.js +190 -0
  3. package/bin/aiva.js +81 -0
  4. package/cli-sync.js +126 -0
  5. package/d2a-prompt-template.txt +106 -0
  6. package/diagnostics-api.js +304 -0
  7. package/docs/ara-dedup-fix-scope.md +112 -0
  8. package/docs/ara-fix-round2-scope.md +61 -0
  9. package/docs/ara-greeting-fix-scope.md +70 -0
  10. package/docs/calendar-date-fix-scope.md +28 -0
  11. package/docs/getting-started.md +115 -0
  12. package/docs/network-architecture-rollout-scope.md +43 -0
  13. package/docs/scope-google-oauth-integration.md +351 -0
  14. package/docs/settings-page-scope.md +50 -0
  15. package/docs/xai-imagine-scope.md +116 -0
  16. package/docs/xai-voice-integration-scope.md +115 -0
  17. package/docs/xai-voice-tools-scope.md +165 -0
  18. package/email-router.js +512 -0
  19. package/follow-up-handler.js +606 -0
  20. package/gateway-monitor.js +158 -0
  21. package/google-email.js +379 -0
  22. package/google-oauth.js +310 -0
  23. package/grok-imagine.js +97 -0
  24. package/health-reporter.js +287 -0
  25. package/invisible-prefix-base.txt +206 -0
  26. package/invisible-prefix-owner.txt +26 -0
  27. package/invisible-prefix-slim.txt +10 -0
  28. package/invisible-prefix.txt +43 -0
  29. package/knowledge-base.js +472 -0
  30. package/lib/cli.js +19 -0
  31. package/lib/config.js +124 -0
  32. package/lib/health.js +57 -0
  33. package/lib/process.js +207 -0
  34. package/lib/server.js +42 -0
  35. package/lib/setup.js +472 -0
  36. package/meta-capi.js +206 -0
  37. package/meta-leads.js +411 -0
  38. package/notion-oauth.js +323 -0
  39. package/package.json +61 -0
  40. package/public/agent-config.html +241 -0
  41. package/public/aiva-avatar-anime.png +0 -0
  42. package/public/css/docs.css.bak +688 -0
  43. package/public/css/onboarding.css +543 -0
  44. package/public/diagrams/claude-subscription-pool.html +329 -0
  45. package/public/diagrams/claude-subscription-pool.png +0 -0
  46. package/public/docs-icon.png +0 -0
  47. package/public/escalation.html +237 -0
  48. package/public/group-config.html +300 -0
  49. package/public/icon-192.png +0 -0
  50. package/public/icon-512.png +0 -0
  51. package/public/icons/agents.svg +1 -0
  52. package/public/icons/attach.svg +1 -0
  53. package/public/icons/characters.svg +1 -0
  54. package/public/icons/chat.svg +1 -0
  55. package/public/icons/docs.svg +1 -0
  56. package/public/icons/heartbeat.svg +1 -0
  57. package/public/icons/messages.svg +1 -0
  58. package/public/icons/mic.svg +1 -0
  59. package/public/icons/notes.svg +1 -0
  60. package/public/icons/settings.svg +1 -0
  61. package/public/icons/tasks.svg +1 -0
  62. package/public/images/onboarding/p0-communication-layer.png +0 -0
  63. package/public/images/onboarding/p0-infinite-surface.png +0 -0
  64. package/public/images/onboarding/p0-learning-model.png +0 -0
  65. package/public/images/onboarding/p0-meet-aiva.png +0 -0
  66. package/public/images/onboarding/p4-contact-intelligence.png +0 -0
  67. package/public/images/onboarding/p4-context-compounds.png +0 -0
  68. package/public/images/onboarding/p4-message-router.png +0 -0
  69. package/public/images/onboarding/p4-per-contact-rules.png +0 -0
  70. package/public/images/onboarding/p4-send-messages.png +0 -0
  71. package/public/images/onboarding/p6-be-precise.png +0 -0
  72. package/public/images/onboarding/p6-review-escalations.png +0 -0
  73. package/public/images/onboarding/p6-voice-input.png +0 -0
  74. package/public/images/onboarding/p7-completion.png +0 -0
  75. package/public/index.html +11594 -0
  76. package/public/js/onboarding.js +699 -0
  77. package/public/manifest.json +24 -0
  78. package/public/messages-v2.html +2824 -0
  79. package/public/permission-approve.html.bak +107 -0
  80. package/public/permissions.html +150 -0
  81. package/public/styles/design-system.css +68 -0
  82. package/router-db.js +604 -0
  83. package/router-utils.js +28 -0
  84. package/router-v2/adapters/imessage.js +191 -0
  85. package/router-v2/adapters/quo.js +82 -0
  86. package/router-v2/adapters/whatsapp.js +192 -0
  87. package/router-v2/contact-manager.js +234 -0
  88. package/router-v2/conversation-engine.js +498 -0
  89. package/router-v2/data/knowledge-base.json +176 -0
  90. package/router-v2/data/router-v2.db +0 -0
  91. package/router-v2/data/router-v2.db-shm +0 -0
  92. package/router-v2/data/router-v2.db-wal +0 -0
  93. package/router-v2/data/router.db +0 -0
  94. package/router-v2/db.js +457 -0
  95. package/router-v2/escalation-bridge.js +540 -0
  96. package/router-v2/follow-up-engine.js +347 -0
  97. package/router-v2/index.js +441 -0
  98. package/router-v2/ingestion.js +213 -0
  99. package/router-v2/knowledge-base.js +231 -0
  100. package/router-v2/lead-qualifier.js +152 -0
  101. package/router-v2/learning-loop.js +202 -0
  102. package/router-v2/outbound-sender.js +160 -0
  103. package/router-v2/package.json +13 -0
  104. package/router-v2/permission-gate.js +86 -0
  105. package/router-v2/playbook.js +177 -0
  106. package/router-v2/prompts/base.js +52 -0
  107. package/router-v2/prompts/first-contact.js +38 -0
  108. package/router-v2/prompts/lead-qualification.js +37 -0
  109. package/router-v2/prompts/scheduling.js +72 -0
  110. package/router-v2/prompts/style-overrides.js +22 -0
  111. package/router-v2/scheduler.js +301 -0
  112. package/router-v2/scripts/migrate-v1-to-v2.js +215 -0
  113. package/router-v2/scripts/seed-faq.js +67 -0
  114. package/router-v2/seed-knowledge-base.js +39 -0
  115. package/router-v2/utils/ai.js +129 -0
  116. package/router-v2/utils/phone.js +52 -0
  117. package/router-v2/utils/response-validator.js +98 -0
  118. package/router-v2/utils/sanitize.js +222 -0
  119. package/router.js +5005 -0
  120. package/routes/google-calendar.js +186 -0
  121. package/scripts/deploy.sh +62 -0
  122. package/scripts/macos-calendar.sh +232 -0
  123. package/scripts/onboard-device.sh +466 -0
  124. package/server.js +5131 -0
  125. package/start.sh +24 -0
  126. package/templates/AGENTS.md +548 -0
  127. package/templates/IDENTITY.md +15 -0
  128. package/templates/docs-agents.html +132 -0
  129. package/templates/docs-app.html +130 -0
  130. package/templates/docs-home.html +83 -0
  131. package/templates/docs-imessage.html +121 -0
  132. package/templates/docs-tasks.html +123 -0
  133. package/templates/docs-tips.html +175 -0
  134. package/templates/getting-started.html +809 -0
  135. package/templates/invisible-prefix-base.txt +171 -0
  136. package/templates/invisible-prefix-owner.txt +282 -0
  137. package/templates/invisible-prefix.txt +338 -0
  138. package/templates/manifest.json +61 -0
  139. package/templates/memory-org/clients.md +7 -0
  140. package/templates/memory-org/credentials.md +9 -0
  141. package/templates/memory-org/devices.md +7 -0
  142. package/templates/updates.html +464 -0
  143. package/templates/workspace/AGENTS.md.tmpl +161 -0
  144. package/templates/workspace/HEARTBEAT.md.tmpl +17 -0
  145. package/templates/workspace/IDENTITY.md.tmpl +15 -0
  146. package/templates/workspace/MEMORY.md.tmpl +16 -0
  147. package/templates/workspace/SOUL.md.tmpl +51 -0
  148. package/templates/workspace/USER.md.tmpl +25 -0
  149. package/tts-proxy.js +96 -0
  150. package/voice-call-local.js +731 -0
  151. package/voice-call.js +732 -0
  152. package/wa-listener.js +354 -0
@@ -0,0 +1,540 @@
1
+ // ── Escalation Bridge - Silent Handoff to Main Agent ─────
2
+ // Per-category timeouts, two-strike retry, contact never knows.
3
+ 'use strict';
4
+
5
+ const crypto = require('crypto');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { getStmts, getSetting } = require('./db');
9
+ const { sendMessage, sendEscalationTimeout } = require('./outbound-sender');
10
+ const { savePreference } = require('./learning-loop');
11
+
12
+ // Read gateway auth for AIVA channel delivery
13
+ let _gatewayToken = null;
14
+ function getGatewayToken() {
15
+ if (_gatewayToken) return _gatewayToken;
16
+ try {
17
+ const configPath = path.join(process.env.HOME || '/Users/brandonburgan', '.openclaw', 'openclaw.json');
18
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
19
+ _gatewayToken = config.hooks?.token || config.gateway?.auth?.password || '';
20
+ } catch { _gatewayToken = ''; }
21
+ return _gatewayToken;
22
+ }
23
+
24
+ /**
25
+ * Send an escalation to the main agent via the AIVA channel plugin.
26
+ * This injects a system message into the main session via the gateway webhook.
27
+ */
28
+ async function sendToMainAgent(text, interactive = null) {
29
+ // 1. Send to gateway (for the agent session)
30
+ const token = getGatewayToken();
31
+ const payload = {
32
+ userId: 'system',
33
+ displayName: 'AIVA Router',
34
+ text: `[System Message] Router Escalation:\n\n${text}`,
35
+ messageId: `esc-${Date.now()}`,
36
+ timestamp: new Date().toISOString(),
37
+ media: [],
38
+ metadata: { source: 'router-v2-escalation' },
39
+ };
40
+ let gatewayOk = false;
41
+ try {
42
+ const resp = await fetch('http://localhost:18789/aiva', {
43
+ method: 'POST',
44
+ headers: {
45
+ 'Content-Type': 'application/json',
46
+ 'Authorization': `Bearer ${token}`,
47
+ },
48
+ body: JSON.stringify(payload),
49
+ signal: AbortSignal.timeout(15000),
50
+ });
51
+ if (!resp.ok) {
52
+ const body = await resp.text().catch(() => '');
53
+ log('Gateway delivery failed', { status: resp.status, body: body.substring(0, 200) });
54
+ } else {
55
+ gatewayOk = true;
56
+ }
57
+ } catch (err) {
58
+ log('Gateway delivery error', { error: err.message });
59
+ }
60
+
61
+ // 2. Send directly to AIVA app chat (so Brandon sees it with interactive buttons)
62
+ let appOk = false;
63
+ try {
64
+ const appPayload = { userId: 'brandon', text, interactive, escalationId: interactive?.escalationMeta?.escalationId || null };
65
+ const appResp = await fetch('http://localhost:3847/api/chat/aiva-reply', {
66
+ method: 'POST',
67
+ headers: {
68
+ 'Content-Type': 'application/json',
69
+ 'x-aiva-internal': 'true',
70
+ },
71
+ body: JSON.stringify(appPayload),
72
+ signal: AbortSignal.timeout(5000),
73
+ });
74
+ if (appResp.ok) {
75
+ appOk = true;
76
+ log('Escalation delivered to AIVA app chat');
77
+ } else {
78
+ log('AIVA app chat delivery failed', { status: appResp.status });
79
+ }
80
+ } catch (err) {
81
+ log('AIVA app chat delivery error', { error: err.message });
82
+ }
83
+
84
+ return gatewayOk || appOk;
85
+ }
86
+
87
+ // Per-category timeout in minutes
88
+ const CATEGORY_TIMEOUTS = {
89
+ lead: 5,
90
+ 'qualified-lead': 5,
91
+ client: 10,
92
+ team: 10,
93
+ unknown: 15,
94
+ friend: 20,
95
+ family: 30,
96
+ };
97
+
98
+ function log(msg, data) {
99
+ const ts = new Date().toISOString();
100
+ if (data) console.log(`[${ts}] [ESCALATION] ${msg}`, JSON.stringify(data));
101
+ else console.log(`[${ts}] [ESCALATION] ${msg}`);
102
+ }
103
+
104
+ function generateEscalationId() {
105
+ return `esc_${crypto.randomBytes(8).toString('hex')}`;
106
+ }
107
+
108
+ /**
109
+ * Escalate a message to the main OpenClaw agent.
110
+ * @param {Object} options
111
+ * @param {Object} options.contact - Contact record
112
+ * @param {string} options.triggerMessage - What the contact said
113
+ * @param {Array} options.conversationHistory - Recent messages
114
+ * @param {Object} [options.contactContext] - Additional context
115
+ * @param {boolean} [options.isClientSupport=false] - Client support escalation
116
+ * @param {boolean} [options.isRetry=false] - Is this a retry after timeout
117
+ * @returns {Promise<string>} Escalation ID
118
+ */
119
+ // Scope mapping for escalation types
120
+ const ESCALATION_TYPE_SCOPES = {
121
+ scheduling: ['calendar.book', 'calendar.view'],
122
+ task: ['tasks.create'],
123
+ reminder: ['reminders.create'],
124
+ messaging: ['messages.send'],
125
+ file_sharing: ['files.send'],
126
+ };
127
+
128
+ function detectEscalationType(text) {
129
+ if (/schedul|call|meet|appointment|book|calendar|availab/i.test(text)) return 'scheduling';
130
+ if (/remind|task|todo|follow.?up/i.test(text)) return 'task';
131
+ if (/send.*file|attach|document|upload/i.test(text)) return 'file_sharing';
132
+ if (/send.*message|text.*someone|forward/i.test(text)) return 'messaging';
133
+ return 'general';
134
+ }
135
+
136
+ // Build contextual interactive options based on what triggered the escalation
137
+ function buildEscalationOptions(contact, triggerMessage, escalationType) {
138
+ // Determine the denied scopes to grant if "allow future" is selected
139
+ // Always detect from the trigger message - the escalationType from the validator
140
+ // describes WHY it was caught (e.g. "sending messages to others"), not WHAT the contact wants
141
+ const resolvedType = detectEscalationType(triggerMessage);
142
+ const scopesToGrant = ESCALATION_TYPE_SCOPES[resolvedType] || [];
143
+
144
+ // Scheduling-related escalations
145
+ if (resolvedType === 'scheduling') {
146
+ return {
147
+ escalationMeta: { phone: contact.phone, type: resolvedType, scopes: scopesToGrant },
148
+ questions: [{
149
+ id: 'escalation_action',
150
+ text: `${contact.name}: "${triggerMessage}"`,
151
+ options: [
152
+ 'Yes, add a reminder and allow future requests like this',
153
+ 'Yes, add a reminder, but keep requiring escalation',
154
+ 'Politely decline',
155
+ 'Ignore future requests like this from this contact',
156
+ ],
157
+ actions: [
158
+ { type: 'approve_and_grant', scopes: scopesToGrant, reply: true },
159
+ { type: 'approve_one_time', scopes: [], reply: true },
160
+ { type: 'decline', scopes: [], reply: true },
161
+ { type: 'ignore', scopes: [], reply: false },
162
+ ],
163
+ multiSelect: false,
164
+ allowOther: true,
165
+ }],
166
+ };
167
+ }
168
+
169
+ // Task/reminder-related
170
+ if (resolvedType === 'task' || resolvedType === 'reminder') {
171
+ return {
172
+ escalationMeta: { phone: contact.phone, type: resolvedType, scopes: scopesToGrant },
173
+ questions: [{
174
+ id: 'escalation_action',
175
+ text: `${contact.name}: "${triggerMessage}"`,
176
+ options: [
177
+ 'Yes, handle it and allow future requests like this',
178
+ 'Yes, handle it, but keep requiring escalation',
179
+ 'Politely decline',
180
+ 'Ignore future requests like this from this contact',
181
+ ],
182
+ actions: [
183
+ { type: 'approve_and_grant', scopes: scopesToGrant, reply: true },
184
+ { type: 'approve_one_time', scopes: [], reply: true },
185
+ { type: 'decline', scopes: [], reply: true },
186
+ { type: 'ignore', scopes: [], reply: false },
187
+ ],
188
+ multiSelect: false,
189
+ allowOther: true,
190
+ }],
191
+ };
192
+ }
193
+
194
+ // File/message sharing
195
+ if (resolvedType === 'messaging' || resolvedType === 'file_sharing') {
196
+ return {
197
+ escalationMeta: { phone: contact.phone, type: resolvedType, scopes: scopesToGrant },
198
+ questions: [{
199
+ id: 'escalation_action',
200
+ text: `${contact.name}: "${triggerMessage}"`,
201
+ options: [
202
+ 'Yes, handle it and enable this capability for them',
203
+ 'Yes, handle it one-time only',
204
+ 'Politely decline',
205
+ 'Ignore future requests like this from this contact',
206
+ ],
207
+ actions: [
208
+ { type: 'approve_and_grant', scopes: scopesToGrant, reply: true },
209
+ { type: 'approve_one_time', scopes: [], reply: true },
210
+ { type: 'decline', scopes: [], reply: true },
211
+ { type: 'ignore', scopes: [], reply: false },
212
+ ],
213
+ multiSelect: false,
214
+ allowOther: true,
215
+ }],
216
+ };
217
+ }
218
+
219
+ // Default / general escalation
220
+ return {
221
+ escalationMeta: { phone: contact.phone, type: 'general', scopes: [] },
222
+ questions: [{
223
+ id: 'escalation_action',
224
+ text: `${contact.name}: "${triggerMessage}"`,
225
+ options: [
226
+ 'Draft a reply for me to approve',
227
+ 'I\'ll handle it personally',
228
+ 'Politely decline',
229
+ 'Ignore',
230
+ ],
231
+ actions: [
232
+ { type: 'approve_one_time', scopes: [], reply: true },
233
+ { type: 'handle_personally', scopes: [], reply: false },
234
+ { type: 'decline', scopes: [], reply: true },
235
+ { type: 'ignore', scopes: [], reply: false },
236
+ ],
237
+ multiSelect: false,
238
+ allowOther: true,
239
+ }],
240
+ };
241
+ }
242
+
243
+ async function escalate({
244
+ contact,
245
+ triggerMessage,
246
+ conversationHistory = [],
247
+ contactContext = {},
248
+ isClientSupport = false,
249
+ isRetry = false,
250
+ escalationType = null,
251
+ }) {
252
+ const stmts = getStmts();
253
+ const escalationId = generateEscalationId();
254
+ const timeoutMin = CATEGORY_TIMEOUTS[contact.category] || 15;
255
+ const timeoutAt = new Date(Date.now() + timeoutMin * 60000).toISOString().replace('T', ' ').split('.')[0];
256
+
257
+ // Build escalation payload
258
+ const payload = {
259
+ escalationId,
260
+ phone: contact.phone,
261
+ contactName: contact.name,
262
+ category: contact.category,
263
+ channel: contact.source || 'imessage',
264
+ triggerMessage,
265
+ conversationHistory: conversationHistory.slice(-20).map(m => ({
266
+ role: m.sent_by === 'contact' ? 'contact' : 'aiva',
267
+ text: m.text,
268
+ at: m.created_at,
269
+ })),
270
+ contactContext: {
271
+ relationship: contactContext.relationship || '',
272
+ lastTopic: contactContext.last_topic || '',
273
+ pendingItems: JSON.parse(contactContext.pending_items || '[]'),
274
+ conversationSummary: contactContext.conversation_summary || '',
275
+ },
276
+ isClientSupport,
277
+ isRetry,
278
+ prepend: `You are responding on behalf of AIVA to a contact who doesn't know they've been escalated. Respond naturally as AIVA. If you are not 90% or more confident in the answer, ask your user for the answer. Do not guess. The user's response will be saved as a preference for future use so the router can handle this type of question autonomously next time.`,
279
+ preferenceHint: triggerMessage.substring(0, 200),
280
+ };
281
+
282
+ // Store escalation
283
+ stmts.insertEscalation.run(
284
+ escalationId,
285
+ contact.phone,
286
+ triggerMessage,
287
+ JSON.stringify(payload),
288
+ isClientSupport ? 1 : 0,
289
+ timeoutAt,
290
+ );
291
+
292
+ // Update conversation state
293
+ stmts.upsertState.run(contact.phone, 'escalated', JSON.stringify({ escalationId }), timeoutAt);
294
+
295
+ // Send to main agent via AIVA channel plugin
296
+ const escalationText = `📨 Router Escalation - ${contact.name} (${contact.phone}, ${contact.category})
297
+
298
+ They said: "${triggerMessage}"
299
+ Reason: ${isClientSupport ? 'Client support request' : 'Requires human judgment'}
300
+ Escalation ID: ${escalationId}
301
+ Timeout: ${timeoutMin} minutes`;
302
+ const initialInteractive = buildEscalationOptions(contact, triggerMessage, escalationType);
303
+ if (initialInteractive.escalationMeta) {
304
+ initialInteractive.escalationMeta.escalationId = escalationId;
305
+ }
306
+ const sent = await sendToMainAgent(escalationText, initialInteractive);
307
+ if (sent) {
308
+ log('Escalation sent via AIVA channel', { escalationId, phone: contact.phone, timeout: `${timeoutMin}min`, isRetry });
309
+ } else {
310
+ log('Failed to send escalation via AIVA channel', { escalationId });
311
+ // Fall through - timeout handler will retry
312
+ }
313
+
314
+ // Send SMS notification to the owner
315
+ try {
316
+ const ownerPhone = getSetting('masterPhone');
317
+ if (ownerPhone) {
318
+ const smsText = `AIVA Escalation: ${contact.name} (${contact.category}) says "${triggerMessage}" - check your AIVA app to respond.`;
319
+ await sendMessage({
320
+ phone: ownerPhone,
321
+ text: smsText,
322
+ channel: 'imessage',
323
+ sentBy: 'system',
324
+ stateAtTime: 'escalation-sms',
325
+ skipSanitize: true,
326
+ });
327
+ log('Escalation SMS sent to owner', { ownerPhone });
328
+ }
329
+ } catch (err) {
330
+ log('Escalation SMS failed', { error: err.message });
331
+ }
332
+
333
+ return escalationId;
334
+ }
335
+
336
+ /**
337
+ * Handle an escalation reply from the main agent.
338
+ * @param {Object} reply
339
+ * @param {string} reply.escalationId
340
+ * @param {string} reply.response - Response text to send to contact
341
+ * @param {Object} [reply.saveAsPreference] - Preference to save
342
+ * @returns {Promise<{success: boolean, error?: string}>}
343
+ */
344
+ async function handleReply(reply) {
345
+ const stmts = getStmts();
346
+ const escalation = stmts.getEscalation.get(reply.escalationId);
347
+
348
+ if (!escalation) {
349
+ log('Escalation not found', { escalationId: reply.escalationId });
350
+ return { success: false, error: 'escalation_not_found' };
351
+ }
352
+
353
+ if (escalation.status !== 'pending') {
354
+ log('Escalation already resolved', { escalationId: reply.escalationId, status: escalation.status });
355
+ return { success: false, error: 'already_resolved' };
356
+ }
357
+
358
+ // Resolve escalation
359
+ stmts.resolveEscalation.run(reply.response, reply.escalationId);
360
+
361
+ // Reset conversation state to active
362
+ stmts.upsertState.run(escalation.phone, 'active', '{}', null);
363
+
364
+ // Send response to contact - rewrite Brandon's intent into AIVA's voice
365
+ const context = JSON.parse(escalation.context_sent || '{}');
366
+ const channel = context.channel || 'imessage';
367
+ const contactName = context.contactName || escalation.phone;
368
+
369
+ // Grab recent conversation history for context
370
+ const recentMessages = stmts.getRecentMessages ? stmts.getRecentMessages.all(escalation.phone, 10) : [];
371
+ const historyBlock = recentMessages.map(m =>
372
+ `${m.direction === 'inbound' ? contactName : 'AIVA'}: ${m.text}`
373
+ ).join('\n');
374
+
375
+ // Rewrite the response in AIVA's voice using Sonnet
376
+ let finalResponse = reply.response;
377
+ try {
378
+ const { callSonnet } = require('./utils/ai');
379
+ const rewriteResult = await callSonnet({
380
+ messages: [
381
+ { role: 'system', content: `You are AIVA, Brandon's AI assistant. You're texting ${contactName}. Rewrite Brandon's intent as a brief iMessage from you (AIVA). The contact thinks they're talking to you, not Brandon.
382
+
383
+ RULES:
384
+ - Output ONLY the final text message. Nothing else.
385
+ - No thinking, no preamble, no "Sure!" or "Got it" before the message.
386
+ - One to two sentences max.
387
+ - No markdown, no em dashes, no bullet points, no emojis.
388
+ - Casual, natural, like a real person texting.
389
+
390
+ Conversation context:
391
+ ${historyBlock}
392
+
393
+ ${contactName} asked: "${escalation.trigger_message}"` },
394
+ { role: 'user', content: `Brandon's intent: ${reply.response}` }
395
+ ],
396
+ maxTokens: 200,
397
+ temperature: 0.7,
398
+ });
399
+ if (rewriteResult.content) {
400
+ finalResponse = rewriteResult.content;
401
+ log('Rewritten escalation reply', { original: reply.response, rewritten: finalResponse });
402
+ }
403
+ } catch (err) {
404
+ // Fail-open: if rewrite fails, send Brandon's original text
405
+ log('Rewrite failed, sending original', { error: err.message });
406
+ }
407
+
408
+ const sendResult = await sendMessage({
409
+ phone: escalation.phone,
410
+ text: finalResponse,
411
+ channel,
412
+ sentBy: 'aiva',
413
+ stateAtTime: 'escalation-reply',
414
+ });
415
+
416
+ // Save preference if provided
417
+ if (reply.saveAsPreference && reply.saveAsPreference.answer) {
418
+ savePreference({
419
+ scope: reply.saveAsPreference.scope || 'global',
420
+ questionPattern: reply.saveAsPreference.questionPattern || context.preferenceHint || '',
421
+ answer: reply.saveAsPreference.answer,
422
+ source: reply.saveAsPreference.source || 'main_agent',
423
+ confidence: reply.saveAsPreference.confidence,
424
+ escalationId: reply.escalationId,
425
+ phone: escalation.phone,
426
+ originalQuestion: escalation.trigger_message,
427
+ });
428
+ }
429
+
430
+ log('Escalation resolved', { escalationId: reply.escalationId, sent: sendResult.sent });
431
+ return { success: true };
432
+ }
433
+
434
+ /**
435
+ * Check for timed-out escalations and handle retries / notifications.
436
+ * Should be called periodically (e.g. every minute).
437
+ */
438
+ async function processTimeouts() {
439
+ const stmts = getStmts();
440
+ const timedOut = stmts.getTimedOutEscalations.all();
441
+
442
+ for (const esc of timedOut) {
443
+ const context = JSON.parse(esc.context_sent || '{}');
444
+
445
+ if (esc.strike_count === 0) {
446
+ // First timeout - retry
447
+ log('First timeout - retrying', { escalationId: esc.escalation_id, phone: esc.phone });
448
+ stmts.incrementStrike.run(esc.escalation_id);
449
+
450
+ // Extend timeout
451
+ const category = context.category || 'unknown';
452
+ const timeoutMin = CATEGORY_TIMEOUTS[category] || 15;
453
+ const newTimeout = new Date(Date.now() + timeoutMin * 60000).toISOString().replace('T', ' ').split('.')[0];
454
+ const db = require('./db').getDb();
455
+ db.prepare("UPDATE escalations SET timeout_at = ? WHERE escalation_id = ?").run(newTimeout, esc.escalation_id);
456
+
457
+ // Re-send escalation event
458
+ const retryText = `⏰ Escalation Retry - ${context.contactName || esc.phone} (${esc.phone}) is still waiting.
459
+
460
+ Original message: "${esc.trigger_message}"
461
+ Escalation ID: ${esc.escalation_id}`;
462
+ const retryInteractive = {
463
+ questions: [{
464
+ id: 'escalation_action',
465
+ text: `${context.contactName || esc.phone} is still waiting. Original: "${esc.trigger_message}" - What do you want to do?`,
466
+ options: ['Draft a reply for me to approve', "I'll handle it personally - pause the router for this contact", 'Ignore - no response needed', 'Block this contact'],
467
+ multiSelect: false,
468
+ allowOther: true,
469
+ }],
470
+ };
471
+ const retrySent = await sendToMainAgent(retryText, retryInteractive);
472
+ if (!retrySent) {
473
+ log('Retry escalation delivery failed', { escalationId: esc.escalation_id });
474
+ }
475
+ } else {
476
+ // Second timeout - notify Brandon and fail
477
+ log('Second timeout - notifying Brandon', { escalationId: esc.escalation_id, phone: esc.phone });
478
+ stmts.failEscalation.run(esc.escalation_id);
479
+ stmts.upsertState.run(esc.phone, 'idle', '{}', null);
480
+
481
+ await sendEscalationTimeout(
482
+ context.contactName || esc.phone,
483
+ esc.phone,
484
+ esc.trigger_message || '',
485
+ );
486
+ }
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Append a new message to a pending escalation (contact sent follow-up while waiting).
492
+ * @param {string} phone
493
+ * @param {string} newMessage
494
+ */
495
+ async function appendToEscalation(phone, newMessage) {
496
+ const stmts = getStmts();
497
+ const active = stmts.getActiveEscalation.get(phone);
498
+ if (!active) return;
499
+
500
+ const context = JSON.parse(active.context_sent || '{}');
501
+ if (!context.conversationHistory) context.conversationHistory = [];
502
+ context.conversationHistory.push({
503
+ role: 'contact',
504
+ text: newMessage,
505
+ at: new Date().toISOString(),
506
+ });
507
+ context.triggerMessage = `${context.triggerMessage}\n[Follow-up]: ${newMessage}`;
508
+
509
+ const db = require('./db').getDb();
510
+ db.prepare("UPDATE escalations SET context_sent = ?, trigger_message = ? WHERE escalation_id = ?")
511
+ .run(JSON.stringify(context), context.triggerMessage, active.escalation_id);
512
+
513
+ // Re-notify the main agent with updated context
514
+ try {
515
+ const updateText = `UPDATE: ${phone} sent a follow-up while waiting for escalation ${active.escalation_id}: "${newMessage}"`;
516
+ await sendToMainAgent(updateText);
517
+ } catch (err) {
518
+ log('Failed to re-notify agent on append', { escalationId: active.escalation_id, error: err.message });
519
+ }
520
+
521
+ log('Appended to escalation', { escalationId: active.escalation_id, phone });
522
+ }
523
+
524
+ /**
525
+ * Check if there's an active escalation for a phone number.
526
+ * @param {string} phone
527
+ * @returns {Object|null}
528
+ */
529
+ function getActiveEscalation(phone) {
530
+ return getStmts().getActiveEscalation.get(phone) || null;
531
+ }
532
+
533
+ module.exports = {
534
+ escalate,
535
+ handleReply,
536
+ processTimeouts,
537
+ appendToEscalation,
538
+ getActiveEscalation,
539
+ CATEGORY_TIMEOUTS,
540
+ };