@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.
- package/README.md +148 -0
- package/auto-deploy.js +190 -0
- package/bin/aiva.js +81 -0
- package/cli-sync.js +126 -0
- package/d2a-prompt-template.txt +106 -0
- package/diagnostics-api.js +304 -0
- package/docs/ara-dedup-fix-scope.md +112 -0
- package/docs/ara-fix-round2-scope.md +61 -0
- package/docs/ara-greeting-fix-scope.md +70 -0
- package/docs/calendar-date-fix-scope.md +28 -0
- package/docs/getting-started.md +115 -0
- package/docs/network-architecture-rollout-scope.md +43 -0
- package/docs/scope-google-oauth-integration.md +351 -0
- package/docs/settings-page-scope.md +50 -0
- package/docs/xai-imagine-scope.md +116 -0
- package/docs/xai-voice-integration-scope.md +115 -0
- package/docs/xai-voice-tools-scope.md +165 -0
- package/email-router.js +512 -0
- package/follow-up-handler.js +606 -0
- package/gateway-monitor.js +158 -0
- package/google-email.js +379 -0
- package/google-oauth.js +310 -0
- package/grok-imagine.js +97 -0
- package/health-reporter.js +287 -0
- package/invisible-prefix-base.txt +206 -0
- package/invisible-prefix-owner.txt +26 -0
- package/invisible-prefix-slim.txt +10 -0
- package/invisible-prefix.txt +43 -0
- package/knowledge-base.js +472 -0
- package/lib/cli.js +19 -0
- package/lib/config.js +124 -0
- package/lib/health.js +57 -0
- package/lib/process.js +207 -0
- package/lib/server.js +42 -0
- package/lib/setup.js +472 -0
- package/meta-capi.js +206 -0
- package/meta-leads.js +411 -0
- package/notion-oauth.js +323 -0
- package/package.json +61 -0
- package/public/agent-config.html +241 -0
- package/public/aiva-avatar-anime.png +0 -0
- package/public/css/docs.css.bak +688 -0
- package/public/css/onboarding.css +543 -0
- package/public/diagrams/claude-subscription-pool.html +329 -0
- package/public/diagrams/claude-subscription-pool.png +0 -0
- package/public/docs-icon.png +0 -0
- package/public/escalation.html +237 -0
- package/public/group-config.html +300 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icons/agents.svg +1 -0
- package/public/icons/attach.svg +1 -0
- package/public/icons/characters.svg +1 -0
- package/public/icons/chat.svg +1 -0
- package/public/icons/docs.svg +1 -0
- package/public/icons/heartbeat.svg +1 -0
- package/public/icons/messages.svg +1 -0
- package/public/icons/mic.svg +1 -0
- package/public/icons/notes.svg +1 -0
- package/public/icons/settings.svg +1 -0
- package/public/icons/tasks.svg +1 -0
- package/public/images/onboarding/p0-communication-layer.png +0 -0
- package/public/images/onboarding/p0-infinite-surface.png +0 -0
- package/public/images/onboarding/p0-learning-model.png +0 -0
- package/public/images/onboarding/p0-meet-aiva.png +0 -0
- package/public/images/onboarding/p4-contact-intelligence.png +0 -0
- package/public/images/onboarding/p4-context-compounds.png +0 -0
- package/public/images/onboarding/p4-message-router.png +0 -0
- package/public/images/onboarding/p4-per-contact-rules.png +0 -0
- package/public/images/onboarding/p4-send-messages.png +0 -0
- package/public/images/onboarding/p6-be-precise.png +0 -0
- package/public/images/onboarding/p6-review-escalations.png +0 -0
- package/public/images/onboarding/p6-voice-input.png +0 -0
- package/public/images/onboarding/p7-completion.png +0 -0
- package/public/index.html +11594 -0
- package/public/js/onboarding.js +699 -0
- package/public/manifest.json +24 -0
- package/public/messages-v2.html +2824 -0
- package/public/permission-approve.html.bak +107 -0
- package/public/permissions.html +150 -0
- package/public/styles/design-system.css +68 -0
- package/router-db.js +604 -0
- package/router-utils.js +28 -0
- package/router-v2/adapters/imessage.js +191 -0
- package/router-v2/adapters/quo.js +82 -0
- package/router-v2/adapters/whatsapp.js +192 -0
- package/router-v2/contact-manager.js +234 -0
- package/router-v2/conversation-engine.js +498 -0
- package/router-v2/data/knowledge-base.json +176 -0
- package/router-v2/data/router-v2.db +0 -0
- package/router-v2/data/router-v2.db-shm +0 -0
- package/router-v2/data/router-v2.db-wal +0 -0
- package/router-v2/data/router.db +0 -0
- package/router-v2/db.js +457 -0
- package/router-v2/escalation-bridge.js +540 -0
- package/router-v2/follow-up-engine.js +347 -0
- package/router-v2/index.js +441 -0
- package/router-v2/ingestion.js +213 -0
- package/router-v2/knowledge-base.js +231 -0
- package/router-v2/lead-qualifier.js +152 -0
- package/router-v2/learning-loop.js +202 -0
- package/router-v2/outbound-sender.js +160 -0
- package/router-v2/package.json +13 -0
- package/router-v2/permission-gate.js +86 -0
- package/router-v2/playbook.js +177 -0
- package/router-v2/prompts/base.js +52 -0
- package/router-v2/prompts/first-contact.js +38 -0
- package/router-v2/prompts/lead-qualification.js +37 -0
- package/router-v2/prompts/scheduling.js +72 -0
- package/router-v2/prompts/style-overrides.js +22 -0
- package/router-v2/scheduler.js +301 -0
- package/router-v2/scripts/migrate-v1-to-v2.js +215 -0
- package/router-v2/scripts/seed-faq.js +67 -0
- package/router-v2/seed-knowledge-base.js +39 -0
- package/router-v2/utils/ai.js +129 -0
- package/router-v2/utils/phone.js +52 -0
- package/router-v2/utils/response-validator.js +98 -0
- package/router-v2/utils/sanitize.js +222 -0
- package/router.js +5005 -0
- package/routes/google-calendar.js +186 -0
- package/scripts/deploy.sh +62 -0
- package/scripts/macos-calendar.sh +232 -0
- package/scripts/onboard-device.sh +466 -0
- package/server.js +5131 -0
- package/start.sh +24 -0
- package/templates/AGENTS.md +548 -0
- package/templates/IDENTITY.md +15 -0
- package/templates/docs-agents.html +132 -0
- package/templates/docs-app.html +130 -0
- package/templates/docs-home.html +83 -0
- package/templates/docs-imessage.html +121 -0
- package/templates/docs-tasks.html +123 -0
- package/templates/docs-tips.html +175 -0
- package/templates/getting-started.html +809 -0
- package/templates/invisible-prefix-base.txt +171 -0
- package/templates/invisible-prefix-owner.txt +282 -0
- package/templates/invisible-prefix.txt +338 -0
- package/templates/manifest.json +61 -0
- package/templates/memory-org/clients.md +7 -0
- package/templates/memory-org/credentials.md +9 -0
- package/templates/memory-org/devices.md +7 -0
- package/templates/updates.html +464 -0
- package/templates/workspace/AGENTS.md.tmpl +161 -0
- package/templates/workspace/HEARTBEAT.md.tmpl +17 -0
- package/templates/workspace/IDENTITY.md.tmpl +15 -0
- package/templates/workspace/MEMORY.md.tmpl +16 -0
- package/templates/workspace/SOUL.md.tmpl +51 -0
- package/templates/workspace/USER.md.tmpl +25 -0
- package/tts-proxy.js +96 -0
- package/voice-call-local.js +731 -0
- package/voice-call.js +732 -0
- package/wa-listener.js +354 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
// ── Escalation Bridge - Silent Handoff to Main Agent ─────
|
|
2
|
+
// Per-category timeouts, two-strike retry, contact never knows.
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { getStmts, getSetting } = require('./db');
|
|
9
|
+
const { sendMessage, sendEscalationTimeout } = require('./outbound-sender');
|
|
10
|
+
const { savePreference } = require('./learning-loop');
|
|
11
|
+
|
|
12
|
+
// Read gateway auth for AIVA channel delivery
|
|
13
|
+
let _gatewayToken = null;
|
|
14
|
+
function getGatewayToken() {
|
|
15
|
+
if (_gatewayToken) return _gatewayToken;
|
|
16
|
+
try {
|
|
17
|
+
const configPath = path.join(process.env.HOME || '/Users/brandonburgan', '.openclaw', 'openclaw.json');
|
|
18
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
19
|
+
_gatewayToken = config.hooks?.token || config.gateway?.auth?.password || '';
|
|
20
|
+
} catch { _gatewayToken = ''; }
|
|
21
|
+
return _gatewayToken;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Send an escalation to the main agent via the AIVA channel plugin.
|
|
26
|
+
* This injects a system message into the main session via the gateway webhook.
|
|
27
|
+
*/
|
|
28
|
+
async function sendToMainAgent(text, interactive = null) {
|
|
29
|
+
// 1. Send to gateway (for the agent session)
|
|
30
|
+
const token = getGatewayToken();
|
|
31
|
+
const payload = {
|
|
32
|
+
userId: 'system',
|
|
33
|
+
displayName: 'AIVA Router',
|
|
34
|
+
text: `[System Message] Router Escalation:\n\n${text}`,
|
|
35
|
+
messageId: `esc-${Date.now()}`,
|
|
36
|
+
timestamp: new Date().toISOString(),
|
|
37
|
+
media: [],
|
|
38
|
+
metadata: { source: 'router-v2-escalation' },
|
|
39
|
+
};
|
|
40
|
+
let gatewayOk = false;
|
|
41
|
+
try {
|
|
42
|
+
const resp = await fetch('http://localhost:18789/aiva', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: {
|
|
45
|
+
'Content-Type': 'application/json',
|
|
46
|
+
'Authorization': `Bearer ${token}`,
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify(payload),
|
|
49
|
+
signal: AbortSignal.timeout(15000),
|
|
50
|
+
});
|
|
51
|
+
if (!resp.ok) {
|
|
52
|
+
const body = await resp.text().catch(() => '');
|
|
53
|
+
log('Gateway delivery failed', { status: resp.status, body: body.substring(0, 200) });
|
|
54
|
+
} else {
|
|
55
|
+
gatewayOk = true;
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
log('Gateway delivery error', { error: err.message });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. Send directly to AIVA app chat (so Brandon sees it with interactive buttons)
|
|
62
|
+
let appOk = false;
|
|
63
|
+
try {
|
|
64
|
+
const appPayload = { userId: 'brandon', text, interactive, escalationId: interactive?.escalationMeta?.escalationId || null };
|
|
65
|
+
const appResp = await fetch('http://localhost:3847/api/chat/aiva-reply', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
'x-aiva-internal': 'true',
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify(appPayload),
|
|
72
|
+
signal: AbortSignal.timeout(5000),
|
|
73
|
+
});
|
|
74
|
+
if (appResp.ok) {
|
|
75
|
+
appOk = true;
|
|
76
|
+
log('Escalation delivered to AIVA app chat');
|
|
77
|
+
} else {
|
|
78
|
+
log('AIVA app chat delivery failed', { status: appResp.status });
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
log('AIVA app chat delivery error', { error: err.message });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return gatewayOk || appOk;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Per-category timeout in minutes
|
|
88
|
+
const CATEGORY_TIMEOUTS = {
|
|
89
|
+
lead: 5,
|
|
90
|
+
'qualified-lead': 5,
|
|
91
|
+
client: 10,
|
|
92
|
+
team: 10,
|
|
93
|
+
unknown: 15,
|
|
94
|
+
friend: 20,
|
|
95
|
+
family: 30,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function log(msg, data) {
|
|
99
|
+
const ts = new Date().toISOString();
|
|
100
|
+
if (data) console.log(`[${ts}] [ESCALATION] ${msg}`, JSON.stringify(data));
|
|
101
|
+
else console.log(`[${ts}] [ESCALATION] ${msg}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function generateEscalationId() {
|
|
105
|
+
return `esc_${crypto.randomBytes(8).toString('hex')}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Escalate a message to the main OpenClaw agent.
|
|
110
|
+
* @param {Object} options
|
|
111
|
+
* @param {Object} options.contact - Contact record
|
|
112
|
+
* @param {string} options.triggerMessage - What the contact said
|
|
113
|
+
* @param {Array} options.conversationHistory - Recent messages
|
|
114
|
+
* @param {Object} [options.contactContext] - Additional context
|
|
115
|
+
* @param {boolean} [options.isClientSupport=false] - Client support escalation
|
|
116
|
+
* @param {boolean} [options.isRetry=false] - Is this a retry after timeout
|
|
117
|
+
* @returns {Promise<string>} Escalation ID
|
|
118
|
+
*/
|
|
119
|
+
// Scope mapping for escalation types
|
|
120
|
+
const ESCALATION_TYPE_SCOPES = {
|
|
121
|
+
scheduling: ['calendar.book', 'calendar.view'],
|
|
122
|
+
task: ['tasks.create'],
|
|
123
|
+
reminder: ['reminders.create'],
|
|
124
|
+
messaging: ['messages.send'],
|
|
125
|
+
file_sharing: ['files.send'],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
function detectEscalationType(text) {
|
|
129
|
+
if (/schedul|call|meet|appointment|book|calendar|availab/i.test(text)) return 'scheduling';
|
|
130
|
+
if (/remind|task|todo|follow.?up/i.test(text)) return 'task';
|
|
131
|
+
if (/send.*file|attach|document|upload/i.test(text)) return 'file_sharing';
|
|
132
|
+
if (/send.*message|text.*someone|forward/i.test(text)) return 'messaging';
|
|
133
|
+
return 'general';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Build contextual interactive options based on what triggered the escalation
|
|
137
|
+
function buildEscalationOptions(contact, triggerMessage, escalationType) {
|
|
138
|
+
// Determine the denied scopes to grant if "allow future" is selected
|
|
139
|
+
// Always detect from the trigger message - the escalationType from the validator
|
|
140
|
+
// describes WHY it was caught (e.g. "sending messages to others"), not WHAT the contact wants
|
|
141
|
+
const resolvedType = detectEscalationType(triggerMessage);
|
|
142
|
+
const scopesToGrant = ESCALATION_TYPE_SCOPES[resolvedType] || [];
|
|
143
|
+
|
|
144
|
+
// Scheduling-related escalations
|
|
145
|
+
if (resolvedType === 'scheduling') {
|
|
146
|
+
return {
|
|
147
|
+
escalationMeta: { phone: contact.phone, type: resolvedType, scopes: scopesToGrant },
|
|
148
|
+
questions: [{
|
|
149
|
+
id: 'escalation_action',
|
|
150
|
+
text: `${contact.name}: "${triggerMessage}"`,
|
|
151
|
+
options: [
|
|
152
|
+
'Yes, add a reminder and allow future requests like this',
|
|
153
|
+
'Yes, add a reminder, but keep requiring escalation',
|
|
154
|
+
'Politely decline',
|
|
155
|
+
'Ignore future requests like this from this contact',
|
|
156
|
+
],
|
|
157
|
+
actions: [
|
|
158
|
+
{ type: 'approve_and_grant', scopes: scopesToGrant, reply: true },
|
|
159
|
+
{ type: 'approve_one_time', scopes: [], reply: true },
|
|
160
|
+
{ type: 'decline', scopes: [], reply: true },
|
|
161
|
+
{ type: 'ignore', scopes: [], reply: false },
|
|
162
|
+
],
|
|
163
|
+
multiSelect: false,
|
|
164
|
+
allowOther: true,
|
|
165
|
+
}],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Task/reminder-related
|
|
170
|
+
if (resolvedType === 'task' || resolvedType === 'reminder') {
|
|
171
|
+
return {
|
|
172
|
+
escalationMeta: { phone: contact.phone, type: resolvedType, scopes: scopesToGrant },
|
|
173
|
+
questions: [{
|
|
174
|
+
id: 'escalation_action',
|
|
175
|
+
text: `${contact.name}: "${triggerMessage}"`,
|
|
176
|
+
options: [
|
|
177
|
+
'Yes, handle it and allow future requests like this',
|
|
178
|
+
'Yes, handle it, but keep requiring escalation',
|
|
179
|
+
'Politely decline',
|
|
180
|
+
'Ignore future requests like this from this contact',
|
|
181
|
+
],
|
|
182
|
+
actions: [
|
|
183
|
+
{ type: 'approve_and_grant', scopes: scopesToGrant, reply: true },
|
|
184
|
+
{ type: 'approve_one_time', scopes: [], reply: true },
|
|
185
|
+
{ type: 'decline', scopes: [], reply: true },
|
|
186
|
+
{ type: 'ignore', scopes: [], reply: false },
|
|
187
|
+
],
|
|
188
|
+
multiSelect: false,
|
|
189
|
+
allowOther: true,
|
|
190
|
+
}],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// File/message sharing
|
|
195
|
+
if (resolvedType === 'messaging' || resolvedType === 'file_sharing') {
|
|
196
|
+
return {
|
|
197
|
+
escalationMeta: { phone: contact.phone, type: resolvedType, scopes: scopesToGrant },
|
|
198
|
+
questions: [{
|
|
199
|
+
id: 'escalation_action',
|
|
200
|
+
text: `${contact.name}: "${triggerMessage}"`,
|
|
201
|
+
options: [
|
|
202
|
+
'Yes, handle it and enable this capability for them',
|
|
203
|
+
'Yes, handle it one-time only',
|
|
204
|
+
'Politely decline',
|
|
205
|
+
'Ignore future requests like this from this contact',
|
|
206
|
+
],
|
|
207
|
+
actions: [
|
|
208
|
+
{ type: 'approve_and_grant', scopes: scopesToGrant, reply: true },
|
|
209
|
+
{ type: 'approve_one_time', scopes: [], reply: true },
|
|
210
|
+
{ type: 'decline', scopes: [], reply: true },
|
|
211
|
+
{ type: 'ignore', scopes: [], reply: false },
|
|
212
|
+
],
|
|
213
|
+
multiSelect: false,
|
|
214
|
+
allowOther: true,
|
|
215
|
+
}],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Default / general escalation
|
|
220
|
+
return {
|
|
221
|
+
escalationMeta: { phone: contact.phone, type: 'general', scopes: [] },
|
|
222
|
+
questions: [{
|
|
223
|
+
id: 'escalation_action',
|
|
224
|
+
text: `${contact.name}: "${triggerMessage}"`,
|
|
225
|
+
options: [
|
|
226
|
+
'Draft a reply for me to approve',
|
|
227
|
+
'I\'ll handle it personally',
|
|
228
|
+
'Politely decline',
|
|
229
|
+
'Ignore',
|
|
230
|
+
],
|
|
231
|
+
actions: [
|
|
232
|
+
{ type: 'approve_one_time', scopes: [], reply: true },
|
|
233
|
+
{ type: 'handle_personally', scopes: [], reply: false },
|
|
234
|
+
{ type: 'decline', scopes: [], reply: true },
|
|
235
|
+
{ type: 'ignore', scopes: [], reply: false },
|
|
236
|
+
],
|
|
237
|
+
multiSelect: false,
|
|
238
|
+
allowOther: true,
|
|
239
|
+
}],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function escalate({
|
|
244
|
+
contact,
|
|
245
|
+
triggerMessage,
|
|
246
|
+
conversationHistory = [],
|
|
247
|
+
contactContext = {},
|
|
248
|
+
isClientSupport = false,
|
|
249
|
+
isRetry = false,
|
|
250
|
+
escalationType = null,
|
|
251
|
+
}) {
|
|
252
|
+
const stmts = getStmts();
|
|
253
|
+
const escalationId = generateEscalationId();
|
|
254
|
+
const timeoutMin = CATEGORY_TIMEOUTS[contact.category] || 15;
|
|
255
|
+
const timeoutAt = new Date(Date.now() + timeoutMin * 60000).toISOString().replace('T', ' ').split('.')[0];
|
|
256
|
+
|
|
257
|
+
// Build escalation payload
|
|
258
|
+
const payload = {
|
|
259
|
+
escalationId,
|
|
260
|
+
phone: contact.phone,
|
|
261
|
+
contactName: contact.name,
|
|
262
|
+
category: contact.category,
|
|
263
|
+
channel: contact.source || 'imessage',
|
|
264
|
+
triggerMessage,
|
|
265
|
+
conversationHistory: conversationHistory.slice(-20).map(m => ({
|
|
266
|
+
role: m.sent_by === 'contact' ? 'contact' : 'aiva',
|
|
267
|
+
text: m.text,
|
|
268
|
+
at: m.created_at,
|
|
269
|
+
})),
|
|
270
|
+
contactContext: {
|
|
271
|
+
relationship: contactContext.relationship || '',
|
|
272
|
+
lastTopic: contactContext.last_topic || '',
|
|
273
|
+
pendingItems: JSON.parse(contactContext.pending_items || '[]'),
|
|
274
|
+
conversationSummary: contactContext.conversation_summary || '',
|
|
275
|
+
},
|
|
276
|
+
isClientSupport,
|
|
277
|
+
isRetry,
|
|
278
|
+
prepend: `You are responding on behalf of AIVA to a contact who doesn't know they've been escalated. Respond naturally as AIVA. If you are not 90% or more confident in the answer, ask your user for the answer. Do not guess. The user's response will be saved as a preference for future use so the router can handle this type of question autonomously next time.`,
|
|
279
|
+
preferenceHint: triggerMessage.substring(0, 200),
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Store escalation
|
|
283
|
+
stmts.insertEscalation.run(
|
|
284
|
+
escalationId,
|
|
285
|
+
contact.phone,
|
|
286
|
+
triggerMessage,
|
|
287
|
+
JSON.stringify(payload),
|
|
288
|
+
isClientSupport ? 1 : 0,
|
|
289
|
+
timeoutAt,
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// Update conversation state
|
|
293
|
+
stmts.upsertState.run(contact.phone, 'escalated', JSON.stringify({ escalationId }), timeoutAt);
|
|
294
|
+
|
|
295
|
+
// Send to main agent via AIVA channel plugin
|
|
296
|
+
const escalationText = `📨 Router Escalation - ${contact.name} (${contact.phone}, ${contact.category})
|
|
297
|
+
|
|
298
|
+
They said: "${triggerMessage}"
|
|
299
|
+
Reason: ${isClientSupport ? 'Client support request' : 'Requires human judgment'}
|
|
300
|
+
Escalation ID: ${escalationId}
|
|
301
|
+
Timeout: ${timeoutMin} minutes`;
|
|
302
|
+
const initialInteractive = buildEscalationOptions(contact, triggerMessage, escalationType);
|
|
303
|
+
if (initialInteractive.escalationMeta) {
|
|
304
|
+
initialInteractive.escalationMeta.escalationId = escalationId;
|
|
305
|
+
}
|
|
306
|
+
const sent = await sendToMainAgent(escalationText, initialInteractive);
|
|
307
|
+
if (sent) {
|
|
308
|
+
log('Escalation sent via AIVA channel', { escalationId, phone: contact.phone, timeout: `${timeoutMin}min`, isRetry });
|
|
309
|
+
} else {
|
|
310
|
+
log('Failed to send escalation via AIVA channel', { escalationId });
|
|
311
|
+
// Fall through - timeout handler will retry
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Send SMS notification to the owner
|
|
315
|
+
try {
|
|
316
|
+
const ownerPhone = getSetting('masterPhone');
|
|
317
|
+
if (ownerPhone) {
|
|
318
|
+
const smsText = `AIVA Escalation: ${contact.name} (${contact.category}) says "${triggerMessage}" - check your AIVA app to respond.`;
|
|
319
|
+
await sendMessage({
|
|
320
|
+
phone: ownerPhone,
|
|
321
|
+
text: smsText,
|
|
322
|
+
channel: 'imessage',
|
|
323
|
+
sentBy: 'system',
|
|
324
|
+
stateAtTime: 'escalation-sms',
|
|
325
|
+
skipSanitize: true,
|
|
326
|
+
});
|
|
327
|
+
log('Escalation SMS sent to owner', { ownerPhone });
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
log('Escalation SMS failed', { error: err.message });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return escalationId;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Handle an escalation reply from the main agent.
|
|
338
|
+
* @param {Object} reply
|
|
339
|
+
* @param {string} reply.escalationId
|
|
340
|
+
* @param {string} reply.response - Response text to send to contact
|
|
341
|
+
* @param {Object} [reply.saveAsPreference] - Preference to save
|
|
342
|
+
* @returns {Promise<{success: boolean, error?: string}>}
|
|
343
|
+
*/
|
|
344
|
+
async function handleReply(reply) {
|
|
345
|
+
const stmts = getStmts();
|
|
346
|
+
const escalation = stmts.getEscalation.get(reply.escalationId);
|
|
347
|
+
|
|
348
|
+
if (!escalation) {
|
|
349
|
+
log('Escalation not found', { escalationId: reply.escalationId });
|
|
350
|
+
return { success: false, error: 'escalation_not_found' };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (escalation.status !== 'pending') {
|
|
354
|
+
log('Escalation already resolved', { escalationId: reply.escalationId, status: escalation.status });
|
|
355
|
+
return { success: false, error: 'already_resolved' };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Resolve escalation
|
|
359
|
+
stmts.resolveEscalation.run(reply.response, reply.escalationId);
|
|
360
|
+
|
|
361
|
+
// Reset conversation state to active
|
|
362
|
+
stmts.upsertState.run(escalation.phone, 'active', '{}', null);
|
|
363
|
+
|
|
364
|
+
// Send response to contact - rewrite Brandon's intent into AIVA's voice
|
|
365
|
+
const context = JSON.parse(escalation.context_sent || '{}');
|
|
366
|
+
const channel = context.channel || 'imessage';
|
|
367
|
+
const contactName = context.contactName || escalation.phone;
|
|
368
|
+
|
|
369
|
+
// Grab recent conversation history for context
|
|
370
|
+
const recentMessages = stmts.getRecentMessages ? stmts.getRecentMessages.all(escalation.phone, 10) : [];
|
|
371
|
+
const historyBlock = recentMessages.map(m =>
|
|
372
|
+
`${m.direction === 'inbound' ? contactName : 'AIVA'}: ${m.text}`
|
|
373
|
+
).join('\n');
|
|
374
|
+
|
|
375
|
+
// Rewrite the response in AIVA's voice using Sonnet
|
|
376
|
+
let finalResponse = reply.response;
|
|
377
|
+
try {
|
|
378
|
+
const { callSonnet } = require('./utils/ai');
|
|
379
|
+
const rewriteResult = await callSonnet({
|
|
380
|
+
messages: [
|
|
381
|
+
{ role: 'system', content: `You are AIVA, Brandon's AI assistant. You're texting ${contactName}. Rewrite Brandon's intent as a brief iMessage from you (AIVA). The contact thinks they're talking to you, not Brandon.
|
|
382
|
+
|
|
383
|
+
RULES:
|
|
384
|
+
- Output ONLY the final text message. Nothing else.
|
|
385
|
+
- No thinking, no preamble, no "Sure!" or "Got it" before the message.
|
|
386
|
+
- One to two sentences max.
|
|
387
|
+
- No markdown, no em dashes, no bullet points, no emojis.
|
|
388
|
+
- Casual, natural, like a real person texting.
|
|
389
|
+
|
|
390
|
+
Conversation context:
|
|
391
|
+
${historyBlock}
|
|
392
|
+
|
|
393
|
+
${contactName} asked: "${escalation.trigger_message}"` },
|
|
394
|
+
{ role: 'user', content: `Brandon's intent: ${reply.response}` }
|
|
395
|
+
],
|
|
396
|
+
maxTokens: 200,
|
|
397
|
+
temperature: 0.7,
|
|
398
|
+
});
|
|
399
|
+
if (rewriteResult.content) {
|
|
400
|
+
finalResponse = rewriteResult.content;
|
|
401
|
+
log('Rewritten escalation reply', { original: reply.response, rewritten: finalResponse });
|
|
402
|
+
}
|
|
403
|
+
} catch (err) {
|
|
404
|
+
// Fail-open: if rewrite fails, send Brandon's original text
|
|
405
|
+
log('Rewrite failed, sending original', { error: err.message });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const sendResult = await sendMessage({
|
|
409
|
+
phone: escalation.phone,
|
|
410
|
+
text: finalResponse,
|
|
411
|
+
channel,
|
|
412
|
+
sentBy: 'aiva',
|
|
413
|
+
stateAtTime: 'escalation-reply',
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// Save preference if provided
|
|
417
|
+
if (reply.saveAsPreference && reply.saveAsPreference.answer) {
|
|
418
|
+
savePreference({
|
|
419
|
+
scope: reply.saveAsPreference.scope || 'global',
|
|
420
|
+
questionPattern: reply.saveAsPreference.questionPattern || context.preferenceHint || '',
|
|
421
|
+
answer: reply.saveAsPreference.answer,
|
|
422
|
+
source: reply.saveAsPreference.source || 'main_agent',
|
|
423
|
+
confidence: reply.saveAsPreference.confidence,
|
|
424
|
+
escalationId: reply.escalationId,
|
|
425
|
+
phone: escalation.phone,
|
|
426
|
+
originalQuestion: escalation.trigger_message,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
log('Escalation resolved', { escalationId: reply.escalationId, sent: sendResult.sent });
|
|
431
|
+
return { success: true };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Check for timed-out escalations and handle retries / notifications.
|
|
436
|
+
* Should be called periodically (e.g. every minute).
|
|
437
|
+
*/
|
|
438
|
+
async function processTimeouts() {
|
|
439
|
+
const stmts = getStmts();
|
|
440
|
+
const timedOut = stmts.getTimedOutEscalations.all();
|
|
441
|
+
|
|
442
|
+
for (const esc of timedOut) {
|
|
443
|
+
const context = JSON.parse(esc.context_sent || '{}');
|
|
444
|
+
|
|
445
|
+
if (esc.strike_count === 0) {
|
|
446
|
+
// First timeout - retry
|
|
447
|
+
log('First timeout - retrying', { escalationId: esc.escalation_id, phone: esc.phone });
|
|
448
|
+
stmts.incrementStrike.run(esc.escalation_id);
|
|
449
|
+
|
|
450
|
+
// Extend timeout
|
|
451
|
+
const category = context.category || 'unknown';
|
|
452
|
+
const timeoutMin = CATEGORY_TIMEOUTS[category] || 15;
|
|
453
|
+
const newTimeout = new Date(Date.now() + timeoutMin * 60000).toISOString().replace('T', ' ').split('.')[0];
|
|
454
|
+
const db = require('./db').getDb();
|
|
455
|
+
db.prepare("UPDATE escalations SET timeout_at = ? WHERE escalation_id = ?").run(newTimeout, esc.escalation_id);
|
|
456
|
+
|
|
457
|
+
// Re-send escalation event
|
|
458
|
+
const retryText = `⏰ Escalation Retry - ${context.contactName || esc.phone} (${esc.phone}) is still waiting.
|
|
459
|
+
|
|
460
|
+
Original message: "${esc.trigger_message}"
|
|
461
|
+
Escalation ID: ${esc.escalation_id}`;
|
|
462
|
+
const retryInteractive = {
|
|
463
|
+
questions: [{
|
|
464
|
+
id: 'escalation_action',
|
|
465
|
+
text: `${context.contactName || esc.phone} is still waiting. Original: "${esc.trigger_message}" - What do you want to do?`,
|
|
466
|
+
options: ['Draft a reply for me to approve', "I'll handle it personally - pause the router for this contact", 'Ignore - no response needed', 'Block this contact'],
|
|
467
|
+
multiSelect: false,
|
|
468
|
+
allowOther: true,
|
|
469
|
+
}],
|
|
470
|
+
};
|
|
471
|
+
const retrySent = await sendToMainAgent(retryText, retryInteractive);
|
|
472
|
+
if (!retrySent) {
|
|
473
|
+
log('Retry escalation delivery failed', { escalationId: esc.escalation_id });
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
// Second timeout - notify Brandon and fail
|
|
477
|
+
log('Second timeout - notifying Brandon', { escalationId: esc.escalation_id, phone: esc.phone });
|
|
478
|
+
stmts.failEscalation.run(esc.escalation_id);
|
|
479
|
+
stmts.upsertState.run(esc.phone, 'idle', '{}', null);
|
|
480
|
+
|
|
481
|
+
await sendEscalationTimeout(
|
|
482
|
+
context.contactName || esc.phone,
|
|
483
|
+
esc.phone,
|
|
484
|
+
esc.trigger_message || '',
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Append a new message to a pending escalation (contact sent follow-up while waiting).
|
|
492
|
+
* @param {string} phone
|
|
493
|
+
* @param {string} newMessage
|
|
494
|
+
*/
|
|
495
|
+
async function appendToEscalation(phone, newMessage) {
|
|
496
|
+
const stmts = getStmts();
|
|
497
|
+
const active = stmts.getActiveEscalation.get(phone);
|
|
498
|
+
if (!active) return;
|
|
499
|
+
|
|
500
|
+
const context = JSON.parse(active.context_sent || '{}');
|
|
501
|
+
if (!context.conversationHistory) context.conversationHistory = [];
|
|
502
|
+
context.conversationHistory.push({
|
|
503
|
+
role: 'contact',
|
|
504
|
+
text: newMessage,
|
|
505
|
+
at: new Date().toISOString(),
|
|
506
|
+
});
|
|
507
|
+
context.triggerMessage = `${context.triggerMessage}\n[Follow-up]: ${newMessage}`;
|
|
508
|
+
|
|
509
|
+
const db = require('./db').getDb();
|
|
510
|
+
db.prepare("UPDATE escalations SET context_sent = ?, trigger_message = ? WHERE escalation_id = ?")
|
|
511
|
+
.run(JSON.stringify(context), context.triggerMessage, active.escalation_id);
|
|
512
|
+
|
|
513
|
+
// Re-notify the main agent with updated context
|
|
514
|
+
try {
|
|
515
|
+
const updateText = `UPDATE: ${phone} sent a follow-up while waiting for escalation ${active.escalation_id}: "${newMessage}"`;
|
|
516
|
+
await sendToMainAgent(updateText);
|
|
517
|
+
} catch (err) {
|
|
518
|
+
log('Failed to re-notify agent on append', { escalationId: active.escalation_id, error: err.message });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
log('Appended to escalation', { escalationId: active.escalation_id, phone });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Check if there's an active escalation for a phone number.
|
|
526
|
+
* @param {string} phone
|
|
527
|
+
* @returns {Object|null}
|
|
528
|
+
*/
|
|
529
|
+
function getActiveEscalation(phone) {
|
|
530
|
+
return getStmts().getActiveEscalation.get(phone) || null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
module.exports = {
|
|
534
|
+
escalate,
|
|
535
|
+
handleReply,
|
|
536
|
+
processTimeouts,
|
|
537
|
+
appendToEscalation,
|
|
538
|
+
getActiveEscalation,
|
|
539
|
+
CATEGORY_TIMEOUTS,
|
|
540
|
+
};
|