squidclaw 1.1.0 → 1.2.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/lib/engine.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Main entry point — starts all subsystems
2
+ * 🦑 Squidclaw Engine
3
+ * Clean middleware-based architecture
3
4
  */
4
- // Knowledge, tools, telegram — loaded dynamically
5
5
 
6
6
  import { loadConfig, getHome, ensureHome } from './core/config.js';
7
7
  import { initLogger, logger } from './core/logger.js';
@@ -13,6 +13,7 @@ import { ChannelHub } from './channels/hub.js';
13
13
  import { HeartbeatSystem } from './features/heartbeat.js';
14
14
  import { createAPIServer } from './api/server.js';
15
15
  import { addMediaSupport } from './channels/hub-media.js';
16
+ import { MessagePipeline, createContext } from './middleware/pipeline.js';
16
17
 
17
18
  export class SquidclawEngine {
18
19
  constructor(options = {}) {
@@ -23,496 +24,263 @@ export class SquidclawEngine {
23
24
  this.aiGateway = null;
24
25
  this.agentManager = null;
25
26
  this.whatsappManager = null;
27
+ this.telegramManager = null;
26
28
  this.channelHub = null;
27
29
  this.heartbeat = null;
28
30
  this.server = null;
31
+ this.pipeline = null;
32
+ this.knowledgeBase = null;
33
+ this.toolRouter = null;
34
+ this.reminders = null;
35
+ this.autoMemory = null;
36
+ this.usageAlerts = null;
29
37
  }
30
38
 
39
+ // ──────────────────────────────────────────
40
+ // Pipeline Setup
41
+ // ──────────────────────────────────────────
42
+
43
+ async _buildPipeline() {
44
+ const pipeline = new MessagePipeline();
45
+
46
+ // Order matters! Each middleware runs in sequence.
47
+ const { allowlistMiddleware } = await import('./middleware/allowlist.js');
48
+ const { apiKeyDetectMiddleware } = await import('./middleware/api-key-detect.js');
49
+ const { commandsMiddleware } = await import('./middleware/commands.js');
50
+ const { mediaMiddleware } = await import('./middleware/media.js');
51
+ const { autoLinksMiddleware } = await import('./middleware/auto-links.js');
52
+ const { autoMemoryMiddleware } = await import('./middleware/auto-memory.js');
53
+ const { skillCheckMiddleware } = await import('./middleware/skill-check.js');
54
+ const { typingMiddleware } = await import('./middleware/typing.js');
55
+ const { aiProcessMiddleware } = await import('./middleware/ai-process.js');
56
+ const { usageAlertsMiddleware } = await import('./middleware/usage-alerts.js');
57
+ const { responseSenderMiddleware } = await import('./middleware/response-sender.js');
58
+
59
+ pipeline.use('allowlist', allowlistMiddleware);
60
+ pipeline.use('api-key-detect', apiKeyDetectMiddleware);
61
+ pipeline.use('commands', commandsMiddleware);
62
+ pipeline.use('media', mediaMiddleware);
63
+ pipeline.use('auto-links', autoLinksMiddleware);
64
+ pipeline.use('auto-memory', autoMemoryMiddleware);
65
+ pipeline.use('skill-check', skillCheckMiddleware);
66
+ pipeline.use('typing', typingMiddleware); // wraps AI call with typing indicator
67
+ pipeline.use('ai-process', aiProcessMiddleware);
68
+ pipeline.use('usage-alerts', usageAlertsMiddleware);
69
+ pipeline.use('response-sender', responseSenderMiddleware);
70
+
71
+ return pipeline;
72
+ }
73
+
74
+ // ──────────────────────────────────────────
75
+ // Message Handler (used by both Telegram & WhatsApp)
76
+ // ──────────────────────────────────────────
77
+
78
+ async handleMessage(agentId, contactId, message, metadata) {
79
+ const agent = this.agentManager.get(agentId);
80
+ if (!agent) return;
81
+
82
+ const ctx = createContext(this, agentId, contactId, message, metadata);
83
+
84
+ try {
85
+ await this.pipeline.process(ctx);
86
+
87
+ // If pipeline short-circuited with a reply, send it
88
+ if (ctx.handled && ctx.response?.messages?.length > 0) {
89
+ if (ctx.platform === 'telegram' && this.telegramManager) {
90
+ await this.telegramManager.sendMessages(agentId, contactId, ctx.response.messages, metadata);
91
+ } else if (this.whatsappManager) {
92
+ for (const msg of ctx.response.messages) {
93
+ await this.whatsappManager.sendMessage(agentId, contactId, msg);
94
+ }
95
+ }
96
+ }
97
+ } catch (err) {
98
+ logger.error('engine', `Message handling error: ${err.message}`);
99
+ logger.error('engine', err.stack);
100
+ }
101
+ }
102
+
103
+ // ──────────────────────────────────────────
104
+ // Engine Startup
105
+ // ──────────────────────────────────────────
106
+
31
107
  async start() {
32
108
  ensureHome();
33
109
  initLogger(this.config.engine?.logLevel || 'info');
34
110
 
35
111
  console.log(`
36
- 🦑 Squidclaw Engine v0.1.0
112
+ 🦑 Squidclaw Engine v1.1.0
37
113
  ──────────────────────────`);
38
114
 
39
115
  // 1. Storage
40
116
  const dbPath = this.config.storage?.sqlite?.path || `${this.home}/squidclaw.db`;
41
117
  this.storage = new SQLiteStorage(dbPath);
42
- console.log(` 💾 Storage: SQLite (${dbPath})`);
118
+ console.log(` 💾 Storage: SQLite`);
43
119
 
44
120
  // 2. AI Gateway
45
121
  this.aiGateway = new AIGateway(this.config);
46
122
  const providers = Object.entries(this.config.ai?.providers || {})
47
- .filter(([_, v]) => v.key)
48
- .map(([k]) => k);
49
- console.log(` 🧠 AI: ${providers.join(', ') || 'none configured'}`);
50
- if (this.config.ai?.defaultModel) {
51
- console.log(` 🎯 Default model: ${this.config.ai.defaultModel}`);
52
- }
123
+ .filter(([_, v]) => v.key).map(([k]) => k);
124
+ console.log(` 🧠 AI: ${providers.join(', ') || 'none'}`);
125
+ if (this.config.ai?.defaultModel) console.log(` 🎯 Model: ${this.config.ai.defaultModel}`);
53
126
 
54
127
  // 3. Agent Manager
55
128
  this.agentManager = new AgentManager(this.storage, this.aiGateway);
56
129
  await this.agentManager.loadAll();
57
130
  const agents = this.agentManager.getAll();
58
- console.log(` 👥 Agents: ${agents.length} loaded`);
131
+ console.log(` 👥 Agents: ${agents.length}`);
59
132
 
60
- // 3b. Knowledge Base + Tools (optional)
61
- try {
62
- const { KnowledgeBase } = await import("./memory/knowledge.js");
63
- const { ToolRouter } = await import("./tools/router.js");
64
- const { addToolSupport } = await import("./core/agent-tools-mixin.js");
65
- this.knowledgeBase = new KnowledgeBase(this.storage, this.config);
66
- this.toolRouter = new ToolRouter(this.config, this.knowledgeBase);
67
- const toolList = ["web search", "page reader"];
68
- if (this.config.tools?.google?.oauthToken) toolList.push("calendar", "email");
69
- console.log(" 🔧 Tools: " + toolList.join(", "));
70
- for (const agent of agents) {
71
- addToolSupport(agent, this.toolRouter, this.knowledgeBase);
72
- }
73
- } catch (err) {
74
- console.log(" 🔧 Tools: basic mode");
75
- }
133
+ // 4. Knowledge + Tools
134
+ await this._initTools(agents);
76
135
 
77
- // 3e. Telegram Bot
78
- if (this.config.channels?.telegram?.enabled && this.config.channels?.telegram?.token) {
79
- try {
80
- const { TelegramManager } = await import('./channels/telegram/bot.js');
81
- this.telegramManager = new TelegramManager(this.config, this.agentManager);
82
-
83
- // Set up message handler
84
- this.telegramManager.onMessage = async (agentId, contactId, message, metadata) => {
85
- const agent = this.agentManager.get(agentId);
86
- if (!agent) return;
87
-
88
- // Allowlist check
89
- const allowFrom = this.config.channels?.telegram?.allowFrom;
90
- if (allowFrom && allowFrom !== '*') {
91
- const senderId = contactId.replace('tg_', '');
92
- const allowed = Array.isArray(allowFrom) ? allowFrom.includes(senderId) : String(allowFrom) === senderId;
93
- if (!allowed) return;
94
- }
136
+ // 5. Features (reminders, auto-memory, usage alerts)
137
+ await this._initFeatures();
95
138
 
96
- // Handle API key detection — user pasting a key
97
- const { detectApiKey, saveApiKey, getKeyConfirmation, detectSkillRequest, checkSkillAvailable, getKeyRequestMessage } = await import('./features/self-config.js');
98
-
99
- const keyDetected = detectApiKey(message);
100
- if (keyDetected && keyDetected.provider !== 'unknown') {
101
- saveApiKey(keyDetected.provider, keyDetected.key);
102
- // Reload config so skills see the new key
103
- const { loadConfig } = await import('./core/config.js');
104
- this.config = loadConfig();
105
- const confirmation = getKeyConfirmation(keyDetected.provider);
106
- await this.telegramManager.sendMessage(agentId, contactId, confirmation, metadata);
107
- return;
108
- }
139
+ // 6. Message Pipeline
140
+ this.pipeline = await this._buildPipeline();
141
+ console.log(` 🔧 Pipeline: ${this.pipeline.middleware.length} middleware`);
109
142
 
110
- // Handle /tasks command
111
- if (message.trim() === '/tasks' || message.trim() === '/todo') {
112
- try {
113
- const { TaskManager } = await import('./tools/tasks.js');
114
- const tm = new TaskManager(this.storage);
115
- const tasks = tm.list(agentId, contactId);
116
- if (tasks.length === 0) {
117
- await this.telegramManager.sendMessage(agentId, contactId, '📋 No pending tasks! Tell me to add one.', metadata);
118
- } else {
119
- const list = tasks.map((t, i) => (i+1) + '. ' + t.task).join('\n');
120
- await this.telegramManager.sendMessage(agentId, contactId, '📋 *Your Tasks*\n\n' + list, metadata);
121
- }
122
- } catch (err) {
123
- await this.telegramManager.sendMessage(agentId, contactId, '❌ ' + err.message, metadata);
124
- }
125
- return;
126
- }
143
+ // 7. Telegram
144
+ await this._initTelegram(agents);
127
145
 
128
- // Handle /usage command
129
- if (message.trim() === '/usage') {
130
- try {
131
- const { UsageAlerts } = await import('./features/usage-alerts.js');
132
- const ua = new UsageAlerts(this.storage);
133
- const summary = await ua.getSummary(agentId);
134
- const fmtT = (n) => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : String(n);
135
- const lines = [
136
- '📊 *Usage Report*', '',
137
- '*Today:*',
138
- ' 💬 ' + summary.today.calls + ' messages',
139
- ' 🪙 ' + fmtT(summary.today.tokens) + ' tokens',
140
- ' 💰 $' + summary.today.cost, '',
141
- '*Last 30 days:*',
142
- ' 💬 ' + summary.month.calls + ' messages',
143
- ' 🪙 ' + fmtT(summary.month.tokens) + ' tokens',
144
- ' 💰 $' + summary.month.cost,
145
- ];
146
- await this.telegramManager.sendMessage(agentId, contactId, lines.join('\n'), metadata);
147
- } catch (err) {
148
- await this.telegramManager.sendMessage(agentId, contactId, '❌ ' + err.message, metadata);
149
- }
150
- return;
151
- }
146
+ // 8. WhatsApp
147
+ await this._initWhatsApp(agents);
152
148
 
153
- // Handle /help command
154
- if (message.trim() === '/help') {
155
- const helpText = [
156
- '🦑 *Commands*',
157
- '',
158
- '/status — model, uptime, usage stats',
159
- '/backup — save me to a backup file',
160
- '/memories — what I remember about you',
161
- '/tasks — your todo list',
162
- '/usage — spending report (today + 30 days)',
163
- '/help — this message',
164
- '',
165
- 'Just chat normally — I\'ll search the web, remember things, and help! 🦑',
166
- ];
167
- await this.telegramManager.sendMessage(agentId, contactId, helpText.join('\n'), metadata);
168
- return;
169
- }
149
+ // 9. Channel Hub
150
+ this.channelHub = new ChannelHub(this.agentManager, this.whatsappManager, this.storage, this.telegramManager);
151
+ addMediaSupport(this.channelHub, this.config, this.home);
170
152
 
171
- // Handle /backup command
172
- if (message.trim() === '/backup' || message.trim().toLowerCase() === 'backup yourself' || message.trim().toLowerCase().includes('backup')) {
173
- if (message.toLowerCase().includes('backup') && (message.toLowerCase().includes('yourself') || message.toLowerCase().includes('your') || message.trim() === '/backup')) {
174
- try {
175
- const { AgentBackup } = await import('./features/backup.js');
176
- const backup = await AgentBackup.create(agentId, this.home, this.storage);
177
- const filename = (agent.name || agentId) + '_backup_' + new Date().toISOString().slice(0,10) + '.json';
178
- const path = this.home + '/backups/' + filename;
179
- const { mkdirSync } = await import('fs');
180
- mkdirSync(this.home + '/backups', { recursive: true });
181
- await AgentBackup.saveToFile(backup, path);
182
- const size = (JSON.stringify(backup).length / 1024).toFixed(1);
183
- const lines = [
184
- '💾 *Backup Complete!*',
185
- '',
186
- '📦 File: ' + filename,
187
- '📏 Size: ' + size + ' KB',
188
- '💬 Messages: ' + (backup.conversations?.length || 0),
189
- '🧠 Memories: ' + (backup.memories?.length || 0),
190
- '📄 Files: ' + Object.keys(backup.files).length,
191
- '',
192
- 'I am safe! You can resurrect me anytime with this file 🦑',
193
- ];
194
- await this.telegramManager.sendMessage(agentId, contactId, lines.join('\n'), metadata);
195
- } catch (err) {
196
- await this.telegramManager.sendMessage(agentId, contactId, '❌ Backup failed: ' + err.message, metadata);
197
- }
198
- return;
199
- }
200
- }
153
+ // 10. Heartbeat
154
+ this.heartbeat = new HeartbeatSystem(this.agentManager, this.whatsappManager, this.storage);
155
+ this.heartbeat.start();
156
+ console.log(` 💓 Heartbeat: active`);
201
157
 
202
- // Handle /memories command
203
- if (message.trim() === '/memories' || message.trim() === '/remember') {
204
- try {
205
- const memories = await this.storage.getMemories(agentId);
206
- if (memories.length === 0) {
207
- await this.telegramManager.sendMessage(agentId, contactId, '🧠 No memories yet. Tell me things and I\'ll remember!', metadata);
208
- } else {
209
- const memList = memories.slice(0, 20).map(m => '• ' + m.key + ': ' + m.value).join('\n');
210
- await this.telegramManager.sendMessage(agentId, contactId, '🧠 *What I Remember*\n\n' + memList, metadata);
211
- }
212
- } catch (err) {
213
- await this.telegramManager.sendMessage(agentId, contactId, '🧠 Memory error: ' + err.message, metadata);
214
- }
215
- return;
216
- }
158
+ // 11. API + Dashboard
159
+ await this._initAPI();
217
160
 
218
- // Handle /status command
219
- if (message.trim() === '/status' || message.trim().toLowerCase() === 'status') {
220
- const uptime = process.uptime();
221
- const h = Math.floor(uptime / 3600);
222
- const m = Math.floor((uptime % 3600) / 60);
223
- const usage = await this.storage.getUsage(agentId) || {};
224
- const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
225
- const fmtT = tokens >= 1000000 ? (tokens/1000000).toFixed(1)+'M' : tokens >= 1000 ? (tokens/1000).toFixed(1)+'K' : String(tokens);
226
- const waOn = Object.values(this.whatsappManager?.getStatuses() || {}).some(s => s.connected);
227
-
228
- const lines = [
229
- `🦑 *${agent.name || agentId} Status*`,
230
- '────────────────────',
231
- `🧠 Model: ${agent.model || 'unknown'}`,
232
- `⏱️ Uptime: ${h}h ${m}m`,
233
- `💬 Messages: ${usage.messages || 0}`,
234
- `🪙 Tokens: ${fmtT}`,
235
- `💰 Cost: $${(usage.cost_usd || 0).toFixed(4)}`,
236
- '────────────────────',
237
- `📱 WhatsApp: ${waOn ? '✅' : '❌'}`,
238
- '✈️ Telegram: ✅ connected',
239
- '────────────────────',
240
- '⚡ Skills: search, reader, vision, voice, memory',
241
- `🗣️ Language: ${agent.language || 'bilingual'}`,
242
- ];
243
-
244
- await this.telegramManager.sendMessage(agentId, contactId, lines.join('\n'), metadata);
245
- return;
246
- }
161
+ // Graceful shutdown
162
+ process.on('SIGINT', () => this.stop());
163
+ process.on('SIGTERM', () => this.stop());
164
+ }
247
165
 
248
- // Process Telegram media (voice, images)
249
- if (metadata._ctx && metadata.mediaType) {
250
- try {
251
- if (metadata.mediaType === 'audio') {
252
- // Download and transcribe voice note
253
- const file = await metadata._ctx.getFile();
254
- const fileUrl = `https://api.telegram.org/file/bot${this.config.channels.telegram.token}/${file.file_path}`;
255
- const resp = await fetch(fileUrl);
256
- const buffer = Buffer.from(await resp.arrayBuffer());
257
-
258
- // Transcribe with Groq Whisper (free) or OpenAI
259
- const groqKey = this.config.ai?.providers?.groq?.key;
260
- const openaiKey = this.config.ai?.providers?.openai?.key;
261
- const apiKey = groqKey || openaiKey;
262
- const apiUrl = groqKey
263
- ? 'https://api.groq.com/openai/v1/audio/transcriptions'
264
- : 'https://api.openai.com/v1/audio/transcriptions';
265
- const model = groqKey ? 'whisper-large-v3' : 'whisper-1';
266
-
267
- if (apiKey) {
268
- const form = new FormData();
269
- form.append('file', new Blob([buffer], { type: 'audio/ogg' }), 'voice.ogg');
270
- form.append('model', model);
271
-
272
- const tRes = await fetch(apiUrl, {
273
- method: 'POST',
274
- headers: { 'Authorization': 'Bearer ' + apiKey },
275
- body: form,
276
- });
277
- const tData = await tRes.json();
278
- if (tData.text) {
279
- message = '[Voice note]: "' + tData.text + '"';
280
- logger.info('telegram', 'Transcribed voice: ' + tData.text.slice(0, 50));
281
- }
282
- }
283
- } else if (metadata.mediaType === 'image') {
284
- // Download and analyze image
285
- const photos = metadata._ctx.message?.photo;
286
- if (photos?.length > 0) {
287
- const photo = photos[photos.length - 1]; // highest res
288
- const file = await metadata._ctx.api.getFile(photo.file_id);
289
- const fileUrl = `https://api.telegram.org/file/bot${this.config.channels.telegram.token}/${file.file_path}`;
290
- const resp = await fetch(fileUrl);
291
- const buffer = Buffer.from(await resp.arrayBuffer());
292
- const base64 = buffer.toString('base64');
293
-
294
- // Analyze with Claude or OpenAI Vision
295
- const anthropicKey = this.config.ai?.providers?.anthropic?.key;
296
- const caption = message.replace('[📸 Image]', '').trim();
297
- const userPrompt = caption || 'What is in this image? Be concise.';
298
-
299
- if (anthropicKey) {
300
- const vRes = await fetch('https://api.anthropic.com/v1/messages', {
301
- method: 'POST',
302
- headers: {
303
- 'x-api-key': anthropicKey,
304
- 'content-type': 'application/json',
305
- 'anthropic-version': '2023-06-01',
306
- },
307
- body: JSON.stringify({
308
- model: 'claude-sonnet-4-20250514',
309
- max_tokens: 300,
310
- messages: [{
311
- role: 'user',
312
- content: [
313
- { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: base64 } },
314
- { type: 'text', text: userPrompt },
315
- ],
316
- }],
317
- }),
318
- });
319
- const vData = await vRes.json();
320
- const analysis = vData.content?.[0]?.text || '';
321
- if (analysis) {
322
- message = caption
323
- ? '[Image with caption: "' + caption + '"] Image shows: ' + analysis
324
- : '[Image] Image shows: ' + analysis;
325
- logger.info('telegram', 'Analyzed image: ' + analysis.slice(0, 50));
326
- }
327
- }
328
- }
329
- } else if (metadata.mediaType === 'document') {
330
- const file = await metadata._ctx.getFile();
331
- const fileUrl = `https://api.telegram.org/file/bot${this.config.channels.telegram.token}/${file.file_path}`;
332
- const resp = await fetch(fileUrl);
333
- const buffer = Buffer.from(await resp.arrayBuffer());
334
- const filename = message.match(/Document: (.+?)\]/)?.[1] || 'file.txt';
335
-
336
- try {
337
- const { DocIngester } = await import('./features/doc-ingest.js');
338
- const ingester = new DocIngester(this.storage, this.knowledgeBase, this.home);
339
- const result = await ingester.ingest(agentId, buffer, filename, metadata.mimeType);
340
-
341
- await this.telegramManager.sendMessage(agentId, contactId,
342
- '📄 *Document absorbed!*\n📁 ' + filename + '\n📊 ' + result.chunks + ' chunks\n📝 ' + result.chars + ' chars\n\nI can answer questions about this! 🦑', metadata);
343
- return;
344
- } catch (err) {
345
- message = '[Document: ' + filename + '] (Could not process: ' + err.message + ')';
346
- }
347
- }
348
- } catch (err) {
349
- logger.error('telegram', 'Media processing error: ' + err.message);
350
- }
351
- }
166
+ // ──────────────────────────────────────────
167
+ // Init Helpers
168
+ // ──────────────────────────────────────────
352
169
 
353
- // Auto-read links in message
354
- const linkRegex = /https?:\/\/[^\s<>"')\]]+/gi;
355
- if (linkRegex.test(message) && this.toolRouter) {
356
- try {
357
- const { extractAndReadLinks } = await import('./features/auto-links.js');
358
- const linkContext = await extractAndReadLinks(message, this.toolRouter.browser);
359
- if (linkContext) {
360
- metadata._linkContext = linkContext;
361
- }
362
- } catch {}
363
- }
170
+ async _initTools(agents) {
171
+ try {
172
+ const { KnowledgeBase } = await import('./memory/knowledge.js');
173
+ const { ToolRouter } = await import('./tools/router.js');
174
+ const { addToolSupport } = await import('./core/agent-tools-mixin.js');
175
+
176
+ this.knowledgeBase = new KnowledgeBase(this.storage, this.config);
177
+ this.toolRouter = new ToolRouter(this.config, this.knowledgeBase);
364
178
 
365
- // Check usage alerts
366
- if (this.usageAlerts) {
367
- try {
368
- const alert = await this.usageAlerts.check(agentId);
369
- if (alert.alert) {
370
- await this.telegramManager.sendMessage(agentId, contactId,
371
- '⚠️ *Usage Alert*\nYou have spent $' + alert.total + ' in the last 24h (threshold: $' + alert.threshold + ')', metadata);
372
- }
373
- } catch {}
374
- }
179
+ for (const agent of agents) {
180
+ addToolSupport(agent, this.toolRouter, this.knowledgeBase);
181
+ }
182
+ console.log(' 🔧 Tools: active');
183
+ } catch (err) {
184
+ console.log(' 🔧 Tools: basic');
185
+ }
186
+ }
375
187
 
376
- // Auto-extract facts from user message
377
- if (this.autoMemory) {
378
- try { await this.autoMemory.extract(agentId, contactId, message); } catch {}
188
+ async _initFeatures() {
189
+ // Reminders
190
+ try {
191
+ const { ReminderManager } = await import('./features/reminders.js');
192
+ this.reminders = new ReminderManager(this.storage);
193
+ this.reminders.onFire = async (agentId, contactId, message, platform, metadata) => {
194
+ try {
195
+ const msg = '⏰ *Reminder!*\n\n' + message;
196
+ if (platform === 'telegram' && this.telegramManager) {
197
+ await this.telegramManager.sendMessage(agentId, contactId, msg, metadata);
198
+ } else if (this.whatsappManager) {
199
+ await this.whatsappManager.sendMessage(agentId, contactId, msg);
379
200
  }
201
+ } catch (err) {
202
+ logger.error('reminder', 'Delivery failed: ' + err.message);
203
+ }
204
+ };
205
+ const pending = this.storage.db.prepare("SELECT COUNT(*) as c FROM reminders WHERE fired = 0 AND fire_at > datetime('now')").get();
206
+ if (pending.c > 0) console.log(` ⏰ Reminders: ${pending.c} pending`);
207
+ } catch {}
380
208
 
381
- // Check if user is asking for a skill we don't have
382
- const skillRequest = detectSkillRequest(message);
383
- if (skillRequest) {
384
- const availability = checkSkillAvailable(skillRequest, this.config);
385
- if (!availability.available) {
386
- const keyMsg = getKeyRequestMessage(skillRequest);
387
- if (keyMsg) {
388
- await this.telegramManager.sendMessage(agentId, contactId, keyMsg, metadata);
389
- return;
390
- }
391
- }
392
- }
209
+ // Auto-memory
210
+ try {
211
+ const { AutoMemory } = await import('./features/auto-memory.js');
212
+ this.autoMemory = new AutoMemory(this.storage);
213
+ } catch {}
393
214
 
394
- // Show typing indicator while processing
395
- const chatId = metadata.chatId || contactId.replace('tg_', '');
396
- const botInfo = this.telegramManager?.bots?.get(agentId);
397
- let typingInterval;
398
- if (botInfo?.bot) {
399
- const sendTyping = () => { try { botInfo.bot.api.sendChatAction(chatId, 'typing').catch(() => {}); } catch {} };
400
- sendTyping();
401
- typingInterval = setInterval(sendTyping, 4000);
402
- }
215
+ // Usage alerts
216
+ try {
217
+ const { UsageAlerts } = await import('./features/usage-alerts.js');
218
+ this.usageAlerts = new UsageAlerts(this.storage);
219
+ } catch {}
220
+ }
403
221
 
404
- let result;
405
- try {
406
- result = await agent.processMessage(contactId, message, metadata);
407
- } finally {
408
- if (typingInterval) clearInterval(typingInterval);
409
- }
410
-
411
- if (result.reaction && metadata.messageId) {
412
- try { await this.telegramManager.sendReaction(agentId, contactId, metadata.messageId, result.reaction, metadata); } catch {}
413
- }
414
-
415
- if (result.messages && result.messages.length > 0) {
416
- // Handle reminder if requested
417
- if (result._reminder && this.reminders) {
418
- const { time, message: remMsg } = result._reminder;
419
- this.reminders.add(agentId, contactId, remMsg, time, 'telegram', metadata);
420
- }
421
-
422
- // Send image if generated
423
- if (result.image) {
424
- const photoData = result.image.url ? { url: result.image.url } : { base64: result.image.base64 };
425
- const caption = result.messages?.[0] || '';
426
- await this.telegramManager.sendPhoto(agentId, contactId, photoData, caption, metadata);
427
- } else {
428
- // Handle reminder if requested
429
- if (result._reminder && this.reminders) {
430
- const { time, message: remMsg } = result._reminder;
431
- this.reminders.add(agentId, contactId, remMsg, time, 'telegram', metadata);
432
- }
433
-
434
- // Send image if generated
435
- if (result.image) {
436
- await this.telegramManager.sendPhoto(agentId, contactId, result.image, result.messages?.[0] || '', metadata);
437
- } else if (metadata.originalType === 'voice' && result.messages.length === 1 && result.messages[0].length < 500) {
438
- // Reply with voice when user sent voice
439
- try {
440
- const { VoiceReply } = await import('./features/voice-reply.js');
441
- const vr = new VoiceReply(this.config);
442
- const lang = /[\u0600-\u06FF]/.test(result.messages[0]) ? 'ar' : 'en';
443
- const audio = await vr.generate(result.messages[0], { language: lang });
444
- await this.telegramManager.sendVoice(agentId, contactId, audio, metadata);
445
- } catch (err) {
446
- // Fallback to text
447
- logger.warn('voice', 'Voice reply failed, sending text: ' + err.message);
448
- await this.telegramManager.sendMessages(agentId, contactId, result.messages, metadata);
449
- }
450
- } else {
451
- await this.telegramManager.sendMessages(agentId, contactId, result.messages, metadata);
452
- }
453
- }
454
- }
455
- };
222
+ async _initTelegram(agents) {
223
+ if (!this.config.channels?.telegram?.enabled || !this.config.channels?.telegram?.token) return;
456
224
 
457
- // Start bot for all agents with telegram config
458
- // Only start ONE bot instance (shared token = shared bot)
459
- try {
460
- const firstAgent = agents[0];
461
- if (firstAgent) {
462
- await this.telegramManager.startBot(firstAgent.id, this.config.channels.telegram.token);
463
- }
464
- } catch (err) {
465
- console.log(' ✈️ Telegram: ' + err.message);
466
- }
467
- console.log(' ✈️ Telegram: connected');
468
- } catch (err) {
469
- console.log(' ✈️ Telegram: failed (' + err.message + ')');
225
+ try {
226
+ const { TelegramManager } = await import('./channels/telegram/bot.js');
227
+ this.telegramManager = new TelegramManager(this.config, this.agentManager);
228
+
229
+ // Route all messages through the pipeline
230
+ this.telegramManager.onMessage = (agentId, contactId, message, metadata) => {
231
+ metadata.platform = 'telegram';
232
+ metadata.chatId = metadata.chatId || contactId.replace('tg_', '');
233
+ return this.handleMessage(agentId, contactId, message, metadata);
234
+ };
235
+
236
+ const firstAgent = agents[0];
237
+ if (firstAgent) {
238
+ await this.telegramManager.startBot(firstAgent.id, this.config.channels.telegram.token);
470
239
  }
240
+ console.log(' ✈️ Telegram: connected');
241
+ } catch (err) {
242
+ console.log(' ✈️ Telegram: ' + err.message);
471
243
  }
244
+ }
472
245
 
473
- // 4. WhatsApp Manager
246
+ async _initWhatsApp(agents) {
474
247
  this.whatsappManager = new WhatsAppManager(this.config, this.agentManager, this.home);
475
248
 
476
- // Start WhatsApp sessions for all agents that have them configured
477
249
  for (const agent of agents) {
478
250
  if (agent.status === 'active' && agent.whatsappNumber) {
479
- try {
480
- await this.whatsappManager.startSession(agent.id);
481
- } catch (err) {
482
- logger.warn('engine', `Failed to start WhatsApp for "${agent.name}": ${err.message}`);
251
+ try { await this.whatsappManager.startSession(agent.id); } catch (err) {
252
+ logger.warn('engine', `WhatsApp for "${agent.name}": ${err.message}`);
483
253
  }
484
254
  }
485
255
  }
256
+
486
257
  const waStatuses = this.whatsappManager.getStatuses();
487
258
  const connected = Object.values(waStatuses).filter(s => s.connected).length;
488
- console.log(` 📱 WhatsApp: ${connected}/${agents.length} connected`);
489
-
490
- // 5. Channel Hub
491
- this.channelHub = new ChannelHub(this.agentManager, this.whatsappManager, this.storage, this.telegramManager);
492
- addMediaSupport(this.channelHub, this.config, this.home);
493
-
494
- // 6. Heartbeat
495
- this.heartbeat = new HeartbeatSystem(this.agentManager, this.whatsappManager, this.storage);
496
- this.heartbeat.start();
497
- console.log(` 💓 Heartbeat: active`);
259
+ console.log(` 📱 WhatsApp: ${connected}/${agents.length}`);
260
+ }
498
261
 
499
- // 7. API Server + Dashboard
262
+ async _initAPI() {
500
263
  const app = createAPIServer(this);
501
- try { const { addDashboardRoutes } = await import('./api/dashboard.js'); addDashboardRoutes(app, this); } catch {}
264
+ try {
265
+ const { addDashboardRoutes } = await import('./api/dashboard.js');
266
+ addDashboardRoutes(app, this);
267
+ } catch {}
268
+
502
269
  this.server = app.listen(this.port, this.config.engine?.bind || '0.0.0.0', () => {
503
- console.log(` 🌐 API: http://${this.config.engine?.bind || '0.0.0.0'}:${this.port}`);
270
+ console.log(` 🌐 API: http://0.0.0.0:${this.port}`);
504
271
  console.log(` ──────────────────────────`);
505
272
  console.log(` ✅ Engine running!\n`);
506
273
  });
507
-
508
- // Graceful shutdown
509
- process.on('SIGINT', () => this.stop());
510
- process.on('SIGTERM', () => this.stop());
511
274
  }
512
275
 
276
+ // ──────────────────────────────────────────
277
+ // Shutdown
278
+ // ──────────────────────────────────────────
279
+
513
280
  async stop() {
514
281
  console.log('\n 🦑 Shutting down...');
515
282
  this.heartbeat?.stop();
283
+ this.reminders?.destroy();
516
284
  await this.whatsappManager?.stopAll();
517
285
  this.storage?.close();
518
286
  this.server?.close();
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Core AI processing — sends message to agent, gets response
3
+ */
4
+ import { logger } from '../core/logger.js';
5
+
6
+ export async function aiProcessMiddleware(ctx, next) {
7
+ if (ctx.handled) return;
8
+
9
+ const result = await ctx.agent.processMessage(ctx.contactId, ctx.message, ctx.metadata);
10
+
11
+ ctx.response = {
12
+ messages: result.messages || [],
13
+ reaction: result.reaction || null,
14
+ image: result.image || null,
15
+ _reminder: result._reminder || null,
16
+ };
17
+ ctx.metadata.originalType = ctx.metadata.originalType || null;
18
+
19
+ await next();
20
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Allowlist middleware — blocks unauthorized senders
3
+ */
4
+ export async function allowlistMiddleware(ctx, next) {
5
+ const allowFrom = ctx.config.channels?.[ctx.platform]?.allowFrom;
6
+ if (allowFrom && allowFrom !== '*') {
7
+ const senderId = ctx.contactId.replace('tg_', '');
8
+ const allowed = Array.isArray(allowFrom) ? allowFrom.includes(senderId) : String(allowFrom) === senderId;
9
+ if (!allowed) {
10
+ ctx.handled = true;
11
+ return;
12
+ }
13
+ }
14
+ await next();
15
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Detects and saves API keys pasted in chat
3
+ */
4
+ export async function apiKeyDetectMiddleware(ctx, next) {
5
+ const { detectApiKey, saveApiKey, getKeyConfirmation } = await import('../features/self-config.js');
6
+
7
+ const keyDetected = detectApiKey(ctx.message);
8
+ if (keyDetected && keyDetected.provider !== 'unknown') {
9
+ saveApiKey(keyDetected.provider, keyDetected.key);
10
+ const { loadConfig } = await import('../core/config.js');
11
+ ctx.engine.config = loadConfig();
12
+ ctx.config = ctx.engine.config;
13
+ await ctx.reply(getKeyConfirmation(keyDetected.provider));
14
+ return;
15
+ }
16
+ await next();
17
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Auto-detect and read URLs in messages
3
+ */
4
+ export async function autoLinksMiddleware(ctx, next) {
5
+ const urlRegex = /https?:\/\/[^\s<>"')\]]+/gi;
6
+ if (urlRegex.test(ctx.message) && ctx.engine.toolRouter?.browser) {
7
+ try {
8
+ const { extractAndReadLinks } = await import('../features/auto-links.js');
9
+ const content = await extractAndReadLinks(ctx.message, ctx.engine.toolRouter.browser);
10
+ if (content) ctx.metadata._linkContext = content;
11
+ } catch {}
12
+ }
13
+ await next();
14
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Extract facts from messages automatically
3
+ */
4
+ export async function autoMemoryMiddleware(ctx, next) {
5
+ if (ctx.engine.autoMemory) {
6
+ try { await ctx.engine.autoMemory.extract(ctx.agentId, ctx.contactId, ctx.message); } catch {}
7
+ }
8
+ await next();
9
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Handle slash commands: /help, /status, /backup, /memories, /tasks, /usage
3
+ */
4
+ export async function commandsMiddleware(ctx, next) {
5
+ const msg = ctx.message.trim();
6
+ const cmd = msg.startsWith('/') ? msg.split(' ')[0].toLowerCase() : null;
7
+
8
+ // Also match plain text commands
9
+ const textCmd = msg.toLowerCase();
10
+
11
+ if (cmd === '/help') {
12
+ await ctx.reply([
13
+ '🦑 *Commands*', '',
14
+ '/status — model, uptime, usage stats',
15
+ '/backup — save me to a backup file',
16
+ '/memories — what I remember about you',
17
+ '/tasks — your todo list',
18
+ '/usage — spending report',
19
+ '/help — this message',
20
+ '', 'Just chat normally! 🦑',
21
+ ].join('\n'));
22
+ return;
23
+ }
24
+
25
+ if (cmd === '/status' || textCmd === 'status') {
26
+ const uptime = process.uptime();
27
+ const h = Math.floor(uptime / 3600);
28
+ const m = Math.floor((uptime % 3600) / 60);
29
+ const usage = await ctx.storage.getUsage(ctx.agentId) || {};
30
+ const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
31
+ const fmtT = tokens >= 1e6 ? (tokens/1e6).toFixed(1)+'M' : tokens >= 1e3 ? (tokens/1e3).toFixed(1)+'K' : String(tokens);
32
+ const waOn = Object.values(ctx.engine.whatsappManager?.getStatuses() || {}).some(s => s.connected);
33
+
34
+ await ctx.reply([
35
+ `🦑 *${ctx.agent?.name || ctx.agentId} Status*`,
36
+ '────────────────────',
37
+ `🧠 Model: ${ctx.agent?.model || 'unknown'}`,
38
+ `⏱️ Uptime: ${h}h ${m}m`,
39
+ `💬 Messages: ${usage.messages || 0}`,
40
+ `🪙 Tokens: ${fmtT}`,
41
+ `💰 Cost: $${(usage.cost_usd || 0).toFixed(4)}`,
42
+ '────────────────────',
43
+ `📱 WhatsApp: ${waOn ? '✅' : '❌'}`,
44
+ `✈️ Telegram: ${ctx.engine.telegramManager ? '✅' : '❌'}`,
45
+ '────────────────────',
46
+ `⚡ Skills: 20+`,
47
+ `🗣️ Language: ${ctx.agent?.language || 'bilingual'}`,
48
+ ].join('\n'));
49
+ return;
50
+ }
51
+
52
+ if (cmd === '/backup' || (textCmd.includes('backup') && textCmd.includes('yourself'))) {
53
+ try {
54
+ const { AgentBackup } = await import('../features/backup.js');
55
+ const { mkdirSync } = await import('fs');
56
+ const backup = await AgentBackup.create(ctx.agentId, ctx.engine.home, ctx.storage);
57
+ const filename = (ctx.agent?.name || ctx.agentId) + '_backup_' + new Date().toISOString().slice(0, 10) + '.json';
58
+ mkdirSync(ctx.engine.home + '/backups', { recursive: true });
59
+ await AgentBackup.saveToFile(backup, ctx.engine.home + '/backups/' + filename);
60
+ const size = (JSON.stringify(backup).length / 1024).toFixed(1);
61
+ await ctx.reply([
62
+ '💾 *Backup Complete!*', '',
63
+ '📦 ' + filename,
64
+ '📏 ' + size + ' KB',
65
+ '💬 ' + (backup.conversations?.length || 0) + ' messages',
66
+ '🧠 ' + (backup.memories?.length || 0) + ' memories',
67
+ '', 'I am safe! 🦑',
68
+ ].join('\n'));
69
+ } catch (err) {
70
+ await ctx.reply('❌ Backup failed: ' + err.message);
71
+ }
72
+ return;
73
+ }
74
+
75
+ if (cmd === '/memories' || cmd === '/remember') {
76
+ const memories = await ctx.storage.getMemories(ctx.agentId);
77
+ if (memories.length === 0) {
78
+ await ctx.reply('🧠 No memories yet. Tell me things!');
79
+ } else {
80
+ const list = memories.slice(0, 20).map(m => '• ' + m.key + ': ' + m.value).join('\n');
81
+ await ctx.reply('🧠 *What I Remember*\n\n' + list);
82
+ }
83
+ return;
84
+ }
85
+
86
+ if (cmd === '/tasks' || cmd === '/todo') {
87
+ try {
88
+ const { TaskManager } = await import('../tools/tasks.js');
89
+ const tm = new TaskManager(ctx.storage);
90
+ const tasks = tm.list(ctx.agentId, ctx.contactId);
91
+ if (tasks.length === 0) {
92
+ await ctx.reply('📋 No pending tasks! Tell me to add one.');
93
+ } else {
94
+ const list = tasks.map((t, i) => (i + 1) + '. ' + t.task).join('\n');
95
+ await ctx.reply('📋 *Your Tasks*\n\n' + list);
96
+ }
97
+ } catch (err) {
98
+ await ctx.reply('❌ ' + err.message);
99
+ }
100
+ return;
101
+ }
102
+
103
+ if (cmd === '/usage') {
104
+ try {
105
+ const { UsageAlerts } = await import('../features/usage-alerts.js');
106
+ const ua = new UsageAlerts(ctx.storage);
107
+ const s = await ua.getSummary(ctx.agentId);
108
+ const fmtT = (n) => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : String(n);
109
+ await ctx.reply([
110
+ '📊 *Usage Report*', '',
111
+ '*Today:*', ' 💬 ' + s.today.calls + ' msgs · 🪙 ' + fmtT(s.today.tokens) + ' tokens · 💰 $' + s.today.cost, '',
112
+ '*30 days:*', ' 💬 ' + s.month.calls + ' msgs · 🪙 ' + fmtT(s.month.tokens) + ' tokens · 💰 $' + s.month.cost,
113
+ ].join('\n'));
114
+ } catch (err) {
115
+ await ctx.reply('❌ ' + err.message);
116
+ }
117
+ return;
118
+ }
119
+
120
+ await next();
121
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Process media: voice → transcribe, image → analyze, document → ingest
3
+ */
4
+ import { logger } from '../core/logger.js';
5
+
6
+ export async function mediaMiddleware(ctx, next) {
7
+ if (!ctx.metadata._ctx || !ctx.metadata.mediaType) {
8
+ await next();
9
+ return;
10
+ }
11
+
12
+ const token = ctx.config.channels?.telegram?.token;
13
+
14
+ try {
15
+ if (ctx.metadata.mediaType === 'audio') {
16
+ await _transcribeVoice(ctx, token);
17
+ } else if (ctx.metadata.mediaType === 'image') {
18
+ await _analyzeImage(ctx, token);
19
+ } else if (ctx.metadata.mediaType === 'document') {
20
+ await _ingestDocument(ctx, token);
21
+ if (ctx.handled) return;
22
+ }
23
+ } catch (err) {
24
+ logger.error('media', 'Processing error: ' + err.message);
25
+ }
26
+
27
+ await next();
28
+ }
29
+
30
+ async function _transcribeVoice(ctx, token) {
31
+ const file = await ctx.metadata._ctx.getFile();
32
+ const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
33
+ const buffer = Buffer.from(await (await fetch(url)).arrayBuffer());
34
+
35
+ const groqKey = ctx.config.ai?.providers?.groq?.key;
36
+ const openaiKey = ctx.config.ai?.providers?.openai?.key;
37
+ const apiKey = groqKey || openaiKey;
38
+ if (!apiKey) return;
39
+
40
+ const apiUrl = groqKey ? 'https://api.groq.com/openai/v1/audio/transcriptions' : 'https://api.openai.com/v1/audio/transcriptions';
41
+ const model = groqKey ? 'whisper-large-v3' : 'whisper-1';
42
+
43
+ const form = new FormData();
44
+ form.append('file', new Blob([buffer], { type: 'audio/ogg' }), 'voice.ogg');
45
+ form.append('model', model);
46
+
47
+ const res = await fetch(apiUrl, { method: 'POST', headers: { 'Authorization': 'Bearer ' + apiKey }, body: form });
48
+ const data = await res.json();
49
+ if (data.text) {
50
+ ctx.message = '[Voice note]: "' + data.text + '"';
51
+ ctx.metadata.originalType = 'voice';
52
+ logger.info('media', 'Transcribed: ' + data.text.slice(0, 50));
53
+ }
54
+ }
55
+
56
+ async function _analyzeImage(ctx, token) {
57
+ const photos = ctx.metadata._ctx.message?.photo;
58
+ if (!photos?.length) return;
59
+
60
+ const photo = photos[photos.length - 1];
61
+ const file = await ctx.metadata._ctx.api.getFile(photo.file_id);
62
+ const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
63
+ const buffer = Buffer.from(await (await fetch(url)).arrayBuffer());
64
+ const base64 = buffer.toString('base64');
65
+
66
+ const anthropicKey = ctx.config.ai?.providers?.anthropic?.key;
67
+ if (!anthropicKey) return;
68
+
69
+ const caption = ctx.message.replace('[📸 Image]', '').trim();
70
+ const prompt = caption || 'What is in this image? Be concise.';
71
+
72
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
73
+ method: 'POST',
74
+ headers: { 'x-api-key': anthropicKey, 'content-type': 'application/json', 'anthropic-version': '2023-06-01' },
75
+ body: JSON.stringify({
76
+ model: 'claude-sonnet-4-20250514',
77
+ max_tokens: 300,
78
+ messages: [{ role: 'user', content: [
79
+ { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: base64 } },
80
+ { type: 'text', text: prompt },
81
+ ]}],
82
+ }),
83
+ });
84
+ const data = await res.json();
85
+ const analysis = data.content?.[0]?.text;
86
+ if (analysis) {
87
+ ctx.message = caption
88
+ ? `[Image: "${caption}"] ${analysis}`
89
+ : `[Image] ${analysis}`;
90
+ logger.info('media', 'Analyzed image: ' + analysis.slice(0, 50));
91
+ }
92
+ }
93
+
94
+ async function _ingestDocument(ctx, token) {
95
+ const file = await ctx.metadata._ctx.getFile();
96
+ const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
97
+ const buffer = Buffer.from(await (await fetch(url)).arrayBuffer());
98
+ const filename = ctx.message.match(/Document: (.+?)\]/)?.[1] || 'file.txt';
99
+
100
+ try {
101
+ const { DocIngester } = await import('../features/doc-ingest.js');
102
+ const ingester = new DocIngester(ctx.storage, ctx.engine.knowledgeBase, ctx.engine.home);
103
+ const result = await ingester.ingest(ctx.agentId, buffer, filename, ctx.metadata.mimeType);
104
+ await ctx.reply(`📄 *Document absorbed!*\n📁 ${filename}\n📊 ${result.chunks} chunks\n📝 ${result.chars} chars\n\nAsk me anything about it! 🦑`);
105
+ } catch (err) {
106
+ ctx.message = `[Document: ${filename}] (Error: ${err.message})`;
107
+ }
108
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * 🦑 Message Pipeline
3
+ * Clean middleware-based message processing
4
+ *
5
+ * Flow: message → [middleware1] → [middleware2] → ... → response
6
+ * Each middleware can: modify context, short-circuit (return response), or pass through
7
+ */
8
+
9
+ import { logger } from '../core/logger.js';
10
+
11
+ export class MessagePipeline {
12
+ constructor() {
13
+ this.middleware = [];
14
+ }
15
+
16
+ /**
17
+ * Add middleware to the pipeline
18
+ * @param {string} name - middleware name for logging
19
+ * @param {Function} fn - async (ctx, next) => void
20
+ */
21
+ use(name, fn) {
22
+ this.middleware.push({ name, fn });
23
+ }
24
+
25
+ /**
26
+ * Process a message through the pipeline
27
+ * @param {object} ctx - message context
28
+ * @returns {object} ctx with response populated
29
+ */
30
+ async process(ctx) {
31
+ let index = 0;
32
+
33
+ const next = async () => {
34
+ if (index >= this.middleware.length) return;
35
+ if (ctx.handled) return; // short-circuited
36
+
37
+ const mw = this.middleware[index++];
38
+ try {
39
+ await mw.fn(ctx, next);
40
+ } catch (err) {
41
+ logger.error('pipeline', `Middleware "${mw.name}" error: ${err.message}`);
42
+ // Don't crash — continue to next middleware
43
+ await next();
44
+ }
45
+ };
46
+
47
+ await next();
48
+ return ctx;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Create a message context object
54
+ */
55
+ export function createContext(engine, agentId, contactId, message, metadata) {
56
+ return {
57
+ // Input
58
+ engine,
59
+ agentId,
60
+ contactId,
61
+ message, // mutable — middleware can modify
62
+ originalMessage: message,
63
+ metadata,
64
+ platform: metadata.platform || 'telegram',
65
+
66
+ // Agent
67
+ agent: engine.agentManager.get(agentId),
68
+ config: engine.config,
69
+ storage: engine.storage,
70
+
71
+ // State (middleware populates these)
72
+ handled: false, // true = stop pipeline, send response
73
+ response: null, // { messages: string[], reaction: string|null, image: object|null, voice: Buffer|null, _reminder: object|null }
74
+ linkContext: null, // fetched URL content
75
+ memoryExtracted: [], // auto-extracted facts
76
+
77
+ // Helpers
78
+ reply: async function(text) {
79
+ this.handled = true;
80
+ this.response = { messages: [text] };
81
+ },
82
+ replyMulti: async function(messages) {
83
+ this.handled = true;
84
+ this.response = { messages };
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Send the response back to the user via the appropriate channel
3
+ */
4
+ import { logger } from '../core/logger.js';
5
+
6
+ export async function responseSenderMiddleware(ctx, next) {
7
+ if (!ctx.response) return;
8
+
9
+ const tm = ctx.engine.telegramManager;
10
+ const wm = ctx.engine.whatsappManager;
11
+ const { agentId, contactId, metadata, response } = ctx;
12
+
13
+ // Handle reminder
14
+ if (response._reminder && ctx.engine.reminders) {
15
+ ctx.engine.reminders.add(agentId, contactId, response._reminder.message, response._reminder.time, ctx.platform, metadata);
16
+ }
17
+
18
+ // Send reaction
19
+ if (response.reaction && metadata.messageId) {
20
+ try {
21
+ if (ctx.platform === 'telegram' && tm) {
22
+ await tm.sendReaction(agentId, contactId, metadata.messageId, response.reaction, metadata);
23
+ }
24
+ } catch {}
25
+ }
26
+
27
+ // No messages to send
28
+ if (!response.messages?.length && !response.image) return;
29
+
30
+ // Send via appropriate channel
31
+ if (ctx.platform === 'telegram' && tm) {
32
+ if (response.image) {
33
+ const photoData = response.image.url ? { url: response.image.url } : { base64: response.image.base64 };
34
+ await tm.sendPhoto(agentId, contactId, photoData, response.messages?.[0] || '', metadata);
35
+ } else if (metadata.originalType === 'voice' && response.messages.length === 1 && response.messages[0].length < 500) {
36
+ try {
37
+ const { VoiceReply } = await import('../features/voice-reply.js');
38
+ const vr = new VoiceReply(ctx.config);
39
+ const lang = /[\u0600-\u06FF]/.test(response.messages[0]) ? 'ar' : 'en';
40
+ const audio = await vr.generate(response.messages[0], { language: lang });
41
+ await tm.sendVoice(agentId, contactId, audio, metadata);
42
+ } catch {
43
+ await tm.sendMessages(agentId, contactId, response.messages, metadata);
44
+ }
45
+ } else {
46
+ await tm.sendMessages(agentId, contactId, response.messages, metadata);
47
+ }
48
+ } else if (wm) {
49
+ for (const msg of response.messages) {
50
+ await wm.sendMessage(agentId, contactId, msg);
51
+ }
52
+ }
53
+
54
+ await next();
55
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Check if user is asking for a skill that needs an API key
3
+ */
4
+ export async function skillCheckMiddleware(ctx, next) {
5
+ const { detectSkillRequest, checkSkillAvailable, getKeyRequestMessage } = await import('../features/self-config.js');
6
+ const skill = detectSkillRequest(ctx.message);
7
+ if (skill) {
8
+ const avail = checkSkillAvailable(skill, ctx.config);
9
+ if (!avail.available) {
10
+ const msg = getKeyRequestMessage(skill);
11
+ if (msg) { await ctx.reply(msg); return; }
12
+ }
13
+ }
14
+ await next();
15
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Show typing indicator while AI processes
3
+ * Wraps the next() call with typing on/off
4
+ */
5
+ export async function typingMiddleware(ctx, next) {
6
+ if (ctx.handled || ctx.platform !== 'telegram') {
7
+ await next();
8
+ return;
9
+ }
10
+
11
+ const chatId = ctx.metadata.chatId || ctx.contactId.replace('tg_', '');
12
+ const botInfo = ctx.engine.telegramManager?.bots?.get(ctx.agentId);
13
+
14
+ let interval;
15
+ if (botInfo?.bot) {
16
+ const sendTyping = () => { try { botInfo.bot.api.sendChatAction(chatId, 'typing').catch(() => {}); } catch {} };
17
+ sendTyping();
18
+ interval = setInterval(sendTyping, 4000);
19
+ }
20
+
21
+ try {
22
+ await next();
23
+ } finally {
24
+ if (interval) clearInterval(interval);
25
+ }
26
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Check spending thresholds and alert
3
+ */
4
+ export async function usageAlertsMiddleware(ctx, next) {
5
+ await next(); // Run after AI responds
6
+
7
+ if (ctx.engine.usageAlerts) {
8
+ try {
9
+ const alert = await ctx.engine.usageAlerts.check(ctx.agentId);
10
+ if (alert.alert) {
11
+ // Append alert to response
12
+ const alertMsg = '⚠️ *Usage Alert* — $' + alert.total + ' spent in 24h';
13
+ if (ctx.response?.messages) {
14
+ ctx.response.messages.push(alertMsg);
15
+ }
16
+ }
17
+ } catch {}
18
+ }
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "🦑 AI agent platform — human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {