@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,231 @@
1
+ // ── Knowledge Base - Structured FAQ + BM25 Workspace Search ──
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { getStmts } = require('./db');
7
+
8
+ const WORKSPACE = path.join(process.env.HOME || '/Users/brandonburgan', '.openclaw', 'workspace');
9
+
10
+ function log(msg, data) {
11
+ const ts = new Date().toISOString();
12
+ if (data) console.log(`[${ts}] [KB] ${msg}`, JSON.stringify(data));
13
+ else console.log(`[${ts}] [KB] ${msg}`);
14
+ }
15
+
16
+ // ── BM25 Search Implementation ──
17
+
18
+ function tokenize(text) {
19
+ return (text || '').toLowerCase()
20
+ .replace(/[^a-z0-9\s-]/g, ' ')
21
+ .split(/\s+/)
22
+ .filter(t => t.length > 1);
23
+ }
24
+
25
+ function bm25Score(queryTokens, docTokens, avgDl, N, dfMap) {
26
+ const k1 = 1.5, b = 0.75;
27
+ const dl = docTokens.length;
28
+ const tf = {};
29
+ for (const t of docTokens) tf[t] = (tf[t] || 0) + 1;
30
+
31
+ let score = 0;
32
+ for (const qt of queryTokens) {
33
+ const termTf = tf[qt] || 0;
34
+ if (termTf === 0) continue;
35
+ const df = dfMap[qt] || 0;
36
+ const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);
37
+ const tfNorm = (termTf * (k1 + 1)) / (termTf + k1 * (1 - b + b * (dl / avgDl)));
38
+ score += idf * tfNorm;
39
+ }
40
+ return score;
41
+ }
42
+
43
+ /**
44
+ * Search FAQ entries for a query. Checks device-specific FAQ first, then global.
45
+ * @param {string} query - Search query
46
+ * @param {string|null} [deviceId] - Device ID for device-specific FAQ
47
+ * @param {number} [limit=5] - Max results
48
+ * @returns {Array<{question: string, answer: string, score: number}>}
49
+ */
50
+ function searchFaq(query, deviceId = null, limit = 5) {
51
+ const stmts = getStmts();
52
+ const entries = deviceId
53
+ ? stmts.getFaqByDevice.all(deviceId)
54
+ : stmts.getAllFaq.all();
55
+
56
+ if (entries.length === 0) return [];
57
+
58
+ const queryTokens = tokenize(query);
59
+ if (queryTokens.length === 0) return [];
60
+
61
+ // Build document frequency map
62
+ const N = entries.length;
63
+ const dfMap = {};
64
+ const docTokensList = entries.map(e => {
65
+ const tokens = tokenize(`${e.question} ${e.answer} ${e.keywords || ''}`);
66
+ const tokenSet = new Set(tokens);
67
+ for (const qt of queryTokens) {
68
+ if (tokenSet.has(qt)) dfMap[qt] = (dfMap[qt] || 0) + 1;
69
+ }
70
+ return tokens;
71
+ });
72
+
73
+ const avgDl = docTokensList.reduce((s, t) => s + t.length, 0) / N;
74
+
75
+ const scored = entries.map((entry, i) => ({
76
+ ...entry,
77
+ score: bm25Score(queryTokens, docTokensList[i], avgDl, N, dfMap),
78
+ }));
79
+
80
+ scored.sort((a, b) => b.score - a.score);
81
+ return scored.filter(s => s.score > 0).slice(0, limit);
82
+ }
83
+
84
+ /**
85
+ * Search workspace files as a fallback when FAQ doesn't have answers.
86
+ * Searches MEMORY.md, SOPs, and other workspace files.
87
+ * @param {string} query
88
+ * @param {number} [limit=3]
89
+ * @returns {Array<{file: string, excerpt: string, score: number}>}
90
+ */
91
+ function searchWorkspace(query, limit = 3) {
92
+ const queryTokens = tokenize(query);
93
+ if (queryTokens.length === 0) return [];
94
+
95
+ const filesToSearch = [];
96
+ const watchPaths = [
97
+ path.join(WORKSPACE, 'memory'),
98
+ path.join(WORKSPACE, 'MEMORY.md'),
99
+ path.join(WORKSPACE, 'TOOLS.md'),
100
+ ];
101
+
102
+ for (const p of watchPaths) {
103
+ try {
104
+ const stat = fs.statSync(p);
105
+ if (stat.isDirectory()) {
106
+ const files = fs.readdirSync(p).filter(f => f.endsWith('.md'));
107
+ for (const f of files) filesToSearch.push(path.join(p, f));
108
+ } else {
109
+ filesToSearch.push(p);
110
+ }
111
+ } catch { /* skip missing files */ }
112
+ }
113
+
114
+ const chunks = [];
115
+ for (const filePath of filesToSearch) {
116
+ try {
117
+ const content = fs.readFileSync(filePath, 'utf-8');
118
+ // Split into paragraphs
119
+ const paragraphs = content.split(/\n\n+/).filter(p => p.trim().length > 50);
120
+ for (const para of paragraphs) {
121
+ chunks.push({ file: path.basename(filePath), text: para.trim() });
122
+ }
123
+ } catch { /* skip unreadable files */ }
124
+ }
125
+
126
+ if (chunks.length === 0) return [];
127
+
128
+ const N = chunks.length;
129
+ const dfMap = {};
130
+ const docTokensList = chunks.map(c => {
131
+ const tokens = tokenize(c.text);
132
+ const tokenSet = new Set(tokens);
133
+ for (const qt of queryTokens) {
134
+ if (tokenSet.has(qt)) dfMap[qt] = (dfMap[qt] || 0) + 1;
135
+ }
136
+ return tokens;
137
+ });
138
+
139
+ const avgDl = docTokensList.reduce((s, t) => s + t.length, 0) / N;
140
+
141
+ const scored = chunks.map((chunk, i) => ({
142
+ file: chunk.file,
143
+ excerpt: chunk.text.substring(0, 300),
144
+ score: bm25Score(queryTokens, docTokensList[i], avgDl, N, dfMap),
145
+ }));
146
+
147
+ scored.sort((a, b) => b.score - a.score);
148
+ return scored.filter(s => s.score > 0).slice(0, limit);
149
+ }
150
+
151
+ /**
152
+ * Combined search - FAQ first, workspace as fallback.
153
+ * @param {string} query
154
+ * @param {string|null} [deviceId]
155
+ * @returns {Array}
156
+ */
157
+ function search(query, deviceId = null) {
158
+ const faqResults = searchFaq(query, deviceId, 3);
159
+ if (faqResults.length > 0) return faqResults.map(r => ({ type: 'faq', question: r.question, answer: r.answer, score: r.score }));
160
+
161
+ const wsResults = searchWorkspace(query, 3);
162
+ return wsResults.map(r => ({ type: 'workspace', file: r.file, excerpt: r.excerpt, score: r.score }));
163
+ }
164
+
165
+ // ── CRUD for FAQ management API ──
166
+
167
+ /**
168
+ * List all FAQ entries.
169
+ * @param {string|null} [deviceId]
170
+ * @returns {Array}
171
+ */
172
+ function listFaq(deviceId = null) {
173
+ return deviceId ? getStmts().getFaqByDevice.all(deviceId) : getStmts().getAllFaq.all();
174
+ }
175
+
176
+ /**
177
+ * Create a FAQ entry.
178
+ * @param {Object} entry
179
+ * @returns {Object}
180
+ */
181
+ function createFaq(entry) {
182
+ const stmts = getStmts();
183
+ const info = stmts.insertFaq.run(
184
+ entry.device_id || null,
185
+ entry.question,
186
+ entry.answer,
187
+ entry.category || 'general',
188
+ entry.keywords || '',
189
+ );
190
+ log('Created FAQ entry', { id: info.lastInsertRowid });
191
+ return stmts.getFaqById.get(info.lastInsertRowid);
192
+ }
193
+
194
+ /**
195
+ * Update a FAQ entry.
196
+ * @param {number} id
197
+ * @param {Object} updates
198
+ * @returns {Object|null}
199
+ */
200
+ function updateFaq(id, updates) {
201
+ const stmts = getStmts();
202
+ const existing = stmts.getFaqById.get(id);
203
+ if (!existing) return null;
204
+ stmts.updateFaq.run(
205
+ updates.question ?? existing.question,
206
+ updates.answer ?? existing.answer,
207
+ updates.category ?? existing.category,
208
+ updates.keywords ?? existing.keywords,
209
+ updates.enabled ?? existing.enabled,
210
+ id,
211
+ );
212
+ return stmts.getFaqById.get(id);
213
+ }
214
+
215
+ /**
216
+ * Delete a FAQ entry.
217
+ * @param {number} id
218
+ */
219
+ function deleteFaq(id) {
220
+ getStmts().deleteFaq.run(id);
221
+ }
222
+
223
+ module.exports = {
224
+ searchFaq,
225
+ searchWorkspace,
226
+ search,
227
+ listFaq,
228
+ createFaq,
229
+ updateFaq,
230
+ deleteFaq,
231
+ };
@@ -0,0 +1,152 @@
1
+ // ── Lead Qualifier - Scoring + Pipeline Stage ────────────
2
+ // Natural conversation-based qualification. Invisible to contacts,
3
+ // visible to Brandon in the AIVA app.
4
+ 'use strict';
5
+
6
+ const { getStmts } = require('./db');
7
+ const { callAI } = require('./utils/ai');
8
+ const { updateQualification } = require('./contact-manager');
9
+
10
+ function log(msg, data) {
11
+ const ts = new Date().toISOString();
12
+ if (data) console.log(`[${ts}] [LEAD-QUAL] ${msg}`, JSON.stringify(data));
13
+ else console.log(`[${ts}] [LEAD-QUAL] ${msg}`);
14
+ }
15
+
16
+ // Pipeline stage thresholds
17
+ const STAGE_THRESHOLDS = {
18
+ cold: { min: 0, max: 20 },
19
+ warm: { min: 21, max: 50 },
20
+ hot: { min: 51, max: 75 },
21
+ qualified: { min: 76, max: 100 },
22
+ };
23
+
24
+ /**
25
+ * Determine pipeline stage from score.
26
+ * @param {number} score - 0-100
27
+ * @returns {string} Pipeline stage
28
+ */
29
+ function scoreToStage(score) {
30
+ if (score >= 76) return 'qualified';
31
+ if (score >= 51) return 'hot';
32
+ if (score >= 21) return 'warm';
33
+ return 'cold';
34
+ }
35
+
36
+ /**
37
+ * Run post-message qualification analysis.
38
+ * Analyzes the latest conversation to update qualification score.
39
+ * Only runs for lead and qualified-lead categories.
40
+ * @param {Object} contact - Contact record
41
+ * @param {Array} recentMessages - Recent messages (last 10-15)
42
+ * @returns {Promise<{score: number, stage: string, changed: boolean}>}
43
+ */
44
+ async function analyzeQualification(contact, recentMessages) {
45
+ if (!['lead', 'qualified-lead'].includes(contact.category)) {
46
+ return { score: contact.qualification_score || 0, stage: contact.pipeline_stage || 'none', changed: false };
47
+ }
48
+
49
+ const currentScore = contact.qualification_score || 0;
50
+ const history = recentMessages.map(m => {
51
+ const role = m.sent_by === 'contact' ? 'Contact' : 'AIVA';
52
+ return `${role}: ${m.text}`;
53
+ }).join('\n');
54
+
55
+ const systemPrompt = `You are a lead qualification scorer. Analyze the conversation and return a qualification score (0-100) based on these signals:
56
+
57
+ POSITIVE SIGNALS (add points):
58
+ - Stated a specific need (+20): "I need a new website"
59
+ - Mentioned timeline (+15): "We're launching in March"
60
+ - Asked about pricing (+15): "What do you charge?"
61
+ - Mentioned budget (+20): "Our budget is around $5k"
62
+ - Business context shared (+10): "We're a dental practice"
63
+ - Responded to follow-up (+10): engagement signal
64
+ - Multiple conversations (+10): came back after initial chat
65
+
66
+ NEGATIVE SIGNALS (subtract points):
67
+ - Just browsing (-10): "Just looking around"
68
+ - Competitor research (-20): "How do you compare to X?"
69
+ - No clear need expressed (-5)
70
+ - Short, disengaged responses (-5)
71
+
72
+ Current score: ${currentScore}
73
+
74
+ Respond with ONLY a JSON object:
75
+ {"score": <0-100>, "reasoning": "<brief explanation>", "signals": ["<signal1>", "<signal2>"]}`;
76
+
77
+ const userMsg = `Contact: ${contact.name} (${contact.phone})\n\nConversation:\n${history}`;
78
+
79
+ try {
80
+ const result = await callAI(systemPrompt, userMsg, { maxTokens: 300, temperature: 0.3 });
81
+ if (!result) return { score: currentScore, stage: scoreToStage(currentScore), changed: false };
82
+
83
+ const jsonMatch = result.match(/\{[\s\S]*\}/);
84
+ if (!jsonMatch) return { score: currentScore, stage: scoreToStage(currentScore), changed: false };
85
+
86
+ const parsed = JSON.parse(jsonMatch[0]);
87
+ const newScore = Math.max(0, Math.min(100, parsed.score || 0));
88
+ const newStage = scoreToStage(newScore);
89
+ const changed = newScore !== currentScore;
90
+
91
+ if (changed) {
92
+ updateQualification(contact.phone, newScore, newStage);
93
+ log('Score updated', {
94
+ phone: contact.phone,
95
+ oldScore: currentScore,
96
+ newScore,
97
+ stage: newStage,
98
+ reasoning: parsed.reasoning,
99
+ });
100
+ }
101
+
102
+ return { score: newScore, stage: newStage, changed, reasoning: parsed.reasoning, signals: parsed.signals };
103
+ } catch (err) {
104
+ log('Analysis error', { phone: contact.phone, error: err.message });
105
+ return { score: currentScore, stage: scoreToStage(currentScore), changed: false };
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Check if a lead should be escalated for personal outreach (score >= 76).
111
+ * @param {Object} contact
112
+ * @returns {boolean}
113
+ */
114
+ function shouldEscalateForOutreach(contact) {
115
+ return (contact.qualification_score || 0) >= 76 && contact.pipeline_stage !== 'client';
116
+ }
117
+
118
+ /**
119
+ * Get follow-up behavior for a lead's pipeline stage.
120
+ * @param {string} stage
121
+ * @returns {{ template: string, urgency: string }}
122
+ */
123
+ function getFollowUpBehavior(stage) {
124
+ const behaviors = {
125
+ cold: {
126
+ template: 'Hey! Just wanted to see if you had any questions about what we do.',
127
+ urgency: 'low',
128
+ },
129
+ warm: {
130
+ template: 'Hey {name}! You mentioned {need} - I had a thought about that. Got a sec?',
131
+ urgency: 'medium',
132
+ },
133
+ hot: {
134
+ template: 'Hi {name}, just following up on {need}. Brandon has some availability this week if you\'d like to hop on a quick call.',
135
+ urgency: 'high',
136
+ },
137
+ qualified: {
138
+ template: 'Hi {name}, wanted to follow up on our conversation. Brandon would love to connect - can I set up a quick call?',
139
+ urgency: 'high',
140
+ },
141
+ };
142
+
143
+ return behaviors[stage] || behaviors.cold;
144
+ }
145
+
146
+ module.exports = {
147
+ analyzeQualification,
148
+ scoreToStage,
149
+ shouldEscalateForOutreach,
150
+ getFollowUpBehavior,
151
+ STAGE_THRESHOLDS,
152
+ };
@@ -0,0 +1,202 @@
1
+ // ── Learning Loop - Three-Tier Preference Cascade ────────
2
+ // Router -> Main Agent -> User. Every resolved escalation becomes
3
+ // a learned preference for future use.
4
+ 'use strict';
5
+
6
+ const { getStmts, getDb } = require('./db');
7
+
8
+ function log(msg, data) {
9
+ const ts = new Date().toISOString();
10
+ if (data) console.log(`[${ts}] [LEARNING] ${msg}`, JSON.stringify(data));
11
+ else console.log(`[${ts}] [LEARNING] ${msg}`);
12
+ }
13
+
14
+ /**
15
+ * Find matching preferences for a question/context.
16
+ * Returns per-contact preferences first, then global.
17
+ * @param {string} phone - Contact phone (for per-contact prefs)
18
+ * @param {string} questionText - The question/message to match
19
+ * @param {number} [limit=5] - Max preferences to return
20
+ * @returns {Array} Matching preferences sorted by relevance
21
+ */
22
+ function findMatchingPreferences(phone, questionText, limit = 5) {
23
+ const stmts = getStmts();
24
+ // Get preferences scoped to this contact or global, with sufficient confidence
25
+ const prefs = stmts.getPreferencesByScope.all(phone, 'global', limit * 3);
26
+
27
+ if (prefs.length === 0) return [];
28
+
29
+ // Simple keyword-based relevance scoring
30
+ const queryTokens = tokenize(questionText);
31
+ if (queryTokens.length === 0) return prefs.slice(0, limit);
32
+
33
+ const scored = prefs.map(pref => {
34
+ const patternTokens = tokenize(pref.question_pattern);
35
+ const overlap = queryTokens.filter(t => patternTokens.includes(t)).length;
36
+ const score = overlap / Math.max(queryTokens.length, 1);
37
+ return { ...pref, relevanceScore: score };
38
+ });
39
+
40
+ scored.sort((a, b) => {
41
+ // Per-contact prefs first, then by relevance, then by confidence
42
+ if (a.scope === phone && b.scope !== phone) return -1;
43
+ if (b.scope === phone && a.scope !== phone) return 1;
44
+ if (b.relevanceScore !== a.relevanceScore) return b.relevanceScore - a.relevanceScore;
45
+ return b.confidence - a.confidence;
46
+ });
47
+
48
+ // Only return prefs with some relevance
49
+ return scored.filter(s => s.relevanceScore > 0.1).slice(0, limit);
50
+ }
51
+
52
+ /**
53
+ * Save a learned preference from an escalation resolution.
54
+ * @param {Object} pref
55
+ * @param {string} pref.scope - 'global' or phone number
56
+ * @param {string} pref.questionPattern - Normalized question pattern
57
+ * @param {string} pref.answer - The learned answer
58
+ * @param {string} pref.source - 'router', 'main_agent', or 'user'
59
+ * @param {number} [pref.confidence] - 0.0-1.0 (defaults based on source)
60
+ * @param {string} [pref.escalationId] - Which escalation triggered this
61
+ * @param {string} [pref.phone] - Contact whose question led to this
62
+ * @param {string} [pref.originalQuestion] - What the contact asked
63
+ * @returns {Object} Created preference
64
+ */
65
+ function savePreference(pref) {
66
+ const stmts = getStmts();
67
+
68
+ // Confidence defaults by source
69
+ const defaultConfidence = { user: 1.0, main_agent: 0.9, router: 0.7 };
70
+ const confidence = pref.confidence ?? defaultConfidence[pref.source] ?? 0.7;
71
+
72
+ // Check for existing matching preference to update rather than duplicate
73
+ const db = getDb();
74
+ const existing = db.prepare(
75
+ "SELECT * FROM preferences WHERE scope = ? AND question_pattern = ?"
76
+ ).get(pref.scope || 'global', pref.questionPattern);
77
+
78
+ let prefId;
79
+ if (existing) {
80
+ stmts.updatePreference.run(pref.answer, pref.source, confidence, existing.id);
81
+ prefId = existing.id;
82
+ log('Updated existing preference', { id: prefId, pattern: pref.questionPattern });
83
+ } else {
84
+ const info = stmts.insertPreference.run(
85
+ pref.scope || 'global',
86
+ pref.questionPattern,
87
+ pref.answer,
88
+ pref.source,
89
+ confidence,
90
+ );
91
+ prefId = info.lastInsertRowid;
92
+ log('Created new preference', { id: prefId, pattern: pref.questionPattern, source: pref.source });
93
+ }
94
+
95
+ // Audit trail
96
+ if (pref.escalationId || pref.phone) {
97
+ stmts.insertPreferenceLog.run(
98
+ prefId,
99
+ pref.escalationId || null,
100
+ pref.phone || null,
101
+ pref.originalQuestion || null,
102
+ pref.source,
103
+ );
104
+ }
105
+
106
+ return { id: prefId, questionPattern: pref.questionPattern, confidence };
107
+ }
108
+
109
+ /**
110
+ * Bump the hit count on a preference (when it's used to answer a question).
111
+ * @param {number} prefId
112
+ */
113
+ function recordPreferenceHit(prefId) {
114
+ getStmts().bumpPreferenceHit.run(prefId);
115
+ }
116
+
117
+ /**
118
+ * Run monthly confidence decay for non-user preferences.
119
+ * - main_agent: -0.05/month
120
+ * - router: -0.1/month
121
+ */
122
+ function decayConfidence() {
123
+ const stmts = getStmts();
124
+ stmts.decayPreferences.run(0.05, 'main_agent');
125
+ stmts.decayPreferences.run(0.1, 'router');
126
+ log('Ran confidence decay');
127
+ }
128
+
129
+ /**
130
+ * Build a preferences prompt section for the Conversation Engine.
131
+ * @param {string} phone - Contact phone
132
+ * @param {string} messageText - Current message text
133
+ * @returns {string} Formatted preferences for prompt injection
134
+ */
135
+ function buildPreferencesPrompt(phone, messageText) {
136
+ const matches = findMatchingPreferences(phone, messageText);
137
+ if (matches.length === 0) return '';
138
+
139
+ const lines = matches.map(p => {
140
+ const scopeLabel = p.scope === 'global' ? '' : ` (specific to this contact)`;
141
+ return `- "${p.question_pattern}" -> "${p.answer}"${scopeLabel}`;
142
+ });
143
+
144
+ return `[Learned Preferences - use these when relevant]\n${lines.join('\n')}`;
145
+ }
146
+
147
+ /**
148
+ * Get learning velocity metrics.
149
+ * @returns {Object} Metrics about learning progress
150
+ */
151
+ function getMetrics() {
152
+ const db = getDb();
153
+ const totalPrefs = db.prepare('SELECT COUNT(*) as count FROM preferences').get().count;
154
+ const activePrefs = db.prepare('SELECT COUNT(*) as count FROM preferences WHERE confidence >= 0.5').get().count;
155
+ const bySource = db.prepare('SELECT source, COUNT(*) as count FROM preferences GROUP BY source').all();
156
+ const recentEscalations = db.prepare("SELECT COUNT(*) as count FROM escalations WHERE created_at >= datetime('now', '-7 days')").get().count;
157
+ const recentResolved = db.prepare("SELECT COUNT(*) as count FROM escalations WHERE status = 'responded' AND created_at >= datetime('now', '-7 days')").get().count;
158
+
159
+ return {
160
+ totalPreferences: totalPrefs,
161
+ activePreferences: activePrefs,
162
+ bySource: Object.fromEntries(bySource.map(r => [r.source, r.count])),
163
+ escalationsLast7Days: recentEscalations,
164
+ resolvedLast7Days: recentResolved,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * List all preferences (for management API).
170
+ * @returns {Array}
171
+ */
172
+ function listPreferences() {
173
+ return getStmts().getAllPreferences.all();
174
+ }
175
+
176
+ /**
177
+ * Delete a preference.
178
+ * @param {number} id
179
+ */
180
+ function deletePreference(id) {
181
+ getStmts().deletePreference.run(id);
182
+ }
183
+
184
+ // ── Helper ──
185
+
186
+ function tokenize(text) {
187
+ return (text || '').toLowerCase()
188
+ .replace(/[^a-z0-9\s-]/g, ' ')
189
+ .split(/\s+/)
190
+ .filter(t => t.length > 1);
191
+ }
192
+
193
+ module.exports = {
194
+ findMatchingPreferences,
195
+ savePreference,
196
+ recordPreferenceHit,
197
+ decayConfidence,
198
+ buildPreferencesPrompt,
199
+ getMetrics,
200
+ listPreferences,
201
+ deletePreference,
202
+ };