@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,165 +0,0 @@
1
- # xAI Voice Agent — Tool Calling Integration
2
-
3
- ## Goal
4
- Add custom function tools to the Ara voice call so Grok can create tasks, manage documents, check calendar, and send messages during live conversation.
5
-
6
- ## How xAI Tool Calling Works
7
- In `session.update`, add a `tools` array with function definitions. When Grok decides to call a function, the server receives a `response.function_call_arguments.done` event with:
8
- - `name` — function name
9
- - `call_id` — unique ID to match the response
10
- - `arguments` — JSON string of arguments
11
-
12
- Server executes the function, then sends back:
13
- 1. `conversation.item.create` with `type: "function_call_output"`, `call_id`, and `output` (JSON string)
14
- 2. `response.create` to let Grok continue speaking with the result
15
-
16
- ## Tools to Implement
17
-
18
- ### 1. `create_task` — Create a task on the AIVA board
19
- ```json
20
- {
21
- "type": "function",
22
- "name": "create_task",
23
- "description": "Create a new task on the AIVA task board. Use this when Brandon asks you to add a task, create a to-do, or remember to do something.",
24
- "parameters": {
25
- "type": "object",
26
- "properties": {
27
- "title": { "type": "string", "description": "Task title" },
28
- "description": { "type": "string", "description": "Task description or details" },
29
- "priority": { "type": "string", "enum": ["low", "normal", "high"], "description": "Task priority" },
30
- "assignee": { "type": "string", "description": "Who to assign to (default: aiva)" }
31
- },
32
- "required": ["title"]
33
- }
34
- }
35
- ```
36
- **Implementation:** `POST http://localhost:3847/api/tasks` with `x-aiva-internal: true` header
37
- Body: `{ "title": ..., "description": ..., "priority": ..., "assignee": ..., "status": "todo", "requestedFor": "brandon" }`
38
-
39
- ### 2. `list_tasks` — List current tasks
40
- ```json
41
- {
42
- "type": "function",
43
- "name": "list_tasks",
44
- "description": "List tasks on the AIVA task board. Use when Brandon asks what's on the board, what tasks are pending, or what's in progress.",
45
- "parameters": {
46
- "type": "object",
47
- "properties": {
48
- "status": { "type": "string", "enum": ["todo", "in-progress", "needs-review", "done", "all"], "description": "Filter by status" }
49
- }
50
- }
51
- }
52
- ```
53
- **Implementation:** `GET http://localhost:3847/api/tasks` with `x-aiva-internal: true`, filter by status if provided. Return summary (title + status + assignee for each).
54
-
55
- ### 3. `update_task` — Update a task
56
- ```json
57
- {
58
- "type": "function",
59
- "name": "update_task",
60
- "description": "Update an existing task (change status, add notes, reassign). Use when Brandon says to move a task, mark something done, or add details to a task.",
61
- "parameters": {
62
- "type": "object",
63
- "properties": {
64
- "task_title": { "type": "string", "description": "Title or partial title to find the task" },
65
- "status": { "type": "string", "enum": ["todo", "in-progress", "needs-review", "done"] },
66
- "notes": { "type": "string", "description": "Note to add to the task" },
67
- "assignee": { "type": "string", "description": "Reassign to someone" }
68
- },
69
- "required": ["task_title"]
70
- }
71
- }
72
- ```
73
- **Implementation:** `GET /api/tasks` → find by title match → `PUT /api/tasks/:id` with updates
74
-
75
- ### 4. `check_calendar` — Check calendar events
76
- ```json
77
- {
78
- "type": "function",
79
- "name": "check_calendar",
80
- "description": "Check Brandon's calendar for today or upcoming events. Use when he asks about his schedule, meetings, or what's coming up.",
81
- "parameters": {
82
- "type": "object",
83
- "properties": {
84
- "timeframe": { "type": "string", "enum": ["today", "tomorrow", "this_week"], "description": "Time range to check" }
85
- }
86
- }
87
- }
88
- ```
89
- **Implementation:** Run `gog cal today` or `gog cal tomorrow` via child_process.exec and return output.
90
-
91
- ### 5. `send_message` — Send a message in AIVA app chat
92
- ```json
93
- {
94
- "type": "function",
95
- "name": "send_message",
96
- "description": "Send a text message in the AIVA app chat. Use when Brandon asks you to write something down, send a note, or post a message.",
97
- "parameters": {
98
- "type": "object",
99
- "properties": {
100
- "message": { "type": "string", "description": "The message to send" }
101
- },
102
- "required": ["message"]
103
- }
104
- }
105
- ```
106
- **Implementation:** `POST http://localhost:3847/api/chat/aiva-reply` with `userId: "brandon"`, `text: message`, `x-aiva-internal: true`
107
-
108
- ### 6. `search_documents` — Search workspace files
109
- ```json
110
- {
111
- "type": "function",
112
- "name": "search_documents",
113
- "description": "Search through AIVA's workspace documents and memory files. Use when Brandon asks about past conversations, notes, or stored information.",
114
- "parameters": {
115
- "type": "object",
116
- "properties": {
117
- "query": { "type": "string", "description": "Search query" }
118
- },
119
- "required": ["query"]
120
- }
121
- }
122
- ```
123
- **Implementation:** Run `grep -r -i -l "<query>" ~/.openclaw/workspace/memory/ ~/.openclaw/workspace/MEMORY.md` then read matching files and return relevant excerpts.
124
-
125
- ## Server Changes (`voice-call.js`)
126
-
127
- ### 1. Add tools to session.update
128
- In the `session.update` message sent to xAI, add the `tools` array with all function definitions above.
129
-
130
- ### 2. Handle function calls
131
- In the WebSocket message handler, add a case for `response.function_call_arguments.done`:
132
- ```javascript
133
- case 'response.function_call_arguments.done':
134
- const { name, call_id, arguments: argsStr } = msg;
135
- const args = JSON.parse(argsStr);
136
- const result = await executeTool(name, args);
137
-
138
- // Send result back
139
- xaiWs.send(JSON.stringify({
140
- type: 'conversation.item.create',
141
- item: {
142
- type: 'function_call_output',
143
- call_id: call_id,
144
- output: JSON.stringify(result)
145
- }
146
- }));
147
-
148
- // Let Grok continue
149
- xaiWs.send(JSON.stringify({ type: 'response.create' }));
150
- break;
151
- ```
152
-
153
- ### 3. Implement `executeTool(name, args)` function
154
- Switch on tool name, execute the appropriate API call, return result object.
155
-
156
- ## DO NOT TOUCH
157
- - Microphone icon / voice-to-text
158
- - Any other app features
159
- - The audio streaming / playback code (already working)
160
-
161
- ## Files to Modify
162
- - `voice-call.js` — Add tools to session config + handle function calls
163
-
164
- ## After changes
165
- `pm2 restart aiva-app`
package/email-router.js DELETED
@@ -1,512 +0,0 @@
1
- // ── Email Router Module ──────────────────────────────────
2
- // Event-driven email routing with rules engine, AI triage, and draft generation
3
- const path = require('path');
4
- const fs = require('fs');
5
- const { db, stmts } = require('./router-db');
6
-
7
- // ── Config ──────────────────────────────────────────────
8
- const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
9
- const ACCOUNTS = ['burgan.brandon@gmail.com', 'brandon@conversionmarketingpros.com'];
10
- const API_BASE = 'http://localhost:3847/api/integrations/google/emails';
11
- const ACCOUNT_MAP = {
12
- 'burgan.brandon@gmail.com': 'bee69b18-3847-4a6f-a518-ada17d0a18c0',
13
- 'brandon@conversionmarketingpros.com': 'ebf27762-3847-4a6f-a518-ada17d0a18c0',
14
- };
15
- const WORKSPACE = process.env.OPENCLAW_WORKSPACE || path.join(process.env.HOME, '.openclaw', 'workspace');
16
- const CONFIG_PATH = path.join(process.env.HOME, '.openclaw', 'openclaw.json');
17
-
18
- let pollTimer = null;
19
- let isPolling = false;
20
-
21
- function emailLog(msg, data) {
22
- const ts = new Date().toISOString();
23
- if (data) console.log(`[${ts}] [EMAIL-ROUTER] ${msg}`, JSON.stringify(data));
24
- else console.log(`[${ts}] [EMAIL-ROUTER] ${msg}`);
25
- }
26
-
27
- function getOpenClawPassword() {
28
- try {
29
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
30
- return config?.gateway?.auth?.password || '';
31
- } catch { return ''; }
32
- }
33
-
34
- function loadPersonalityFiles() {
35
- const files = ['SOUL.md', 'USER.md'];
36
- const parts = [];
37
- for (const f of files) {
38
- try {
39
- const content = fs.readFileSync(path.join(WORKSPACE, f), 'utf-8');
40
- parts.push(`[${f}]\n${content}`);
41
- } catch { /* skip missing */ }
42
- }
43
- const sensitivePatterns = [
44
- /api[_-]?key/i, /password/i, /token/i, /secret/i, /openclaw/i,
45
- /localhost/i, /127\.0\.0\.1/i, /\.openclaw\//i, /curl\s/i,
46
- /sk-ant-/i, /sk-or-/i, /ghp_/i, /xai-/i, /r8_/i,
47
- /supabase/i, /cloudflare/i, /twilio/i, /replicate/i,
48
- ];
49
- for (let i = 0; i < parts.length; i++) {
50
- const lines = parts[i].split('\n');
51
- parts[i] = lines.filter(line => !sensitivePatterns.some(p => p.test(line))).join('\n');
52
- }
53
- return parts.join('\n\n---\n\n');
54
- }
55
-
56
- async function callClaude(model, systemPrompt, messages, maxTokens = 1024) {
57
- const password = getOpenClawPassword();
58
- if (!password) { emailLog('No OpenClaw password found'); return null; }
59
- const allMessages = [{ role: 'system', content: systemPrompt }, ...messages];
60
- try {
61
- const resp = await fetch('http://127.0.0.1:18789/v1/chat/completions', {
62
- method: 'POST',
63
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${password}` },
64
- body: JSON.stringify({ model, max_tokens: maxTokens, messages: allMessages }),
65
- signal: AbortSignal.timeout(60000),
66
- });
67
- if (!resp.ok) {
68
- const errText = await resp.text();
69
- emailLog('Claude API error', { status: resp.status, body: errText.substring(0, 200) });
70
- return null;
71
- }
72
- const json = await resp.json();
73
- return json.choices?.[0]?.message?.content?.trim() || null;
74
- } catch (err) {
75
- emailLog('Claude API call failed', { error: err.message });
76
- return null;
77
- }
78
- }
79
-
80
- // ── Gmail Integration ───────────────────────────────────
81
-
82
- async function fetchUnreadEmails(account) {
83
- try {
84
- const accountId = ACCOUNT_MAP[account];
85
- const url = `${API_BASE}?q=is:unread&accountId=${accountId}`;
86
- const resp = await fetch(url, { signal: AbortSignal.timeout(30000) });
87
- if (!resp.ok) {
88
- emailLog('Failed to fetch unread emails', { account, status: resp.status });
89
- return [];
90
- }
91
- const data = await resp.json();
92
- // API returns array of emails directly; wrap as thread-like objects with id
93
- return (data.emails || data || []).map(e => ({ id: e.id || e.threadId, ...e }));
94
- } catch (err) {
95
- emailLog('Failed to fetch unread emails', { account, error: err.message });
96
- return [];
97
- }
98
- }
99
-
100
- async function fetchEmailThread(threadId, account) {
101
- try {
102
- const accountId = ACCOUNT_MAP[account];
103
- const url = `${API_BASE}/${threadId}?accountId=${accountId}`;
104
- const resp = await fetch(url, { signal: AbortSignal.timeout(15000) });
105
- if (!resp.ok) {
106
- emailLog('Failed to read email', { threadId, account, status: resp.status });
107
- return null;
108
- }
109
- const data = await resp.json();
110
- // The internal API returns a structured email object
111
- return {
112
- id: data.id,
113
- threadId: data.threadId || threadId,
114
- from: data.from || '',
115
- to: data.to || '',
116
- subject: data.subject || '',
117
- date: data.date || '',
118
- bodyText: data.bodyText || data.body || data.snippet || '',
119
- bodyHtml: data.bodyHtml || '',
120
- labels: data.labels || data.labelIds || [],
121
- };
122
- } catch (err) {
123
- emailLog('Failed to read email thread', { threadId, account, error: err.message });
124
- return null;
125
- }
126
- }
127
-
128
- function parseFromHeader(fromStr) {
129
- // "Name <email@example.com>" or "email@example.com"
130
- const match = fromStr.match(/^(.+?)\s*<(.+?)>$/);
131
- if (match) return { name: match[1].replace(/^"|"$/g, '').trim(), email: match[2].trim().toLowerCase() };
132
- return { name: '', email: fromStr.trim().toLowerCase() };
133
- }
134
-
135
- async function archiveEmail(gmailId, account) {
136
- try {
137
- const accountId = ACCOUNT_MAP[account];
138
- const resp = await fetch(`${API_BASE}/${gmailId}/labels`, {
139
- method: 'POST',
140
- headers: { 'Content-Type': 'application/json' },
141
- body: JSON.stringify({ removeLabelIds: ['INBOX'], accountId }),
142
- signal: AbortSignal.timeout(10000),
143
- });
144
- if (!resp.ok) {
145
- emailLog('Failed to archive email', { gmailId, status: resp.status });
146
- return;
147
- }
148
- emailLog('Archived email', { gmailId, account });
149
- } catch (err) {
150
- emailLog('Failed to archive email', { gmailId, error: err.message });
151
- }
152
- }
153
-
154
- // ── Rules Engine ────────────────────────────────────────
155
-
156
- function findRule(senderEmail) {
157
- // Check exact email match first
158
- let rule = stmts.getEmailRuleByEmail.get(senderEmail.toLowerCase());
159
- if (rule) return rule;
160
-
161
- // Check domain match
162
- const domain = '@' + senderEmail.split('@')[1];
163
- rule = stmts.getEmailRuleByDomain.get(domain);
164
- return rule || null;
165
- }
166
-
167
- // ── Email Processing Pipeline ───────────────────────────
168
-
169
- async function processEmail(emailData, account) {
170
- const { name: fromName, email: fromEmail } = parseFromHeader(emailData.from);
171
- const gmailId = emailData.id;
172
-
173
- // Already processed?
174
- const existing = stmts.getEmailProcessed.get(gmailId);
175
- if (existing) return;
176
-
177
- const rule = findRule(fromEmail);
178
- const action = rule?.action || 'surface';
179
-
180
- emailLog('Processing email', { gmailId, from: fromEmail, action, subject: emailData.subject?.substring(0, 60) });
181
-
182
- switch (action) {
183
- case 'auto-archive':
184
- await archiveEmail(gmailId, account);
185
- stmts.insertEmailProcessed.run(gmailId, account, 'auto-archived');
186
- break;
187
-
188
- case 'auto-unsubscribe':
189
- await archiveEmail(gmailId, account);
190
- stmts.insertEmailProcessed.run(gmailId, account, 'auto-unsubscribed');
191
- break;
192
-
193
- case 'auto-respond':
194
- await handleAutoRespond(emailData, account, fromEmail, fromName, rule);
195
- break;
196
-
197
- case 'ai-draft':
198
- await handleAiDraft(emailData, account, fromEmail, fromName, rule);
199
- break;
200
-
201
- case 'special-attention':
202
- await handleSpecialAttention(emailData, account, fromEmail, fromName, rule);
203
- break;
204
-
205
- case 'surface':
206
- default:
207
- await surfaceEmail(emailData, account, fromEmail, fromName, rule);
208
- break;
209
- }
210
- }
211
-
212
- async function surfaceEmail(emailData, account, fromEmail, fromName, rule) {
213
- stmts.insertEmailDraft.run(
214
- emailData.id, account, fromEmail, fromName,
215
- emailData.to || account, emailData.subject || '',
216
- emailData.bodyText || '', emailData.bodyHtml || '',
217
- null, 'pending', 'surfaced', rule?.id || null
218
- );
219
- stmts.insertEmailProcessed.run(emailData.id, account, 'surfaced');
220
- await archiveEmail(emailData.id, account);
221
- }
222
-
223
- async function handleAutoRespond(emailData, account, fromEmail, fromName, rule) {
224
- const template = rule.auto_respond_template || '';
225
- let responseBody = template;
226
-
227
- if (!template) {
228
- // Generate AI response
229
- const personality = loadPersonalityFiles();
230
- const prompt = `You are drafting an email reply. ${rule.instructions || ''}
231
-
232
- Original email from ${fromName} <${fromEmail}>:
233
- Subject: ${emailData.subject}
234
- Body: ${(emailData.bodyText || '').substring(0, 3000)}
235
-
236
- Write a concise, professional reply. Output ONLY the email body text, no subject line or headers.`;
237
-
238
- responseBody = await callClaude('claude-sonnet-4-20250514', personality, [{ role: 'user', content: prompt }]);
239
- if (!responseBody) {
240
- emailLog('AI response failed, surfacing instead', { gmailId: emailData.id });
241
- await surfaceEmail(emailData, account, fromEmail, fromName, rule);
242
- return;
243
- }
244
- }
245
-
246
- // Send the response
247
- try {
248
- const accountId = ACCOUNT_MAP[account];
249
- const sendResp = await fetch(`${API_BASE}/send`, {
250
- method: 'POST',
251
- headers: { 'Content-Type': 'application/json' },
252
- body: JSON.stringify({
253
- to: fromEmail,
254
- subject: `Re: ${emailData.subject || ''}`,
255
- body: responseBody,
256
- accountId,
257
- }),
258
- signal: AbortSignal.timeout(15000),
259
- });
260
- if (!sendResp.ok) throw new Error(`HTTP ${sendResp.status}: ${await sendResp.text()}`);
261
-
262
- stmts.insertEmailDraft.run(
263
- emailData.id, account, fromEmail, fromName,
264
- fromEmail, emailData.subject || '',
265
- emailData.bodyText || '', emailData.bodyHtml || '',
266
- responseBody, 'sent', 'auto-responded', rule?.id || null
267
- );
268
- stmts.insertEmailProcessed.run(emailData.id, account, 'auto-responded');
269
- await archiveEmail(emailData.id, account);
270
- emailLog('Auto-responded', { gmailId: emailData.id, to: fromEmail });
271
- } catch (err) {
272
- emailLog('Auto-respond send failed', { error: err.message });
273
- await surfaceEmail(emailData, account, fromEmail, fromName, rule);
274
- }
275
- }
276
-
277
- async function handleAiDraft(emailData, account, fromEmail, fromName, rule) {
278
- const personality = loadPersonalityFiles();
279
- const prompt = `You are drafting an email reply on behalf of Brandon. ${rule?.instructions || ''}
280
-
281
- Original email from ${fromName} <${fromEmail}>:
282
- Subject: ${emailData.subject}
283
- Body: ${(emailData.bodyText || '').substring(0, 3000)}
284
-
285
- Write a concise, natural reply as Brandon would write it. Output ONLY the email body text, no subject line or headers.`;
286
-
287
- const draft = await callClaude('claude-sonnet-4-20250514', personality, [{ role: 'user', content: prompt }]);
288
-
289
- stmts.insertEmailDraft.run(
290
- emailData.id, account, fromEmail, fromName,
291
- fromEmail, emailData.subject || '',
292
- emailData.bodyText || '', emailData.bodyHtml || '',
293
- draft || '', 'pending', 'ai-drafted', rule?.id || null
294
- );
295
- stmts.insertEmailProcessed.run(emailData.id, account, 'ai-drafted');
296
- await archiveEmail(emailData.id, account);
297
- emailLog('AI draft created', { gmailId: emailData.id, hasDraft: !!draft });
298
- }
299
-
300
- async function handleSpecialAttention(emailData, account, fromEmail, fromName, rule) {
301
- const triagePrompt = `You are an email triage assistant. Evaluate this email and decide what to do.
302
-
303
- ${rule?.instructions ? `Custom instructions: ${rule.instructions}\n` : ''}
304
- From: ${fromName} <${fromEmail}>
305
- Subject: ${emailData.subject}
306
- Body: ${(emailData.bodyText || '').substring(0, 2000)}
307
-
308
- Respond with EXACTLY one word: archive, surface, or escalate
309
- - archive = not important, auto-archive
310
- - surface = show to user for manual handling
311
- - escalate = important, generate an AI draft response`;
312
-
313
- const decision = await callClaude('claude-haiku-3-5', 'You are a concise email triage bot. Respond with exactly one word.', [{ role: 'user', content: triagePrompt }]);
314
- const action = (decision || 'surface').toLowerCase().trim();
315
-
316
- emailLog('Haiku triage decision', { gmailId: emailData.id, decision: action });
317
-
318
- if (action === 'archive') {
319
- await archiveEmail(emailData.id, account);
320
- stmts.insertEmailProcessed.run(emailData.id, account, 'haiku-archived');
321
- } else if (action === 'escalate') {
322
- await handleAiDraft(emailData, account, fromEmail, fromName, rule);
323
- } else {
324
- await surfaceEmail(emailData, account, fromEmail, fromName, rule);
325
- }
326
- }
327
-
328
- // ── Poll Cycle ──────────────────────────────────────────
329
-
330
- async function pollAllAccounts() {
331
- if (isPolling) {
332
- emailLog('Poll already in progress, skipping');
333
- return { skipped: true };
334
- }
335
- isPolling = true;
336
- const results = {};
337
-
338
- try {
339
- for (const account of ACCOUNTS) {
340
- emailLog('Polling account', { account });
341
- const threads = await fetchUnreadEmails(account);
342
- let processed = 0;
343
-
344
- for (const thread of threads) {
345
- try {
346
- const emailData = await fetchEmailThread(thread.id, account);
347
- if (emailData) {
348
- await processEmail(emailData, account);
349
- processed++;
350
- }
351
- } catch (err) {
352
- emailLog('Error processing thread', { threadId: thread.id, error: err.message });
353
- }
354
- }
355
-
356
- results[account] = { total: threads.length, processed };
357
- emailLog('Account poll complete', { account, total: threads.length, processed });
358
- }
359
- } catch (err) {
360
- emailLog('Poll cycle error', { error: err.message });
361
- } finally {
362
- isPolling = false;
363
- }
364
-
365
- return results;
366
- }
367
-
368
- // ── API Route Mounting ──────────────────────────────────
369
-
370
- function mountEmailRoutes(app, authMiddleware) {
371
- // List email drafts with filters
372
- app.get('/api/emails', authMiddleware, (req, res) => {
373
- const status = req.query.status;
374
- const limit = parseInt(req.query.limit) || 100;
375
- let drafts;
376
- if (status) {
377
- drafts = stmts.getEmailDraftsByStatus.all(status, limit);
378
- } else {
379
- drafts = stmts.getAllEmailDrafts.all();
380
- }
381
- res.json(drafts);
382
- });
383
-
384
- // List all email rules
385
- app.get('/api/emails/rules', authMiddleware, (req, res) => {
386
- res.json(stmts.getAllEmailRules.all());
387
- });
388
-
389
- // Create a rule
390
- app.post('/api/emails/rules', authMiddleware, (req, res) => {
391
- const { email, domain, sender_name, action, instructions, auto_respond_template, auto_respond_mode } = req.body;
392
- if (!action) return res.status(400).json({ error: 'action required' });
393
- if (!email && !domain) return res.status(400).json({ error: 'email or domain required' });
394
-
395
- const info = stmts.insertEmailRule.run(
396
- email || null, domain || null, sender_name || null,
397
- action, instructions || '', auto_respond_template || '', auto_respond_mode || 'once'
398
- );
399
- res.json({ id: Number(info.lastInsertRowid), success: true });
400
- });
401
-
402
- // Update a rule
403
- app.put('/api/emails/rules/:id', authMiddleware, (req, res) => {
404
- const existing = stmts.getEmailRuleById.get(parseInt(req.params.id));
405
- if (!existing) return res.status(404).json({ error: 'Rule not found' });
406
-
407
- const { email, domain, sender_name, action, instructions, auto_respond_template, auto_respond_mode } = req.body;
408
- stmts.updateEmailRule.run(
409
- email ?? existing.email, domain ?? existing.domain,
410
- sender_name ?? existing.sender_name, action ?? existing.action,
411
- instructions ?? existing.instructions,
412
- auto_respond_template ?? existing.auto_respond_template,
413
- auto_respond_mode ?? existing.auto_respond_mode,
414
- parseInt(req.params.id)
415
- );
416
- res.json({ success: true });
417
- });
418
-
419
- // Delete a rule
420
- app.delete('/api/emails/rules/:id', authMiddleware, (req, res) => {
421
- stmts.deleteEmailRule.run(parseInt(req.params.id));
422
- res.json({ success: true });
423
- });
424
-
425
- // Action on a draft (approve/send/archive/edit)
426
- app.put('/api/emails/:id/action', authMiddleware, async (req, res) => {
427
- const draft = stmts.getEmailDraftById.get(parseInt(req.params.id));
428
- if (!draft) return res.status(404).json({ error: 'Draft not found' });
429
-
430
- const { action, edited_draft } = req.body;
431
-
432
- if (action === 'send' || action === 'approve') {
433
- const bodyToSend = edited_draft || draft.ai_draft || draft.body_text || '';
434
- const account = draft.account || ACCOUNTS[0];
435
- const accountId = ACCOUNT_MAP[account];
436
-
437
- try {
438
- const sendResp = await fetch(`${API_BASE}/send`, {
439
- method: 'POST',
440
- headers: { 'Content-Type': 'application/json' },
441
- body: JSON.stringify({
442
- to: draft.from_email || '',
443
- subject: `Re: ${draft.subject || ''}`,
444
- body: bodyToSend,
445
- accountId,
446
- }),
447
- signal: AbortSignal.timeout(15000),
448
- });
449
- if (!sendResp.ok) throw new Error(`HTTP ${sendResp.status}: ${await sendResp.text()}`);
450
- stmts.updateEmailDraftStatus.run('sent', 'approved-and-sent', 'sent', parseInt(req.params.id));
451
- if (edited_draft) stmts.updateEmailDraftAiDraft.run(edited_draft, parseInt(req.params.id));
452
- return res.json({ success: true, status: 'sent' });
453
- } catch (err) {
454
- emailLog('Send failed', { error: err.message });
455
- return res.status(500).json({ error: 'Send failed: ' + err.message });
456
- }
457
- } else if (action === 'archive' || action === 'reject') {
458
- stmts.updateEmailDraftStatus.run('archived', action, action, parseInt(req.params.id));
459
- return res.json({ success: true, status: 'archived' });
460
- } else if (action === 'edit') {
461
- if (edited_draft) stmts.updateEmailDraftAiDraft.run(edited_draft, parseInt(req.params.id));
462
- return res.json({ success: true });
463
- } else {
464
- return res.status(400).json({ error: 'Invalid action. Use: send, approve, archive, reject, edit' });
465
- }
466
- });
467
-
468
- // List connected accounts
469
- app.get('/api/emails/accounts', authMiddleware, (req, res) => {
470
- res.json({ accounts: ACCOUNTS });
471
- });
472
-
473
- // Manually trigger poll
474
- app.post('/api/emails/poll', authMiddleware, async (req, res) => {
475
- try {
476
- const results = await pollAllAccounts();
477
- res.json({ success: true, results });
478
- } catch (err) {
479
- res.status(500).json({ error: err.message });
480
- }
481
- });
482
-
483
- emailLog('Email Router routes mounted');
484
- }
485
-
486
- // ── Start / Stop ────────────────────────────────────────
487
-
488
- function startEmailRouter(app, authMiddleware) {
489
- mountEmailRoutes(app, authMiddleware);
490
-
491
- // Start periodic polling
492
- pollTimer = setInterval(() => {
493
- pollAllAccounts().catch(err => emailLog('Poll error', { error: err.message }));
494
- }, POLL_INTERVAL_MS);
495
-
496
- // Initial poll after 30 seconds (let server fully start)
497
- setTimeout(() => {
498
- pollAllAccounts().catch(err => emailLog('Initial poll error', { error: err.message }));
499
- }, 30000);
500
-
501
- emailLog('Email Router started', { accounts: ACCOUNTS, pollIntervalMs: POLL_INTERVAL_MS });
502
- }
503
-
504
- function stopEmailRouter() {
505
- if (pollTimer) {
506
- clearInterval(pollTimer);
507
- pollTimer = null;
508
- }
509
- emailLog('Email Router stopped');
510
- }
511
-
512
- module.exports = { startEmailRouter, stopEmailRouter, pollAllAccounts };