squidclaw 0.1.0 → 0.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.
@@ -147,3 +147,10 @@ You are ${agent.name || 'an AI assistant'}.
147
147
  }
148
148
  }
149
149
  }
150
+
151
+ /**
152
+ * Add tool descriptions to an existing system prompt
153
+ */
154
+ PromptBuilder.prototype.addToolContext = function(systemPrompt, toolDescriptions) {
155
+ return systemPrompt + '\n\n' + toolDescriptions;
156
+ };
package/lib/api/server.js CHANGED
@@ -233,3 +233,41 @@ export function createAPIServer(engine) {
233
233
 
234
234
  return app;
235
235
  }
236
+
237
+ // ── Knowledge Upload (enhanced) ──
238
+ export function addKnowledgeRoutes(app, engine) {
239
+ app.post('/api/agents/:id/knowledge', async (req, res) => {
240
+ const { filename, fileType, content } = req.body;
241
+ if (!content) return res.status(400).json({ error: 'content required' });
242
+
243
+ try {
244
+ const result = await engine.knowledgeBase.ingest(req.params.id, filename || 'upload.' + (fileType || 'txt'), content);
245
+ res.json({ status: 'ingested', docId: result.docId, chunks: result.chunks });
246
+ } catch (err) {
247
+ res.status(500).json({ error: err.message });
248
+ }
249
+ });
250
+
251
+ app.post('/api/agents/:id/knowledge/url', async (req, res) => {
252
+ const { url } = req.body;
253
+ if (!url) return res.status(400).json({ error: 'url required' });
254
+
255
+ try {
256
+ const result = await engine.knowledgeBase.ingestURL(req.params.id, url);
257
+ res.json({ status: 'ingested', docId: result.docId, chunks: result.chunks });
258
+ } catch (err) {
259
+ res.status(500).json({ error: err.message });
260
+ }
261
+ });
262
+
263
+ // Tool test endpoint
264
+ app.post('/api/tools/search', async (req, res) => {
265
+ const { query } = req.body;
266
+ try {
267
+ const results = await engine.toolRouter.browser.search(query);
268
+ res.json(results);
269
+ } catch (err) {
270
+ res.status(500).json({ error: err.message });
271
+ }
272
+ });
273
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * 🦑 Telegram Bot Channel
3
+ * Multi-agent Telegram bot support
4
+ */
5
+
6
+ import { logger } from '../../core/logger.js';
7
+
8
+ export class TelegramManager {
9
+ constructor(config, agentManager) {
10
+ this.config = config;
11
+ this.agentManager = agentManager;
12
+ this.bots = new Map(); // agentId → { bot, status }
13
+ this.onMessage = null;
14
+ }
15
+
16
+ /**
17
+ * Start a Telegram bot for an agent
18
+ */
19
+ async startBot(agentId, botToken) {
20
+ if (this.bots.has(agentId)) {
21
+ const existing = this.bots.get(agentId);
22
+ if (existing.status === 'running') return existing;
23
+ }
24
+
25
+ try {
26
+ // Dynamic import grammy
27
+ const { Bot } = await import('grammy');
28
+ const bot = new Bot(botToken);
29
+
30
+ const botInfo = { agentId, bot, status: 'starting' };
31
+ this.bots.set(agentId, botInfo);
32
+
33
+ // Handle text messages
34
+ bot.on('message:text', async (ctx) => {
35
+ const contactId = `tg_${ctx.from.id}`;
36
+ const message = ctx.message.text;
37
+ const metadata = {
38
+ pushName: ctx.from.first_name || ctx.from.username || 'User',
39
+ messageId: ctx.message.message_id.toString(),
40
+ isGroup: ctx.chat.type !== 'private',
41
+ platform: 'telegram',
42
+ chatId: ctx.chat.id,
43
+ _ctx: ctx,
44
+ };
45
+
46
+ if (this.onMessage) {
47
+ await this.onMessage(agentId, contactId, message, metadata);
48
+ }
49
+ });
50
+
51
+ // Handle voice messages
52
+ bot.on('message:voice', async (ctx) => {
53
+ const contactId = `tg_${ctx.from.id}`;
54
+ const metadata = {
55
+ pushName: ctx.from.first_name,
56
+ messageId: ctx.message.message_id.toString(),
57
+ mediaType: 'audio',
58
+ platform: 'telegram',
59
+ _ctx: ctx,
60
+ };
61
+
62
+ if (this.onMessage) {
63
+ await this.onMessage(agentId, contactId, '[🎤 Voice note]', metadata);
64
+ }
65
+ });
66
+
67
+ // Handle photos
68
+ bot.on('message:photo', async (ctx) => {
69
+ const contactId = `tg_${ctx.from.id}`;
70
+ const caption = ctx.message.caption || '';
71
+ const metadata = {
72
+ pushName: ctx.from.first_name,
73
+ messageId: ctx.message.message_id.toString(),
74
+ mediaType: 'image',
75
+ platform: 'telegram',
76
+ _ctx: ctx,
77
+ };
78
+
79
+ if (this.onMessage) {
80
+ await this.onMessage(agentId, contactId, `[📸 Image] ${caption}`, metadata);
81
+ }
82
+ });
83
+
84
+ // Handle documents
85
+ bot.on('message:document', async (ctx) => {
86
+ const contactId = `tg_${ctx.from.id}`;
87
+ const filename = ctx.message.document.file_name || 'file';
88
+ const metadata = {
89
+ pushName: ctx.from.first_name,
90
+ messageId: ctx.message.message_id.toString(),
91
+ mediaType: 'document',
92
+ platform: 'telegram',
93
+ _ctx: ctx,
94
+ };
95
+
96
+ if (this.onMessage) {
97
+ await this.onMessage(agentId, contactId, `[📄 Document: ${filename}]`, metadata);
98
+ }
99
+ });
100
+
101
+ // Start polling
102
+ bot.start({
103
+ onStart: () => {
104
+ botInfo.status = 'running';
105
+ logger.info('telegram', `✅ Agent ${agentId} Telegram bot started`);
106
+ },
107
+ });
108
+
109
+ bot.catch((err) => {
110
+ logger.error('telegram', `Bot error for ${agentId}: ${err.message}`);
111
+ });
112
+
113
+ return botInfo;
114
+ } catch (err) {
115
+ logger.error('telegram', `Failed to start bot for ${agentId}: ${err.message}`);
116
+ throw err;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Send a text message
122
+ */
123
+ async sendMessage(agentId, contactId, text, metadata = {}) {
124
+ const botInfo = this.bots.get(agentId);
125
+ if (!botInfo?.bot || botInfo.status !== 'running') {
126
+ throw new Error(`Telegram bot not running for agent ${agentId}`);
127
+ }
128
+
129
+ // Extract chat ID from contactId or metadata
130
+ const chatId = metadata.chatId || contactId.replace('tg_', '');
131
+ await botInfo.bot.api.sendMessage(chatId, text);
132
+ }
133
+
134
+ /**
135
+ * Send multiple messages with delays
136
+ */
137
+ async sendMessages(agentId, contactId, messages, metadata = {}, delayMs = 700) {
138
+ const chatId = metadata.chatId || contactId.replace('tg_', '');
139
+ const botInfo = this.bots.get(agentId);
140
+ if (!botInfo?.bot) return;
141
+
142
+ for (let i = 0; i < messages.length; i++) {
143
+ if (i > 0) {
144
+ // Show typing indicator
145
+ try { await botInfo.bot.api.sendChatAction(chatId, 'typing'); } catch {}
146
+ await new Promise(r => setTimeout(r, delayMs + Math.random() * 500 - 250));
147
+ }
148
+ await botInfo.bot.api.sendMessage(chatId, messages[i]);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Send a reaction
154
+ */
155
+ async sendReaction(agentId, contactId, messageId, emoji, metadata = {}) {
156
+ const botInfo = this.bots.get(agentId);
157
+ if (!botInfo?.bot) return;
158
+ const chatId = metadata.chatId || contactId.replace('tg_', '');
159
+ try {
160
+ await botInfo.bot.api.setMessageReaction(chatId, parseInt(messageId), [{ type: 'emoji', emoji }]);
161
+ } catch {} // Reactions might not be supported in all chats
162
+ }
163
+
164
+ /**
165
+ * Send voice note
166
+ */
167
+ async sendVoiceNote(agentId, contactId, audioBuffer, metadata = {}) {
168
+ const botInfo = this.bots.get(agentId);
169
+ if (!botInfo?.bot) return;
170
+ const chatId = metadata.chatId || contactId.replace('tg_', '');
171
+ const { InputFile } = await import('grammy');
172
+ await botInfo.bot.api.sendVoice(chatId, new InputFile(audioBuffer, 'voice.ogg'));
173
+ }
174
+
175
+ getStatuses() {
176
+ const statuses = {};
177
+ for (const [agentId, info] of this.bots) {
178
+ statuses[agentId] = { status: info.status, connected: info.status === 'running' };
179
+ }
180
+ return statuses;
181
+ }
182
+
183
+ async stopBot(agentId) {
184
+ const info = this.bots.get(agentId);
185
+ if (info?.bot) {
186
+ try { await info.bot.stop(); } catch {}
187
+ this.bots.delete(agentId);
188
+ }
189
+ }
190
+
191
+ async stopAll() {
192
+ for (const agentId of this.bots.keys()) {
193
+ await this.stopBot(agentId);
194
+ }
195
+ }
196
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 🦑 Agent Tools Mixin
3
+ * Enhances Agent with tool usage and knowledge base search
4
+ */
5
+
6
+ import { logger } from './logger.js';
7
+
8
+ /**
9
+ * Add tool support to an agent
10
+ */
11
+ export function addToolSupport(agent, toolRouter, knowledgeBase) {
12
+ const originalProcessMessage = agent.processMessage.bind(agent);
13
+
14
+ agent.processMessage = async function(contactId, message, metadata = {}) {
15
+ // First, search knowledge base for relevant context
16
+ if (knowledgeBase) {
17
+ try {
18
+ const relevantChunks = await knowledgeBase.search(agent.id, message, 3);
19
+ if (relevantChunks.length > 0) {
20
+ metadata._knowledgeContext = relevantChunks.map(c => c.content).join('\n\n');
21
+ }
22
+ } catch (err) {
23
+ logger.warn('agent', `Knowledge search failed: ${err.message}`);
24
+ }
25
+ }
26
+
27
+ // Inject tool descriptions and knowledge into the prompt builder
28
+ const origBuild = agent.promptBuilder.build.bind(agent.promptBuilder);
29
+ agent.promptBuilder.build = async function(agentObj, cId, msg) {
30
+ let prompt = await origBuild(agentObj, cId, msg);
31
+
32
+ // Add knowledge context
33
+ if (metadata._knowledgeContext) {
34
+ prompt += '\n\n## Relevant Knowledge\nUse this information to answer:\n\n' + metadata._knowledgeContext;
35
+ }
36
+
37
+ // Add tool descriptions
38
+ if (toolRouter) {
39
+ prompt += '\n\n' + toolRouter.getToolDescriptions();
40
+ }
41
+
42
+ return prompt;
43
+ };
44
+
45
+ // Call original process
46
+ const result = await originalProcessMessage(contactId, message, metadata);
47
+
48
+ // Restore original prompt builder
49
+ agent.promptBuilder.build = origBuild;
50
+
51
+ // Check if agent wants to use a tool
52
+ if (toolRouter && result.messages.length > 0) {
53
+ const fullResponse = result.messages.join('\n');
54
+ const toolResult = await toolRouter.processResponse(fullResponse, agent.id);
55
+
56
+ if (toolResult.toolUsed && toolResult.toolResult) {
57
+ // Agent used a tool — now call AI again with the tool result
58
+ logger.info('agent', `Tool ${toolResult.toolName} returned, calling AI again...`);
59
+
60
+ // Save the tool interaction
61
+ await agent.storage.saveMessage(agent.id, contactId, 'assistant', `[Using ${toolResult.toolName}...]`);
62
+ await agent.storage.saveMessage(agent.id, contactId, 'system', `Tool result: ${toolResult.toolResult}`);
63
+
64
+ // Call AI again with tool result context
65
+ const history = await agent.storage.getConversation(agent.id, contactId, 50);
66
+ let systemPrompt = await origBuild(agent, contactId, message);
67
+ if (metadata._knowledgeContext) {
68
+ systemPrompt += '\n\n## Relevant Knowledge\n' + metadata._knowledgeContext;
69
+ }
70
+
71
+ const messages = [
72
+ { role: 'system', content: systemPrompt },
73
+ ...history.map(h => ({ role: h.role === 'system' ? 'user' : h.role, content: h.content })),
74
+ { role: 'user', content: `[Tool result for ${toolResult.toolName}]:\n${toolResult.toolResult}\n\nNow respond to the user based on this information. Be natural, don't mention "tool" or "search results".` },
75
+ ];
76
+
77
+ try {
78
+ const aiResponse = await agent.aiGateway.chat(messages, {
79
+ model: agent.model,
80
+ fallbackChain: agent.fallbackChain,
81
+ });
82
+
83
+ await agent.storage.trackUsage(agent.id, aiResponse.model, aiResponse.inputTokens, aiResponse.outputTokens, aiResponse.costUsd);
84
+
85
+ const processed = agent.behaviorEngine.process(aiResponse.content);
86
+ const fullReply = processed.messages.join('\n');
87
+ if (fullReply) await agent.storage.saveMessage(agent.id, contactId, 'assistant', fullReply);
88
+
89
+ return {
90
+ messages: processed.messages,
91
+ reaction: processed.reaction || result.reaction,
92
+ handoff: processed.handoff || result.handoff,
93
+ usage: {
94
+ inputTokens: (result.usage?.inputTokens || 0) + aiResponse.inputTokens,
95
+ outputTokens: (result.usage?.outputTokens || 0) + aiResponse.outputTokens,
96
+ cost: (result.usage?.cost || 0) + aiResponse.costUsd,
97
+ },
98
+ };
99
+ } catch (err) {
100
+ logger.error('agent', `Tool follow-up AI call failed: ${err.message}`);
101
+ // Return the clean response without tool tag
102
+ return { ...result, messages: toolResult.cleanResponse ? [toolResult.cleanResponse] : result.messages };
103
+ }
104
+ }
105
+ }
106
+
107
+ return result;
108
+ };
109
+ }
package/lib/engine.js CHANGED
@@ -1,5 +1,9 @@
1
1
  /**
2
2
  * 🦑 Squidclaw Engine
3
+ import { KnowledgeBase } from './memory/knowledge.js';
4
+ import { ToolRouter } from './tools/router.js';
5
+ import { TelegramManager } from './channels/telegram/bot.js';
6
+ import { addToolSupport } from './core/agent-tools-mixin.js';
3
7
  * Main entry point — starts all subsystems
4
8
  */
5
9
 
@@ -57,6 +61,21 @@ export class SquidclawEngine {
57
61
  const agents = this.agentManager.getAll();
58
62
  console.log(` 👥 Agents: ${agents.length} loaded`);
59
63
 
64
+ // 3b. Knowledge Base
65
+ this.knowledgeBase = new KnowledgeBase(this.storage, this.config);
66
+ console.log(` 📚 Knowledge Base: ready`);
67
+
68
+ // 3c. Tool Router
69
+ this.toolRouter = new ToolRouter(this.config, this.knowledgeBase);
70
+ const toolList = ["web search", "page reader"];
71
+ if (this.config.tools?.google?.oauthToken) toolList.push("calendar", "email");
72
+ console.log(` 🔧 Tools: ${toolList.join(", ")}`);
73
+
74
+ // 3d. Add tool support to all agents
75
+ for (const agent of agents) {
76
+ addToolSupport(agent, this.toolRouter, this.knowledgeBase);
77
+ }
78
+
60
79
  // 4. WhatsApp Manager
61
80
  this.whatsappManager = new WhatsAppManager(this.config, this.agentManager, this.home);
62
81
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 🦑 Embeddings Engine
3
+ * Generate vector embeddings for knowledge base search
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+
8
+ export class EmbeddingsEngine {
9
+ constructor(config) {
10
+ this.config = config;
11
+ }
12
+
13
+ /**
14
+ * Generate embedding for a text chunk
15
+ * Tries providers in order: OpenAI → Google → Ollama → fallback to null
16
+ */
17
+ async embed(text) {
18
+ const openaiKey = this.config.ai?.providers?.openai?.key;
19
+ if (openaiKey) return this._openaiEmbed(text, openaiKey);
20
+
21
+ const googleKey = this.config.ai?.providers?.google?.key;
22
+ if (googleKey) return this._googleEmbed(text, googleKey);
23
+
24
+ // No embedding provider — use keyword search fallback
25
+ return null;
26
+ }
27
+
28
+ async embedBatch(texts) {
29
+ const results = [];
30
+ for (const text of texts) {
31
+ results.push(await this.embed(text));
32
+ }
33
+ return results;
34
+ }
35
+
36
+ async _openaiEmbed(text, apiKey) {
37
+ const res = await fetch('https://api.openai.com/v1/embeddings', {
38
+ method: 'POST',
39
+ headers: { 'content-type': 'application/json', 'authorization': `Bearer ${apiKey}` },
40
+ body: JSON.stringify({ model: 'text-embedding-3-small', input: text }),
41
+ });
42
+ if (!res.ok) throw new Error(`OpenAI embedding error: ${res.status}`);
43
+ const data = await res.json();
44
+ return new Float32Array(data.data[0].embedding);
45
+ }
46
+
47
+ async _googleEmbed(text, apiKey) {
48
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=${apiKey}`, {
49
+ method: 'POST',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify({ content: { parts: [{ text }] } }),
52
+ });
53
+ if (!res.ok) throw new Error(`Google embedding error: ${res.status}`);
54
+ const data = await res.json();
55
+ return new Float32Array(data.embedding.values);
56
+ }
57
+
58
+ /**
59
+ * Cosine similarity between two vectors
60
+ */
61
+ static cosineSimilarity(a, b) {
62
+ if (!a || !b) return 0;
63
+ let dot = 0, normA = 0, normB = 0;
64
+ for (let i = 0; i < a.length; i++) {
65
+ dot += a[i] * b[i];
66
+ normA += a[i] * a[i];
67
+ normB += b[i] * b[i];
68
+ }
69
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
70
+ }
71
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * 🦑 Knowledge Base
3
+ * Upload, chunk, embed, and search documents
4
+ */
5
+
6
+ import { readFileSync } from 'fs';
7
+ import { extname } from 'path';
8
+ import { logger } from '../core/logger.js';
9
+ import { EmbeddingsEngine } from './embeddings.js';
10
+
11
+ export class KnowledgeBase {
12
+ constructor(storage, config) {
13
+ this.storage = storage;
14
+ this.config = config;
15
+ this.embeddings = new EmbeddingsEngine(config);
16
+ }
17
+
18
+ /**
19
+ * Ingest a document — chunk it, embed it, store it
20
+ */
21
+ async ingest(agentId, filePath, content = null) {
22
+ const ext = extname(filePath).toLowerCase();
23
+ const filename = filePath.split('/').pop();
24
+
25
+ // Read content if not provided
26
+ if (!content) {
27
+ content = readFileSync(filePath, 'utf8');
28
+ }
29
+
30
+ // Parse based on file type
31
+ let text = content;
32
+ if (ext === '.pdf') {
33
+ text = await this._parsePDF(filePath);
34
+ } else if (ext === '.html' || ext === '.htm') {
35
+ text = this._parseHTML(content);
36
+ }
37
+ // .txt, .md, .csv — use content as-is
38
+
39
+ // Chunk the text
40
+ const chunks = this._chunk(text);
41
+ logger.info('knowledge', `Chunked "${filename}" into ${chunks.length} chunks`);
42
+
43
+ // Save document record
44
+ const docId = await this.storage.saveDocument(agentId, {
45
+ filename,
46
+ fileType: ext.slice(1),
47
+ fileSize: Buffer.byteLength(content, 'utf8'),
48
+ });
49
+
50
+ // Generate embeddings and save chunks
51
+ const chunkData = [];
52
+ for (const chunk of chunks) {
53
+ let embedding = null;
54
+ try {
55
+ embedding = await this.embeddings.embed(chunk);
56
+ } catch (err) {
57
+ logger.warn('knowledge', `Embedding failed for chunk: ${err.message}`);
58
+ }
59
+ chunkData.push({
60
+ content: chunk,
61
+ embedding: embedding ? Buffer.from(embedding.buffer) : null,
62
+ });
63
+ }
64
+
65
+ await this.storage.saveChunks(docId, agentId, chunkData);
66
+ logger.info('knowledge', `✅ "${filename}" ingested — ${chunks.length} chunks`);
67
+
68
+ return { docId, chunks: chunks.length };
69
+ }
70
+
71
+ /**
72
+ * Ingest from URL
73
+ */
74
+ async ingestURL(agentId, url) {
75
+ try {
76
+ const res = await fetch(url);
77
+ const html = await res.text();
78
+ const text = this._parseHTML(html);
79
+ const filename = new URL(url).hostname + new URL(url).pathname.replace(/\//g, '_');
80
+
81
+ const docId = await this.storage.saveDocument(agentId, {
82
+ filename: filename.slice(0, 100),
83
+ fileType: 'url',
84
+ fileSize: Buffer.byteLength(text, 'utf8'),
85
+ });
86
+
87
+ const chunks = this._chunk(text);
88
+ const chunkData = [];
89
+ for (const chunk of chunks) {
90
+ let embedding = null;
91
+ try { embedding = await this.embeddings.embed(chunk); } catch {}
92
+ chunkData.push({ content: chunk, embedding: embedding ? Buffer.from(embedding.buffer) : null });
93
+ }
94
+
95
+ await this.storage.saveChunks(docId, agentId, chunkData);
96
+ return { docId, chunks: chunks.length };
97
+ } catch (err) {
98
+ throw new Error(`Failed to ingest URL: ${err.message}`);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Search knowledge base for relevant chunks
104
+ */
105
+ async search(agentId, query, limit = 5) {
106
+ // Try vector search first
107
+ let queryEmbedding = null;
108
+ try {
109
+ queryEmbedding = await this.embeddings.embed(query);
110
+ } catch {}
111
+
112
+ if (queryEmbedding) {
113
+ return this._vectorSearch(agentId, queryEmbedding, limit);
114
+ }
115
+
116
+ // Fallback to keyword search
117
+ return this._keywordSearch(agentId, query, limit);
118
+ }
119
+
120
+ async _vectorSearch(agentId, queryEmbedding, limit) {
121
+ // Get all chunks for this agent and compute similarity
122
+ const allChunks = await this.storage.searchKnowledge(agentId, null, 1000);
123
+ const scored = [];
124
+
125
+ for (const chunk of allChunks) {
126
+ if (chunk.embedding) {
127
+ const chunkEmb = new Float32Array(chunk.embedding.buffer || chunk.embedding);
128
+ const score = EmbeddingsEngine.cosineSimilarity(queryEmbedding, chunkEmb);
129
+ scored.push({ content: chunk.content, score });
130
+ } else {
131
+ scored.push({ content: chunk.content, score: 0 });
132
+ }
133
+ }
134
+
135
+ scored.sort((a, b) => b.score - a.score);
136
+ return scored.slice(0, limit).filter(s => s.score > 0.3);
137
+ }
138
+
139
+ async _keywordSearch(agentId, query, limit) {
140
+ // Simple keyword matching
141
+ const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 2);
142
+ const allChunks = await this.storage.searchKnowledge(agentId, null, 1000);
143
+ const scored = [];
144
+
145
+ for (const chunk of allChunks) {
146
+ const lower = chunk.content.toLowerCase();
147
+ let score = 0;
148
+ for (const word of words) {
149
+ if (lower.includes(word)) score++;
150
+ }
151
+ if (score > 0) scored.push({ content: chunk.content, score: score / words.length });
152
+ }
153
+
154
+ scored.sort((a, b) => b.score - a.score);
155
+ return scored.slice(0, limit);
156
+ }
157
+
158
+ /**
159
+ * Chunk text into ~500 char segments with overlap
160
+ */
161
+ _chunk(text, chunkSize = 500, overlap = 50) {
162
+ const chunks = [];
163
+ const paragraphs = text.split(/\n\n+/);
164
+ let current = '';
165
+
166
+ for (const para of paragraphs) {
167
+ if ((current + '\n\n' + para).length > chunkSize && current) {
168
+ chunks.push(current.trim());
169
+ // Keep overlap
170
+ const words = current.split(' ');
171
+ current = words.slice(-Math.floor(overlap / 5)).join(' ') + '\n\n' + para;
172
+ } else {
173
+ current = current ? current + '\n\n' + para : para;
174
+ }
175
+ }
176
+ if (current.trim()) chunks.push(current.trim());
177
+
178
+ return chunks.length > 0 ? chunks : [text.trim()];
179
+ }
180
+
181
+ async _parsePDF(filePath) {
182
+ try {
183
+ const pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs');
184
+ const data = new Uint8Array(readFileSync(filePath));
185
+ const doc = await pdfjsLib.getDocument({ data }).promise;
186
+ const pages = [];
187
+ for (let i = 1; i <= doc.numPages; i++) {
188
+ const page = await doc.getPage(i);
189
+ const content = await page.getTextContent();
190
+ pages.push(content.items.map(item => item.str).join(' '));
191
+ }
192
+ return pages.join('\n\n');
193
+ } catch (err) {
194
+ logger.warn('knowledge', `PDF parsing failed: ${err.message}`);
195
+ return readFileSync(filePath, 'utf8');
196
+ }
197
+ }
198
+
199
+ _parseHTML(html) {
200
+ try {
201
+ const { parseHTML } = require('linkedom');
202
+ const { document } = parseHTML(html);
203
+ // Remove scripts and styles
204
+ for (const el of document.querySelectorAll('script, style, nav, footer, header')) {
205
+ el.remove();
206
+ }
207
+ return document.body?.textContent?.replace(/\s+/g, ' ').trim() || html;
208
+ } catch {
209
+ return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
210
+ }
211
+ }
212
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 🦑 Browser Tool
3
+ * Web search + page reading — no browser needed
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+
8
+ export class BrowserTool {
9
+ constructor(config) {
10
+ this.config = config;
11
+ }
12
+
13
+ /**
14
+ * Search the web using DuckDuckGo (no API key needed)
15
+ */
16
+ async search(query, maxResults = 5) {
17
+ // Use DuckDuckGo HTML (no API key required)
18
+ const encoded = encodeURIComponent(query);
19
+ const res = await fetch(`https://html.duckduckgo.com/html/?q=${encoded}`, {
20
+ headers: { 'User-Agent': 'Squidclaw/0.1.0' },
21
+ });
22
+
23
+ if (!res.ok) throw new Error(`Search failed: ${res.status}`);
24
+ const html = await res.text();
25
+
26
+ // Parse results from HTML
27
+ const results = [];
28
+ const regex = /<a rel="nofollow" class="result__a" href="([^"]+)"[^>]*>(.+?)<\/a>[\s\S]*?<a class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
29
+ let match;
30
+ while ((match = regex.exec(html)) && results.length < maxResults) {
31
+ results.push({
32
+ url: match[1],
33
+ title: match[2].replace(/<[^>]+>/g, ''),
34
+ snippet: match[3].replace(/<[^>]+>/g, '').trim(),
35
+ });
36
+ }
37
+
38
+ return results;
39
+ }
40
+
41
+ /**
42
+ * Fetch and extract readable content from a URL
43
+ */
44
+ async readPage(url, maxLength = 5000) {
45
+ try {
46
+ const res = await fetch(url, {
47
+ headers: { 'User-Agent': 'Squidclaw/0.1.0' },
48
+ redirect: 'follow',
49
+ });
50
+
51
+ if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
52
+ const html = await res.text();
53
+
54
+ // Try Readability
55
+ try {
56
+ const { parseHTML } = await import('linkedom');
57
+ const { Readability } = await import('@mozilla/readability');
58
+ const { document } = parseHTML(html);
59
+ const reader = new Readability(document);
60
+ const article = reader.parse();
61
+ if (article?.textContent) {
62
+ return {
63
+ title: article.title,
64
+ content: article.textContent.slice(0, maxLength),
65
+ length: article.textContent.length,
66
+ };
67
+ }
68
+ } catch {}
69
+
70
+ // Fallback: strip HTML tags
71
+ const text = html.replace(/<script[\s\S]*?<\/script>/gi, '')
72
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
73
+ .replace(/<[^>]+>/g, ' ')
74
+ .replace(/\s+/g, ' ')
75
+ .trim();
76
+
77
+ return {
78
+ title: html.match(/<title[^>]*>(.*?)<\/title>/i)?.[1] || url,
79
+ content: text.slice(0, maxLength),
80
+ length: text.length,
81
+ };
82
+ } catch (err) {
83
+ throw new Error(`Failed to read ${url}: ${err.message}`);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Search and summarize — search then read top result
89
+ */
90
+ async searchAndRead(query) {
91
+ const results = await this.search(query, 3);
92
+ if (results.length === 0) return { results: [], content: null };
93
+
94
+ // Try to read the first result
95
+ let content = null;
96
+ for (const result of results) {
97
+ try {
98
+ content = await this.readPage(result.url, 3000);
99
+ break;
100
+ } catch {}
101
+ }
102
+
103
+ return { results, content };
104
+ }
105
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 🦑 Calendar Tool
3
+ * Google Calendar integration — read/create/update events
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+
8
+ export class CalendarTool {
9
+ constructor(config) {
10
+ this.apiKey = config.tools?.google?.apiKey || config.ai?.providers?.google?.key;
11
+ this.calendarId = config.tools?.calendar?.calendarId || 'primary';
12
+ this.oauthToken = config.tools?.google?.oauthToken || null;
13
+ }
14
+
15
+ /**
16
+ * Get upcoming events
17
+ */
18
+ async getEvents(timeMin, timeMax, maxResults = 10) {
19
+ if (!this.oauthToken) throw new Error('Google OAuth token required for calendar. Set tools.google.oauthToken in config');
20
+
21
+ const params = new URLSearchParams({
22
+ timeMin: timeMin || new Date().toISOString(),
23
+ timeMax: timeMax || new Date(Date.now() + 7 * 86400000).toISOString(),
24
+ maxResults: maxResults.toString(),
25
+ singleEvents: 'true',
26
+ orderBy: 'startTime',
27
+ });
28
+
29
+ const res = await fetch(`https://www.googleapis.com/calendar/v3/calendars/${this.calendarId}/events?${params}`, {
30
+ headers: { 'Authorization': `Bearer ${this.oauthToken}` },
31
+ });
32
+
33
+ if (!res.ok) throw new Error(`Calendar API error: ${res.status}`);
34
+ const data = await res.json();
35
+
36
+ return (data.items || []).map(event => ({
37
+ id: event.id,
38
+ title: event.summary,
39
+ start: event.start?.dateTime || event.start?.date,
40
+ end: event.end?.dateTime || event.end?.date,
41
+ location: event.location,
42
+ description: event.description,
43
+ link: event.htmlLink,
44
+ }));
45
+ }
46
+
47
+ /**
48
+ * Create a new event
49
+ */
50
+ async createEvent(title, start, end, description = '', location = '') {
51
+ if (!this.oauthToken) throw new Error('Google OAuth token required');
52
+
53
+ const event = {
54
+ summary: title,
55
+ start: { dateTime: start, timeZone: 'UTC' },
56
+ end: { dateTime: end, timeZone: 'UTC' },
57
+ description,
58
+ location,
59
+ };
60
+
61
+ const res = await fetch(`https://www.googleapis.com/calendar/v3/calendars/${this.calendarId}/events`, {
62
+ method: 'POST',
63
+ headers: {
64
+ 'Authorization': `Bearer ${this.oauthToken}`,
65
+ 'Content-Type': 'application/json',
66
+ },
67
+ body: JSON.stringify(event),
68
+ });
69
+
70
+ if (!res.ok) throw new Error(`Calendar API error: ${res.status}`);
71
+ return res.json();
72
+ }
73
+
74
+ /**
75
+ * Get today's summary (for heartbeat)
76
+ */
77
+ async getTodaySummary() {
78
+ const now = new Date();
79
+ const endOfDay = new Date(now);
80
+ endOfDay.setHours(23, 59, 59);
81
+
82
+ const events = await this.getEvents(now.toISOString(), endOfDay.toISOString());
83
+ if (events.length === 0) return 'No more events today';
84
+
85
+ return events.map(e => {
86
+ const time = new Date(e.start).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
87
+ return `${time} — ${e.title}${e.location ? ' @ ' + e.location : ''}`;
88
+ }).join('\n');
89
+ }
90
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 🦑 Email Tool
3
+ * Read and send emails — Gmail API or generic SMTP
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+
8
+ export class EmailTool {
9
+ constructor(config) {
10
+ this.oauthToken = config.tools?.google?.oauthToken || null;
11
+ this.provider = config.tools?.email?.provider || 'gmail'; // gmail | smtp
12
+ }
13
+
14
+ /**
15
+ * Get unread emails
16
+ */
17
+ async getUnread(maxResults = 10) {
18
+ if (this.provider === 'gmail') return this._gmailUnread(maxResults);
19
+ throw new Error(`Email provider ${this.provider} not supported yet`);
20
+ }
21
+
22
+ async _gmailUnread(maxResults) {
23
+ if (!this.oauthToken) throw new Error('Google OAuth token required for email');
24
+
25
+ const res = await fetch(`https://gmail.googleapis.com/gmail/v1/users/me/messages?q=is:unread&maxResults=${maxResults}`, {
26
+ headers: { 'Authorization': `Bearer ${this.oauthToken}` },
27
+ });
28
+ if (!res.ok) throw new Error(`Gmail API error: ${res.status}`);
29
+ const data = await res.json();
30
+
31
+ const emails = [];
32
+ for (const msg of (data.messages || []).slice(0, maxResults)) {
33
+ const detail = await fetch(`https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=metadata&metadataHeaders=From&metadataHeaders=Subject`, {
34
+ headers: { 'Authorization': `Bearer ${this.oauthToken}` },
35
+ });
36
+ const d = await detail.json();
37
+ const headers = d.payload?.headers || [];
38
+ emails.push({
39
+ id: msg.id,
40
+ from: headers.find(h => h.name === 'From')?.value || 'unknown',
41
+ subject: headers.find(h => h.name === 'Subject')?.value || '(no subject)',
42
+ snippet: d.snippet,
43
+ date: new Date(parseInt(d.internalDate)).toISOString(),
44
+ });
45
+ }
46
+ return emails;
47
+ }
48
+
49
+ /**
50
+ * Read a specific email
51
+ */
52
+ async readEmail(messageId) {
53
+ if (!this.oauthToken) throw new Error('Google OAuth token required');
54
+
55
+ const res = await fetch(`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}`, {
56
+ headers: { 'Authorization': `Bearer ${this.oauthToken}` },
57
+ });
58
+ if (!res.ok) throw new Error(`Gmail API error: ${res.status}`);
59
+ const data = await res.json();
60
+
61
+ // Decode body
62
+ let body = data.snippet;
63
+ const parts = data.payload?.parts || [data.payload];
64
+ for (const part of parts) {
65
+ if (part?.mimeType === 'text/plain' && part.body?.data) {
66
+ body = Buffer.from(part.body.data, 'base64url').toString('utf8');
67
+ break;
68
+ }
69
+ }
70
+
71
+ const headers = data.payload?.headers || [];
72
+ return {
73
+ id: messageId,
74
+ from: headers.find(h => h.name === 'From')?.value,
75
+ to: headers.find(h => h.name === 'To')?.value,
76
+ subject: headers.find(h => h.name === 'Subject')?.value,
77
+ body,
78
+ date: new Date(parseInt(data.internalDate)).toISOString(),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Send an email
84
+ */
85
+ async sendEmail(to, subject, body) {
86
+ if (!this.oauthToken) throw new Error('Google OAuth token required');
87
+
88
+ const raw = Buffer.from(
89
+ `To: ${to}\r\nSubject: ${subject}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${body}`
90
+ ).toString('base64url');
91
+
92
+ const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
93
+ method: 'POST',
94
+ headers: {
95
+ 'Authorization': `Bearer ${this.oauthToken}`,
96
+ 'Content-Type': 'application/json',
97
+ },
98
+ body: JSON.stringify({ raw }),
99
+ });
100
+
101
+ if (!res.ok) throw new Error(`Gmail send error: ${res.status}`);
102
+ return res.json();
103
+ }
104
+
105
+ /**
106
+ * Get summary for heartbeat
107
+ */
108
+ async getSummary() {
109
+ const emails = await this.getUnread(5);
110
+ if (emails.length === 0) return 'No unread emails';
111
+ return emails.map(e => `📧 ${e.from.split('<')[0].trim()}: ${e.subject}`).join('\n');
112
+ }
113
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * 🦑 Tool Router
3
+ * Detects when an agent needs to use a tool and executes it
4
+ */
5
+
6
+ import { BrowserTool } from './browser.js';
7
+ import { CalendarTool } from './calendar.js';
8
+ import { EmailTool } from './email.js';
9
+ import { logger } from '../core/logger.js';
10
+
11
+ export class ToolRouter {
12
+ constructor(config, knowledgeBase) {
13
+ this.config = config;
14
+ this.browser = new BrowserTool(config);
15
+ this.calendar = config.tools?.google?.oauthToken ? new CalendarTool(config) : null;
16
+ this.email = config.tools?.google?.oauthToken ? new EmailTool(config) : null;
17
+ this.knowledgeBase = knowledgeBase;
18
+ }
19
+
20
+ /**
21
+ * Get tool descriptions for the system prompt
22
+ */
23
+ getToolDescriptions() {
24
+ const tools = [
25
+ '## Available Tools',
26
+ 'You can use tools by including special tags in your response:',
27
+ '',
28
+ '### Web Search',
29
+ '---TOOL:search:your search query---',
30
+ 'Search the web for information. Use when you don\'t know something or need current info.',
31
+ '',
32
+ '### Read Web Page',
33
+ '---TOOL:read:https://example.com---',
34
+ 'Read the content of a webpage.',
35
+ '',
36
+ '### Knowledge Base',
37
+ '---TOOL:knowledge:search query---',
38
+ 'Search the agent\'s uploaded knowledge base for relevant information.',
39
+ ];
40
+
41
+ if (this.calendar) {
42
+ tools.push('', '### Calendar', '---TOOL:calendar:today--- or ---TOOL:calendar:week---',
43
+ 'Check upcoming calendar events.');
44
+ tools.push('', '### Create Event', '---TOOL:calendar_create:title|2026-03-05T10:00:00|2026-03-05T11:00:00|description---',
45
+ 'Create a new calendar event.');
46
+ }
47
+
48
+ if (this.email) {
49
+ tools.push('', '### Check Email', '---TOOL:email:unread---',
50
+ 'Check for unread emails.');
51
+ tools.push('', '### Send Email', '---TOOL:email_send:to@example.com|Subject|Body text---',
52
+ 'Send an email.');
53
+ }
54
+
55
+ tools.push('', '**Important:** Use tools when needed. The tool result will be injected into the conversation automatically. Only use one tool per response.');
56
+
57
+ return tools.join('\n');
58
+ }
59
+
60
+ /**
61
+ * Process AI response — extract and execute tool calls
62
+ * Returns: { toolUsed, toolResult, cleanResponse }
63
+ */
64
+ async processResponse(response, agentId) {
65
+ const toolMatch = response.match(/---TOOL:(\w+):(.+?)---/);
66
+ if (!toolMatch) return { toolUsed: false, toolResult: null, cleanResponse: response };
67
+
68
+ const [fullMatch, toolName, toolArg] = toolMatch;
69
+ const cleanResponse = response.replace(fullMatch, '').trim();
70
+
71
+ logger.info('tools', `Agent using tool: ${toolName} — ${toolArg}`);
72
+
73
+ let toolResult = null;
74
+ try {
75
+ switch (toolName) {
76
+ case 'search':
77
+ const results = await this.browser.search(toolArg, 5);
78
+ toolResult = results.map(r => `• ${r.title}\n ${r.snippet}\n ${r.url}`).join('\n\n');
79
+ break;
80
+
81
+ case 'read':
82
+ const page = await this.browser.readPage(toolArg, 3000);
83
+ toolResult = `Title: ${page.title}\n\n${page.content}`;
84
+ break;
85
+
86
+ case 'knowledge':
87
+ if (this.knowledgeBase) {
88
+ const chunks = await this.knowledgeBase.search(agentId, toolArg, 5);
89
+ toolResult = chunks.map(c => c.content).join('\n\n---\n\n');
90
+ } else {
91
+ toolResult = 'No knowledge base configured';
92
+ }
93
+ break;
94
+
95
+ case 'calendar':
96
+ if (!this.calendar) { toolResult = 'Calendar not configured'; break; }
97
+ if (toolArg === 'today') {
98
+ toolResult = await this.calendar.getTodaySummary();
99
+ } else {
100
+ const events = await this.calendar.getEvents();
101
+ toolResult = events.map(e => `${e.start} — ${e.title}`).join('\n');
102
+ }
103
+ break;
104
+
105
+ case 'calendar_create':
106
+ if (!this.calendar) { toolResult = 'Calendar not configured'; break; }
107
+ const [title, start, end, desc] = toolArg.split('|');
108
+ await this.calendar.createEvent(title, start, end, desc);
109
+ toolResult = `Event "${title}" created`;
110
+ break;
111
+
112
+ case 'email':
113
+ if (!this.email) { toolResult = 'Email not configured'; break; }
114
+ toolResult = await this.email.getSummary();
115
+ break;
116
+
117
+ case 'email_send':
118
+ if (!this.email) { toolResult = 'Email not configured'; break; }
119
+ const [to, subject, body] = toolArg.split('|');
120
+ await this.email.sendEmail(to, subject, body);
121
+ toolResult = `Email sent to ${to}`;
122
+ break;
123
+
124
+ default:
125
+ toolResult = `Unknown tool: ${toolName}`;
126
+ }
127
+ } catch (err) {
128
+ toolResult = `Tool error: ${err.message}`;
129
+ logger.error('tools', `Tool ${toolName} failed: ${err.message}`);
130
+ }
131
+
132
+ return { toolUsed: true, toolResult, cleanResponse, toolName };
133
+ }
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "🦑 AI agent platform — human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {
@@ -12,7 +12,15 @@
12
12
  "test": "vitest",
13
13
  "dev": "node --watch lib/engine.js"
14
14
  },
15
- "keywords": ["ai", "agent", "whatsapp", "chatbot", "claude", "openai", "squidclaw"],
15
+ "keywords": [
16
+ "ai",
17
+ "agent",
18
+ "whatsapp",
19
+ "chatbot",
20
+ "claude",
21
+ "openai",
22
+ "squidclaw"
23
+ ],
16
24
  "author": "Squidclaw",
17
25
  "license": "MIT",
18
26
  "homepage": "https://squidclaw.dev",
@@ -24,10 +32,10 @@
24
32
  "node": ">=20.0.0"
25
33
  },
26
34
  "dependencies": {
27
- "@whiskeysockets/baileys": "^7.0.0-rc.9",
28
- "@hapi/boom": "^10.0.1",
29
35
  "@clack/prompts": "^1.0.1",
36
+ "@hapi/boom": "^10.0.1",
30
37
  "@mozilla/readability": "^0.6.0",
38
+ "@whiskeysockets/baileys": "^7.0.0-rc.9",
31
39
  "better-sqlite3": "^11.0.0",
32
40
  "chalk": "^5.6.2",
33
41
  "commander": "^14.0.3",
@@ -35,6 +43,7 @@
35
43
  "dotenv": "^17.3.1",
36
44
  "express": "^5.2.1",
37
45
  "file-type": "^21.3.0",
46
+ "grammy": "^1.40.1",
38
47
  "linkedom": "^0.18.12",
39
48
  "node-edge-tts": "^1.2.10",
40
49
  "pdfjs-dist": "^5.4.624",