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/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?.values()?.next()?.value;
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
- // Send image if generated
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
- this.server = app.listen(this.port, this.config.engine?.bind || '127.0.0.1', () => {
299
- console.log(` 🌐 API: http://${this.config.engine?.bind || '127.0.0.1'}:${this.port}`);
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
+ }