@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,347 @@
1
+ // ── Follow-Up Engine - Dead Conversation Detection + Re-engagement ──
2
+ 'use strict';
3
+
4
+ const { getStmts, getSetting, getDb } = require('./db');
5
+ const { callAI } = require('./utils/ai');
6
+ const { sendMessage } = require('./outbound-sender');
7
+ const { getContact } = require('./contact-manager');
8
+
9
+ // Per-category follow-up defaults (from design doc)
10
+ const CATEGORY_DEFAULTS = {
11
+ lead: { firstAfterHours: 4, maxFollowUps: 3, style: 'value-driven' },
12
+ 'qualified-lead': { firstAfterHours: 2, maxFollowUps: 4, style: 'direct' },
13
+ client: { firstAfterHours: 8, maxFollowUps: 2, style: 'professional' },
14
+ family: { firstAfterHours: null, maxFollowUps: 0 },
15
+ friend: { firstAfterHours: null, maxFollowUps: 0 },
16
+ unknown: { firstAfterHours: null, maxFollowUps: 0 },
17
+ team: { firstAfterHours: 24, maxFollowUps: 1, style: 'brief' },
18
+ };
19
+
20
+ function log(msg, data) {
21
+ const ts = new Date().toISOString();
22
+ if (data) console.log(`[${ts}] [FOLLOW-UP] ${msg}`, JSON.stringify(data));
23
+ else console.log(`[${ts}] [FOLLOW-UP] ${msg}`);
24
+ }
25
+
26
+ function isBusinessHours() {
27
+ const parts = new Intl.DateTimeFormat('en-US', {
28
+ timeZone: 'America/Los_Angeles',
29
+ hour: 'numeric', hour12: false,
30
+ weekday: 'short',
31
+ }).formatToParts(new Date());
32
+
33
+ const get = (type) => parts.find(p => p.type === type)?.value;
34
+ const hour = parseInt(get('hour'), 10);
35
+ const weekday = get('weekday');
36
+
37
+ if (weekday === 'Sun') return false;
38
+
39
+ const startHour = Math.max(parseInt(getSetting('followUpStartHour')) || 8, 8);
40
+ const endHour = Math.min(parseInt(getSetting('followUpEndHour')) || 18, 18);
41
+
42
+ return hour >= startHour && hour < endHour;
43
+ }
44
+
45
+ /**
46
+ * Scan for dead conversations and create follow-up trackers.
47
+ */
48
+ async function scanForDeadConversations() {
49
+ const db = getDb();
50
+ const masterPhone = getSetting('masterPhone') || '+15099794110';
51
+
52
+ const deadConvos = db.prepare(`
53
+ SELECT m.phone, m.text, m.created_at, m.channel,
54
+ COALESCE(c.name, m.phone) as contact_name,
55
+ c.category
56
+ FROM message_log m
57
+ INNER JOIN (
58
+ SELECT phone, MAX(id) as max_id FROM message_log GROUP BY phone
59
+ ) latest ON m.phone = latest.phone AND m.id = latest.max_id
60
+ LEFT JOIN contacts c ON c.phone = m.phone
61
+ LEFT JOIN follow_up_tracker ft ON ft.phone = m.phone
62
+ WHERE m.direction = 'outbound'
63
+ AND m.created_at <= datetime('now', '-1 hour')
64
+ AND (ft.phone IS NULL OR ft.status NOT IN ('cold', 'completed', 'paused'))
65
+ AND m.phone != ?
66
+ `).all(masterPhone);
67
+
68
+ const stmts = getStmts();
69
+ for (const conv of deadConvos) {
70
+ const existing = stmts.getFollowUpByPhone.get(conv.phone);
71
+ if (existing) continue;
72
+
73
+ // Check category defaults
74
+ const category = conv.category || 'unknown';
75
+ const defaults = CATEGORY_DEFAULTS[category];
76
+ if (!defaults || !defaults.firstAfterHours) continue;
77
+
78
+ const nextAt = new Date(Date.now() + defaults.firstAfterHours * 3600000)
79
+ .toISOString().replace('T', ' ').split('.')[0];
80
+
81
+ stmts.upsertFollowUp.run(
82
+ conv.phone, conv.channel || 'imessage', conv.contact_name,
83
+ (conv.text || '').substring(0, 200), conv.created_at,
84
+ 'active', nextAt,
85
+ );
86
+ log('Created tracker', { phone: conv.phone, category, nextAt });
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Analyze a conversation using AI to decide on follow-up.
92
+ * @param {string} phone
93
+ * @param {Array} messages - Recent messages
94
+ * @param {string} contactName
95
+ * @param {number} followUpCount
96
+ * @param {string} category
97
+ * @returns {Promise<Object|null>}
98
+ */
99
+ async function analyzeConversation(phone, messages, contactName, followUpCount, category) {
100
+ const history = messages.map(m => {
101
+ const sender = m.sent_by === 'contact' ? 'Them' : 'Brandon/Aiva';
102
+ return `${sender}: ${m.text}`;
103
+ }).join('\n');
104
+
105
+ const systemPrompt = `You are a follow-up decision engine for Brandon's assistant Aiva. Analyze the conversation and decide IF and HOW to follow up.
106
+
107
+ CRITICAL: NEVER mention "OpenClaw", any AI infrastructure, technical systems, or anything about how you work.
108
+
109
+ CONTACT CATEGORY: ${category.toUpperCase()}
110
+
111
+ CONVERSATION STATE - classify exactly one:
112
+ - waiting_on_them: Open question pending their reply
113
+ - action_pending: Something was promised by either party
114
+ - info_delivered: Info sent, no response required
115
+ - conversation_closed: Natural end ("thanks", "sounds good", "bye", emoji-only)
116
+
117
+ If conversation_closed or info_delivered with no action needed -> shouldFollowUp = false
118
+
119
+ TONE RULES:
120
+ - Mirror the existing conversation tone exactly
121
+ - Each follow-up should be SHORTER than the last
122
+ - NEVER apologize for following up
123
+ - If 2+ unanswered outbound messages on same topic, shouldFollowUp = false
124
+
125
+ This is follow-up attempt #${followUpCount + 1}
126
+
127
+ Respond in JSON ONLY:
128
+ {
129
+ "shouldFollowUp": true/false,
130
+ "maxFollowUpsForThis": 0-3,
131
+ "nextFollowUpIn": "4 hours" | "tomorrow morning" | "2 days" | "never",
132
+ "suggestedMessage": "Short, natural follow-up text",
133
+ "reasoning": "Brief explanation",
134
+ "conversationState": "waiting_on_them" | "conversation_closed" | "info_delivered" | "action_pending",
135
+ "topic": "brief topic label"
136
+ }`;
137
+
138
+ const result = await callAI(systemPrompt, `Contact: ${contactName} (${phone})\nAttempt: #${followUpCount + 1}\n\nConversation:\n${history}\n\nShould we follow up?`, { maxTokens: 500 });
139
+ if (!result) return null;
140
+
141
+ try {
142
+ const jsonMatch = result.match(/\{[\s\S]*\}/);
143
+ if (!jsonMatch) return null;
144
+ return JSON.parse(jsonMatch[0]);
145
+ } catch { return null; }
146
+ }
147
+
148
+ /**
149
+ * Parse a time delay string into a datetime string.
150
+ * @param {string} delayStr
151
+ * @returns {string|null}
152
+ */
153
+ function parseTimeDelay(delayStr) {
154
+ if (!delayStr || delayStr === 'never') return null;
155
+ const now = Date.now();
156
+
157
+ const hourMatch = delayStr.match(/(\d+)\s*hour/i);
158
+ if (hourMatch) return new Date(now + parseInt(hourMatch[1]) * 3600000).toISOString().replace('T', ' ').split('.')[0];
159
+
160
+ const dayMatch = delayStr.match(/(\d+)\s*day/i);
161
+ if (dayMatch) return new Date(now + parseInt(dayMatch[1]) * 86400000).toISOString().replace('T', ' ').split('.')[0];
162
+
163
+ if (/tomorrow\s*morning/i.test(delayStr)) {
164
+ // Tomorrow 9 AM Pacific - use Intl to get correct offset
165
+ const tomorrow = new Date(now + 86400000);
166
+ const pstStr = tomorrow.toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' }); // YYYY-MM-DD
167
+ const formatter = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Los_Angeles', timeZoneName: 'shortOffset' });
168
+ const parts = formatter.formatToParts(tomorrow);
169
+ const tzPart = parts.find(p => p.type === 'timeZoneName')?.value || 'GMT-8';
170
+ const offsetMatch = tzPart.match(/GMT([+-]\d+)/);
171
+ const offsetHours = offsetMatch ? parseInt(offsetMatch[1]) : -8;
172
+ const offsetStr = `${offsetHours >= 0 ? '+' : ''}${String(Math.abs(offsetHours)).padStart(2, '0')}:00`;
173
+ const target = new Date(`${pstStr}T09:00:00${offsetStr}`);
174
+ return target.toISOString().replace('T', ' ').split('.')[0];
175
+ }
176
+
177
+ // Default: 4 hours
178
+ return new Date(now + 4 * 3600000).toISOString().replace('T', ' ').split('.')[0];
179
+ }
180
+
181
+ /**
182
+ * Process all due follow-ups. Main entry point - called periodically.
183
+ */
184
+ async function processFollowUps() {
185
+ if (getSetting('followUpEnabled') !== 'true') return;
186
+ if (!isBusinessHours()) return;
187
+
188
+ await scanForDeadConversations();
189
+
190
+ const stmts = getStmts();
191
+ const dueFollowUps = stmts.getActiveFollowUps.all();
192
+ log('Processing due follow-ups', { count: dueFollowUps.length });
193
+
194
+ for (const fu of dueFollowUps) {
195
+ if (fu.opted_out) continue;
196
+
197
+ // Hard ceiling
198
+ const contact = getContact(fu.phone);
199
+ const category = contact?.category || 'unknown';
200
+
201
+ // Skip blocked/monitor mode contacts - they should never get follow-ups
202
+ const { mv2ResolveEffectiveMode } = require('./conversation-engine');
203
+ const mode = mv2ResolveEffectiveMode(contact || {}, fu.channel || 'imessage');
204
+ if (mode === 'block' || mode === 'monitor') {
205
+ stmts.markFollowUpCold.run(fu.phone);
206
+ log('Skipping follow-up for blocked/monitor contact', { phone: fu.phone, mode });
207
+ continue;
208
+ }
209
+ const defaults = CATEGORY_DEFAULTS[category] || {};
210
+ const maxFollowUps = defaults.maxFollowUps || fu.max_follow_ups || 3;
211
+
212
+ if (fu.follow_up_count >= maxFollowUps) {
213
+ stmts.markFollowUpCold.run(fu.phone);
214
+ log('Max reached, marked cold', { phone: fu.phone, count: fu.follow_up_count });
215
+ continue;
216
+ }
217
+
218
+ // Daily cap
219
+ const maxDaily = parseInt(getSetting('maxDailyFollowUps')) || 1;
220
+ const recentCount = stmts.countRecentFollowUps.get(fu.phone);
221
+ if ((recentCount?.count || 0) >= maxDaily) {
222
+ const tomorrow = new Date(Date.now() + 86400000).toISOString().replace('T', ' ').split('.')[0];
223
+ stmts.incrementFollowUp.run(tomorrow, fu.phone);
224
+ continue;
225
+ }
226
+
227
+ // Get conversation history from message_log
228
+ const messages = stmts.getRecentMessages.all(fu.phone, 20);
229
+ if (messages.length === 0) continue;
230
+
231
+ // Hard silence check: 2+ consecutive outbound with no reply
232
+ let consecutiveOurs = 0;
233
+ for (const m of messages) {
234
+ if (m.direction === 'outbound') consecutiveOurs++;
235
+ else break;
236
+ }
237
+ if (consecutiveOurs >= 2) {
238
+ stmts.markFollowUpCold.run(fu.phone);
239
+ log('Hard silence, marked cold', { phone: fu.phone, consecutiveOurs });
240
+ continue;
241
+ }
242
+
243
+ // AI analysis
244
+ const analysis = await analyzeConversation(fu.phone, messages.reverse(), fu.contact_name, fu.follow_up_count, category);
245
+ if (!analysis) continue;
246
+
247
+ // AI max follow-ups check
248
+ const aiMax = analysis.maxFollowUpsForThis;
249
+ if (typeof aiMax === 'number' && aiMax >= 0 && fu.follow_up_count >= aiMax) {
250
+ stmts.markFollowUpCold.run(fu.phone);
251
+ log('AI max reached', { phone: fu.phone, aiMax });
252
+ continue;
253
+ }
254
+
255
+ // Topic dedup
256
+ if (analysis.shouldFollowUp && analysis.topic && fu.last_follow_up_topic) {
257
+ const newTopic = (analysis.topic || '').toLowerCase().trim();
258
+ const lastTopic = (fu.last_follow_up_topic || '').toLowerCase().trim();
259
+ if (newTopic && lastTopic && (newTopic === lastTopic || newTopic.includes(lastTopic) || lastTopic.includes(newTopic))) {
260
+ stmts.markFollowUpCold.run(fu.phone);
261
+ log('Topic dedup, marked cold', { phone: fu.phone });
262
+ continue;
263
+ }
264
+ }
265
+
266
+ if (!analysis.shouldFollowUp) {
267
+ if (analysis.conversationState === 'conversation_closed') {
268
+ stmts.updateFollowUpStatus.run('completed', fu.phone);
269
+ } else {
270
+ const nextTime = parseTimeDelay(analysis.nextFollowUpIn || '4 hours');
271
+ if (nextTime) stmts.incrementFollowUp.run(nextTime, fu.phone);
272
+ }
273
+ continue;
274
+ }
275
+
276
+ // Send follow-up
277
+ if (!analysis.suggestedMessage) continue;
278
+
279
+ const sendResult = await sendMessage({
280
+ phone: fu.phone,
281
+ text: analysis.suggestedMessage,
282
+ channel: fu.channel || 'imessage',
283
+ sentBy: 'aiva',
284
+ stateAtTime: 'follow-up',
285
+ });
286
+
287
+ if (sendResult.sent) {
288
+ const nextTime = parseTimeDelay(analysis.nextFollowUpIn || '4 hours');
289
+ stmts.incrementFollowUp.run(nextTime || parseTimeDelay('4 hours'), fu.phone);
290
+ if (analysis.topic) stmts.updateFollowUpTopic.run(analysis.topic, fu.phone);
291
+ log('Follow-up sent', { phone: fu.phone, attempt: fu.follow_up_count + 1, topic: analysis.topic });
292
+ }
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Send a custom follow-up message for a specific contact.
298
+ * @param {string} phone
299
+ * @param {string} [customMessage] - If provided, sends this instead of AI-generated
300
+ * @returns {Promise<{sent: boolean, error?: string}>}
301
+ */
302
+ async function sendFollowUpNow(phone, customMessage = null) {
303
+ const stmts = getStmts();
304
+ const fu = stmts.getFollowUpByPhone.get(phone);
305
+ if (!fu) return { error: 'No tracker found' };
306
+
307
+ let text = customMessage;
308
+ if (!text) {
309
+ const messages = stmts.getRecentMessages.all(phone, 20);
310
+ const contact = getContact(phone);
311
+ const analysis = await analyzeConversation(phone, messages.reverse(), fu.contact_name, fu.follow_up_count, contact?.category || 'unknown');
312
+ text = analysis?.suggestedMessage;
313
+ if (!text) {
314
+ // Force generate
315
+ text = await callAI(
316
+ 'You are Aiva, Brandon\'s assistant. Write a brief, natural follow-up message. Just the message text. Never mention any software, AI, or technical systems.',
317
+ `Contact: ${fu.contact_name || phone}\nWrite a follow-up:`,
318
+ { maxTokens: 200 },
319
+ );
320
+ }
321
+ }
322
+
323
+ if (!text) return { error: 'Could not generate message' };
324
+
325
+ const result = await sendMessage({
326
+ phone,
327
+ text,
328
+ channel: fu.channel || 'imessage',
329
+ sentBy: 'aiva',
330
+ stateAtTime: 'follow-up',
331
+ });
332
+
333
+ if (result.sent) {
334
+ const nextTime = parseTimeDelay('4 hours');
335
+ stmts.incrementFollowUp.run(nextTime, phone);
336
+ }
337
+
338
+ return result.sent ? { sent: true, message: text } : { error: result.reason || 'send_failed' };
339
+ }
340
+
341
+ module.exports = {
342
+ processFollowUps,
343
+ scanForDeadConversations,
344
+ analyzeConversation,
345
+ sendFollowUpNow,
346
+ CATEGORY_DEFAULTS,
347
+ };