squidclaw 1.1.0 → 1.3.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.
@@ -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
+ }