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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/bin/squidclaw.js +512 -0
  4. package/lib/ai/gateway.js +283 -0
  5. package/lib/ai/prompt-builder.js +149 -0
  6. package/lib/api/server.js +235 -0
  7. package/lib/behavior/engine.js +187 -0
  8. package/lib/channels/hub-media.js +128 -0
  9. package/lib/channels/hub.js +89 -0
  10. package/lib/channels/whatsapp/manager.js +319 -0
  11. package/lib/channels/whatsapp/media.js +228 -0
  12. package/lib/cli/agent-cmd.js +182 -0
  13. package/lib/cli/brain-cmd.js +49 -0
  14. package/lib/cli/broadcast-cmd.js +28 -0
  15. package/lib/cli/channels-cmd.js +157 -0
  16. package/lib/cli/config-cmd.js +26 -0
  17. package/lib/cli/conversations-cmd.js +27 -0
  18. package/lib/cli/engine-cmd.js +115 -0
  19. package/lib/cli/handoff-cmd.js +26 -0
  20. package/lib/cli/hours-cmd.js +38 -0
  21. package/lib/cli/key-cmd.js +62 -0
  22. package/lib/cli/knowledge-cmd.js +59 -0
  23. package/lib/cli/memory-cmd.js +50 -0
  24. package/lib/cli/platform-cmd.js +51 -0
  25. package/lib/cli/setup.js +226 -0
  26. package/lib/cli/stats-cmd.js +66 -0
  27. package/lib/cli/tui.js +308 -0
  28. package/lib/cli/update-cmd.js +25 -0
  29. package/lib/cli/webhook-cmd.js +40 -0
  30. package/lib/core/agent-manager.js +83 -0
  31. package/lib/core/agent.js +162 -0
  32. package/lib/core/config.js +172 -0
  33. package/lib/core/logger.js +43 -0
  34. package/lib/engine.js +117 -0
  35. package/lib/features/heartbeat.js +71 -0
  36. package/lib/storage/interface.js +56 -0
  37. package/lib/storage/sqlite.js +409 -0
  38. package/package.json +48 -0
  39. package/templates/BEHAVIOR.md +42 -0
  40. package/templates/IDENTITY.md +7 -0
  41. package/templates/RULES.md +9 -0
  42. 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
+ }