@conversionpros/aiva 1.0.1 → 2.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 (149) hide show
  1. package/bin/aiva.js +26 -14
  2. package/lib/bluebubbles.js +145 -0
  3. package/lib/config-gen.js +253 -0
  4. package/lib/constants.js +72 -0
  5. package/lib/launch-agent.js +112 -0
  6. package/lib/prerequisites.js +236 -0
  7. package/lib/process.js +59 -145
  8. package/lib/setup.js +224 -194
  9. package/lib/validate.js +194 -0
  10. package/package.json +7 -32
  11. package/auto-deploy.js +0 -190
  12. package/cli-sync.js +0 -126
  13. package/d2a-prompt-template.txt +0 -106
  14. package/diagnostics-api.js +0 -304
  15. package/docs/ara-dedup-fix-scope.md +0 -112
  16. package/docs/ara-fix-round2-scope.md +0 -61
  17. package/docs/ara-greeting-fix-scope.md +0 -70
  18. package/docs/calendar-date-fix-scope.md +0 -28
  19. package/docs/getting-started.md +0 -115
  20. package/docs/network-architecture-rollout-scope.md +0 -43
  21. package/docs/scope-google-oauth-integration.md +0 -351
  22. package/docs/settings-page-scope.md +0 -50
  23. package/docs/xai-imagine-scope.md +0 -116
  24. package/docs/xai-voice-integration-scope.md +0 -115
  25. package/docs/xai-voice-tools-scope.md +0 -165
  26. package/email-router.js +0 -512
  27. package/follow-up-handler.js +0 -606
  28. package/gateway-monitor.js +0 -158
  29. package/google-email.js +0 -379
  30. package/google-oauth.js +0 -310
  31. package/grok-imagine.js +0 -97
  32. package/health-reporter.js +0 -287
  33. package/invisible-prefix-base.txt +0 -206
  34. package/invisible-prefix-owner.txt +0 -26
  35. package/invisible-prefix-slim.txt +0 -10
  36. package/invisible-prefix.txt +0 -43
  37. package/knowledge-base.js +0 -472
  38. package/lib/cli.js +0 -19
  39. package/lib/server.js +0 -42
  40. package/meta-capi.js +0 -206
  41. package/meta-leads.js +0 -411
  42. package/notion-oauth.js +0 -323
  43. package/public/agent-config.html +0 -241
  44. package/public/aiva-avatar-anime.png +0 -0
  45. package/public/css/docs.css.bak +0 -688
  46. package/public/css/onboarding.css +0 -543
  47. package/public/diagrams/claude-subscription-pool.html +0 -329
  48. package/public/diagrams/claude-subscription-pool.png +0 -0
  49. package/public/docs-icon.png +0 -0
  50. package/public/escalation.html +0 -237
  51. package/public/group-config.html +0 -300
  52. package/public/icon-192.png +0 -0
  53. package/public/icon-512.png +0 -0
  54. package/public/icons/agents.svg +0 -1
  55. package/public/icons/attach.svg +0 -1
  56. package/public/icons/characters.svg +0 -1
  57. package/public/icons/chat.svg +0 -1
  58. package/public/icons/docs.svg +0 -1
  59. package/public/icons/heartbeat.svg +0 -1
  60. package/public/icons/messages.svg +0 -1
  61. package/public/icons/mic.svg +0 -1
  62. package/public/icons/notes.svg +0 -1
  63. package/public/icons/settings.svg +0 -1
  64. package/public/icons/tasks.svg +0 -1
  65. package/public/images/onboarding/p0-communication-layer.png +0 -0
  66. package/public/images/onboarding/p0-infinite-surface.png +0 -0
  67. package/public/images/onboarding/p0-learning-model.png +0 -0
  68. package/public/images/onboarding/p0-meet-aiva.png +0 -0
  69. package/public/images/onboarding/p4-contact-intelligence.png +0 -0
  70. package/public/images/onboarding/p4-context-compounds.png +0 -0
  71. package/public/images/onboarding/p4-message-router.png +0 -0
  72. package/public/images/onboarding/p4-per-contact-rules.png +0 -0
  73. package/public/images/onboarding/p4-send-messages.png +0 -0
  74. package/public/images/onboarding/p6-be-precise.png +0 -0
  75. package/public/images/onboarding/p6-review-escalations.png +0 -0
  76. package/public/images/onboarding/p6-voice-input.png +0 -0
  77. package/public/images/onboarding/p7-completion.png +0 -0
  78. package/public/index.html +0 -11594
  79. package/public/js/onboarding.js +0 -699
  80. package/public/manifest.json +0 -24
  81. package/public/messages-v2.html +0 -2824
  82. package/public/permission-approve.html.bak +0 -107
  83. package/public/permissions.html +0 -150
  84. package/public/styles/design-system.css +0 -68
  85. package/router-db.js +0 -604
  86. package/router-utils.js +0 -28
  87. package/router-v2/adapters/imessage.js +0 -191
  88. package/router-v2/adapters/quo.js +0 -82
  89. package/router-v2/adapters/whatsapp.js +0 -192
  90. package/router-v2/contact-manager.js +0 -234
  91. package/router-v2/conversation-engine.js +0 -498
  92. package/router-v2/data/knowledge-base.json +0 -176
  93. package/router-v2/data/router-v2.db +0 -0
  94. package/router-v2/data/router-v2.db-shm +0 -0
  95. package/router-v2/data/router-v2.db-wal +0 -0
  96. package/router-v2/data/router.db +0 -0
  97. package/router-v2/db.js +0 -457
  98. package/router-v2/escalation-bridge.js +0 -540
  99. package/router-v2/follow-up-engine.js +0 -347
  100. package/router-v2/index.js +0 -441
  101. package/router-v2/ingestion.js +0 -213
  102. package/router-v2/knowledge-base.js +0 -231
  103. package/router-v2/lead-qualifier.js +0 -152
  104. package/router-v2/learning-loop.js +0 -202
  105. package/router-v2/outbound-sender.js +0 -160
  106. package/router-v2/package.json +0 -13
  107. package/router-v2/permission-gate.js +0 -86
  108. package/router-v2/playbook.js +0 -177
  109. package/router-v2/prompts/base.js +0 -52
  110. package/router-v2/prompts/first-contact.js +0 -38
  111. package/router-v2/prompts/lead-qualification.js +0 -37
  112. package/router-v2/prompts/scheduling.js +0 -72
  113. package/router-v2/prompts/style-overrides.js +0 -22
  114. package/router-v2/scheduler.js +0 -301
  115. package/router-v2/scripts/migrate-v1-to-v2.js +0 -215
  116. package/router-v2/scripts/seed-faq.js +0 -67
  117. package/router-v2/seed-knowledge-base.js +0 -39
  118. package/router-v2/utils/ai.js +0 -129
  119. package/router-v2/utils/phone.js +0 -52
  120. package/router-v2/utils/response-validator.js +0 -98
  121. package/router-v2/utils/sanitize.js +0 -222
  122. package/router.js +0 -5005
  123. package/routes/google-calendar.js +0 -186
  124. package/scripts/deploy.sh +0 -62
  125. package/scripts/macos-calendar.sh +0 -232
  126. package/scripts/onboard-device.sh +0 -466
  127. package/server.js +0 -5131
  128. package/start.sh +0 -24
  129. package/templates/AGENTS.md +0 -548
  130. package/templates/IDENTITY.md +0 -15
  131. package/templates/docs-agents.html +0 -132
  132. package/templates/docs-app.html +0 -130
  133. package/templates/docs-home.html +0 -83
  134. package/templates/docs-imessage.html +0 -121
  135. package/templates/docs-tasks.html +0 -123
  136. package/templates/docs-tips.html +0 -175
  137. package/templates/getting-started.html +0 -809
  138. package/templates/invisible-prefix-base.txt +0 -171
  139. package/templates/invisible-prefix-owner.txt +0 -282
  140. package/templates/invisible-prefix.txt +0 -338
  141. package/templates/manifest.json +0 -61
  142. package/templates/memory-org/clients.md +0 -7
  143. package/templates/memory-org/credentials.md +0 -9
  144. package/templates/memory-org/devices.md +0 -7
  145. package/templates/updates.html +0 -464
  146. package/tts-proxy.js +0 -96
  147. package/voice-call-local.js +0 -731
  148. package/voice-call.js +0 -732
  149. package/wa-listener.js +0 -354
