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
@@ -0,0 +1,319 @@
1
+ /**
2
+ * 🦑 WhatsApp Manager
3
+ * Multi-tenant WhatsApp session manager using Baileys
4
+ */
5
+
6
+ import { makeWASocket, useMultiFileAuthState, makeCacheableSignalKeyStore, DisconnectReason } from '@whiskeysockets/baileys';
7
+ import { Boom } from '@hapi/boom';
8
+ import { join } from 'path';
9
+ import { mkdirSync } from 'fs';
10
+ import { logger } from '../../core/logger.js';
11
+ import pino from 'pino';
12
+
13
+ const baileyLogger = pino({ level: 'silent' });
14
+
15
+ export class WhatsAppManager {
16
+ constructor(config, agentManager, home) {
17
+ this.config = config;
18
+ this.agentManager = agentManager;
19
+ this.home = home;
20
+ this.sessions = new Map(); // agentId → { socket, status }
21
+ this.onMessage = null; // callback: (agentId, contactId, message, metadata) => void
22
+ }
23
+
24
+ /**
25
+ * Start WhatsApp session for an agent
26
+ */
27
+ async startSession(agentId) {
28
+ if (this.sessions.has(agentId)) {
29
+ const existing = this.sessions.get(agentId);
30
+ if (existing.status === 'connected') {
31
+ logger.info('whatsapp', `Session for agent ${agentId} already connected`);
32
+ return existing;
33
+ }
34
+ }
35
+
36
+ const authDir = join(this.home, 'channels', 'whatsapp', agentId, 'auth');
37
+ mkdirSync(authDir, { recursive: true });
38
+
39
+ const { state, saveCreds } = await useMultiFileAuthState(authDir);
40
+
41
+ const socket = makeWASocket({
42
+ auth: {
43
+ creds: state.creds,
44
+ keys: makeCacheableSignalKeyStore(state.keys, baileyLogger),
45
+ },
46
+ printQRInTerminal: false,
47
+ logger: baileyLogger,
48
+ browser: ['Squidclaw', 'Chrome', '120.0.0'],
49
+ connectTimeoutMs: 30000,
50
+ retryRequestDelayMs: 2000,
51
+ });
52
+
53
+ const session = { socket, status: 'connecting', agentId };
54
+ this.sessions.set(agentId, session);
55
+
56
+ // Handle connection updates
57
+ socket.ev.on('connection.update', (update) => {
58
+ const { connection, lastDisconnect } = update;
59
+
60
+ if (connection === 'open') {
61
+ session.status = 'connected';
62
+ logger.info('whatsapp', `✅ Agent ${agentId} WhatsApp connected`);
63
+ }
64
+
65
+ if (connection === 'close') {
66
+ const statusCode = new Boom(lastDisconnect?.error)?.output?.statusCode;
67
+ session.status = 'disconnected';
68
+
69
+ if (statusCode === DisconnectReason.loggedOut) {
70
+ logger.warn('whatsapp', `Agent ${agentId} logged out — needs re-pairing`);
71
+ this.sessions.delete(agentId);
72
+ } else {
73
+ // Auto-reconnect
74
+ logger.info('whatsapp', `Agent ${agentId} disconnected (${statusCode}), reconnecting...`);
75
+ setTimeout(() => this.startSession(agentId), 5000);
76
+ }
77
+ }
78
+ });
79
+
80
+ // Save credentials on update
81
+ socket.ev.on('creds.update', saveCreds);
82
+
83
+ // Handle incoming messages
84
+ socket.ev.on('messages.upsert', async ({ messages: msgs }) => {
85
+ for (const msg of msgs) {
86
+ if (msg.key.fromMe) continue; // Skip our own messages
87
+ if (!msg.message) continue;
88
+
89
+ const contactId = msg.key.remoteJid;
90
+ if (!contactId || contactId === 'status@broadcast') continue;
91
+
92
+ // Extract message content
93
+ const content = this._extractContent(msg);
94
+ if (!content) continue;
95
+
96
+ const metadata = {
97
+ _rawMessage: msg,
98
+ pushName: msg.pushName,
99
+ messageId: msg.key.id,
100
+ timestamp: msg.messageTimestamp,
101
+ isGroup: contactId.endsWith('@g.us'),
102
+ mediaType: this._getMediaType(msg),
103
+ };
104
+
105
+ logger.info('whatsapp', `📱 ${agentId}: Message from ${msg.pushName || contactId}: ${content.substring(0, 50)}...`);
106
+
107
+ // Route to message handler
108
+ if (this.onMessage) {
109
+ try {
110
+ await this.onMessage(agentId, contactId, content, metadata);
111
+ } catch (err) {
112
+ logger.error('whatsapp', `Error processing message: ${err.message}`);
113
+ }
114
+ }
115
+ }
116
+ });
117
+
118
+ return session;
119
+ }
120
+
121
+ /**
122
+ * Send a message (with split support)
123
+ */
124
+ async sendMessage(agentId, contactId, text) {
125
+ const session = this.sessions.get(agentId);
126
+ if (!session?.socket || session.status !== 'connected') {
127
+ throw new Error(`WhatsApp not connected for agent ${agentId}`);
128
+ }
129
+ await session.socket.sendMessage(contactId, { text });
130
+ }
131
+
132
+ /**
133
+ * Send multiple messages with delays (human-like)
134
+ */
135
+ async sendMessages(agentId, contactId, messages, delayMs = 700) {
136
+ for (let i = 0; i < messages.length; i++) {
137
+ if (i > 0) {
138
+ // Random delay between 500-1000ms
139
+ const delay = delayMs + Math.random() * 500 - 250;
140
+ await new Promise(r => setTimeout(r, Math.max(300, delay)));
141
+ }
142
+ await this.sendMessage(agentId, contactId, messages[i]);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Send a reaction emoji
148
+ */
149
+ async sendReaction(agentId, contactId, messageId, emoji) {
150
+ const session = this.sessions.get(agentId);
151
+ if (!session?.socket || session.status !== 'connected') return;
152
+
153
+ await session.socket.sendMessage(contactId, {
154
+ react: { text: emoji, key: { remoteJid: contactId, id: messageId } },
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Request a pairing code for WhatsApp login
160
+ */
161
+ async requestPairingCode(agentId, phoneNumber) {
162
+ const session = this.sessions.get(agentId);
163
+ if (!session?.socket) {
164
+ // Start a session first
165
+ await this.startSession(agentId);
166
+ await new Promise(r => setTimeout(r, 3000));
167
+ }
168
+
169
+ const sock = this.sessions.get(agentId)?.socket;
170
+ if (!sock) throw new Error('Could not create WhatsApp session');
171
+
172
+ const clean = phoneNumber.replace(/[^0-9]/g, '');
173
+ const code = await sock.requestPairingCode(clean);
174
+ return code;
175
+ }
176
+
177
+ /**
178
+ * Get all session statuses
179
+ */
180
+ getStatuses() {
181
+ const statuses = {};
182
+ for (const [agentId, session] of this.sessions) {
183
+ statuses[agentId] = {
184
+ status: session.status,
185
+ connected: session.status === 'connected',
186
+ };
187
+ }
188
+ return statuses;
189
+ }
190
+
191
+ /**
192
+ * Stop a session
193
+ */
194
+ async stopSession(agentId) {
195
+ const session = this.sessions.get(agentId);
196
+ if (session?.socket) {
197
+ try {
198
+ session.socket.end(undefined);
199
+ } catch {}
200
+ this.sessions.delete(agentId);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Stop all sessions
206
+ */
207
+ async stopAll() {
208
+ for (const agentId of this.sessions.keys()) {
209
+ await this.stopSession(agentId);
210
+ }
211
+ }
212
+
213
+ // ── Private helpers ──
214
+
215
+ _extractContent(msg) {
216
+ const m = msg.message;
217
+ if (!m) return null;
218
+
219
+ // Text message
220
+ if (m.conversation) return m.conversation;
221
+ if (m.extendedTextMessage?.text) return m.extendedTextMessage.text;
222
+
223
+ // Image with caption
224
+ if (m.imageMessage?.caption) return `[📸 Image] ${m.imageMessage.caption}`;
225
+ if (m.imageMessage) return '[📸 Image]';
226
+
227
+ // Video
228
+ if (m.videoMessage?.caption) return `[🎥 Video] ${m.videoMessage.caption}`;
229
+ if (m.videoMessage) return '[🎥 Video]';
230
+
231
+ // Audio/Voice
232
+ if (m.audioMessage) return '[🎤 Voice note]';
233
+
234
+ // Document
235
+ if (m.documentMessage) return `[📄 Document: ${m.documentMessage.fileName || 'file'}]`;
236
+
237
+ // Location
238
+ if (m.locationMessage) {
239
+ return `[📍 Location: ${m.locationMessage.degreesLatitude}, ${m.locationMessage.degreesLongitude}]`;
240
+ }
241
+
242
+ // Contact
243
+ if (m.contactMessage) return `[👤 Contact: ${m.contactMessage.displayName || 'contact'}]`;
244
+
245
+ // Sticker
246
+ if (m.stickerMessage) return '[🎭 Sticker]';
247
+
248
+ return null;
249
+ }
250
+
251
+ _getMediaType(msg) {
252
+ const m = msg.message;
253
+ if (!m) return null;
254
+ if (m.imageMessage) return 'image';
255
+ if (m.videoMessage) return 'video';
256
+ if (m.audioMessage) return 'audio';
257
+ if (m.documentMessage) return 'document';
258
+ if (m.locationMessage) return 'location';
259
+ if (m.contactMessage) return 'contact';
260
+ if (m.stickerMessage) return 'sticker';
261
+ return null;
262
+ }
263
+ }
264
+
265
+ // ── Voice Note Sending ──
266
+ WhatsAppManager.prototype.sendVoiceNote = async function(agentId, contactId, audioBuffer) {
267
+ const session = this.sessions.get(agentId);
268
+ if (!session?.socket || session.status !== 'connected') {
269
+ throw new Error(`WhatsApp not connected for agent ${agentId}`);
270
+ }
271
+ await session.socket.sendMessage(contactId, {
272
+ audio: audioBuffer,
273
+ mimetype: 'audio/mp4',
274
+ ptt: true, // push to talk = voice note
275
+ });
276
+ };
277
+
278
+ // ── Image Sending ──
279
+ WhatsAppManager.prototype.sendImage = async function(agentId, contactId, imageBuffer, caption) {
280
+ const session = this.sessions.get(agentId);
281
+ if (!session?.socket || session.status !== 'connected') {
282
+ throw new Error(`WhatsApp not connected for agent ${agentId}`);
283
+ }
284
+ await session.socket.sendMessage(contactId, {
285
+ image: imageBuffer,
286
+ caption: caption || '',
287
+ });
288
+ };
289
+
290
+ // ── Location Sending ──
291
+ WhatsAppManager.prototype.sendLocation = async function(agentId, contactId, lat, lng, name) {
292
+ const session = this.sessions.get(agentId);
293
+ if (!session?.socket || session.status !== 'connected') return;
294
+ await session.socket.sendMessage(contactId, {
295
+ location: { degreesLatitude: lat, degreesLongitude: lng, name: name || '' },
296
+ });
297
+ };
298
+
299
+ // ── Contact Card Sending ──
300
+ WhatsAppManager.prototype.sendContact = async function(agentId, contactId, vcard) {
301
+ const session = this.sessions.get(agentId);
302
+ if (!session?.socket || session.status !== 'connected') return;
303
+ await session.socket.sendMessage(contactId, {
304
+ contacts: { contacts: [{ vcard }] },
305
+ });
306
+ };
307
+
308
+ // ── Download Media from Message ──
309
+ WhatsAppManager.prototype.downloadMedia = async function(agentId, message) {
310
+ const session = this.sessions.get(agentId);
311
+ if (!session?.socket) return null;
312
+ try {
313
+ const { downloadMediaMessage } = await import('@whiskeysockets/baileys');
314
+ return await downloadMediaMessage(message, 'buffer', {});
315
+ } catch (err) {
316
+ logger.error('whatsapp', `Media download failed: ${err.message}`);
317
+ return null;
318
+ }
319
+ };
@@ -0,0 +1,228 @@
1
+ /**
2
+ * 🦑 WhatsApp Media Handler
3
+ * Voice notes (receive/send), images, documents
4
+ */
5
+
6
+ import { writeFileSync, mkdirSync, readFileSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { logger } from '../../core/logger.js';
9
+
10
+ export class MediaHandler {
11
+ constructor(config, home) {
12
+ this.config = config;
13
+ this.home = home;
14
+ this.mediaDir = join(home, 'media');
15
+ mkdirSync(this.mediaDir, { recursive: true });
16
+ }
17
+
18
+ /**
19
+ * Download media from a WhatsApp message
20
+ */
21
+ async downloadMedia(socket, message) {
22
+ try {
23
+ const { downloadMediaMessage } = await import('@whiskeysockets/baileys');
24
+ const buffer = await downloadMediaMessage(message, 'buffer', {});
25
+ return buffer;
26
+ } catch (err) {
27
+ logger.error('media', `Failed to download media: ${err.message}`);
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Transcribe a voice note using AI provider
34
+ */
35
+ async transcribeAudio(buffer, config) {
36
+ // Try OpenAI Whisper first
37
+ const openaiKey = config.ai?.providers?.openai?.key;
38
+ if (openaiKey) {
39
+ return this._transcribeWhisper(buffer, openaiKey);
40
+ }
41
+
42
+ // Try Groq Whisper
43
+ const groqKey = config.ai?.providers?.groq?.key;
44
+ if (groqKey) {
45
+ return this._transcribeGroqWhisper(buffer, groqKey);
46
+ }
47
+
48
+ logger.warn('media', 'No transcription provider available (need OpenAI or Groq key)');
49
+ return null;
50
+ }
51
+
52
+ async _transcribeWhisper(buffer, apiKey) {
53
+ const formData = new FormData();
54
+ const blob = new Blob([buffer], { type: 'audio/ogg' });
55
+ formData.append('file', blob, 'audio.ogg');
56
+ formData.append('model', 'whisper-1');
57
+
58
+ const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
59
+ method: 'POST',
60
+ headers: { 'Authorization': `Bearer ${apiKey}` },
61
+ body: formData,
62
+ });
63
+
64
+ if (!res.ok) throw new Error(`Whisper API error: ${res.status}`);
65
+ const data = await res.json();
66
+ return data.text;
67
+ }
68
+
69
+ async _transcribeGroqWhisper(buffer, apiKey) {
70
+ const formData = new FormData();
71
+ const blob = new Blob([buffer], { type: 'audio/ogg' });
72
+ formData.append('file', blob, 'audio.ogg');
73
+ formData.append('model', 'whisper-large-v3');
74
+
75
+ const res = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
76
+ method: 'POST',
77
+ headers: { 'Authorization': `Bearer ${apiKey}` },
78
+ body: formData,
79
+ });
80
+
81
+ if (!res.ok) throw new Error(`Groq Whisper error: ${res.status}`);
82
+ const data = await res.json();
83
+ return data.text;
84
+ }
85
+
86
+ /**
87
+ * Generate voice note from text using TTS
88
+ */
89
+ async textToSpeech(text, voiceConfig = {}) {
90
+ const provider = voiceConfig.provider || 'edge-tts';
91
+
92
+ if (provider === 'edge-tts') {
93
+ return this._edgeTTS(text, voiceConfig.name || 'en-US-AriaNeural');
94
+ }
95
+
96
+ if (provider === 'openai') {
97
+ return this._openaiTTS(text, voiceConfig);
98
+ }
99
+
100
+ return null;
101
+ }
102
+
103
+ async _edgeTTS(text, voiceName) {
104
+ try {
105
+ const { MsEdgeTTS } = await import('node-edge-tts');
106
+ const tts = new MsEdgeTTS();
107
+ await tts.setMetadata(voiceName, 'audio-24khz-96kbitrate-mono-mp3');
108
+ const readable = tts.toStream(text);
109
+
110
+ const chunks = [];
111
+ for await (const chunk of readable) {
112
+ chunks.push(chunk);
113
+ }
114
+ return Buffer.concat(chunks);
115
+ } catch (err) {
116
+ logger.error('media', `Edge TTS failed: ${err.message}`);
117
+ return null;
118
+ }
119
+ }
120
+
121
+ async _openaiTTS(text, voiceConfig) {
122
+ const apiKey = this.config.ai?.providers?.openai?.key;
123
+ if (!apiKey) return null;
124
+
125
+ const res = await fetch('https://api.openai.com/v1/audio/speech', {
126
+ method: 'POST',
127
+ headers: {
128
+ 'Authorization': `Bearer ${apiKey}`,
129
+ 'Content-Type': 'application/json',
130
+ },
131
+ body: JSON.stringify({
132
+ model: 'tts-1',
133
+ input: text,
134
+ voice: voiceConfig.voice || 'nova',
135
+ response_format: 'opus',
136
+ }),
137
+ });
138
+
139
+ if (!res.ok) throw new Error(`OpenAI TTS error: ${res.status}`);
140
+ return Buffer.from(await res.arrayBuffer());
141
+ }
142
+
143
+ /**
144
+ * Analyze an image using vision API
145
+ */
146
+ async analyzeImage(buffer, prompt, config) {
147
+ // Try Anthropic Vision
148
+ const anthropicKey = config.ai?.providers?.anthropic?.key;
149
+ if (anthropicKey) {
150
+ return this._anthropicVision(buffer, prompt, anthropicKey, config);
151
+ }
152
+
153
+ // Try OpenAI Vision
154
+ const openaiKey = config.ai?.providers?.openai?.key;
155
+ if (openaiKey) {
156
+ return this._openaiVision(buffer, prompt, openaiKey);
157
+ }
158
+
159
+ return 'I can see you sent an image, but I don\'t have image analysis configured right now.';
160
+ }
161
+
162
+ async _anthropicVision(buffer, prompt, apiKey, config) {
163
+ const base64 = buffer.toString('base64');
164
+ const isOAuth = apiKey.includes('sk-ant-oat');
165
+
166
+ const headers = {
167
+ 'content-type': 'application/json',
168
+ 'anthropic-version': '2023-06-01',
169
+ };
170
+
171
+ if (isOAuth) {
172
+ headers['authorization'] = `Bearer ${apiKey}`;
173
+ headers['anthropic-beta'] = 'claude-code-20250219,oauth-2025-04-20';
174
+ headers['user-agent'] = 'squidclaw/0.1.0';
175
+ headers['x-app'] = 'squidclaw';
176
+ headers['anthropic-dangerous-direct-browser-access'] = 'true';
177
+ } else {
178
+ headers['x-api-key'] = apiKey;
179
+ }
180
+
181
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
182
+ method: 'POST',
183
+ headers,
184
+ body: JSON.stringify({
185
+ model: 'claude-sonnet-4-20250514',
186
+ max_tokens: 1024,
187
+ messages: [{
188
+ role: 'user',
189
+ content: [
190
+ { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: base64 } },
191
+ { type: 'text', text: prompt || 'Describe this image briefly.' },
192
+ ],
193
+ }],
194
+ }),
195
+ });
196
+
197
+ if (!res.ok) throw new Error(`Vision API error: ${res.status}`);
198
+ const data = await res.json();
199
+ return data.content?.[0]?.text || 'Could not analyze image';
200
+ }
201
+
202
+ async _openaiVision(buffer, prompt, apiKey) {
203
+ const base64 = buffer.toString('base64');
204
+
205
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
206
+ method: 'POST',
207
+ headers: {
208
+ 'content-type': 'application/json',
209
+ 'authorization': `Bearer ${apiKey}`,
210
+ },
211
+ body: JSON.stringify({
212
+ model: 'gpt-4o',
213
+ max_tokens: 1024,
214
+ messages: [{
215
+ role: 'user',
216
+ content: [
217
+ { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${base64}` } },
218
+ { type: 'text', text: prompt || 'Describe this image briefly.' },
219
+ ],
220
+ }],
221
+ }),
222
+ });
223
+
224
+ if (!res.ok) throw new Error(`Vision API error: ${res.status}`);
225
+ const data = await res.json();
226
+ return data.choices?.[0]?.message?.content || 'Could not analyze image';
227
+ }
228
+ }