@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,165 @@
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`
@@ -0,0 +1,512 @@
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 };