squidclaw 0.1.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/LICENSE +21 -0
- package/README.md +149 -0
- package/bin/squidclaw.js +512 -0
- package/lib/ai/gateway.js +283 -0
- package/lib/ai/prompt-builder.js +149 -0
- package/lib/api/server.js +235 -0
- package/lib/behavior/engine.js +187 -0
- package/lib/channels/hub-media.js +128 -0
- package/lib/channels/hub.js +89 -0
- package/lib/channels/whatsapp/manager.js +319 -0
- package/lib/channels/whatsapp/media.js +228 -0
- package/lib/cli/agent-cmd.js +182 -0
- package/lib/cli/brain-cmd.js +49 -0
- package/lib/cli/broadcast-cmd.js +28 -0
- package/lib/cli/channels-cmd.js +157 -0
- package/lib/cli/config-cmd.js +26 -0
- package/lib/cli/conversations-cmd.js +27 -0
- package/lib/cli/engine-cmd.js +115 -0
- package/lib/cli/handoff-cmd.js +26 -0
- package/lib/cli/hours-cmd.js +38 -0
- package/lib/cli/key-cmd.js +62 -0
- package/lib/cli/knowledge-cmd.js +59 -0
- package/lib/cli/memory-cmd.js +50 -0
- package/lib/cli/platform-cmd.js +51 -0
- package/lib/cli/setup.js +226 -0
- package/lib/cli/stats-cmd.js +66 -0
- package/lib/cli/tui.js +308 -0
- package/lib/cli/update-cmd.js +25 -0
- package/lib/cli/webhook-cmd.js +40 -0
- package/lib/core/agent-manager.js +83 -0
- package/lib/core/agent.js +162 -0
- package/lib/core/config.js +172 -0
- package/lib/core/logger.js +43 -0
- package/lib/engine.js +117 -0
- package/lib/features/heartbeat.js +71 -0
- package/lib/storage/interface.js +56 -0
- package/lib/storage/sqlite.js +409 -0
- package/package.json +48 -0
- package/templates/BEHAVIOR.md +42 -0
- package/templates/IDENTITY.md +7 -0
- package/templates/RULES.md +9 -0
- package/templates/SOUL.md +19 -0
package/lib/cli/tui.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🦑 Squidclaw TUI
|
|
3
|
+
* Beautiful terminal chat interface — zero extra dependencies
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createInterface } from 'readline';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { loadConfig, getHome } from '../core/config.js';
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
|
|
12
|
+
const ESC = '\x1b[';
|
|
13
|
+
const CLEAR_SCREEN = `${ESC}2J${ESC}H`;
|
|
14
|
+
const HIDE_CURSOR = `${ESC}?25l`;
|
|
15
|
+
const SHOW_CURSOR = `${ESC}?25h`;
|
|
16
|
+
const SAVE_CURSOR = `${ESC}s`;
|
|
17
|
+
const RESTORE_CURSOR = `${ESC}u`;
|
|
18
|
+
|
|
19
|
+
export async function tui(opts) {
|
|
20
|
+
const config = loadConfig();
|
|
21
|
+
const port = config.engine?.port || 9500;
|
|
22
|
+
|
|
23
|
+
// Check engine
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
|
26
|
+
if (!res.ok) throw new Error();
|
|
27
|
+
} catch {
|
|
28
|
+
console.log(chalk.red('Engine not running. Start with: squidclaw start'));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Get agents
|
|
33
|
+
let agents = [];
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/agents`);
|
|
36
|
+
agents = await res.json();
|
|
37
|
+
} catch {}
|
|
38
|
+
|
|
39
|
+
if (agents.length === 0) {
|
|
40
|
+
console.log(chalk.red('No agents found. Create one: squidclaw agent create'));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Select agent
|
|
45
|
+
let currentAgent = agents.find(a => a.id === opts.agent) || agents[0];
|
|
46
|
+
let currentModel = currentAgent.model || config.ai?.defaultModel || 'unknown';
|
|
47
|
+
|
|
48
|
+
// State
|
|
49
|
+
const messages = [];
|
|
50
|
+
let totalTokens = 0;
|
|
51
|
+
let totalCost = 0;
|
|
52
|
+
let status = 'idle';
|
|
53
|
+
let inputBuffer = '';
|
|
54
|
+
|
|
55
|
+
// Load history
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/agents/${currentAgent.id}/history?contactId=tui&limit=20`);
|
|
58
|
+
const history = await res.json();
|
|
59
|
+
for (const msg of history) {
|
|
60
|
+
messages.push({ role: msg.role, content: msg.content, time: msg.created_at });
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
|
|
64
|
+
const rl = createInterface({
|
|
65
|
+
input: process.stdin,
|
|
66
|
+
output: process.stdout,
|
|
67
|
+
terminal: true,
|
|
68
|
+
prompt: '',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Enable raw mode for better key handling
|
|
72
|
+
if (process.stdin.isTTY) {
|
|
73
|
+
process.stdin.setRawMode(false);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getTermSize() {
|
|
77
|
+
return {
|
|
78
|
+
cols: process.stdout.columns || 80,
|
|
79
|
+
rows: process.stdout.rows || 24,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function render() {
|
|
84
|
+
const { cols, rows } = getTermSize();
|
|
85
|
+
const headerHeight = 3;
|
|
86
|
+
const footerHeight = 3;
|
|
87
|
+
const inputHeight = 1;
|
|
88
|
+
const chatHeight = rows - headerHeight - footerHeight - inputHeight - 2;
|
|
89
|
+
|
|
90
|
+
let output = '';
|
|
91
|
+
|
|
92
|
+
// Header
|
|
93
|
+
const waIcon = currentAgent.whatsappConnected ? chalk.green('📱') : chalk.gray('📱');
|
|
94
|
+
const title = `🦑 Squidclaw TUI`;
|
|
95
|
+
const agentInfo = `Agent: ${currentAgent.name}`;
|
|
96
|
+
const modelInfo = currentModel;
|
|
97
|
+
const header = ` ${title} │ ${agentInfo} │ ${modelInfo} │ ${waIcon}`;
|
|
98
|
+
|
|
99
|
+
output += CLEAR_SCREEN;
|
|
100
|
+
output += chalk.bgCyan.black(' '.repeat(cols)) + '\n';
|
|
101
|
+
output += chalk.bgCyan.black(header.padEnd(cols)) + '\n';
|
|
102
|
+
output += chalk.bgCyan.black(' '.repeat(cols)) + '\n';
|
|
103
|
+
|
|
104
|
+
// Chat area
|
|
105
|
+
const visibleMessages = getVisibleMessages(chatHeight, cols);
|
|
106
|
+
for (let i = 0; i < chatHeight; i++) {
|
|
107
|
+
if (i < visibleMessages.length) {
|
|
108
|
+
output += visibleMessages[i] + '\n';
|
|
109
|
+
} else {
|
|
110
|
+
output += '\n';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Status bar
|
|
115
|
+
const statusIcon = status === 'thinking' ? chalk.yellow('⏳ thinking...') : chalk.green('● idle');
|
|
116
|
+
const tokenStr = `${fmtNum(totalTokens)} tokens`;
|
|
117
|
+
const costStr = `$${totalCost.toFixed(4)}`;
|
|
118
|
+
const agentCount = `${agents.length} agents`;
|
|
119
|
+
const statusLine = ` ${statusIcon} │ ${tokenStr} │ ${costStr} │ ${agentCount}`;
|
|
120
|
+
|
|
121
|
+
output += chalk.bgGray.white(' '.repeat(cols)) + '\n';
|
|
122
|
+
output += chalk.bgGray.white(statusLine.padEnd(cols)) + '\n';
|
|
123
|
+
|
|
124
|
+
// Input area
|
|
125
|
+
output += chalk.gray('─'.repeat(cols)) + '\n';
|
|
126
|
+
output += chalk.green(' > ') + inputBuffer;
|
|
127
|
+
|
|
128
|
+
process.stdout.write(output);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getVisibleMessages(maxLines, cols) {
|
|
132
|
+
const lines = [];
|
|
133
|
+
const maxWidth = cols - 4;
|
|
134
|
+
|
|
135
|
+
for (const msg of messages) {
|
|
136
|
+
if (msg.role === 'user') {
|
|
137
|
+
const wrapped = wrapText(msg.content, maxWidth);
|
|
138
|
+
for (const line of wrapped) {
|
|
139
|
+
lines.push(chalk.green(` You: `) + line);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
const wrapped = wrapText(msg.content, maxWidth);
|
|
143
|
+
const name = chalk.cyan(` ${currentAgent.name}: `);
|
|
144
|
+
for (let i = 0; i < wrapped.length; i++) {
|
|
145
|
+
lines.push((i === 0 ? name : ' ') + wrapped[i]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
lines.push(''); // spacing
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Return last N lines that fit
|
|
152
|
+
return lines.slice(-maxLines);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function wrapText(text, maxWidth) {
|
|
156
|
+
const lines = [];
|
|
157
|
+
for (const paragraph of text.split('\n')) {
|
|
158
|
+
if (paragraph.length <= maxWidth) {
|
|
159
|
+
lines.push(paragraph);
|
|
160
|
+
} else {
|
|
161
|
+
let remaining = paragraph;
|
|
162
|
+
while (remaining.length > maxWidth) {
|
|
163
|
+
let breakAt = remaining.lastIndexOf(' ', maxWidth);
|
|
164
|
+
if (breakAt <= 0) breakAt = maxWidth;
|
|
165
|
+
lines.push(remaining.slice(0, breakAt));
|
|
166
|
+
remaining = remaining.slice(breakAt).trim();
|
|
167
|
+
}
|
|
168
|
+
if (remaining) lines.push(remaining);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function sendMessage(text) {
|
|
175
|
+
if (!text.trim()) return;
|
|
176
|
+
|
|
177
|
+
// Handle slash commands
|
|
178
|
+
if (text.startsWith('/')) {
|
|
179
|
+
await handleCommand(text.trim());
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
messages.push({ role: 'user', content: text });
|
|
184
|
+
status = 'thinking';
|
|
185
|
+
render();
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/agents/${currentAgent.id}/chat`, {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: { 'content-type': 'application/json' },
|
|
191
|
+
body: JSON.stringify({ message: text, contactId: 'tui' }),
|
|
192
|
+
});
|
|
193
|
+
const data = await res.json();
|
|
194
|
+
|
|
195
|
+
if (data.reaction && !data.messages?.length) {
|
|
196
|
+
messages.push({ role: 'assistant', content: data.reaction });
|
|
197
|
+
} else {
|
|
198
|
+
for (const msg of data.messages || []) {
|
|
199
|
+
messages.push({ role: 'assistant', content: msg });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (data.usage) {
|
|
204
|
+
totalTokens += (data.usage.inputTokens || 0) + (data.usage.outputTokens || 0);
|
|
205
|
+
totalCost += data.usage.cost || 0;
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
messages.push({ role: 'assistant', content: chalk.red(`Error: ${err.message}`) });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
status = 'idle';
|
|
212
|
+
render();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function handleCommand(cmd) {
|
|
216
|
+
const [command, ...args] = cmd.slice(1).split(' ');
|
|
217
|
+
|
|
218
|
+
switch (command) {
|
|
219
|
+
case 'help':
|
|
220
|
+
messages.push({ role: 'assistant', content:
|
|
221
|
+
'📋 Commands:\n/help — this message\n/agents — list agents\n/agent <id> — switch agent\n/model <name> — switch model\n/status — show status\n/clear — clear chat\n/usage — show usage\n/exit — quit' });
|
|
222
|
+
break;
|
|
223
|
+
|
|
224
|
+
case 'agents':
|
|
225
|
+
const list = agents.map(a => ` ${a.id === currentAgent.id ? '→' : ' '} ${a.name} (${a.id})`).join('\n');
|
|
226
|
+
messages.push({ role: 'assistant', content: `🦑 Agents:\n${list}` });
|
|
227
|
+
break;
|
|
228
|
+
|
|
229
|
+
case 'agent':
|
|
230
|
+
if (args[0]) {
|
|
231
|
+
const found = agents.find(a => a.id === args[0] || a.name.toLowerCase() === args[0].toLowerCase());
|
|
232
|
+
if (found) {
|
|
233
|
+
currentAgent = found;
|
|
234
|
+
currentModel = found.model || config.ai?.defaultModel;
|
|
235
|
+
messages.length = 0;
|
|
236
|
+
messages.push({ role: 'assistant', content: `Switched to ${found.name} 🦑` });
|
|
237
|
+
} else {
|
|
238
|
+
messages.push({ role: 'assistant', content: `Agent "${args[0]}" not found` });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
|
|
243
|
+
case 'model':
|
|
244
|
+
if (args[0]) {
|
|
245
|
+
currentModel = args[0];
|
|
246
|
+
messages.push({ role: 'assistant', content: `Model → ${args[0]}` });
|
|
247
|
+
} else {
|
|
248
|
+
messages.push({ role: 'assistant', content: `Current model: ${currentModel}` });
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
|
|
252
|
+
case 'status':
|
|
253
|
+
try {
|
|
254
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`);
|
|
255
|
+
const data = await res.json();
|
|
256
|
+
messages.push({ role: 'assistant', content:
|
|
257
|
+
`🦑 Status:\n Agents: ${data.agents}\n WhatsApp: ${data.whatsapp} connected\n Uptime: ${Math.floor(data.uptime / 60)}m\n Tokens: ${fmtNum(totalTokens)}\n Cost: $${totalCost.toFixed(4)}` });
|
|
258
|
+
} catch {
|
|
259
|
+
messages.push({ role: 'assistant', content: 'Engine not responding' });
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
|
|
263
|
+
case 'clear':
|
|
264
|
+
messages.length = 0;
|
|
265
|
+
break;
|
|
266
|
+
|
|
267
|
+
case 'usage':
|
|
268
|
+
messages.push({ role: 'assistant', content:
|
|
269
|
+
`📊 This session:\n Tokens: ${fmtNum(totalTokens)}\n Cost: $${totalCost.toFixed(4)}\n Messages: ${messages.filter(m => m.role === 'user').length}` });
|
|
270
|
+
break;
|
|
271
|
+
|
|
272
|
+
case 'exit':
|
|
273
|
+
case 'quit':
|
|
274
|
+
console.log(CLEAR_SCREEN + SHOW_CURSOR);
|
|
275
|
+
console.log(chalk.cyan(' 👋 Bye!\n'));
|
|
276
|
+
process.exit(0);
|
|
277
|
+
break;
|
|
278
|
+
|
|
279
|
+
default:
|
|
280
|
+
messages.push({ role: 'assistant', content: `Unknown command: /${command}. Try /help` });
|
|
281
|
+
}
|
|
282
|
+
render();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Initial render
|
|
286
|
+
render();
|
|
287
|
+
|
|
288
|
+
// Handle input
|
|
289
|
+
rl.on('line', async (line) => {
|
|
290
|
+
inputBuffer = '';
|
|
291
|
+
await sendMessage(line);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
rl.on('close', () => {
|
|
295
|
+
console.log(CLEAR_SCREEN + SHOW_CURSOR);
|
|
296
|
+
console.log(chalk.cyan(' 👋 Bye!\n'));
|
|
297
|
+
process.exit(0);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Handle resize
|
|
301
|
+
process.stdout.on('resize', () => render());
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function fmtNum(n) {
|
|
305
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
306
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
307
|
+
return String(n);
|
|
308
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
|
|
4
|
+
export async function update() {
|
|
5
|
+
console.log(chalk.cyan('🦑 Checking for updates...'));
|
|
6
|
+
try {
|
|
7
|
+
const current = execSync('npm view squidclaw version 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
8
|
+
const { readFileSync } = await import('fs');
|
|
9
|
+
const { join, dirname } = await import('path');
|
|
10
|
+
const { fileURLToPath } = await import('url');
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
|
13
|
+
|
|
14
|
+
if (current && current !== pkg.version) {
|
|
15
|
+
console.log(` Current: ${pkg.version} → Latest: ${current}`);
|
|
16
|
+
console.log(chalk.cyan(' Updating...'));
|
|
17
|
+
execSync('npm i -g squidclaw@latest', { stdio: 'inherit' });
|
|
18
|
+
console.log(chalk.green(' ✅ Updated!'));
|
|
19
|
+
} else {
|
|
20
|
+
console.log(chalk.green(` ✅ Already on latest (${pkg.version})`));
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
console.log(chalk.yellow(' Could not check for updates. Try: npm i -g squidclaw@latest'));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadConfig } from '../core/config.js';
|
|
3
|
+
|
|
4
|
+
export async function addWebhook(url) {
|
|
5
|
+
const config = loadConfig();
|
|
6
|
+
const port = config.engine?.port || 9500;
|
|
7
|
+
try {
|
|
8
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/webhooks`, {
|
|
9
|
+
method: 'POST',
|
|
10
|
+
headers: { 'content-type': 'application/json' },
|
|
11
|
+
body: JSON.stringify({ url }),
|
|
12
|
+
});
|
|
13
|
+
const data = await res.json();
|
|
14
|
+
console.log(chalk.green(`✅ Webhook added (${data.id}): ${url}`));
|
|
15
|
+
} catch { console.log(chalk.red('Engine not running')); }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function listWebhooks() {
|
|
19
|
+
const config = loadConfig();
|
|
20
|
+
const port = config.engine?.port || 9500;
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/webhooks`);
|
|
23
|
+
const webhooks = await res.json();
|
|
24
|
+
console.log(chalk.cyan(`\n 🔗 Webhooks (${webhooks.length})\n ──────────`));
|
|
25
|
+
for (const wh of webhooks) {
|
|
26
|
+
console.log(` ${wh.id}: ${wh.url}`);
|
|
27
|
+
}
|
|
28
|
+
if (webhooks.length === 0) console.log(chalk.gray(' No webhooks'));
|
|
29
|
+
console.log();
|
|
30
|
+
} catch { console.log(chalk.red('Engine not running')); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function removeWebhook(id) {
|
|
34
|
+
const config = loadConfig();
|
|
35
|
+
const port = config.engine?.port || 9500;
|
|
36
|
+
try {
|
|
37
|
+
await fetch(`http://127.0.0.1:${port}/api/webhooks/${id}`, { method: 'DELETE' });
|
|
38
|
+
console.log(chalk.green(`✅ Webhook ${id} removed`));
|
|
39
|
+
} catch { console.log(chalk.red('Engine not running')); }
|
|
40
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🦑 Agent Manager
|
|
3
|
+
* Loads and manages all agent instances
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Agent } from './agent.js';
|
|
7
|
+
import { logger } from './logger.js';
|
|
8
|
+
|
|
9
|
+
export class AgentManager {
|
|
10
|
+
constructor(storage, aiGateway) {
|
|
11
|
+
this.storage = storage;
|
|
12
|
+
this.aiGateway = aiGateway;
|
|
13
|
+
this.agents = new Map(); // id → Agent instance
|
|
14
|
+
this.whatsappMap = new Map(); // whatsapp number → agent id
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async loadAll() {
|
|
18
|
+
const agentRows = await this.storage.listAgents();
|
|
19
|
+
for (const row of agentRows) {
|
|
20
|
+
this._loadAgent(row);
|
|
21
|
+
}
|
|
22
|
+
logger.info('agent-manager', `Loaded ${this.agents.size} agents`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_loadAgent(row) {
|
|
26
|
+
const agent = new Agent(row, this.storage, this.aiGateway);
|
|
27
|
+
this.agents.set(agent.id, agent);
|
|
28
|
+
if (row.whatsapp_number) {
|
|
29
|
+
this.whatsappMap.set(row.whatsapp_number, agent.id);
|
|
30
|
+
}
|
|
31
|
+
return agent;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get(id) {
|
|
35
|
+
return this.agents.get(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getByWhatsApp(number) {
|
|
39
|
+
// Clean the number for matching
|
|
40
|
+
const clean = number.replace(/[^0-9]/g, '');
|
|
41
|
+
for (const [num, agentId] of this.whatsappMap) {
|
|
42
|
+
if (num.replace(/[^0-9]/g, '') === clean) {
|
|
43
|
+
return this.agents.get(agentId);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// If only one agent exists, route to it (single-agent mode)
|
|
47
|
+
if (this.agents.size === 1) {
|
|
48
|
+
return this.agents.values().next().value;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
getAll() {
|
|
54
|
+
return Array.from(this.agents.values());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async create(agentData) {
|
|
58
|
+
const created = await this.storage.createAgent(agentData);
|
|
59
|
+
return this._loadAgent({ ...agentData, id: created.id });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async update(id, updates) {
|
|
63
|
+
await this.storage.updateAgent(id, updates);
|
|
64
|
+
// Reload the agent
|
|
65
|
+
const row = await this.storage.getAgent(id);
|
|
66
|
+
if (row) {
|
|
67
|
+
const oldAgent = this.agents.get(id);
|
|
68
|
+
if (oldAgent?.whatsappNumber) {
|
|
69
|
+
this.whatsappMap.delete(oldAgent.whatsappNumber);
|
|
70
|
+
}
|
|
71
|
+
this._loadAgent(row);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async delete(id) {
|
|
76
|
+
const agent = this.agents.get(id);
|
|
77
|
+
if (agent?.whatsappNumber) {
|
|
78
|
+
this.whatsappMap.delete(agent.whatsappNumber);
|
|
79
|
+
}
|
|
80
|
+
this.agents.delete(id);
|
|
81
|
+
await this.storage.deleteAgent(id);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🦑 Agent
|
|
3
|
+
* Single agent instance — soul, memory, chat processing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { logger } from './logger.js';
|
|
7
|
+
import { PromptBuilder } from '../ai/prompt-builder.js';
|
|
8
|
+
import { BehaviorEngine } from '../behavior/engine.js';
|
|
9
|
+
|
|
10
|
+
export class Agent {
|
|
11
|
+
constructor(agentData, storage, aiGateway) {
|
|
12
|
+
this.id = agentData.id;
|
|
13
|
+
this.name = agentData.name;
|
|
14
|
+
this.soul = agentData.soul;
|
|
15
|
+
this.language = agentData.language || 'en';
|
|
16
|
+
this.tone = agentData.tone ?? 50;
|
|
17
|
+
this.model = agentData.model;
|
|
18
|
+
this.fallbackChain = agentData.fallback_chain || [];
|
|
19
|
+
this.behavior = agentData.behavior || {};
|
|
20
|
+
this.timezone = agentData.timezone || 'UTC';
|
|
21
|
+
this.status = agentData.status || 'active';
|
|
22
|
+
this.whatsappNumber = agentData.whatsapp_number;
|
|
23
|
+
|
|
24
|
+
this.storage = storage;
|
|
25
|
+
this.aiGateway = aiGateway;
|
|
26
|
+
this.promptBuilder = new PromptBuilder(storage);
|
|
27
|
+
this.behaviorEngine = new BehaviorEngine();
|
|
28
|
+
|
|
29
|
+
// Track active handoffs
|
|
30
|
+
this.activeHandoffs = new Set();
|
|
31
|
+
|
|
32
|
+
logger.info('agent', `Agent "${this.name}" (${this.id}) loaded`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Process an incoming message and generate response
|
|
37
|
+
* Returns: { messages: string[], reaction: string|null, handoff: boolean }
|
|
38
|
+
*/
|
|
39
|
+
async processMessage(contactId, message, metadata = {}) {
|
|
40
|
+
if (this.status !== 'active') {
|
|
41
|
+
return { messages: [], reaction: null, handoff: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check if this contact is in handoff mode
|
|
45
|
+
if (this.activeHandoffs.has(contactId)) {
|
|
46
|
+
logger.info('agent', `Message from ${contactId} — in handoff mode, skipping`);
|
|
47
|
+
return { messages: [], reaction: null, handoff: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Save incoming message
|
|
51
|
+
await this.storage.saveMessage(this.id, contactId, 'user', message, metadata);
|
|
52
|
+
|
|
53
|
+
// Detect emotion and language
|
|
54
|
+
const emotion = this.behaviorEngine.detectEmotion(message);
|
|
55
|
+
const language = this.behaviorEngine.detectLanguage(message);
|
|
56
|
+
|
|
57
|
+
// Check if it's just a conversation ending
|
|
58
|
+
if (this.behaviorEngine.isConversationEnding(message)) {
|
|
59
|
+
const reaction = this.behaviorEngine.suggestReaction(message, emotion);
|
|
60
|
+
if (reaction) {
|
|
61
|
+
return { messages: [], reaction, handoff: false };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Build system prompt
|
|
66
|
+
const systemPrompt = await this.promptBuilder.build(this, contactId, message);
|
|
67
|
+
|
|
68
|
+
// Get conversation history
|
|
69
|
+
const history = await this.storage.getConversation(this.id, contactId, 50);
|
|
70
|
+
|
|
71
|
+
// Build messages array for AI
|
|
72
|
+
const messages = [
|
|
73
|
+
{ role: 'system', content: systemPrompt },
|
|
74
|
+
...history.map(h => ({ role: h.role, content: h.content })),
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// If the last message in history is already the current message, don't add again
|
|
78
|
+
if (history.length === 0 || history[history.length - 1].content !== message) {
|
|
79
|
+
messages.push({ role: 'user', content: message });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Add emotion context hint
|
|
83
|
+
if (emotion !== 'neutral') {
|
|
84
|
+
const emotionHints = {
|
|
85
|
+
angry: '[The person seems upset/angry. Be empathetic, acknowledge their frustration, keep responses short.]',
|
|
86
|
+
happy: '[The person seems happy/positive. Match their energy!]',
|
|
87
|
+
confused: '[The person seems confused. Simplify your explanation, use examples.]',
|
|
88
|
+
urgent: '[This seems urgent. Be direct and skip pleasantries.]',
|
|
89
|
+
};
|
|
90
|
+
messages.push({ role: 'user', content: emotionHints[emotion] });
|
|
91
|
+
// Remove the hint — it's just context for the AI
|
|
92
|
+
messages.pop();
|
|
93
|
+
// Instead, append to system prompt
|
|
94
|
+
messages[0].content += '\n\n' + emotionHints[emotion];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Call AI
|
|
98
|
+
try {
|
|
99
|
+
const aiResponse = await this.aiGateway.chat(messages, {
|
|
100
|
+
model: this.model,
|
|
101
|
+
fallbackChain: this.fallbackChain,
|
|
102
|
+
temperature: this.behavior.temperature,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Track usage
|
|
106
|
+
await this.storage.trackUsage(
|
|
107
|
+
this.id,
|
|
108
|
+
aiResponse.model,
|
|
109
|
+
aiResponse.inputTokens,
|
|
110
|
+
aiResponse.outputTokens,
|
|
111
|
+
aiResponse.costUsd
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Process response through behavior engine
|
|
115
|
+
const processed = this.behaviorEngine.process(aiResponse.content);
|
|
116
|
+
|
|
117
|
+
// Save memory updates
|
|
118
|
+
for (const mem of processed.memoryUpdates) {
|
|
119
|
+
await this.storage.saveMemory(this.id, mem.key, mem.value, 'fact');
|
|
120
|
+
logger.debug('agent', `Memory saved: ${mem.key} = ${mem.value}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Save assistant response(s)
|
|
124
|
+
const fullResponse = processed.messages.join('\n');
|
|
125
|
+
if (fullResponse) {
|
|
126
|
+
await this.storage.saveMessage(this.id, contactId, 'assistant', fullResponse);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Handle handoff
|
|
130
|
+
if (processed.handoff) {
|
|
131
|
+
this.activeHandoffs.add(contactId);
|
|
132
|
+
await this.storage.createHandoff(this.id, contactId, processed.handoff);
|
|
133
|
+
logger.info('agent', `Handoff triggered for ${contactId}: ${processed.handoff}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
messages: processed.messages,
|
|
138
|
+
reaction: processed.reaction,
|
|
139
|
+
handoff: !!processed.handoff,
|
|
140
|
+
usage: {
|
|
141
|
+
inputTokens: aiResponse.inputTokens,
|
|
142
|
+
outputTokens: aiResponse.outputTokens,
|
|
143
|
+
cost: aiResponse.costUsd,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
} catch (err) {
|
|
147
|
+
logger.error('agent', `AI call failed for ${this.name}: ${err.message}`);
|
|
148
|
+
return {
|
|
149
|
+
messages: ['Sorry, I\'m having a technical issue right now. Please try again in a moment 🙏'],
|
|
150
|
+
reaction: null,
|
|
151
|
+
handoff: false,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resolve a handoff — hand conversation back to agent
|
|
158
|
+
*/
|
|
159
|
+
resolveHandoff(contactId) {
|
|
160
|
+
this.activeHandoffs.delete(contactId);
|
|
161
|
+
}
|
|
162
|
+
}
|