@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,191 @@
1
+ // ── iMessage Adapter (BlueBubbles REST API) ──────────────
2
+ // No CLI dependency - uses BlueBubbles REST API directly.
3
+ 'use strict';
4
+
5
+ const { getSetting } = require('../db');
6
+
7
+ // BlueBubbles config - read from settings or env
8
+ function getBBConfig() {
9
+ return {
10
+ url: process.env.BLUEBUBBLES_URL || getSetting('blueBubblesUrl') || 'http://localhost:1234',
11
+ password: process.env.BLUEBUBBLES_PASSWORD || getSetting('blueBubblesPassword') || '',
12
+ };
13
+ }
14
+
15
+ function log(msg, data) {
16
+ const ts = new Date().toISOString();
17
+ if (data) console.log(`[${ts}] [IMESSAGE] ${msg}`, JSON.stringify(data));
18
+ else console.log(`[${ts}] [IMESSAGE] ${msg}`);
19
+ }
20
+
21
+ /**
22
+ * Send an iMessage via BlueBubbles REST API.
23
+ * @param {string} phone - E.164 phone number
24
+ * @param {string} text - Message text
25
+ * @returns {Promise<{success: boolean, error?: string}>}
26
+ */
27
+ async function send(phone, text) {
28
+ const config = getBBConfig();
29
+ if (!config.password) {
30
+ log('No BlueBubbles password configured');
31
+ return { success: false, error: 'no_bb_password' };
32
+ }
33
+
34
+ try {
35
+ const resp = await fetch(`${config.url}/api/v1/message/text?password=${encodeURIComponent(config.password)}`, {
36
+ method: 'POST',
37
+ headers: { 'Content-Type': 'application/json' },
38
+ body: JSON.stringify({
39
+ chatGuid: `iMessage;-;${phone}`,
40
+ tempGuid: `temp-${Date.now()}`,
41
+ message: text,
42
+ method: 'private-api',
43
+ }),
44
+ signal: AbortSignal.timeout(30000),
45
+ });
46
+
47
+ if (!resp.ok) {
48
+ const errText = await resp.text().catch(() => 'unknown');
49
+ log('BB API error', { status: resp.status, body: errText.substring(0, 200) });
50
+ return { success: false, error: `bb_http_${resp.status}` };
51
+ }
52
+
53
+ const data = await resp.json();
54
+ if (data.status === 200 || data.status === 201) {
55
+ return { success: true };
56
+ }
57
+
58
+ log('BB API unexpected status', { data });
59
+ return { success: false, error: `bb_status_${data.status}` };
60
+ } catch (err) {
61
+ log('BB API request failed', { error: err.message });
62
+ return { success: false, error: err.message };
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Fetch recent messages from BlueBubbles for a specific chat.
68
+ * Used for manual message polling (tracking Brandon's direct texts).
69
+ * @param {string} phone - E.164 phone number
70
+ * @param {number} [limit=20]
71
+ * @param {number} [afterMs] - Only messages after this timestamp (epoch ms)
72
+ * @returns {Promise<Array>}
73
+ */
74
+ async function fetchRecentMessages(phone, limit = 20, afterMs = null) {
75
+ const config = getBBConfig();
76
+ if (!config.password) return [];
77
+
78
+ try {
79
+ const chatGuid = `iMessage;-;${phone}`;
80
+ let url = `${config.url}/api/v1/chat/${encodeURIComponent(chatGuid)}/message?password=${encodeURIComponent(config.password)}&limit=${limit}&sort=desc`;
81
+ if (afterMs) url += `&after=${afterMs}`;
82
+
83
+ const resp = await fetch(url, {
84
+ signal: AbortSignal.timeout(15000),
85
+ });
86
+ if (!resp.ok) return [];
87
+
88
+ const data = await resp.json();
89
+ return (data.data || []).map(msg => ({
90
+ id: msg.guid,
91
+ text: msg.text || '',
92
+ isFromMe: msg.isFromMe,
93
+ dateCreated: msg.dateCreated,
94
+ attachments: (msg.attachments || []).map(a => ({
95
+ type: a.mimeType,
96
+ filename: a.transferName,
97
+ url: `${config.url}/api/v1/attachment/${a.guid}/download`,
98
+ password: config.password,
99
+ })),
100
+ }));
101
+ } catch (err) {
102
+ log('Fetch messages failed', { phone, error: err.message });
103
+ return [];
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Poll BlueBubbles for messages Brandon sent directly (manual messages).
109
+ * Returns messages sent by Brandon (isFromMe=true) since the given timestamp.
110
+ * @param {number} sinceMs - Epoch milliseconds
111
+ * @returns {Promise<Array<{phone: string, text: string, timestamp: number}>>}
112
+ */
113
+ async function pollBrandonMessages(sinceMs) {
114
+ const config = getBBConfig();
115
+ if (!config.password) return [];
116
+
117
+ try {
118
+ const url = `${config.url}/api/v1/message?password=${encodeURIComponent(config.password)}&after=${sinceMs}&sort=desc&limit=50`;
119
+ const resp = await fetch(url, {
120
+ signal: AbortSignal.timeout(15000),
121
+ });
122
+ if (!resp.ok) return [];
123
+
124
+ const data = await resp.json();
125
+ const messages = (data.data || [])
126
+ .filter(msg => msg.isFromMe && !msg.isArchived && msg.text)
127
+ .map(msg => {
128
+ // Extract phone from chat participants
129
+ const chat = msg.chats?.[0];
130
+ const chatGuid = chat?.guid || '';
131
+ const phoneMatch = chatGuid.match(/;([+-]?\d+)$/);
132
+ const phone = phoneMatch ? (phoneMatch[1].startsWith('+') ? phoneMatch[1] : `+${phoneMatch[1]}`) : '';
133
+
134
+ return {
135
+ phone,
136
+ text: msg.text,
137
+ timestamp: msg.dateCreated,
138
+ guid: msg.guid,
139
+ };
140
+ })
141
+ .filter(msg => msg.phone);
142
+
143
+ return messages;
144
+ } catch (err) {
145
+ log('Poll Brandon messages failed', { error: err.message });
146
+ return [];
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Parse an incoming BlueBubbles webhook payload into normalized format.
152
+ * @param {Object} payload - Raw webhook payload
153
+ * @returns {Object|null} Normalized message or null if not a relevant message
154
+ */
155
+ function parseWebhook(payload) {
156
+ const msg = payload?.data || payload;
157
+ if (!msg) return null;
158
+
159
+ // Skip non-text messages, reactions, and group chats
160
+ if (msg.associatedMessageType || msg.isArchived) return null;
161
+ if (msg.isFromMe) return null; // We track Brandon's messages via polling
162
+
163
+ const chatGuid = msg.chats?.[0]?.guid || '';
164
+ // Skip group chats (v2.1)
165
+ if (chatGuid.includes(';+;')) return null;
166
+
167
+ const phoneMatch = chatGuid.match(/;([+-]?\d+)$/);
168
+ if (!phoneMatch) return null;
169
+
170
+ let phone = phoneMatch[1];
171
+ if (!phone.startsWith('+')) phone = `+${phone}`;
172
+
173
+ // Check for tapback/reaction
174
+ const tapbackPattern = /^(Loved|Liked|Disliked|Laughed at|Emphasized|Questioned) ".+"/;
175
+ if (tapbackPattern.test(msg.text?.trim())) return null;
176
+
177
+ return {
178
+ id: msg.guid,
179
+ phone,
180
+ channel: 'imessage',
181
+ text: msg.text || '',
182
+ attachments: (msg.attachments || []).map(a => ({
183
+ type: a.mimeType,
184
+ filename: a.transferName,
185
+ })),
186
+ timestamp: new Date(msg.dateCreated),
187
+ raw: payload,
188
+ };
189
+ }
190
+
191
+ module.exports = { send, fetchRecentMessages, pollBrandonMessages, parseWebhook };
@@ -0,0 +1,82 @@
1
+ // ── Quo / OpenPhone Adapter ──────────────────────────────
2
+ // Handles OpenPhone webhook ingestion and outbound via API.
3
+ 'use strict';
4
+
5
+ const { getSetting } = require('../db');
6
+
7
+ function log(msg, data) {
8
+ const ts = new Date().toISOString();
9
+ if (data) console.log(`[${ts}] [QUO] ${msg}`, JSON.stringify(data));
10
+ else console.log(`[${ts}] [QUO] ${msg}`);
11
+ }
12
+
13
+ /**
14
+ * Send a message via OpenPhone/Quo API.
15
+ * @param {string} phone - E.164 phone number
16
+ * @param {string} text - Message text
17
+ * @returns {Promise<{success: boolean, error?: string}>}
18
+ */
19
+ async function send(phone, text) {
20
+ const apiKey = process.env.OPENPHONE_API_KEY || getSetting('openphoneApiKey');
21
+ const fromNumber = process.env.OPENPHONE_NUMBER || getSetting('openphoneNumber');
22
+
23
+ if (!apiKey) {
24
+ log('No OpenPhone API key configured');
25
+ return { success: false, error: 'no_api_key' };
26
+ }
27
+
28
+ try {
29
+ const resp = await fetch('https://api.openphone.com/v1/messages', {
30
+ method: 'POST',
31
+ headers: {
32
+ 'Content-Type': 'application/json',
33
+ 'Authorization': apiKey,
34
+ },
35
+ body: JSON.stringify({
36
+ from: fromNumber,
37
+ to: [phone],
38
+ content: text,
39
+ }),
40
+ signal: AbortSignal.timeout(30000),
41
+ });
42
+
43
+ if (!resp.ok) {
44
+ const errText = await resp.text().catch(() => 'unknown');
45
+ log('API error', { status: resp.status, body: errText.substring(0, 200) });
46
+ return { success: false, error: `openphone_http_${resp.status}` };
47
+ }
48
+
49
+ return { success: true };
50
+ } catch (err) {
51
+ log('API request failed', { error: err.message });
52
+ return { success: false, error: err.message };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Parse an OpenPhone/Quo webhook payload into normalized format.
58
+ * @param {Object} payload - Raw webhook payload
59
+ * @returns {Object|null}
60
+ */
61
+ function parseWebhook(payload) {
62
+ if (!payload) return null;
63
+
64
+ const data = payload.data || payload;
65
+ const phone = data.from || '';
66
+ if (!phone) return null;
67
+
68
+ return {
69
+ id: data.id || `quo-${Date.now()}`,
70
+ phone: phone.startsWith('+') ? phone : `+${phone}`,
71
+ channel: 'quo',
72
+ text: data.body || data.content || data.text || '',
73
+ attachments: (data.media || []).map(m => ({
74
+ type: m.type || 'unknown',
75
+ url: m.url,
76
+ })),
77
+ timestamp: data.createdAt ? new Date(data.createdAt) : new Date(),
78
+ raw: payload,
79
+ };
80
+ }
81
+
82
+ module.exports = { send, parseWebhook };
@@ -0,0 +1,192 @@
1
+ // ── WhatsApp Adapter (wacli CLI) ─────────────────────────
2
+ // Improved send queue - no longer stops the world.
3
+ // Uses a serialized queue that briefly pauses sync for sends.
4
+ 'use strict';
5
+
6
+ const { execSync, spawn, exec } = require('child_process');
7
+ const fs = require('fs');
8
+
9
+ const WACLI_BIN = '/opt/homebrew/bin/wacli';
10
+ let WACLI_AVAILABLE = false;
11
+
12
+ try {
13
+ fs.accessSync(WACLI_BIN, fs.constants.X_OK);
14
+ WACLI_AVAILABLE = true;
15
+ } catch {
16
+ try {
17
+ execSync('which wacli', { timeout: 3000, encoding: 'utf-8' });
18
+ WACLI_AVAILABLE = true;
19
+ } catch {
20
+ WACLI_AVAILABLE = false;
21
+ }
22
+ }
23
+
24
+ let syncProcess = null;
25
+ let syncPid = null;
26
+ let sendQueue = [];
27
+ let sendProcessing = false;
28
+
29
+ function log(msg, data) {
30
+ const ts = new Date().toISOString();
31
+ if (data) console.log(`[${ts}] [WHATSAPP] ${msg}`, JSON.stringify(data));
32
+ else console.log(`[${ts}] [WHATSAPP] ${msg}`);
33
+ }
34
+
35
+ function killAllSync() {
36
+ try { execSync('pkill -f "wacli (sync|auth)"', { timeout: 5000 }); } catch { /* none running */ }
37
+ syncProcess = null;
38
+ syncPid = null;
39
+ }
40
+
41
+ /**
42
+ * Start wacli sync process if not already running.
43
+ * @returns {boolean}
44
+ */
45
+ function startSync() {
46
+ if (!WACLI_AVAILABLE) return false;
47
+
48
+ try {
49
+ const ps = execSync('pgrep -f "wacli (sync|auth)"', { timeout: 5000, encoding: 'utf-8' }).trim();
50
+ if (ps) {
51
+ syncPid = parseInt(ps.split('\n')[0]);
52
+ return true;
53
+ }
54
+ } catch { /* no process */ }
55
+
56
+ try {
57
+ syncProcess = spawn(WACLI_BIN, ['sync', '--follow', '--refresh-contacts'], {
58
+ detached: true,
59
+ stdio: 'ignore',
60
+ });
61
+ syncProcess.unref();
62
+ syncPid = syncProcess.pid;
63
+ log('Started wacli sync', { pid: syncPid });
64
+ return true;
65
+ } catch (err) {
66
+ log('Failed to start sync', { error: err.message });
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function stopSync() {
72
+ if (syncPid) {
73
+ try { process.kill(syncPid, 'SIGTERM'); } catch { /* dead */ }
74
+ syncProcess = null;
75
+ syncPid = null;
76
+ }
77
+ killAllSync();
78
+ }
79
+
80
+ /**
81
+ * Send a WhatsApp message. Queued to avoid lock contention.
82
+ * @param {string} phone - E.164 phone number
83
+ * @param {string} text - Message text
84
+ * @returns {Promise<{success: boolean, error?: string}>}
85
+ */
86
+ async function send(phone, text) {
87
+ if (!WACLI_AVAILABLE) {
88
+ return { success: false, error: 'wacli not installed' };
89
+ }
90
+
91
+ return new Promise((resolve) => {
92
+ sendQueue.push({ phone, text, resolve });
93
+ processSendQueue();
94
+ });
95
+ }
96
+
97
+ async function processSendQueue() {
98
+ if (sendProcessing || sendQueue.length === 0) return;
99
+ sendProcessing = true;
100
+
101
+ // Briefly stop sync to release lock
102
+ stopSync();
103
+ await new Promise(r => setTimeout(r, 2000));
104
+
105
+ while (sendQueue.length > 0) {
106
+ const { phone, text, resolve } = sendQueue.shift();
107
+ log('Sending', { phone, textLen: text.length });
108
+
109
+ try {
110
+ const result = await new Promise((res, rej) => {
111
+ exec(
112
+ `${WACLI_BIN} send text --to "${phone.replace(/^\+/, '')}" --message ${JSON.stringify(text)} --timeout 60s`,
113
+ { timeout: 90000, encoding: 'utf-8' },
114
+ (err, stdout) => {
115
+ if (err) rej(err);
116
+ else res(stdout);
117
+ }
118
+ );
119
+ });
120
+ log('Send success', { phone });
121
+ resolve({ success: true });
122
+ } catch (err) {
123
+ log('Send FAILED', { phone, error: err.message });
124
+ resolve({ success: false, error: err.message });
125
+ }
126
+
127
+ // Small pause between sends
128
+ if (sendQueue.length > 0) await new Promise(r => setTimeout(r, 500));
129
+ }
130
+
131
+ sendProcessing = false;
132
+ // Restart sync
133
+ await new Promise(r => setTimeout(r, 300));
134
+ startSync();
135
+ }
136
+
137
+ /**
138
+ * Fetch recent messages from wacli.
139
+ * @param {string} [afterTimestamp] - ISO timestamp
140
+ * @param {number} [limit=20]
141
+ * @returns {Array}
142
+ */
143
+ function fetchMessages(afterTimestamp = null, limit = 20) {
144
+ if (!WACLI_AVAILABLE) return [];
145
+
146
+ try {
147
+ let cmd = `${WACLI_BIN} messages list --json --limit ${limit}`;
148
+ if (afterTimestamp) cmd += ` --after "${afterTimestamp}"`;
149
+
150
+ const output = execSync(cmd, { timeout: 15000, encoding: 'utf-8' });
151
+ const messages = JSON.parse(output);
152
+ return Array.isArray(messages) ? messages : [];
153
+ } catch {
154
+ return [];
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Parse a WhatsApp webhook payload into normalized format.
160
+ * @param {Object} payload - Raw webhook payload
161
+ * @returns {Object|null}
162
+ */
163
+ function parseWebhook(payload) {
164
+ if (!payload) return null;
165
+
166
+ // Handle wacli webhook format
167
+ const msg = payload.message || payload;
168
+ const phone = msg.from || msg.phone || '';
169
+ if (!phone) return null;
170
+
171
+ const normalized = phone.startsWith('+') ? phone : `+${phone.replace(/^\\+/, '')}`;
172
+
173
+ return {
174
+ id: msg.id || `wa-${Date.now()}`,
175
+ phone: normalized,
176
+ channel: 'whatsapp',
177
+ text: msg.text || msg.body || '',
178
+ attachments: [],
179
+ timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
180
+ raw: payload,
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Check if wacli is available and authenticated.
186
+ * @returns {boolean}
187
+ */
188
+ function isAvailable() {
189
+ return WACLI_AVAILABLE;
190
+ }
191
+
192
+ module.exports = { send, fetchMessages, parseWebhook, startSync, stopSync, isAvailable };
@@ -0,0 +1,234 @@
1
+ // ── Contact Manager - CRUD + Context + Qualification ─────
2
+ 'use strict';
3
+
4
+ const { getStmts, getSetting } = require('./db');
5
+ const { normalizePhone } = require('./utils/phone');
6
+
7
+ // Default scopes per category (from design doc Section 7.3)
8
+ const DEFAULT_SCOPES = {
9
+ family: ['calendar.view', 'calendar.book', 'calendar.modify', 'messages.send', 'messages.auto-reply', 'info.business', 'info.personal', 'reminders.create'],
10
+ friend: ['calendar.view', 'calendar.book', 'messages.auto-reply', 'info.business'],
11
+ client: ['calendar.view', 'calendar.book', 'messages.auto-reply', 'info.business', 'support.technical'],
12
+ 'qualified-lead': ['calendar.view', 'calendar.book', 'messages.auto-reply', 'info.business'],
13
+ lead: ['messages.auto-reply', 'info.business'],
14
+ team: ['calendar.view', 'calendar.book', 'calendar.modify', 'messages.send', 'messages.auto-reply', 'tasks.create', 'info.business', 'reminders.create'],
15
+ unknown: ['messages.auto-reply'],
16
+ blocked: [],
17
+ };
18
+
19
+ function log(msg, data) {
20
+ const ts = new Date().toISOString();
21
+ if (data) console.log(`[${ts}] [CONTACT-MGR] ${msg}`, JSON.stringify(data));
22
+ else console.log(`[${ts}] [CONTACT-MGR] ${msg}`);
23
+ }
24
+
25
+ /**
26
+ * Get a contact by phone number. Returns null if not found.
27
+ * @param {string} phone - E.164 phone
28
+ * @returns {Object|null}
29
+ */
30
+ function getContact(phone) {
31
+ const stmts = getStmts();
32
+ const contact = stmts.getContact.get(normalizePhone(phone));
33
+ if (!contact) return null;
34
+ const context = stmts.getContext.get(contact.phone) || {};
35
+ const scopes = stmts.getAllScopes.all(contact.phone).filter(s => s.granted).map(s => s.scope);
36
+ return { ...contact, context, scopes };
37
+ }
38
+
39
+ /**
40
+ * Get or create a contact. If the contact doesn't exist, creates with defaults.
41
+ * @param {string} phone - E.164 phone
42
+ * @param {Object} [defaults] - Default values for new contacts
43
+ * @returns {Object}
44
+ */
45
+ function getOrCreateContact(phone, defaults = {}) {
46
+ const normalized = normalizePhone(phone);
47
+ let contact = getContact(normalized);
48
+ if (contact) return contact;
49
+
50
+ const stmts = getStmts();
51
+ const name = defaults.name || 'Unknown';
52
+ const category = defaults.category || 'unknown';
53
+ const source = defaults.source || 'unknown';
54
+
55
+ stmts.upsertContact.run(normalized, name, category, 'auto', 'casual', '', source, 0, 0, 'none');
56
+ stmts.upsertContext.run(normalized, '', '', '[]', '', '{}');
57
+
58
+ // Apply default scopes for category
59
+ const categoryScopes = DEFAULT_SCOPES[category] || DEFAULT_SCOPES.unknown;
60
+ for (const scope of categoryScopes) {
61
+ stmts.upsertScope.run(normalized, scope, 1, 'auto');
62
+ }
63
+
64
+ log('Created new contact', { phone: normalized, name, category, source });
65
+ return getContact(normalized);
66
+ }
67
+
68
+ /**
69
+ * Update a contact's fields.
70
+ * @param {string} phone - E.164 phone
71
+ * @param {Object} updates - Fields to update
72
+ * @returns {Object|null} Updated contact
73
+ */
74
+ function updateContact(phone, updates) {
75
+ const normalized = normalizePhone(phone);
76
+ const existing = getContact(normalized);
77
+ if (!existing) return null;
78
+
79
+ const stmts = getStmts();
80
+ stmts.upsertContact.run(
81
+ normalized,
82
+ updates.name ?? existing.name,
83
+ updates.category ?? existing.category,
84
+ updates.response_mode ?? existing.response_mode,
85
+ updates.style ?? existing.style,
86
+ updates.instructions ?? existing.instructions,
87
+ updates.source ?? existing.source,
88
+ updates.introduced ?? existing.introduced,
89
+ updates.qualification_score ?? existing.qualification_score,
90
+ updates.pipeline_stage ?? existing.pipeline_stage,
91
+ );
92
+
93
+ // If category changed, apply new default scopes
94
+ if (updates.category && updates.category !== existing.category) {
95
+ applyDefaultScopes(normalized, updates.category);
96
+ }
97
+
98
+ log('Updated contact', { phone: normalized, updates: Object.keys(updates) });
99
+ return getContact(normalized);
100
+ }
101
+
102
+ /**
103
+ * Update contact context.
104
+ * @param {string} phone - E.164 phone
105
+ * @param {Object} contextUpdates
106
+ */
107
+ function updateContext(phone, contextUpdates) {
108
+ const normalized = normalizePhone(phone);
109
+ const stmts = getStmts();
110
+ const existing = stmts.getContext.get(normalized) || {};
111
+
112
+ stmts.upsertContext.run(
113
+ normalized,
114
+ contextUpdates.relationship ?? existing.relationship ?? '',
115
+ contextUpdates.last_topic ?? existing.last_topic ?? '',
116
+ contextUpdates.pending_items ?? existing.pending_items ?? '[]',
117
+ contextUpdates.conversation_summary ?? existing.conversation_summary ?? '',
118
+ contextUpdates.preferences ?? existing.preferences ?? '{}',
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Delete a contact and all associated data.
124
+ * @param {string} phone - E.164 phone
125
+ * @returns {boolean}
126
+ */
127
+ function deleteContact(phone) {
128
+ const normalized = normalizePhone(phone);
129
+ const stmts = getStmts();
130
+ stmts.deleteContext.run(normalized);
131
+ stmts.deleteScopesByPhone.run(normalized);
132
+ stmts.deleteContact.run(normalized);
133
+ log('Deleted contact', { phone: normalized });
134
+ return true;
135
+ }
136
+
137
+ /**
138
+ * Get all contacts, optionally filtered.
139
+ * @param {Object} [filter] - { category, search }
140
+ * @returns {Array}
141
+ */
142
+ function listContacts(filter = {}) {
143
+ const stmts = getStmts();
144
+ if (filter.category) {
145
+ return stmts.filterByCategory.all(filter.category);
146
+ }
147
+ if (filter.search) {
148
+ const q = `%${filter.search}%`;
149
+ return stmts.searchContacts.all(q, q);
150
+ }
151
+ return stmts.getAllContacts.all();
152
+ }
153
+
154
+ /**
155
+ * Apply default scopes for a category. Does NOT revoke existing scopes.
156
+ * @param {string} phone
157
+ * @param {string} category
158
+ */
159
+ function applyDefaultScopes(phone, category) {
160
+ const stmts = getStmts();
161
+ const scopes = DEFAULT_SCOPES[category] || DEFAULT_SCOPES.unknown;
162
+ for (const scope of scopes) {
163
+ stmts.upsertScope.run(phone, scope, 1, 'auto');
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Set specific scopes for a contact.
169
+ * @param {string} phone
170
+ * @param {Object} scopeMap - { 'calendar.book': true, 'info.personal': false }
171
+ */
172
+ function setScopes(phone, scopeMap) {
173
+ const normalized = normalizePhone(phone);
174
+ const stmts = getStmts();
175
+ for (const [scope, granted] of Object.entries(scopeMap)) {
176
+ stmts.upsertScope.run(normalized, scope, granted ? 1 : 0, 'manual');
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Check if a contact has a specific scope.
182
+ * @param {string} phone
183
+ * @param {string} scope
184
+ * @returns {boolean}
185
+ */
186
+ function hasScope(phone, scope) {
187
+ const stmts = getStmts();
188
+ const row = stmts.getScope.get(normalizePhone(phone), scope);
189
+ return row?.granted === 1;
190
+ }
191
+
192
+ /**
193
+ * Mark a contact as introduced by AIVA.
194
+ * @param {string} phone
195
+ */
196
+ function markIntroduced(phone) {
197
+ getStmts().markIntroduced.run(normalizePhone(phone));
198
+ }
199
+
200
+ /**
201
+ * Update qualification score and pipeline stage.
202
+ * @param {string} phone
203
+ * @param {number} score - 0-100
204
+ * @param {string} stage - none, cold, warm, hot, qualified, client
205
+ */
206
+ function updateQualification(phone, score, stage) {
207
+ getStmts().updateQualification.run(score, stage, normalizePhone(phone));
208
+ }
209
+
210
+ /**
211
+ * Check if the last outbound message was sent by Brandon (for reintro logic).
212
+ * @param {string} phone
213
+ * @returns {boolean}
214
+ */
215
+ function needsReintro(phone) {
216
+ const row = getStmts().getLastSentBy.get(normalizePhone(phone));
217
+ return row && row.sent_by === 'brandon';
218
+ }
219
+
220
+ module.exports = {
221
+ getContact,
222
+ getOrCreateContact,
223
+ updateContact,
224
+ updateContext,
225
+ deleteContact,
226
+ listContacts,
227
+ applyDefaultScopes,
228
+ setScopes,
229
+ hasScope,
230
+ markIntroduced,
231
+ updateQualification,
232
+ needsReintro,
233
+ DEFAULT_SCOPES,
234
+ };