@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.
- package/README.md +148 -0
- package/auto-deploy.js +190 -0
- package/bin/aiva.js +81 -0
- package/cli-sync.js +126 -0
- package/d2a-prompt-template.txt +106 -0
- package/diagnostics-api.js +304 -0
- package/docs/ara-dedup-fix-scope.md +112 -0
- package/docs/ara-fix-round2-scope.md +61 -0
- package/docs/ara-greeting-fix-scope.md +70 -0
- package/docs/calendar-date-fix-scope.md +28 -0
- package/docs/getting-started.md +115 -0
- package/docs/network-architecture-rollout-scope.md +43 -0
- package/docs/scope-google-oauth-integration.md +351 -0
- package/docs/settings-page-scope.md +50 -0
- package/docs/xai-imagine-scope.md +116 -0
- package/docs/xai-voice-integration-scope.md +115 -0
- package/docs/xai-voice-tools-scope.md +165 -0
- package/email-router.js +512 -0
- package/follow-up-handler.js +606 -0
- package/gateway-monitor.js +158 -0
- package/google-email.js +379 -0
- package/google-oauth.js +310 -0
- package/grok-imagine.js +97 -0
- package/health-reporter.js +287 -0
- package/invisible-prefix-base.txt +206 -0
- package/invisible-prefix-owner.txt +26 -0
- package/invisible-prefix-slim.txt +10 -0
- package/invisible-prefix.txt +43 -0
- package/knowledge-base.js +472 -0
- package/lib/cli.js +19 -0
- package/lib/config.js +124 -0
- package/lib/health.js +57 -0
- package/lib/process.js +207 -0
- package/lib/server.js +42 -0
- package/lib/setup.js +472 -0
- package/meta-capi.js +206 -0
- package/meta-leads.js +411 -0
- package/notion-oauth.js +323 -0
- package/package.json +61 -0
- package/public/agent-config.html +241 -0
- package/public/aiva-avatar-anime.png +0 -0
- package/public/css/docs.css.bak +688 -0
- package/public/css/onboarding.css +543 -0
- package/public/diagrams/claude-subscription-pool.html +329 -0
- package/public/diagrams/claude-subscription-pool.png +0 -0
- package/public/docs-icon.png +0 -0
- package/public/escalation.html +237 -0
- package/public/group-config.html +300 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icons/agents.svg +1 -0
- package/public/icons/attach.svg +1 -0
- package/public/icons/characters.svg +1 -0
- package/public/icons/chat.svg +1 -0
- package/public/icons/docs.svg +1 -0
- package/public/icons/heartbeat.svg +1 -0
- package/public/icons/messages.svg +1 -0
- package/public/icons/mic.svg +1 -0
- package/public/icons/notes.svg +1 -0
- package/public/icons/settings.svg +1 -0
- package/public/icons/tasks.svg +1 -0
- package/public/images/onboarding/p0-communication-layer.png +0 -0
- package/public/images/onboarding/p0-infinite-surface.png +0 -0
- package/public/images/onboarding/p0-learning-model.png +0 -0
- package/public/images/onboarding/p0-meet-aiva.png +0 -0
- package/public/images/onboarding/p4-contact-intelligence.png +0 -0
- package/public/images/onboarding/p4-context-compounds.png +0 -0
- package/public/images/onboarding/p4-message-router.png +0 -0
- package/public/images/onboarding/p4-per-contact-rules.png +0 -0
- package/public/images/onboarding/p4-send-messages.png +0 -0
- package/public/images/onboarding/p6-be-precise.png +0 -0
- package/public/images/onboarding/p6-review-escalations.png +0 -0
- package/public/images/onboarding/p6-voice-input.png +0 -0
- package/public/images/onboarding/p7-completion.png +0 -0
- package/public/index.html +11594 -0
- package/public/js/onboarding.js +699 -0
- package/public/manifest.json +24 -0
- package/public/messages-v2.html +2824 -0
- package/public/permission-approve.html.bak +107 -0
- package/public/permissions.html +150 -0
- package/public/styles/design-system.css +68 -0
- package/router-db.js +604 -0
- package/router-utils.js +28 -0
- package/router-v2/adapters/imessage.js +191 -0
- package/router-v2/adapters/quo.js +82 -0
- package/router-v2/adapters/whatsapp.js +192 -0
- package/router-v2/contact-manager.js +234 -0
- package/router-v2/conversation-engine.js +498 -0
- package/router-v2/data/knowledge-base.json +176 -0
- package/router-v2/data/router-v2.db +0 -0
- package/router-v2/data/router-v2.db-shm +0 -0
- package/router-v2/data/router-v2.db-wal +0 -0
- package/router-v2/data/router.db +0 -0
- package/router-v2/db.js +457 -0
- package/router-v2/escalation-bridge.js +540 -0
- package/router-v2/follow-up-engine.js +347 -0
- package/router-v2/index.js +441 -0
- package/router-v2/ingestion.js +213 -0
- package/router-v2/knowledge-base.js +231 -0
- package/router-v2/lead-qualifier.js +152 -0
- package/router-v2/learning-loop.js +202 -0
- package/router-v2/outbound-sender.js +160 -0
- package/router-v2/package.json +13 -0
- package/router-v2/permission-gate.js +86 -0
- package/router-v2/playbook.js +177 -0
- package/router-v2/prompts/base.js +52 -0
- package/router-v2/prompts/first-contact.js +38 -0
- package/router-v2/prompts/lead-qualification.js +37 -0
- package/router-v2/prompts/scheduling.js +72 -0
- package/router-v2/prompts/style-overrides.js +22 -0
- package/router-v2/scheduler.js +301 -0
- package/router-v2/scripts/migrate-v1-to-v2.js +215 -0
- package/router-v2/scripts/seed-faq.js +67 -0
- package/router-v2/seed-knowledge-base.js +39 -0
- package/router-v2/utils/ai.js +129 -0
- package/router-v2/utils/phone.js +52 -0
- package/router-v2/utils/response-validator.js +98 -0
- package/router-v2/utils/sanitize.js +222 -0
- package/router.js +5005 -0
- package/routes/google-calendar.js +186 -0
- package/scripts/deploy.sh +62 -0
- package/scripts/macos-calendar.sh +232 -0
- package/scripts/onboard-device.sh +466 -0
- package/server.js +5131 -0
- package/start.sh +24 -0
- package/templates/AGENTS.md +548 -0
- package/templates/IDENTITY.md +15 -0
- package/templates/docs-agents.html +132 -0
- package/templates/docs-app.html +130 -0
- package/templates/docs-home.html +83 -0
- package/templates/docs-imessage.html +121 -0
- package/templates/docs-tasks.html +123 -0
- package/templates/docs-tips.html +175 -0
- package/templates/getting-started.html +809 -0
- package/templates/invisible-prefix-base.txt +171 -0
- package/templates/invisible-prefix-owner.txt +282 -0
- package/templates/invisible-prefix.txt +338 -0
- package/templates/manifest.json +61 -0
- package/templates/memory-org/clients.md +7 -0
- package/templates/memory-org/credentials.md +9 -0
- package/templates/memory-org/devices.md +7 -0
- package/templates/updates.html +464 -0
- package/templates/workspace/AGENTS.md.tmpl +161 -0
- package/templates/workspace/HEARTBEAT.md.tmpl +17 -0
- package/templates/workspace/IDENTITY.md.tmpl +15 -0
- package/templates/workspace/MEMORY.md.tmpl +16 -0
- package/templates/workspace/SOUL.md.tmpl +51 -0
- package/templates/workspace/USER.md.tmpl +25 -0
- package/tts-proxy.js +96 -0
- package/voice-call-local.js +731 -0
- package/voice-call.js +732 -0
- 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
|
+
};
|