@@ -1,606 +0,0 @@
1
- // ── Follow-Up Handler ────────────────────────────────────
2
- // Scans for dead conversations and uses AI to decide follow-ups
3
- const path = require('path');
4
- const fs = require('fs');
5
- const { execSync } = require('child_process');
6
- const { db, stmts } = require('./router-db');
7
-
8
- const CONFIG_PATH = path.join(process.env.HOME, '.openclaw', 'openclaw.json');
9
-
10
- // ── Sent-By Reintroduction Logic (shared) ────────────────
11
- const { addReintroIfNeeded } = require('./router-utils');
12
-
13
- function getOpenClawPassword() {
14
- try {
15
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
16
- return config?.gateway?.auth?.password || '';
17
- } catch { return ''; }
18
- }
19
-
20
- function getSetting(key) {
21
- const row = stmts.getSetting.get(key);
22
- return row ? row.value : '';
23
- }
24
-
25
- function log(msg, data) {
26
- const ts = new Date().toISOString();
27
- if (data) console.log(`[${ts}] [FOLLOW-UP] ${msg}`, JSON.stringify(data));
28
- else console.log(`[${ts}] [FOLLOW-UP] ${msg}`);
29
- }
30
-
31
- function isBusinessHours() {
32
- // Robust PST/PDT calculation using Intl.DateTimeFormat for reliable timezone parts
33
- const now = new Date();
34
- const parts = new Intl.DateTimeFormat('en-US', {
35
- timeZone: 'America/Los_Angeles',
36
- hour: 'numeric', hour12: false,
37
- weekday: 'short',
38
- year: 'numeric', month: 'numeric', day: 'numeric'
39
- }).formatToParts(now);
40
-
41
- const get = (type) => parts.find(p => p.type === type)?.value;
42
- const hour = parseInt(get('hour'), 10); // 0-23 in America/Los_Angeles
43
- const weekday = get('weekday'); // "Sun", "Mon", etc.
44
-
45
- if (weekday === 'Sun') return false; // No Sundays
46
-
47
- const startHour = parseInt(getSetting('followUpStartHour')) || 8;
48
- const endHour = parseInt(getSetting('followUpEndHour')) || 18;
49
-
50
- // Hard floor/ceiling: never outside 8-18 regardless of settings
51
- const effectiveStart = Math.max(startHour, 8);
52
- const effectiveEnd = Math.min(endHour, 18);
53
-
54
- return hour >= effectiveStart && hour < effectiveEnd;
55
- }
56
-
57
- function isErrorMessage(text) {
58
- if (!text) return false;
59
- const t = text.toLowerCase();
60
- return /no response from openclaw|openclaw.*error|internal server error|ECONNREFUSED|agent.*timeout|session.*failed|tool.*error/i.test(t);
61
- }
62
-
63
- // Sanitize outbound messages — strip any mention of internal systems
64
- function sanitizeOutbound(text) {
65
- if (!text) return text;
66
- // If the message mentions OpenClaw at all, it's contaminated — reject it
67
- if (/openclaw/i.test(text)) {
68
- log('BLOCKED outbound message containing "OpenClaw"', { snippet: text.substring(0, 100) });
69
- return null;
70
- }
71
- return text;
72
- }
73
-
74
- function sendIMessage(phone, text) {
75
- if (isErrorMessage(text)) {
76
- log('BLOCKED error message from reaching contact', { phone, text: text.substring(0, 80) });
77
- return false;
78
- }
79
- try {
80
- const tmpFile = `/tmp/imsg-fu-${Date.now()}.txt`;
81
- fs.writeFileSync(tmpFile, text);
82
- execSync(`imsg send --to "${phone}" --text "$(cat ${tmpFile})"`, { timeout: 15000, encoding: 'utf-8', shell: '/bin/bash' });
83
- try { fs.unlinkSync(tmpFile); } catch(e) {}
84
- log('iMessage sent', { phone, len: text.length });
85
- return true;
86
- } catch (err) {
87
- log('iMessage send failed', { phone, error: err.message });
88
- return false;
89
- }
90
- }
91
-
92
- async function sendWhatsApp(phone, text) {
93
- if (isErrorMessage(text)) {
94
- log('BLOCKED error message from reaching contact (WA)', { phone, text: text.substring(0, 80) });
95
- return false;
96
- }
97
- try {
98
- const waListener = require('./wa-listener');
99
- const result = await waListener.sendMessage(phone, text);
100
- if (result.success) {
101
- log('WhatsApp sent', { phone, len: text.length });
102
- return true;
103
- } else {
104
- log('WhatsApp send failed', { phone, error: result.error });
105
- return false;
106
- }
107
- } catch (err) {
108
- log('WhatsApp send error', { phone, error: err.message });
109
- return false;
110
- }
111
- }
112
-
113
- function parseTimeDelay(delayStr) {
114
- // Returns a datetime string for next_follow_up_at
115
- const now = new Date();
116
-
117
- if (!delayStr || delayStr === 'never') return null;
118
-
119
- const hourMatch = delayStr.match(/(\d+)\s*hour/i);
120
- if (hourMatch) {
121
- const ms = parseInt(hourMatch[1]) * 3600000;
122
- return new Date(now.getTime() + ms).toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
123
- }
124
-
125
- const dayMatch = delayStr.match(/(\d+)\s*day/i);
126
- if (dayMatch) {
127
- const ms = parseInt(dayMatch[1]) * 86400000;
128
- return new Date(now.getTime() + ms).toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
129
- }
130
-
131
- if (/tomorrow\s*morning/i.test(delayStr)) {
132
- // Calculate tomorrow 9 AM in America/Los_Angeles properly
133
- // Get current LA date parts
134
- const laParts = new Intl.DateTimeFormat('en-US', {
135
- timeZone: 'America/Los_Angeles',
136
- year: 'numeric', month: '2-digit', day: '2-digit'
137
- }).formatToParts(now);
138
- const laGet = (t) => laParts.find(p => p.type === t)?.value;
139
- const laYear = parseInt(laGet('year'));
140
- const laMonth = parseInt(laGet('month')) - 1;
141
- const laDay = parseInt(laGet('day'));
142
- // Create tomorrow 9 AM in LA by brute-force: scan UTC hours to find when LA shows 9 AM tomorrow
143
- const tomorrowDay = laDay + 1;
144
- // Start from a rough UTC guess (tomorrow 17:00 UTC ≈ 9 AM PST) and adjust
145
- let target = new Date(Date.UTC(laYear, laMonth, tomorrowDay, 17, 0, 0));
146
- // Verify and adjust: get the LA hour for this UTC time
147
- const checkHour = (d) => parseInt(new Intl.DateTimeFormat('en-US', { timeZone: 'America/Los_Angeles', hour: 'numeric', hour12: false }).format(d));
148
- const checkDay = (d) => parseInt(new Intl.DateTimeFormat('en-US', { timeZone: 'America/Los_Angeles', day: 'numeric' }).format(d));
149
- // Adjust until we hit 9 AM on the right day
150
- for (let adj = -3; adj <= 3; adj++) {
151
- const candidate = new Date(target.getTime() + adj * 3600000);
152
- if (checkHour(candidate) === 9 && checkDay(candidate) === tomorrowDay) {
153
- target = candidate;
154
- break;
155
- }
156
- }
157
- return target.toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
158
- }
159
-
160
- // Default: 4 hours
161
- return new Date(now.getTime() + 4 * 3600000).toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
162
- }
163
-
164
- function getAnthropicApiKey() {
165
- if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY;
166
- // Read from openclaw's auth config
167
- try {
168
- const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
169
- // Try to extract from the auth token (OpenClaw uses this for its proxy)
170
- const authToken = cfg?.auth?.apiKeys?.[0] || cfg?.gateway?.auth?.apiKeys?.[0];
171
- if (authToken) return authToken;
172
- } catch(e) {}
173
- // Fallback: the known key for this installation
174
- return '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';
175
- }
176
-
177
- async function callXAIDirect(systemPrompt, userMessage) {
178
- const apiKey = 'xai-Gn37fuJg5ty4gvWFG2rbth34AxNORUKH8r4vTXQDtjwMGUqKZ7nYy8u2YStosGUCVBEg7VMHSqQZcKS4';
179
- try {
180
- const resp = await fetch('https://api.x.ai/v1/chat/completions', {
181
- method: 'POST',
182
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
183
- body: JSON.stringify({ model: 'grok-4-1-fast-non-reasoning', max_tokens: 500, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userMessage }] }),
184
- signal: AbortSignal.timeout(30000)
185
- });
186
- if (!resp.ok) { log('xAI API failed', { status: resp.status }); return null; }
187
- const data = await resp.json();
188
- return data.choices?.[0]?.message?.content?.trim() || null;
189
- } catch(e) { log('xAI API error', { error: e.message }); return null; }
190
- }
191
-
192
- async function callOpenClawProxy(systemPrompt, userMessage) {
193
- const password = getOpenClawPassword();
194
- if (!password) return null;
195
- try {
196
- const resp = await fetch('http://127.0.0.1:18789/v1/chat/completions', {
197
- method: 'POST',
198
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${password}` },
199
- body: JSON.stringify({ model: 'claude-sonnet-4-5', max_tokens: 500, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userMessage }] }),
200
- signal: AbortSignal.timeout(60000)
201
- });
202
- if (!resp.ok) { const errText = await resp.text(); log('Proxy HTTP error', { status: resp.status, body: errText.substring(0, 200) }); return null; }
203
- const data = await resp.json();
204
- const content = data.choices?.[0]?.message?.content?.trim() || '';
205
- if (isErrorMessage(content)) { log('Proxy returned error content', { snippet: content.substring(0, 100) }); return null; }
206
- return content;
207
- } catch(e) { return null; }
208
- }
209
-
210
- function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
211
-
212
- async function callAI(systemPrompt, userMessage) {
213
- // Auto-retry with exponential backoff (handles proxy busy during compaction)
214
- const delays = [0, 5000, 15000, 30000, 60000]; // 5 retries: immediate, 5s, 15s, 30s, 60s
215
- for (let attempt = 0; attempt < delays.length; attempt++) {
216
- if (attempt > 0) {
217
- log(`AI call retry ${attempt}/${delays.length - 1}, waiting ${delays[attempt] / 1000}s`);
218
- await sleep(delays[attempt]);
219
- }
220
- // Try xAI direct first (always available), then proxy as fallback
221
- let result = await callXAIDirect(systemPrompt, userMessage);
222
- if (result) return result;
223
- result = await callOpenClawProxy(systemPrompt, userMessage);
224
- if (result) return result;
225
- }
226
- log('AI call failed after all retries');
227
- return null;
228
- }
229
-
230
- async function analyzeConversation(phone, history, contactName, followUpCount, category) {
231
- const categoryLabel = category || 'unknown';
232
- 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.
233
-
234
- CRITICAL: NEVER mention "OpenClaw", any AI infrastructure, technical systems, or anything about how you work.
235
-
236
- ## CONTACT CATEGORY: ${categoryLabel.toUpperCase()}
237
-
238
- ## RELATIONSHIP-BASED DEFAULTS (you may override based on conversation context):
239
- - family/friend: Max 1 gentle nudge total, then stop. Timing: 2-3 days minimum.
240
- - lead/prospect: Up to 3 follow-ups. You decide spacing (2 hours to 2 days based on urgency).
241
- - client: Up to 2 follow-ups. Professional tone. Timing: contextual.
242
- - unknown/new: 1 follow-up max. Then stop.
243
- - vendor: 1-2 follow-ups. Timing: 1-2 days.
244
-
245
- ## CONVERSATION STATE — classify exactly one:
246
- - waiting_on_them: Open question or request pending their reply
247
- - action_pending: Something was promised by either party
248
- - info_delivered: Info was sent, no response required
249
- - conversation_closed: Natural end ("thanks", "sounds good", "bye", emoji-only, laughter)
250
-
251
- If conversation_closed → shouldFollowUp = false, maxFollowUpsForThis = 0.
252
- If info_delivered and no action needed → shouldFollowUp = false.
253
-
254
- ## TONE MATCHING — READ THE THREAD CAREFULLY:
255
- - Mirror the tone of the existing conversation exactly
256
- - Casual thread → casual follow-up ("Hey, any update on that?")
257
- - Professional thread → professional follow-up ("Wanted to circle back on...")
258
- - DO NOT escalate formality or emotion beyond what's in the thread
259
- - DO NOT manufacture empathy ("you deserve better", "that's not fair to you")
260
- - DO NOT apologize for following up ("sorry to bother", "I know you're busy")
261
- - DO NOT use emotional manipulation or over-promising
262
- - Each follow-up should be SHORTER than the last, not longer
263
- - Casual: 1-2 sentences max. Business: 2-3 sentences max.
264
-
265
- ## HARD RULES:
266
- - This is follow-up attempt #${followUpCount + 1}
267
- - If attempt >= maxFollowUpsForThis you set, shouldFollowUp = false
268
- - If the last 2+ outbound messages are about the same topic and the contact hasn't responded with new information, return shouldFollowUp: false
269
- - Business hours only (8 AM - 6 PM PST), never on Sundays
270
- - Current time: ${new Date().toISOString()}
271
-
272
- ## TONE RULES FOR FOLLOW-UPS:
273
- - Each follow-up should be SHORTER and MORE CASUAL than the last, not longer or more emotional
274
- - NEVER apologize for following up ("sorry to bother", "I feel bad", "I'm embarrassed")
275
- - NEVER escalate emotional language or make dramatic promises
276
- - If you've already followed up once, the next one should be ultra-brief (e.g., "Hey, just circling back on this")
277
- - If you've followed up twice with no reply, STOP (shouldFollowUp = false)
278
- - A human would send ONE brief nudge and then wait. Be that human.
279
-
280
- Respond in JSON ONLY:
281
- {
282
- "shouldFollowUp": true/false,
283
- "maxFollowUpsForThis": 0-3,
284
- "nextFollowUpIn": "4 hours" | "tomorrow morning" | "2 days" | "never",
285
- "suggestedMessage": "Short, natural follow-up text",
286
- "reasoning": "Brief explanation",
287
- "conversationState": "waiting_on_them" | "conversation_closed" | "info_delivered" | "action_pending",
288
- "topic": "brief topic label (e.g., 'plane tickets', 'meeting schedule')"
289
- }`;
290
- const userMsg = `Contact: ${contactName} (${phone})\nContact category: ${categoryLabel}\nFollow-up attempt: #${followUpCount + 1}\n\nConversation:\n${history}\n\nShould we follow up?`;
291
-
292
- const content = await callAI(systemPrompt, userMsg);
293
- if (!content) return null;
294
- try {
295
- const jsonMatch = content.match(/\{[\s\S]*\}/);
296
- if (!jsonMatch) return null;
297
- return JSON.parse(jsonMatch[0]);
298
- } catch(e) { return null; }
299
- }
300
-
301
- function fetchConversationHistory(phone, limit = 25) {
302
- try {
303
- const output = execSync(`imsg chats 2>/dev/null | grep "${phone}"`, { timeout: 5000, encoding: 'utf-8' }).trim();
304
- const chatIdMatch = output.match(/^\[(\d+)\]/);
305
- if (!chatIdMatch) return null;
306
-
307
- const chatId = chatIdMatch[1];
308
- const historyRaw = execSync(`imsg history --chat-id ${chatId} --limit ${limit}`, { timeout: 10000, encoding: 'utf-8' }).trim();
309
- if (!historyRaw) return null;
310
-
311
- const lines = historyRaw.split('\n').filter(l => l.trim());
312
- const messages = lines.map(line => {
313
- const match = line.match(/^(\S+)\s+\[(sent|recv)\]\s+\S+:\s+(.*)$/);
314
- if (!match) return null;
315
- return { ts: match[1], direction: match[2], text: match[3] };
316
- }).filter(Boolean);
317
-
318
- return messages.reverse().map(m => {
319
- const sender = m.direction === 'sent' ? 'Brandon/Aiva' : 'Them';
320
- return `[${new Date(m.ts).toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}] ${sender}: ${m.text}`;
321
- }).join('\n');
322
- } catch {
323
- return null;
324
- }
325
- }
326
-
327
- async function checkForDeadConversations() {
328
- log('Scanning for dead conversations...');
329
-
330
- // Get conversations where our last message was outbound and at least 1 hour ago
331
- const deadConvos = db.prepare(`
332
- SELECT m.phone, m.message_preview, m.timestamp,
333
- COALESCE(NULLIF(cr.name, ''), NULLIF(cr.name, 'Unknown'), m.phone) as contact_name,
334
- COALESCE(cr.source, 'imessage') as channel
335
- FROM message_log m
336
- INNER JOIN (
337
- SELECT phone, MAX(id) as max_id FROM message_log
338
- WHERE forwarded_to != 'group' AND forwarded_to NOT LIKE '%group%'
339
- GROUP BY phone
340
- ) latest ON m.phone = latest.phone AND m.id = latest.max_id
341
- LEFT JOIN contact_rules cr ON cr.phone = m.phone
342
- LEFT JOIN follow_up_tracker ft ON ft.phone = m.phone
343
- WHERE m.direction = 'outbound'
344
- AND m.timestamp <= datetime('now', '-1 hour')
345
- AND (ft.phone IS NULL OR ft.status NOT IN ('cold', 'completed', 'paused'))
346
- AND m.phone != ?
347
- `).all(getSetting('masterPhone') || '+15099794110');
348
-
349
- log('Dead conversations found', { count: deadConvos.length });
350
-
351
- for (const conv of deadConvos) {
352
- const existing = stmts.getFollowUpByPhone.get(conv.phone);
353
- if (existing) continue; // Already tracked
354
-
355
- // Create a new tracker entry
356
- const now = new Date().toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
357
- stmts.upsertFollowUp.run(
358
- conv.phone,
359
- conv.channel === 'whatsapp' ? 'whatsapp' : 'imessage',
360
- conv.contact_name,
361
- conv.message_preview || '',
362
- conv.timestamp || now,
363
- 'active',
364
- conv.timestamp || now
365
- );
366
- log('New follow-up tracker created', { phone: conv.phone, name: conv.contact_name });
367
- }
368
- }
369
-
370
- async function processFollowUps() {
371
- if (getSetting('followUpEnabled') !== 'true') {
372
- log('Follow-ups disabled, skipping');
373
- return;
374
- }
375
-
376
- if (!isBusinessHours()) {
377
- log('Outside business hours, skipping');
378
- return;
379
- }
380
-
381
- // First scan for new dead conversations
382
- await checkForDeadConversations();
383
-
384
- // Then process active follow-ups that are due
385
- const dueFollowUps = stmts.getActiveFollowUps.all();
386
- log('Due follow-ups', { count: dueFollowUps.length });
387
-
388
- const maxDefault = parseInt(getSetting('followUpMaxDefault')) || 3;
389
-
390
- for (const fu of dueFollowUps) {
391
- if (fu.opted_out) {
392
- log('Skipping opted-out contact', { phone: fu.phone });
393
- continue;
394
- }
395
-
396
- // Safety ceiling — hard max before AI even runs (AI will typically cap lower)
397
- const maxFollowUps = fu.max_follow_ups || maxDefault;
398
- if (fu.follow_up_count >= Math.max(maxFollowUps, 3)) {
399
- stmts.markFollowUpCold.run(fu.phone);
400
- // Notify Brandon
401
- const masterPhone = getSetting('masterPhone') || '+15099794110';
402
- sendIMessage(masterPhone, `Follow-up limit reached for ${fu.contact_name} (${fu.phone}). Marked as cold after ${fu.follow_up_count} attempts. Last message: "${(fu.last_our_message || '').substring(0, 100)}"`);
403
- log('Marked cold — max follow-ups reached', { phone: fu.phone, count: fu.follow_up_count });
404
- continue;
405
- }
406
-
407
- // Get conversation history
408
- const history = fetchConversationHistory(fu.phone);
409
- if (!history) {
410
- log('No history found, skipping', { phone: fu.phone });
411
- continue;
412
- }
413
-
414
- // ── Daily follow-up cap (max 1 per contact per day) ──
415
- const maxDailyFollowUps = parseInt(getSetting('maxDailyFollowUps')) || 1;
416
- const recentFollowUps = stmts.countRecentFollowUps.get(fu.phone);
417
- const followUpCountToday = recentFollowUps?.count || 0;
418
- if (followUpCountToday >= maxDailyFollowUps) {
419
- log('RATE LIMIT: daily follow-up cap reached, skipping', { phone: fu.phone, followUpCountToday, maxDailyFollowUps });
420
- // Push next check to tomorrow
421
- const tomorrow = new Date(Date.now() + 24 * 3600000).toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
422
- stmts.incrementFollowUp.run(tomorrow, fu.phone);
423
- continue;
424
- }
425
-
426
- // Hard silence check: if contact hasn't replied to our last 2+ messages, mark cold immediately
427
- const historyLines = history.split('\n').filter(l => l.trim());
428
- let consecutiveOurs = 0;
429
- for (let i = historyLines.length - 1; i >= 0; i--) {
430
- if (/Brandon\/Aiva:/.test(historyLines[i])) consecutiveOurs++;
431
- else break;
432
- }
433
- if (consecutiveOurs >= 2) {
434
- stmts.markFollowUpCold.run(fu.phone);
435
- const masterPhone = getSetting('masterPhone') || '+15099794110';
436
- sendIMessage(masterPhone, `Silence detected for ${fu.contact_name} (${fu.phone}) — ${consecutiveOurs} unanswered messages. Marked cold.`);
437
- log('Hard silence cap — contact not replying, marked cold', { phone: fu.phone, consecutiveOurs });
438
- continue;
439
- }
440
-
441
- // Fetch contact category from contact_rules
442
- const contactRule = db.prepare('SELECT category FROM contact_rules WHERE phone = ?').get(fu.phone);
443
- const category = contactRule?.category || 'unknown';
444
-
445
- // AI analysis
446
- const analysis = await analyzeConversation(fu.phone, history, fu.contact_name, fu.follow_up_count, category);
447
- if (!analysis) {
448
- log('AI analysis failed, skipping', { phone: fu.phone });
449
- continue;
450
- }
451
-
452
- // Use AI's max follow-ups instead of flat default
453
- const aiMax = analysis.maxFollowUpsForThis ?? analysis.maxFollowUpsForThisConvo;
454
- if (typeof aiMax === 'number' && aiMax >= 0 && fu.follow_up_count >= aiMax) {
455
- stmts.markFollowUpCold.run(fu.phone);
456
- log('AI max follow-ups reached', { phone: fu.phone, aiMax, count: fu.follow_up_count, relationship: analysis.relationshipType });
457
- continue;
458
- }
459
-
460
- log('AI analysis result', { phone: fu.phone, shouldFollowUp: analysis.shouldFollowUp, state: analysis.conversationState, reasoning: analysis.reasoning, category, maxForThis: aiMax, topic: analysis.topic });
461
-
462
- // ── Topic dedup: if AI suggests same topic as last follow-up, mark cold ──
463
- if (analysis.shouldFollowUp && analysis.topic && fu.last_follow_up_topic) {
464
- const newTopic = (analysis.topic || '').toLowerCase().trim();
465
- const lastTopic = (fu.last_follow_up_topic || '').toLowerCase().trim();
466
- if (newTopic && lastTopic && (newTopic === lastTopic || newTopic.includes(lastTopic) || lastTopic.includes(newTopic))) {
467
- stmts.markFollowUpCold.run(fu.phone);
468
- log('Topic dedup: same topic as last follow-up, marked cold', { phone: fu.phone, topic: newTopic, lastTopic });
469
- continue;
470
- }
471
- }
472
-
473
- if (!analysis.shouldFollowUp) {
474
- // Mark as completed if conversation is closed
475
- if (analysis.conversationState === 'conversation_closed') {
476
- stmts.updateFollowUpStatus.run('completed', fu.phone);
477
- log('Conversation closed, marking completed', { phone: fu.phone });
478
- } else {
479
- // Push next check further out
480
- const nextTime = parseTimeDelay(analysis.nextFollowUpIn || '4 hours');
481
- if (nextTime) {
482
- stmts.incrementFollowUp.run(nextTime, fu.phone);
483
- }
484
- }
485
- continue;
486
- }
487
-
488
- // Send the follow-up (with safety retry)
489
- let message = sanitizeOutbound(analysis.suggestedMessage);
490
- if (!message) {
491
- for (let retry = 1; retry <= 3; retry++) {
492
- log(`Safety filter retry ${retry}/3 for ${fu.phone}`);
493
- const cleanMsg = await callAI(
494
- 'You are Aiva, Brandon\'s friendly assistant. Write a brief, natural follow-up message. Just the message text. Do NOT mention any software, AI, technical systems, platforms, or tools.',
495
- `Contact: ${fu.contact_name || fu.phone}\nWrite a clean follow-up message:`
496
- );
497
- if (cleanMsg) { message = sanitizeOutbound(cleanMsg); if (message) break; }
498
- }
499
- if (!message) continue;
500
- }
501
-
502
- message = addReintroIfNeeded(fu.phone, message);
503
- const sent = fu.channel === 'whatsapp'
504
- ? await sendWhatsApp(fu.phone, message)
505
- : sendIMessage(fu.phone, message);
506
-
507
- if (sent) {
508
- // Log it
509
- stmts.insertLog.run(fu.phone, 'outbound', `[FOLLOW-UP] ${message.substring(0, 80)}`, JSON.stringify({ followUp: true, attempt: fu.follow_up_count + 1 }), 'follow-up', fu.channel || 'imessage', 'aiva');
510
-
511
- // Calculate next follow-up time
512
- const nextTime = parseTimeDelay(analysis.nextFollowUpIn || '4 hours');
513
- stmts.incrementFollowUp.run(nextTime || new Date(Date.now() + 4 * 3600000).toISOString().replace('T', ' ').replace('Z', '').split('.')[0], fu.phone);
514
-
515
- // Save topic for dedup
516
- if (analysis.topic) {
517
- try { db.prepare("UPDATE follow_up_tracker SET last_follow_up_topic = ? WHERE phone = ?").run(analysis.topic, fu.phone); } catch(e) {}
518
- }
519
-
520
- log('Follow-up sent', { phone: fu.phone, attempt: fu.follow_up_count + 1, nextIn: analysis.nextFollowUpIn, topic: analysis.topic });
521
- }
522
- }
523
-
524
- log('Follow-up processing complete');
525
- }
526
-
527
- async function sendCustomFollowUp(phone, message) {
528
- const tracker = stmts.getFollowUpByPhone.get(phone);
529
- if (!tracker) return { error: 'No tracker found for this phone' };
530
-
531
- const channel = tracker.channel || 'imessage';
532
- message = addReintroIfNeeded(phone, message);
533
- const sent = channel === 'whatsapp'
534
- ? await sendWhatsApp(phone, message)
535
- : sendIMessage(phone, message);
536
-
537
- if (sent) {
538
- stmts.insertLog.run(phone, 'outbound', `[FOLLOW-UP] ${message.substring(0, 80)}`, JSON.stringify({ followUp: true, custom: true }), 'follow-up', channel, 'aiva');
539
- const nextTime = new Date(Date.now() + 4 * 3600000).toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
540
- stmts.incrementFollowUp.run(nextTime, phone);
541
- return { sent: true };
542
- }
543
- return { error: 'Send failed' };
544
- }
545
-
546
- async function sendFollowUpNow(phone, customMessage) {
547
- const tracker = stmts.getFollowUpByPhone.get(phone);
548
- if (!tracker) return { error: 'No tracker found for this phone' };
549
-
550
- // If custom message, just send it
551
- if (customMessage) return sendCustomFollowUp(phone, customMessage);
552
-
553
- // Otherwise, AI-generate one (force mode since user clicked Send Now)
554
- const history = fetchConversationHistory(phone, 25);
555
- if (!history) return { error: 'Could not fetch conversation history' };
556
-
557
- const contactRule = db.prepare('SELECT category FROM contact_rules WHERE phone = ?').get(phone);
558
- const category = contactRule?.category || 'unknown';
559
- let analysis = await analyzeConversation(phone, history, tracker.contact_name || phone, tracker.follow_up_count || 0, category);
560
- log('Initial analysis result', { hasAnalysis: !!analysis, hasSuggested: !!analysis?.suggestedMessage, shouldFollowUp: analysis?.shouldFollowUp });
561
- // If AI says don't follow up but user clicked Send Now, force a generic follow-up
562
- if (!analysis || !analysis.suggestedMessage) {
563
- log('Force-generating follow-up message (Send Now override)');
564
- const forceMsg = await callAI(
565
- 'You are Aiva, Brandon\'s friendly assistant. Write a brief, natural follow-up message for this conversation. Just the message text, nothing else. CRITICAL: NEVER mention OpenClaw, AI systems, technical infrastructure, or anything about how you work internally.',
566
- `Contact: ${tracker.contact_name || phone}\nConversation:\n${history}\n\nWrite a follow-up message:`
567
- );
568
- if (forceMsg && !isErrorMessage(forceMsg)) analysis = { suggestedMessage: forceMsg };
569
- if (!analysis || !analysis.suggestedMessage) return { error: 'AI could not generate a follow-up message' };
570
- }
571
-
572
- let message = sanitizeOutbound(analysis.suggestedMessage);
573
-
574
- // If blocked by safety filter, retry with explicit clean prompt (up to 3 times)
575
- if (!message) {
576
- for (let retry = 1; retry <= 3; retry++) {
577
- log(`Safety filter retry ${retry}/3 — regenerating clean message`);
578
- const cleanMsg = await callAI(
579
- 'You are Aiva, Brandon\'s friendly assistant. Your previous message was rejected because it referenced internal systems. Write a completely new, natural follow-up message for this conversation. Just the message text. Do NOT mention any software, AI, technical systems, platforms, or tools. Keep it human and conversational.',
580
- `Contact: ${tracker.contact_name || phone}\nConversation:\n${history}\n\nWrite a clean, natural follow-up message:`
581
- );
582
- if (cleanMsg) {
583
- message = sanitizeOutbound(cleanMsg);
584
- if (message) break;
585
- }
586
- }
587
- if (!message) return { error: 'Could not generate a clean follow-up message after retries' };
588
- }
589
-
590
- const channel = tracker.channel || 'imessage';
591
- message = addReintroIfNeeded(phone, message);
592
- const sent = channel === 'whatsapp'
593
- ? await sendWhatsApp(phone, message)
594
- : sendIMessage(phone, message);
595
-
596
- if (sent) {
597
- stmts.insertLog.run(phone, 'outbound', `[FOLLOW-UP] ${message.substring(0, 80)}`, JSON.stringify({ followUp: true, sendNow: true, attempt: tracker.follow_up_count + 1 }), 'follow-up', channel, 'aiva');
598
- const nextTime = new Date(Date.now() + 4 * 3600000).toISOString().replace('T', ' ').replace('Z', '').split('.')[0];
599
- stmts.incrementFollowUp.run(nextTime, phone);
600
- log('Send-now follow-up sent', { phone, attempt: tracker.follow_up_count + 1 });
601
- return { sent: true, message };
602
- }
603
- return { error: 'Send failed' };
604
- }
605
-
606
- module.exports = { processFollowUps, checkForDeadConversations, analyzeConversation, sendCustomFollowUp, sendFollowUpNow };