@conversionpros/aiva 1.0.1 → 2.0.1

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 +9 -34
  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,231 +0,0 @@
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
- };
@@ -1,152 +0,0 @@
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
- };
@@ -1,202 +0,0 @@
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
- };