squidclaw 0.1.0 → 0.2.1
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/ai/prompt-builder.js +7 -0
- package/lib/api/server.js +38 -0
- package/lib/channels/telegram/bot.js +196 -0
- package/lib/core/agent-tools-mixin.js +109 -0
- package/lib/engine.js +19 -1
- package/lib/memory/embeddings.js +71 -0
- package/lib/memory/knowledge.js +212 -0
- package/lib/tools/browser.js +105 -0
- package/lib/tools/calendar.js +90 -0
- package/lib/tools/email.js +113 -0
- package/lib/tools/router.js +134 -0
- package/package.json +13 -4
package/lib/ai/prompt-builder.js
CHANGED
|
@@ -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,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 🦑 Squidclaw Engine
|
|
3
2
|
* Main entry point — starts all subsystems
|
|
4
3
|
*/
|
|
4
|
+
import { KnowledgeBase } from "./memory/knowledge.js";
|
|
5
|
+
import { ToolRouter } from "./tools/router.js";
|
|
6
|
+
import { TelegramManager } from "./channels/telegram/bot.js";
|
|
7
|
+
import { addToolSupport } from "./core/agent-tools-mixin.js";
|
|
5
8
|
|
|
6
9
|
import { loadConfig, getHome, ensureHome } from './core/config.js';
|
|
7
10
|
import { initLogger, logger } from './core/logger.js';
|
|
@@ -57,6 +60,21 @@ export class SquidclawEngine {
|
|
|
57
60
|
const agents = this.agentManager.getAll();
|
|
58
61
|
console.log(` 👥 Agents: ${agents.length} loaded`);
|
|
59
62
|
|
|
63
|
+
// 3b. Knowledge Base
|
|
64
|
+
this.knowledgeBase = new KnowledgeBase(this.storage, this.config);
|
|
65
|
+
console.log(` 📚 Knowledge Base: ready`);
|
|
66
|
+
|
|
67
|
+
// 3c. Tool Router
|
|
68
|
+
this.toolRouter = new ToolRouter(this.config, this.knowledgeBase);
|
|
69
|
+
const toolList = ["web search", "page reader"];
|
|
70
|
+
if (this.config.tools?.google?.oauthToken) toolList.push("calendar", "email");
|
|
71
|
+
console.log(` 🔧 Tools: ${toolList.join(", ")}`);
|
|
72
|
+
|
|
73
|
+
// 3d. Add tool support to all agents
|
|
74
|
+
for (const agent of agents) {
|
|
75
|
+
addToolSupport(agent, this.toolRouter, this.knowledgeBase);
|
|
76
|
+
}
|
|
77
|
+
|
|
60
78
|
// 4. WhatsApp Manager
|
|
61
79
|
this.whatsappManager = new WhatsAppManager(this.config, this.agentManager, this.home);
|
|
62
80
|
|
|
@@ -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
|
|
3
|
+
"version": "0.2.1",
|
|
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": [
|
|
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",
|