squidclaw 0.8.3 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/ai/prompt-builder.js +39 -3
- package/lib/api/dashboard.js +167 -0
- package/lib/channels/telegram/bot.js +13 -0
- package/lib/cli/agent-cmd.js +107 -138
- package/lib/cli/update-cmd.js +44 -16
- package/lib/core/agent-tools-mixin.js +16 -0
- package/lib/engine.js +192 -7
- package/lib/features/auto-links.js +36 -0
- package/lib/features/auto-memory.js +107 -0
- package/lib/features/doc-ingest.js +103 -0
- package/lib/features/reminders.js +144 -0
- package/lib/features/usage-alerts.js +49 -0
- package/lib/features/voice-reply.js +55 -0
- package/lib/tools/router.js +23 -0
- package/package.json +3 -3
package/lib/engine.js
CHANGED
|
@@ -107,6 +107,31 @@ export class SquidclawEngine {
|
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// Handle /usage command
|
|
111
|
+
if (message.trim() === '/usage') {
|
|
112
|
+
try {
|
|
113
|
+
const { UsageAlerts } = await import('./features/usage-alerts.js');
|
|
114
|
+
const ua = new UsageAlerts(this.storage);
|
|
115
|
+
const summary = await ua.getSummary(agentId);
|
|
116
|
+
const fmtT = (n) => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : String(n);
|
|
117
|
+
const lines = [
|
|
118
|
+
'📊 *Usage Report*', '',
|
|
119
|
+
'*Today:*',
|
|
120
|
+
' 💬 ' + summary.today.calls + ' messages',
|
|
121
|
+
' 🪙 ' + fmtT(summary.today.tokens) + ' tokens',
|
|
122
|
+
' 💰 $' + summary.today.cost, '',
|
|
123
|
+
'*Last 30 days:*',
|
|
124
|
+
' 💬 ' + summary.month.calls + ' messages',
|
|
125
|
+
' 🪙 ' + fmtT(summary.month.tokens) + ' tokens',
|
|
126
|
+
' 💰 $' + summary.month.cost,
|
|
127
|
+
];
|
|
128
|
+
await this.telegramManager.sendMessage(agentId, contactId, lines.join('\n'), metadata);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
await this.telegramManager.sendMessage(agentId, contactId, '❌ ' + err.message, metadata);
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
110
135
|
// Handle /help command
|
|
111
136
|
if (message.trim() === '/help') {
|
|
112
137
|
const helpText = [
|
|
@@ -115,6 +140,7 @@ export class SquidclawEngine {
|
|
|
115
140
|
'/status — model, uptime, usage stats',
|
|
116
141
|
'/backup — save me to a backup file',
|
|
117
142
|
'/memories — what I remember about you',
|
|
143
|
+
'/usage — spending report (today + 30 days)',
|
|
118
144
|
'/help — this message',
|
|
119
145
|
'',
|
|
120
146
|
'Just chat normally — I\'ll search the web, remember things, and help! 🦑',
|
|
@@ -200,6 +226,139 @@ export class SquidclawEngine {
|
|
|
200
226
|
return;
|
|
201
227
|
}
|
|
202
228
|
|
|
229
|
+
// Process Telegram media (voice, images)
|
|
230
|
+
if (metadata._ctx && metadata.mediaType) {
|
|
231
|
+
try {
|
|
232
|
+
if (metadata.mediaType === 'audio') {
|
|
233
|
+
// Download and transcribe voice note
|
|
234
|
+
const file = await metadata._ctx.getFile();
|
|
235
|
+
const fileUrl = `https://api.telegram.org/file/bot${this.config.channels.telegram.token}/${file.file_path}`;
|
|
236
|
+
const resp = await fetch(fileUrl);
|
|
237
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
238
|
+
|
|
239
|
+
// Transcribe with Groq Whisper (free) or OpenAI
|
|
240
|
+
const groqKey = this.config.ai?.providers?.groq?.key;
|
|
241
|
+
const openaiKey = this.config.ai?.providers?.openai?.key;
|
|
242
|
+
const apiKey = groqKey || openaiKey;
|
|
243
|
+
const apiUrl = groqKey
|
|
244
|
+
? 'https://api.groq.com/openai/v1/audio/transcriptions'
|
|
245
|
+
: 'https://api.openai.com/v1/audio/transcriptions';
|
|
246
|
+
const model = groqKey ? 'whisper-large-v3' : 'whisper-1';
|
|
247
|
+
|
|
248
|
+
if (apiKey) {
|
|
249
|
+
const form = new FormData();
|
|
250
|
+
form.append('file', new Blob([buffer], { type: 'audio/ogg' }), 'voice.ogg');
|
|
251
|
+
form.append('model', model);
|
|
252
|
+
|
|
253
|
+
const tRes = await fetch(apiUrl, {
|
|
254
|
+
method: 'POST',
|
|
255
|
+
headers: { 'Authorization': 'Bearer ' + apiKey },
|
|
256
|
+
body: form,
|
|
257
|
+
});
|
|
258
|
+
const tData = await tRes.json();
|
|
259
|
+
if (tData.text) {
|
|
260
|
+
message = '[Voice note]: "' + tData.text + '"';
|
|
261
|
+
logger.info('telegram', 'Transcribed voice: ' + tData.text.slice(0, 50));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} else if (metadata.mediaType === 'image') {
|
|
265
|
+
// Download and analyze image
|
|
266
|
+
const photos = metadata._ctx.message?.photo;
|
|
267
|
+
if (photos?.length > 0) {
|
|
268
|
+
const photo = photos[photos.length - 1]; // highest res
|
|
269
|
+
const file = await metadata._ctx.api.getFile(photo.file_id);
|
|
270
|
+
const fileUrl = `https://api.telegram.org/file/bot${this.config.channels.telegram.token}/${file.file_path}`;
|
|
271
|
+
const resp = await fetch(fileUrl);
|
|
272
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
273
|
+
const base64 = buffer.toString('base64');
|
|
274
|
+
|
|
275
|
+
// Analyze with Claude or OpenAI Vision
|
|
276
|
+
const anthropicKey = this.config.ai?.providers?.anthropic?.key;
|
|
277
|
+
const caption = message.replace('[📸 Image]', '').trim();
|
|
278
|
+
const userPrompt = caption || 'What is in this image? Be concise.';
|
|
279
|
+
|
|
280
|
+
if (anthropicKey) {
|
|
281
|
+
const vRes = await fetch('https://api.anthropic.com/v1/messages', {
|
|
282
|
+
method: 'POST',
|
|
283
|
+
headers: {
|
|
284
|
+
'x-api-key': anthropicKey,
|
|
285
|
+
'content-type': 'application/json',
|
|
286
|
+
'anthropic-version': '2023-06-01',
|
|
287
|
+
},
|
|
288
|
+
body: JSON.stringify({
|
|
289
|
+
model: 'claude-sonnet-4-20250514',
|
|
290
|
+
max_tokens: 300,
|
|
291
|
+
messages: [{
|
|
292
|
+
role: 'user',
|
|
293
|
+
content: [
|
|
294
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: base64 } },
|
|
295
|
+
{ type: 'text', text: userPrompt },
|
|
296
|
+
],
|
|
297
|
+
}],
|
|
298
|
+
}),
|
|
299
|
+
});
|
|
300
|
+
const vData = await vRes.json();
|
|
301
|
+
const analysis = vData.content?.[0]?.text || '';
|
|
302
|
+
if (analysis) {
|
|
303
|
+
message = caption
|
|
304
|
+
? '[Image with caption: "' + caption + '"] Image shows: ' + analysis
|
|
305
|
+
: '[Image] Image shows: ' + analysis;
|
|
306
|
+
logger.info('telegram', 'Analyzed image: ' + analysis.slice(0, 50));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
} else if (metadata.mediaType === 'document') {
|
|
311
|
+
const file = await metadata._ctx.getFile();
|
|
312
|
+
const fileUrl = `https://api.telegram.org/file/bot${this.config.channels.telegram.token}/${file.file_path}`;
|
|
313
|
+
const resp = await fetch(fileUrl);
|
|
314
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
315
|
+
const filename = message.match(/Document: (.+?)\]/)?.[1] || 'file.txt';
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const { DocIngester } = await import('./features/doc-ingest.js');
|
|
319
|
+
const ingester = new DocIngester(this.storage, this.knowledgeBase, this.home);
|
|
320
|
+
const result = await ingester.ingest(agentId, buffer, filename, metadata.mimeType);
|
|
321
|
+
|
|
322
|
+
await this.telegramManager.sendMessage(agentId, contactId,
|
|
323
|
+
'📄 *Document absorbed!*\n📁 ' + filename + '\n📊 ' + result.chunks + ' chunks\n📝 ' + result.chars + ' chars\n\nI can answer questions about this! 🦑', metadata);
|
|
324
|
+
return;
|
|
325
|
+
} catch (err) {
|
|
326
|
+
message = '[Document: ' + filename + '] (Could not process: ' + err.message + ')';
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
logger.error('telegram', 'Media processing error: ' + err.message);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Auto-read links in message
|
|
335
|
+
const linkRegex = /https?:\/\/[^\s<>"')\]]+/gi;
|
|
336
|
+
if (linkRegex.test(message) && this.toolRouter) {
|
|
337
|
+
try {
|
|
338
|
+
const { extractAndReadLinks } = await import('./features/auto-links.js');
|
|
339
|
+
const linkContext = await extractAndReadLinks(message, this.toolRouter.browser);
|
|
340
|
+
if (linkContext) {
|
|
341
|
+
metadata._linkContext = linkContext;
|
|
342
|
+
}
|
|
343
|
+
} catch {}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Check usage alerts
|
|
347
|
+
if (this.usageAlerts) {
|
|
348
|
+
try {
|
|
349
|
+
const alert = await this.usageAlerts.check(agentId);
|
|
350
|
+
if (alert.alert) {
|
|
351
|
+
await this.telegramManager.sendMessage(agentId, contactId,
|
|
352
|
+
'⚠️ *Usage Alert*\nYou have spent $' + alert.total + ' in the last 24h (threshold: $' + alert.threshold + ')', metadata);
|
|
353
|
+
}
|
|
354
|
+
} catch {}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Auto-extract facts from user message
|
|
358
|
+
if (this.autoMemory) {
|
|
359
|
+
try { await this.autoMemory.extract(agentId, contactId, message); } catch {}
|
|
360
|
+
}
|
|
361
|
+
|
|
203
362
|
// Check if user is asking for a skill we don't have
|
|
204
363
|
const skillRequest = detectSkillRequest(message);
|
|
205
364
|
if (skillRequest) {
|
|
@@ -214,10 +373,10 @@ export class SquidclawEngine {
|
|
|
214
373
|
}
|
|
215
374
|
|
|
216
375
|
// Show typing indicator while processing
|
|
217
|
-
const chatId = metadata.chatId || contactId;
|
|
218
|
-
const botInfo = this.telegramManager?.bots?.
|
|
376
|
+
const chatId = metadata.chatId || contactId.replace('tg_', '');
|
|
377
|
+
const botInfo = this.telegramManager?.bots?.get(agentId);
|
|
219
378
|
let typingInterval;
|
|
220
|
-
if (botInfo) {
|
|
379
|
+
if (botInfo?.bot) {
|
|
221
380
|
const sendTyping = () => { try { botInfo.bot.api.sendChatAction(chatId, 'typing').catch(() => {}); } catch {} };
|
|
222
381
|
sendTyping();
|
|
223
382
|
typingInterval = setInterval(sendTyping, 4000);
|
|
@@ -235,15 +394,40 @@ export class SquidclawEngine {
|
|
|
235
394
|
}
|
|
236
395
|
|
|
237
396
|
if (result.messages && result.messages.length > 0) {
|
|
397
|
+
// Handle reminder if requested
|
|
398
|
+
if (result._reminder && this.reminders) {
|
|
399
|
+
const { time, message: remMsg } = result._reminder;
|
|
400
|
+
this.reminders.add(agentId, contactId, remMsg, time, 'telegram', metadata);
|
|
401
|
+
}
|
|
402
|
+
|
|
238
403
|
// Send image if generated
|
|
239
404
|
if (result.image) {
|
|
240
405
|
const photoData = result.image.url ? { url: result.image.url } : { base64: result.image.base64 };
|
|
241
406
|
const caption = result.messages?.[0] || '';
|
|
242
407
|
await this.telegramManager.sendPhoto(agentId, contactId, photoData, caption, metadata);
|
|
243
408
|
} else {
|
|
244
|
-
//
|
|
409
|
+
// Handle reminder if requested
|
|
410
|
+
if (result._reminder && this.reminders) {
|
|
411
|
+
const { time, message: remMsg } = result._reminder;
|
|
412
|
+
this.reminders.add(agentId, contactId, remMsg, time, 'telegram', metadata);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Send image if generated
|
|
245
416
|
if (result.image) {
|
|
246
417
|
await this.telegramManager.sendPhoto(agentId, contactId, result.image, result.messages?.[0] || '', metadata);
|
|
418
|
+
} else if (metadata.originalType === 'voice' && result.messages.length === 1 && result.messages[0].length < 500) {
|
|
419
|
+
// Reply with voice when user sent voice
|
|
420
|
+
try {
|
|
421
|
+
const { VoiceReply } = await import('./features/voice-reply.js');
|
|
422
|
+
const vr = new VoiceReply(this.config);
|
|
423
|
+
const lang = /[\u0600-\u06FF]/.test(result.messages[0]) ? 'ar' : 'en';
|
|
424
|
+
const audio = await vr.generate(result.messages[0], { language: lang });
|
|
425
|
+
await this.telegramManager.sendVoice(agentId, contactId, audio, metadata);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
// Fallback to text
|
|
428
|
+
logger.warn('voice', 'Voice reply failed, sending text: ' + err.message);
|
|
429
|
+
await this.telegramManager.sendMessages(agentId, contactId, result.messages, metadata);
|
|
430
|
+
}
|
|
247
431
|
} else {
|
|
248
432
|
await this.telegramManager.sendMessages(agentId, contactId, result.messages, metadata);
|
|
249
433
|
}
|
|
@@ -293,10 +477,11 @@ export class SquidclawEngine {
|
|
|
293
477
|
this.heartbeat.start();
|
|
294
478
|
console.log(` 💓 Heartbeat: active`);
|
|
295
479
|
|
|
296
|
-
// 7. API Server
|
|
480
|
+
// 7. API Server + Dashboard
|
|
297
481
|
const app = createAPIServer(this);
|
|
298
|
-
|
|
299
|
-
|
|
482
|
+
try { const { addDashboardRoutes } = await import('./api/dashboard.js'); addDashboardRoutes(app, this); } catch {}
|
|
483
|
+
this.server = app.listen(this.port, this.config.engine?.bind || '0.0.0.0', () => {
|
|
484
|
+
console.log(` 🌐 API: http://${this.config.engine?.bind || '0.0.0.0'}:${this.port}`);
|
|
300
485
|
console.log(` ──────────────────────────`);
|
|
301
486
|
console.log(` ✅ Engine running!\n`);
|
|
302
487
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🦑 Auto-Link Reader
|
|
3
|
+
* Detects URLs in messages and fetches their content for context
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { logger } from '../core/logger.js';
|
|
7
|
+
|
|
8
|
+
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/gi;
|
|
9
|
+
|
|
10
|
+
export async function extractAndReadLinks(message, browser) {
|
|
11
|
+
const urls = message.match(URL_REGEX);
|
|
12
|
+
if (!urls || urls.length === 0) return null;
|
|
13
|
+
|
|
14
|
+
const results = [];
|
|
15
|
+
for (const url of urls.slice(0, 3)) { // Max 3 links
|
|
16
|
+
try {
|
|
17
|
+
const page = await browser.readPage(url, 2000);
|
|
18
|
+
if (page && page.content) {
|
|
19
|
+
results.push({
|
|
20
|
+
url,
|
|
21
|
+
title: page.title || url,
|
|
22
|
+
content: page.content.slice(0, 1500),
|
|
23
|
+
});
|
|
24
|
+
logger.info('auto-links', `Read: ${page.title || url}`);
|
|
25
|
+
}
|
|
26
|
+
} catch (err) {
|
|
27
|
+
logger.warn('auto-links', `Failed to read ${url}: ${err.message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (results.length === 0) return null;
|
|
32
|
+
|
|
33
|
+
return results.map(r =>
|
|
34
|
+
`[Link: ${r.title}]\nURL: ${r.url}\nContent:\n${r.content}`
|
|
35
|
+
).join('\n\n---\n\n');
|
|
36
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🦑 Auto Memory — Extracts facts from conversations automatically
|
|
3
|
+
* No AI tags needed — scans messages for personal info, preferences, decisions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { logger } from '../core/logger.js';
|
|
7
|
+
|
|
8
|
+
const FACT_PATTERNS = [
|
|
9
|
+
// Names
|
|
10
|
+
{ regex: /(?:my name is|call me|i'?m|ana|اسمي|اسم)\s+([A-Z\u0600-\u06FF][a-z\u0600-\u06FF]+)/i, key: 'name', extract: 1 },
|
|
11
|
+
|
|
12
|
+
// Location
|
|
13
|
+
{ regex: /(?:i live in|i'?m from|i'?m in|based in|located in|ساكن في|من)\s+([A-Z\u0600-\u06FF][\w\u0600-\u06FF\s]{2,20})/i, key: 'location', extract: 1 },
|
|
14
|
+
|
|
15
|
+
// Job
|
|
16
|
+
{ regex: /(?:i work (?:as|at|in|for)|my job is|i'?m a|i'?m an|اشتغل|شغلي)\s+(.{3,40}?)(?:\.|,|!|\?|$)/i, key: 'job', extract: 1 },
|
|
17
|
+
|
|
18
|
+
// Age
|
|
19
|
+
{ regex: /(?:i'?m|i am|عمري)\s+(\d{1,3})\s*(?:years? old|سنة|سنه)?/i, key: 'age', extract: 1 },
|
|
20
|
+
|
|
21
|
+
// Family
|
|
22
|
+
{ regex: /(?:my (?:wife|husband|son|daughter|brother|sister|mom|dad|father|mother)(?:'s name)? is)\s+(\w+)/i, key: (m) => m[0].match(/wife|husband|son|daughter|brother|sister|mom|dad|father|mother/i)[0], extract: 1 },
|
|
23
|
+
|
|
24
|
+
// Favorites
|
|
25
|
+
{ regex: /(?:my fav(?:orite|ourite)?\s+(\w+)\s+is)\s+(.+?)(?:\.|,|!|$)/i, key: (m) => 'favorite_' + m[1], extract: 2 },
|
|
26
|
+
{ regex: /(?:i (?:love|like|prefer|enjoy))\s+(.{3,30}?)(?:\.|,|!|\?|$)/i, key: 'likes', extract: 1, append: true },
|
|
27
|
+
{ regex: /(?:i (?:hate|dislike|don'?t like))\s+(.{3,30}?)(?:\.|,|!|\?|$)/i, key: 'dislikes', extract: 1, append: true },
|
|
28
|
+
|
|
29
|
+
// Timezone
|
|
30
|
+
{ regex: /(?:my time(?:zone)? is|i'?m in)\s+(UTC[+-]\d+|GMT[+-]\d+|[A-Z]{2,4}\/[A-Za-z_]+)/i, key: 'timezone', extract: 1 },
|
|
31
|
+
|
|
32
|
+
// Birthday
|
|
33
|
+
{ regex: /(?:my birthday is|born on|ميلادي)\s+(.{5,20})/i, key: 'birthday', extract: 1 },
|
|
34
|
+
|
|
35
|
+
// Language
|
|
36
|
+
{ regex: /(?:i speak|my language is|لغتي)\s+(\w+)/i, key: 'language', extract: 1 },
|
|
37
|
+
|
|
38
|
+
// Pet
|
|
39
|
+
{ regex: /(?:my (?:cat|dog|pet)(?:'s name)? is)\s+(\w+)/i, key: (m) => m[0].match(/cat|dog|pet/i)[0] + '_name', extract: 1 },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Extract per-contact facts
|
|
43
|
+
const CONTACT_PATTERNS = [
|
|
44
|
+
{ regex: /(?:remember|don'?t forget|note|تذكر|لا تنسى)\s+(?:that\s+)?(.{5,100})/i, key: 'noted', extract: 1 },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export class AutoMemory {
|
|
48
|
+
constructor(storage) {
|
|
49
|
+
this.storage = storage;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Scan a user message for facts and auto-save them
|
|
54
|
+
*/
|
|
55
|
+
async extract(agentId, contactId, message) {
|
|
56
|
+
const extracted = [];
|
|
57
|
+
|
|
58
|
+
for (const pattern of FACT_PATTERNS) {
|
|
59
|
+
const match = message.match(pattern.regex);
|
|
60
|
+
if (!match) continue;
|
|
61
|
+
|
|
62
|
+
const key = typeof pattern.key === 'function' ? pattern.key(match) : pattern.key;
|
|
63
|
+
const value = match[pattern.extract].trim();
|
|
64
|
+
|
|
65
|
+
if (value.length < 2 || value.length > 100) continue;
|
|
66
|
+
|
|
67
|
+
if (pattern.append) {
|
|
68
|
+
// Append to existing
|
|
69
|
+
const existing = await this._getMemory(agentId, key);
|
|
70
|
+
if (existing && existing.includes(value)) continue;
|
|
71
|
+
const newValue = existing ? existing + ', ' + value : value;
|
|
72
|
+
await this.storage.saveMemory(agentId, key, newValue, 'auto');
|
|
73
|
+
} else {
|
|
74
|
+
await this.storage.saveMemory(agentId, key, value, 'auto');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
extracted.push({ key, value });
|
|
78
|
+
logger.info('auto-memory', `Extracted: ${key} = ${value}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check "remember this" patterns
|
|
82
|
+
for (const pattern of CONTACT_PATTERNS) {
|
|
83
|
+
const match = message.match(pattern.regex);
|
|
84
|
+
if (!match) continue;
|
|
85
|
+
const value = match[pattern.extract].trim();
|
|
86
|
+
if (value.length < 3) continue;
|
|
87
|
+
|
|
88
|
+
const key = 'user_note_' + Date.now().toString(36);
|
|
89
|
+
await this.storage.saveMemory(agentId, key, value, 'noted');
|
|
90
|
+
extracted.push({ key, value });
|
|
91
|
+
logger.info('auto-memory', `User note: ${value}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return extracted;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async _getMemory(agentId, key) {
|
|
98
|
+
try {
|
|
99
|
+
const row = this.storage.db.prepare(
|
|
100
|
+
'SELECT value FROM memories WHERE agent_id = ? AND key = ?'
|
|
101
|
+
).get(agentId, key);
|
|
102
|
+
return row?.value;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🦑 Document Ingestion
|
|
3
|
+
* Process PDFs, text files, docs sent via chat into knowledge base
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { logger } from '../core/logger.js';
|
|
7
|
+
import { writeFileSync, mkdirSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
|
|
10
|
+
export class DocIngester {
|
|
11
|
+
constructor(storage, knowledgeBase, home) {
|
|
12
|
+
this.storage = storage;
|
|
13
|
+
this.kb = knowledgeBase;
|
|
14
|
+
this.home = home;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async ingest(agentId, buffer, filename, mimeType) {
|
|
18
|
+
logger.info('doc-ingest', `Processing ${filename} (${mimeType})`);
|
|
19
|
+
|
|
20
|
+
let text = '';
|
|
21
|
+
|
|
22
|
+
if (mimeType === 'application/pdf') {
|
|
23
|
+
text = await this._extractPdf(buffer);
|
|
24
|
+
} else if (mimeType?.includes('text') || filename.endsWith('.txt') || filename.endsWith('.md') || filename.endsWith('.csv')) {
|
|
25
|
+
text = buffer.toString('utf8');
|
|
26
|
+
} else if (filename.endsWith('.json')) {
|
|
27
|
+
text = buffer.toString('utf8');
|
|
28
|
+
} else {
|
|
29
|
+
// Try as text
|
|
30
|
+
text = buffer.toString('utf8');
|
|
31
|
+
if (text.includes('\ufffd') || /[\x00-\x08\x0e-\x1f]/.test(text.slice(0, 100))) {
|
|
32
|
+
throw new Error('Unsupported file format: ' + mimeType);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!text || text.trim().length < 10) {
|
|
37
|
+
throw new Error('Could not extract text from file');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Save raw file
|
|
41
|
+
const docsDir = join(this.home, 'agents', agentId, 'docs');
|
|
42
|
+
mkdirSync(docsDir, { recursive: true });
|
|
43
|
+
writeFileSync(join(docsDir, filename), buffer);
|
|
44
|
+
|
|
45
|
+
// Chunk and save to knowledge base
|
|
46
|
+
const chunks = this._chunk(text, 500);
|
|
47
|
+
const docId = 'doc_' + Date.now().toString(36);
|
|
48
|
+
|
|
49
|
+
await this.storage.saveDocument(agentId, {
|
|
50
|
+
id: docId,
|
|
51
|
+
title: filename,
|
|
52
|
+
content: text.slice(0, 500),
|
|
53
|
+
type: mimeType,
|
|
54
|
+
chunk_count: chunks.length,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
58
|
+
await this.storage.saveKnowledgeChunk(agentId, {
|
|
59
|
+
document_id: docId,
|
|
60
|
+
content: chunks[i],
|
|
61
|
+
chunk_index: i,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
logger.info('doc-ingest', `Saved ${chunks.length} chunks from ${filename}`);
|
|
66
|
+
return { docId, chunks: chunks.length, chars: text.length };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async _extractPdf(buffer) {
|
|
70
|
+
// Try pdf-parse if available
|
|
71
|
+
try {
|
|
72
|
+
const pdfParse = (await import('pdf-parse')).default;
|
|
73
|
+
const data = await pdfParse(buffer);
|
|
74
|
+
return data.text;
|
|
75
|
+
} catch {
|
|
76
|
+
// Fallback: basic text extraction
|
|
77
|
+
const text = buffer.toString('utf8');
|
|
78
|
+
const readable = text.replace(/[^\x20-\x7E\n\r\t\u0600-\u06FF]/g, ' ')
|
|
79
|
+
.replace(/\s{3,}/g, '\n')
|
|
80
|
+
.trim();
|
|
81
|
+
if (readable.length > 50) return readable;
|
|
82
|
+
throw new Error('PDF parsing requires pdf-parse package. Run: npm i pdf-parse');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
_chunk(text, maxWords) {
|
|
87
|
+
const paragraphs = text.split(/\n\s*\n/);
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let current = '';
|
|
90
|
+
|
|
91
|
+
for (const para of paragraphs) {
|
|
92
|
+
const words = (current + '\n\n' + para).split(/\s+/).length;
|
|
93
|
+
if (words > maxWords && current) {
|
|
94
|
+
chunks.push(current.trim());
|
|
95
|
+
current = para;
|
|
96
|
+
} else {
|
|
97
|
+
current = current ? current + '\n\n' + para : para;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (current.trim()) chunks.push(current.trim());
|
|
101
|
+
return chunks;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🦑 Reminders & Scheduled Messages
|
|
3
|
+
* Agent can set reminders and proactively message users at scheduled times
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { logger } from '../core/logger.js';
|
|
7
|
+
|
|
8
|
+
export class ReminderManager {
|
|
9
|
+
constructor(storage) {
|
|
10
|
+
this.storage = storage;
|
|
11
|
+
this.timers = new Map(); // id -> timeout
|
|
12
|
+
this.onFire = null; // callback(agentId, contactId, message, platform, metadata)
|
|
13
|
+
this._initDb();
|
|
14
|
+
this._loadPending();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_initDb() {
|
|
18
|
+
try {
|
|
19
|
+
this.storage.db.exec(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS reminders (
|
|
21
|
+
id TEXT PRIMARY KEY,
|
|
22
|
+
agent_id TEXT NOT NULL,
|
|
23
|
+
contact_id TEXT NOT NULL,
|
|
24
|
+
message TEXT NOT NULL,
|
|
25
|
+
fire_at TEXT NOT NULL,
|
|
26
|
+
platform TEXT DEFAULT 'telegram',
|
|
27
|
+
metadata TEXT DEFAULT '{}',
|
|
28
|
+
fired INTEGER DEFAULT 0,
|
|
29
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
30
|
+
)
|
|
31
|
+
`);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
logger.error('reminders', 'Failed to init DB: ' + err.message);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_loadPending() {
|
|
38
|
+
try {
|
|
39
|
+
const rows = this.storage.db.prepare(
|
|
40
|
+
"SELECT * FROM reminders WHERE fired = 0 AND fire_at > datetime('now')"
|
|
41
|
+
).all();
|
|
42
|
+
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
this._schedule(row);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (rows.length > 0) {
|
|
48
|
+
logger.info('reminders', `Loaded ${rows.length} pending reminders`);
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
logger.error('reminders', 'Failed to load pending: ' + err.message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_schedule(reminder) {
|
|
56
|
+
const fireAt = new Date(reminder.fire_at + 'Z');
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const delay = fireAt.getTime() - now;
|
|
59
|
+
|
|
60
|
+
if (delay <= 0) {
|
|
61
|
+
// Already past — fire immediately
|
|
62
|
+
this._fire(reminder);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const timer = setTimeout(() => this._fire(reminder), delay);
|
|
67
|
+
this.timers.set(reminder.id, timer);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async _fire(reminder) {
|
|
71
|
+
logger.info('reminders', `Firing reminder ${reminder.id} for ${reminder.contact_id}`);
|
|
72
|
+
|
|
73
|
+
// Mark as fired
|
|
74
|
+
try {
|
|
75
|
+
this.storage.db.prepare('UPDATE reminders SET fired = 1 WHERE id = ?').run(reminder.id);
|
|
76
|
+
} catch {}
|
|
77
|
+
|
|
78
|
+
this.timers.delete(reminder.id);
|
|
79
|
+
|
|
80
|
+
// Call the callback
|
|
81
|
+
if (this.onFire) {
|
|
82
|
+
const metadata = JSON.parse(reminder.metadata || '{}');
|
|
83
|
+
metadata.platform = reminder.platform;
|
|
84
|
+
await this.onFire(reminder.agent_id, reminder.contact_id, reminder.message, reminder.platform, metadata);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Add a reminder
|
|
90
|
+
* @param {string} agentId
|
|
91
|
+
* @param {string} contactId
|
|
92
|
+
* @param {string} message - What to say when reminder fires
|
|
93
|
+
* @param {Date|string} fireAt - When to fire (UTC)
|
|
94
|
+
* @param {string} platform - telegram/whatsapp
|
|
95
|
+
* @param {object} metadata - chat metadata for sending
|
|
96
|
+
*/
|
|
97
|
+
add(agentId, contactId, message, fireAt, platform = 'telegram', metadata = {}) {
|
|
98
|
+
const id = 'rem_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
99
|
+
const fireAtStr = typeof fireAt === 'string' ? fireAt : fireAt.toISOString().replace('Z', '');
|
|
100
|
+
|
|
101
|
+
this.storage.db.prepare(
|
|
102
|
+
'INSERT INTO reminders (id, agent_id, contact_id, message, fire_at, platform, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
103
|
+
).run(id, agentId, contactId, message, fireAtStr, platform, JSON.stringify(metadata));
|
|
104
|
+
|
|
105
|
+
this._schedule({ id, agent_id: agentId, contact_id: contactId, message, fire_at: fireAtStr, platform, metadata: JSON.stringify(metadata) });
|
|
106
|
+
|
|
107
|
+
logger.info('reminders', `Reminder ${id} set for ${fireAtStr}`);
|
|
108
|
+
return id;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* List pending reminders for a contact
|
|
113
|
+
*/
|
|
114
|
+
list(agentId, contactId) {
|
|
115
|
+
return this.storage.db.prepare(
|
|
116
|
+
"SELECT * FROM reminders WHERE agent_id = ? AND contact_id = ? AND fired = 0 AND fire_at > datetime('now') ORDER BY fire_at"
|
|
117
|
+
).all(agentId, contactId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Cancel a reminder
|
|
122
|
+
*/
|
|
123
|
+
cancel(id) {
|
|
124
|
+
const timer = this.timers.get(id);
|
|
125
|
+
if (timer) clearTimeout(timer);
|
|
126
|
+
this.timers.delete(id);
|
|
127
|
+
this.storage.db.prepare('UPDATE reminders SET fired = 1 WHERE id = ?').run(id);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Cancel all reminders for a contact
|
|
133
|
+
*/
|
|
134
|
+
cancelAll(agentId, contactId) {
|
|
135
|
+
const pending = this.list(agentId, contactId);
|
|
136
|
+
for (const r of pending) this.cancel(r.id);
|
|
137
|
+
return pending.length;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
destroy() {
|
|
141
|
+
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
142
|
+
this.timers.clear();
|
|
143
|
+
}
|
|
144
|
+
}
|