squidclaw 2.3.0 β†’ 2.4.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
@@ -226,6 +226,12 @@ export class SquidclawEngine {
226
226
  if (pending.c > 0) console.log(` ⏰ Reminders: ${pending.c} pending`);
227
227
  } catch {}
228
228
 
229
+ // Cron jobs
230
+ try {
231
+ const { CronManager } = await import('./features/cron.js');
232
+ this.cron = new CronManager(this.storage, this);
233
+ } catch (err) { logger.error('engine', 'Cron init failed: ' + err.message); }
234
+
229
235
  // Auto-memory
230
236
  try {
231
237
  const { AutoMemory } = await import('./features/auto-memory.js');
@@ -0,0 +1,217 @@
1
+ /**
2
+ * πŸ¦‘ Cron System
3
+ * Persistent scheduled jobs β€” survives restarts
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+
8
+ export class CronManager {
9
+ constructor(storage, engine) {
10
+ this.storage = storage;
11
+ this.engine = engine;
12
+ this.timers = new Map();
13
+ this._initDb();
14
+ this._loadJobs();
15
+ this._startTicker();
16
+ }
17
+
18
+ _initDb() {
19
+ this.storage.db.exec(`
20
+ CREATE TABLE IF NOT EXISTS cron_jobs (
21
+ id TEXT PRIMARY KEY,
22
+ agent_id TEXT NOT NULL,
23
+ contact_id TEXT NOT NULL,
24
+ name TEXT NOT NULL,
25
+ schedule TEXT NOT NULL,
26
+ action TEXT NOT NULL,
27
+ action_data TEXT DEFAULT '',
28
+ platform TEXT DEFAULT 'telegram',
29
+ metadata TEXT DEFAULT '{}',
30
+ enabled INTEGER DEFAULT 1,
31
+ last_run TEXT,
32
+ next_run TEXT,
33
+ created_at TEXT DEFAULT (datetime('now'))
34
+ )
35
+ `);
36
+ }
37
+
38
+ /**
39
+ * Schedule types:
40
+ * - every:5m / every:1h / every:24h β€” interval
41
+ * - daily:09:00 β€” every day at HH:MM UTC
42
+ * - weekly:fri:09:00 β€” every week on day at HH:MM UTC
43
+ * - monthly:1:09:00 β€” every month on day at HH:MM UTC
44
+ * - cron:* * * * * β€” standard cron expression (min hour dom mon dow)
45
+ */
46
+ add(agentId, contactId, name, schedule, action, actionData, platform, metadata) {
47
+ const id = 'cron_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
48
+ const nextRun = this._calcNextRun(schedule);
49
+
50
+ this.storage.db.prepare(
51
+ 'INSERT INTO cron_jobs (id, agent_id, contact_id, name, schedule, action, action_data, platform, metadata, next_run) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
52
+ ).run(id, agentId, contactId, name, schedule, action, actionData || '', platform || 'telegram', JSON.stringify(metadata || {}), nextRun);
53
+
54
+ logger.info('cron', `Added job ${id}: "${name}" schedule=${schedule} next=${nextRun}`);
55
+ return { id, name, schedule, nextRun };
56
+ }
57
+
58
+ list(agentId, contactId) {
59
+ return this.storage.db.prepare(
60
+ 'SELECT * FROM cron_jobs WHERE agent_id = ? AND contact_id = ? ORDER BY created_at'
61
+ ).all(agentId, contactId);
62
+ }
63
+
64
+ listAll(agentId) {
65
+ return this.storage.db.prepare(
66
+ 'SELECT * FROM cron_jobs WHERE agent_id = ? ORDER BY created_at'
67
+ ).all(agentId);
68
+ }
69
+
70
+ remove(id) {
71
+ this.storage.db.prepare('DELETE FROM cron_jobs WHERE id = ?').run(id);
72
+ logger.info('cron', `Removed job ${id}`);
73
+ }
74
+
75
+ toggle(id, enabled) {
76
+ this.storage.db.prepare('UPDATE cron_jobs SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, id);
77
+ }
78
+
79
+ // ── Ticker β€” runs every 60 seconds ──
80
+
81
+ _startTicker() {
82
+ this._tick(); // run once immediately
83
+ this._interval = setInterval(() => this._tick(), 60000);
84
+ }
85
+
86
+ _tick() {
87
+ const now = new Date().toISOString().slice(0, 16);
88
+ const due = this.storage.db.prepare(
89
+ "SELECT * FROM cron_jobs WHERE enabled = 1 AND next_run <= ?"
90
+ ).all(now);
91
+
92
+ for (const job of due) {
93
+ this._execute(job);
94
+ // Update next_run
95
+ const nextRun = this._calcNextRun(job.schedule);
96
+ this.storage.db.prepare(
97
+ 'UPDATE cron_jobs SET last_run = ?, next_run = ? WHERE id = ?'
98
+ ).run(now, nextRun, job.id);
99
+ }
100
+ }
101
+
102
+ async _execute(job) {
103
+ logger.info('cron', `Executing job ${job.id}: "${job.name}"`);
104
+
105
+ try {
106
+ const metadata = JSON.parse(job.metadata || '{}');
107
+
108
+ if (job.action === 'message') {
109
+ // Send a message
110
+ const msg = 'πŸ”„ *Scheduled: ' + job.name + '*\n\n' + (job.action_data || '');
111
+ await this._send(job.agent_id, job.contact_id, msg, job.platform, metadata);
112
+ }
113
+ else if (job.action === 'briefing') {
114
+ // Send daily briefing
115
+ try {
116
+ const { generateBriefing } = await import('./daily-briefing.js');
117
+ const briefing = await generateBriefing(this.engine, job.agent_id);
118
+ await this._send(job.agent_id, job.contact_id, briefing, job.platform, metadata);
119
+ } catch (err) {
120
+ await this._send(job.agent_id, job.contact_id, 'β˜€οΈ Good morning! (Briefing failed: ' + err.message + ')', job.platform, metadata);
121
+ }
122
+ }
123
+ else if (job.action === 'ai') {
124
+ // Run AI prompt and send result
125
+ const messages = [
126
+ { role: 'system', content: 'You are a helpful assistant. Complete this task concisely.' },
127
+ { role: 'user', content: job.action_data },
128
+ ];
129
+ const response = await this.engine.aiGateway.chat(messages, {
130
+ model: this.engine.config.ai?.defaultModel,
131
+ });
132
+ await this._send(job.agent_id, job.contact_id, response.content, job.platform, metadata);
133
+ }
134
+ else if (job.action === 'remind') {
135
+ await this._send(job.agent_id, job.contact_id, '⏰ *Reminder:* ' + (job.action_data || job.name), job.platform, metadata);
136
+ }
137
+ } catch (err) {
138
+ logger.error('cron', `Job ${job.id} failed: ${err.message}`);
139
+ }
140
+ }
141
+
142
+ async _send(agentId, contactId, message, platform, metadata) {
143
+ if (platform === 'telegram' && this.engine.telegramManager) {
144
+ await this.engine.telegramManager.sendMessage(agentId, contactId, message, metadata);
145
+ } else if (this.engine.whatsappManager) {
146
+ await this.engine.whatsappManager.sendMessage(agentId, contactId, message);
147
+ }
148
+ }
149
+
150
+ // ── Schedule Parser ──
151
+
152
+ _calcNextRun(schedule) {
153
+ const now = new Date();
154
+
155
+ // every:5m / every:1h / every:24h
156
+ const everyMatch = schedule.match(/^every:(\d+)(m|h|d)$/);
157
+ if (everyMatch) {
158
+ const num = parseInt(everyMatch[1]);
159
+ const unit = everyMatch[2];
160
+ const ms = unit === 'm' ? num * 60000 : unit === 'h' ? num * 3600000 : num * 86400000;
161
+ return new Date(now.getTime() + ms).toISOString().slice(0, 16);
162
+ }
163
+
164
+ // daily:09:00
165
+ const dailyMatch = schedule.match(/^daily:(\d{2}):(\d{2})$/);
166
+ if (dailyMatch) {
167
+ const target = new Date(now);
168
+ target.setUTCHours(parseInt(dailyMatch[1]), parseInt(dailyMatch[2]), 0, 0);
169
+ if (target <= now) target.setDate(target.getDate() + 1);
170
+ return target.toISOString().slice(0, 16);
171
+ }
172
+
173
+ // weekly:fri:09:00
174
+ const weeklyMatch = schedule.match(/^weekly:(\w{3}):(\d{2}):(\d{2})$/);
175
+ if (weeklyMatch) {
176
+ const days = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
177
+ const targetDay = days[weeklyMatch[1].toLowerCase()] ?? 0;
178
+ const target = new Date(now);
179
+ target.setUTCHours(parseInt(weeklyMatch[2]), parseInt(weeklyMatch[3]), 0, 0);
180
+ let diff = targetDay - target.getUTCDay();
181
+ if (diff < 0 || (diff === 0 && target <= now)) diff += 7;
182
+ target.setDate(target.getDate() + diff);
183
+ return target.toISOString().slice(0, 16);
184
+ }
185
+
186
+ // monthly:1:09:00
187
+ const monthlyMatch = schedule.match(/^monthly:(\d+):(\d{2}):(\d{2})$/);
188
+ if (monthlyMatch) {
189
+ const target = new Date(now);
190
+ target.setUTCDate(parseInt(monthlyMatch[1]));
191
+ target.setUTCHours(parseInt(monthlyMatch[2]), parseInt(monthlyMatch[3]), 0, 0);
192
+ if (target <= now) target.setMonth(target.getMonth() + 1);
193
+ return target.toISOString().slice(0, 16);
194
+ }
195
+
196
+ // cron expression (basic: min hour dom mon dow)
197
+ const cronMatch = schedule.match(/^cron:(.+)$/);
198
+ if (cronMatch) {
199
+ // Simple next-minute for cron β€” full parser would be heavy
200
+ return new Date(now.getTime() + 60000).toISOString().slice(0, 16);
201
+ }
202
+
203
+ // Default: 1 hour
204
+ return new Date(now.getTime() + 3600000).toISOString().slice(0, 16);
205
+ }
206
+
207
+ _loadJobs() {
208
+ const count = this.storage.db.prepare("SELECT COUNT(*) as c FROM cron_jobs WHERE enabled = 1").get()?.c || 0;
209
+ if (count > 0) {
210
+ logger.info('cron', `Loaded ${count} active cron jobs`);
211
+ }
212
+ }
213
+
214
+ stop() {
215
+ if (this._interval) clearInterval(this._interval);
216
+ }
217
+ }
@@ -68,7 +68,7 @@ export class ReminderManager {
68
68
  }
69
69
 
70
70
  async _fire(reminder) {
71
- logger.info('reminders', `Firing reminder ${reminder.id} for ${reminder.contact_id}`);
71
+ logger.info('reminders', `πŸ”” FIRING reminder ${reminder.id} for ${reminder.contact_id}: ${reminder.message}`);
72
72
 
73
73
  // Mark as fired
74
74
  try {
@@ -95,6 +95,7 @@ export class ReminderManager {
95
95
  * @param {object} metadata - chat metadata for sending
96
96
  */
97
97
  add(agentId, contactId, message, fireAt, platform = 'telegram', metadata = {}) {
98
+ logger.info('reminders', `πŸ“ ADDING reminder for ${contactId}: ${message} at ${fireAt}`);
98
99
  const id = 'rem_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
99
100
  const fireAtStr = typeof fireAt === 'string' ? fireAt : fireAt.toISOString().replace('Z', '');
100
101
 
@@ -145,6 +145,31 @@ export async function commandsMiddleware(ctx, next) {
145
145
  return;
146
146
  }
147
147
 
148
+ if (cmd === '/cron') {
149
+ const args = msg.slice(6).trim();
150
+ if (!ctx.engine.cron) { await ctx.reply('❌ Cron not available'); return; }
151
+
152
+ if (!args || args === 'list') {
153
+ const jobs = ctx.engine.cron.listAll(ctx.agentId);
154
+ if (jobs.length === 0) { await ctx.reply('⏰ No scheduled jobs'); return; }
155
+ const lines = jobs.map(j =>
156
+ (j.enabled ? 'βœ…' : '⏸️') + ' *' + j.name + '*\n ' + j.schedule + ' β†’ ' + j.action + '\n Next: ' + (j.next_run || '?') + '\n ID: `' + j.id + '`'
157
+ );
158
+ await ctx.reply('⏰ *Cron Jobs*\n\n' + lines.join('\n\n'));
159
+ return;
160
+ }
161
+
162
+ if (args.startsWith('remove ') || args.startsWith('delete ')) {
163
+ const id = args.split(' ')[1];
164
+ ctx.engine.cron.remove(id);
165
+ await ctx.reply('βœ… Removed cron job ' + id);
166
+ return;
167
+ }
168
+
169
+ await ctx.reply('Usage:\n/cron β€” list jobs\n/cron remove <id> β€” delete job\n\nOr tell me naturally: "Every morning at 9am send me a briefing"');
170
+ return;
171
+ }
172
+
148
173
  if (cmd === '/allow') {
149
174
  const args = msg.slice(7).trim();
150
175
  if (!args) { await ctx.reply('Usage: /allow <user_id or phone>'); return; }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * πŸ¦‘ Excel Generator
3
+ * Create .xlsx spreadsheets
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+ import { mkdirSync } from 'fs';
8
+ import { join } from 'path';
9
+
10
+ export class ExcelGenerator {
11
+ constructor() {
12
+ this.outputDir = '/tmp/squidclaw-files';
13
+ mkdirSync(this.outputDir, { recursive: true });
14
+ }
15
+
16
+ async create(title, sheets, options = {}) {
17
+ const ExcelJS = (await import('exceljs')).default;
18
+ const wb = new ExcelJS.Workbook();
19
+ wb.creator = 'Squidclaw AI πŸ¦‘';
20
+ wb.created = new Date();
21
+
22
+ const themes = {
23
+ blue: { header: '2563EB', headerFont: 'FFFFFF', alt: 'EFF6FF', border: 'BFDBFE' },
24
+ green: { header: '059669', headerFont: 'FFFFFF', alt: 'ECFDF5', border: 'A7F3D0' },
25
+ dark: { header: '1F2937', headerFont: 'FFFFFF', alt: 'F3F4F6', border: 'D1D5DB' },
26
+ red: { header: 'DC2626', headerFont: 'FFFFFF', alt: 'FEF2F2', border: 'FECACA' },
27
+ saudi: { header: '166534', headerFont: 'FFFFFF', alt: 'F0FDF4', border: 'BBF7D0' },
28
+ corporate: { header: '1E40AF', headerFont: 'FFFFFF', alt: 'F8FAFC', border: 'CBD5E1' },
29
+ };
30
+ const theme = themes[options.theme] || themes.corporate;
31
+
32
+ for (const sheet of sheets) {
33
+ const ws = wb.addWorksheet(sheet.name || 'Sheet');
34
+
35
+ if (!sheet.rows || sheet.rows.length === 0) continue;
36
+
37
+ // Auto-detect columns from first row
38
+ const headers = sheet.rows[0];
39
+
40
+ // Set columns with auto-width
41
+ ws.columns = headers.map((h, i) => ({
42
+ header: String(h),
43
+ key: 'col' + i,
44
+ width: Math.max(String(h).length + 4, 15),
45
+ }));
46
+
47
+ // Style header row
48
+ const headerRow = ws.getRow(1);
49
+ headerRow.height = 28;
50
+ headerRow.eachCell((cell) => {
51
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: theme.header } };
52
+ cell.font = { color: { argb: theme.headerFont }, bold: true, size: 12 };
53
+ cell.alignment = { vertical: 'middle', horizontal: 'center' };
54
+ cell.border = {
55
+ bottom: { style: 'medium', color: { argb: theme.border } },
56
+ };
57
+ });
58
+
59
+ // Data rows
60
+ for (let r = 1; r < sheet.rows.length; r++) {
61
+ const row = sheet.rows[r];
62
+ const wsRow = ws.addRow(row.reduce((obj, val, i) => { obj['col' + i] = val; return obj; }, {}));
63
+ wsRow.height = 22;
64
+
65
+ wsRow.eachCell((cell) => {
66
+ if (r % 2 === 0) {
67
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: theme.alt } };
68
+ }
69
+ cell.alignment = { vertical: 'middle' };
70
+ cell.border = {
71
+ bottom: { style: 'thin', color: { argb: theme.border } },
72
+ };
73
+ // Auto-detect numbers
74
+ const val = cell.value;
75
+ if (typeof val === 'string' && !isNaN(val.replace(/[,$%]/g, ''))) {
76
+ cell.value = parseFloat(val.replace(/[,$]/g, ''));
77
+ if (val.includes('%')) cell.numFmt = '0.0%';
78
+ else if (val.includes('$')) cell.numFmt = '$#,##0.00';
79
+ else cell.numFmt = '#,##0';
80
+ }
81
+ });
82
+ }
83
+
84
+ // Auto-filter
85
+ if (sheet.rows.length > 1) {
86
+ ws.autoFilter = { from: 'A1', to: String.fromCharCode(64 + headers.length) + '1' };
87
+ }
88
+
89
+ // Freeze header
90
+ ws.views = [{ state: 'frozen', ySplit: 1 }];
91
+ }
92
+
93
+ const filename = title.replace(/[^a-zA-Z0-9\u0600-\u06FF ]/g, '').replace(/\s+/g, '_').slice(0, 50) + '.xlsx';
94
+ const filepath = join(this.outputDir, filename);
95
+ await wb.xlsx.writeFile(filepath);
96
+
97
+ logger.info('excel', `Created: ${filepath}`);
98
+ return { filepath, filename, sheetCount: sheets.length };
99
+ }
100
+
101
+ /**
102
+ * Parse AI content: rows separated by newlines, cells by | or ,
103
+ */
104
+ static parseContent(text) {
105
+ const sheets = [];
106
+ const sections = text.split(/^### /gm).filter(s => s.trim());
107
+
108
+ if (sections.length === 0) {
109
+ // Single sheet
110
+ sheets.push({ name: 'Data', rows: ExcelGenerator._parseRows(text) });
111
+ } else {
112
+ for (const section of sections) {
113
+ const lines = section.trim().split('\n');
114
+ const name = lines[0].trim();
115
+ sheets.push({ name, rows: ExcelGenerator._parseRows(lines.slice(1).join('\n')) });
116
+ }
117
+ }
118
+ return sheets;
119
+ }
120
+
121
+ static _parseRows(text) {
122
+ const rows = [];
123
+ for (const line of text.split('\n')) {
124
+ const trimmed = line.trim();
125
+ if (!trimmed || /^[-|:=]+$/.test(trimmed)) continue;
126
+ if (trimmed.includes('|')) {
127
+ rows.push(trimmed.split('|').map(c => c.trim()).filter(Boolean));
128
+ } else if (trimmed.includes(',')) {
129
+ rows.push(trimmed.split(',').map(c => c.trim()));
130
+ } else if (trimmed.includes('\t')) {
131
+ rows.push(trimmed.split('\t').map(c => c.trim()));
132
+ }
133
+ }
134
+ return rows;
135
+ }
136
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * πŸ¦‘ HTML Generator
3
+ * Create styled HTML pages/reports
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+ import { writeFileSync, mkdirSync } from 'fs';
8
+ import { join } from 'path';
9
+
10
+ export class HtmlGenerator {
11
+ constructor() {
12
+ this.outputDir = '/tmp/squidclaw-files';
13
+ mkdirSync(this.outputDir, { recursive: true });
14
+ }
15
+
16
+ create(title, content, options = {}) {
17
+ const theme = options.theme || 'light';
18
+ const isDark = ['dark', 'ocean', 'gradient'].includes(theme);
19
+
20
+ const css = isDark ? `
21
+ body { background: #0d1117; color: #c9d1d9; }
22
+ h1 { color: #58a6ff; border-bottom: 2px solid #58a6ff; }
23
+ h2 { color: #58a6ff; }
24
+ h3 { color: #8b949e; }
25
+ a { color: #58a6ff; }
26
+ blockquote { border-left: 3px solid #58a6ff; color: #8b949e; }
27
+ table th { background: #161b22; color: #58a6ff; }
28
+ table td { border-color: #30363d; }
29
+ tr:nth-child(even) { background: #161b22; }
30
+ code { background: #161b22; color: #f0883e; }
31
+ .accent { color: #58a6ff; }
32
+ ` : `
33
+ body { background: #ffffff; color: #1f2937; }
34
+ h1 { color: #1e40af; border-bottom: 2px solid #2563eb; }
35
+ h2 { color: #1e40af; }
36
+ h3 { color: #374151; }
37
+ a { color: #2563eb; }
38
+ blockquote { border-left: 3px solid #2563eb; color: #6b7280; }
39
+ table th { background: #2563eb; color: white; }
40
+ table td { border-color: #e5e7eb; }
41
+ tr:nth-child(even) { background: #f8fafc; }
42
+ code { background: #f1f5f9; color: #dc2626; }
43
+ .accent { color: #2563eb; }
44
+ `;
45
+
46
+ // Convert markdown-like content to HTML
47
+ let html = this._markdownToHtml(content);
48
+
49
+ const page = `<!DOCTYPE html>
50
+ <html lang="en">
51
+ <head>
52
+ <meta charset="UTF-8">
53
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
54
+ <title>${this._escape(title)}</title>
55
+ <style>
56
+ * { margin: 0; padding: 0; box-sizing: border-box; }
57
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.7; padding: 40px; max-width: 900px; margin: 0 auto; }
58
+ h1 { font-size: 2.2em; margin-bottom: 8px; padding-bottom: 12px; }
59
+ h2 { font-size: 1.6em; margin-top: 32px; margin-bottom: 12px; }
60
+ h3 { font-size: 1.2em; margin-top: 24px; margin-bottom: 8px; }
61
+ p { margin-bottom: 12px; }
62
+ ul, ol { margin: 12px 0; padding-left: 28px; }
63
+ li { margin-bottom: 6px; }
64
+ blockquote { padding: 12px 20px; margin: 16px 0; font-style: italic; }
65
+ table { width: 100%; border-collapse: collapse; margin: 16px 0; }
66
+ th, td { padding: 10px 14px; text-align: left; }
67
+ td { border-bottom: 1px solid; }
68
+ th { font-weight: 600; text-transform: uppercase; font-size: 0.85em; letter-spacing: 0.5px; }
69
+ code { padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
70
+ pre { padding: 16px; border-radius: 8px; overflow-x: auto; margin: 16px 0; }
71
+ img { max-width: 100%; border-radius: 8px; }
72
+ hr { border: none; border-top: 1px solid #e5e7eb; margin: 24px 0; }
73
+ .meta { font-size: 0.85em; opacity: 0.6; margin-top: 40px; text-align: center; }
74
+ ${css}
75
+ </style>
76
+ </head>
77
+ <body>
78
+ <h1>${this._escape(title)}</h1>
79
+ <p style="opacity:0.5;font-size:0.9em">${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
80
+ ${html}
81
+ <p class="meta">Created with Squidclaw AI πŸ¦‘</p>
82
+ </body>
83
+ </html>`;
84
+
85
+ const filename = title.replace(/[^a-zA-Z0-9\u0600-\u06FF ]/g, '').replace(/\s+/g, '_').slice(0, 50) + '.html';
86
+ const filepath = join(this.outputDir, filename);
87
+ writeFileSync(filepath, page);
88
+
89
+ logger.info('html', `Created: ${filepath}`);
90
+ return { filepath, filename };
91
+ }
92
+
93
+ _markdownToHtml(md) {
94
+ let html = md
95
+ .replace(/^## (.+)$/gm, '<h2>$1</h2>')
96
+ .replace(/^### (.+)$/gm, '<h3>$1</h3>')
97
+ .replace(/^\> (.+)$/gm, '<blockquote>$1</blockquote>')
98
+ .replace(/^---$/gm, '<hr>')
99
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
100
+ .replace(/\*(.+?)\*/g, '<em>$1</em>')
101
+ .replace(/`(.+?)`/g, '<code>$1</code>')
102
+ .replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
103
+
104
+ // Bullet lists
105
+ const lines = html.split('\n');
106
+ let result = '';
107
+ let inList = false;
108
+
109
+ for (const line of lines) {
110
+ const trimmed = line.trim();
111
+ if (trimmed.startsWith('- ') || trimmed.startsWith('β€’ ') || trimmed.startsWith('* ')) {
112
+ if (!inList) { result += '<ul>'; inList = true; }
113
+ result += '<li>' + trimmed.replace(/^[-β€’*]\s*/, '') + '</li>';
114
+ } else {
115
+ if (inList) { result += '</ul>'; inList = false; }
116
+ if (trimmed.startsWith('<h') || trimmed.startsWith('<blockquote') || trimmed.startsWith('<hr') || !trimmed) {
117
+ result += trimmed;
118
+ } else if (trimmed.startsWith('|')) {
119
+ result += this._parseTable(trimmed, lines);
120
+ } else {
121
+ result += '<p>' + trimmed + '</p>';
122
+ }
123
+ }
124
+ }
125
+ if (inList) result += '</ul>';
126
+
127
+ return result;
128
+ }
129
+
130
+ _parseTable(firstLine, allLines) {
131
+ // Find consecutive | lines
132
+ const tableLines = allLines.filter(l => l.trim().startsWith('|') && !/^[-|:\s]+$/.test(l.trim()));
133
+ if (tableLines.length === 0) return '';
134
+
135
+ let html = '<table>';
136
+ tableLines.forEach((line, i) => {
137
+ const cells = line.split('|').filter(c => c.trim()).map(c => c.trim());
138
+ const tag = i === 0 ? 'th' : 'td';
139
+ html += '<tr>' + cells.map(c => `<${tag}>${c}</${tag}>`).join('') + '</tr>';
140
+ });
141
+ html += '</table>';
142
+ return html;
143
+ }
144
+
145
+ _escape(str) {
146
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
147
+ }
148
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * πŸ¦‘ PDF Generator
3
+ * Create .pdf documents
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+ import { mkdirSync, createWriteStream } from 'fs';
8
+ import { join } from 'path';
9
+
10
+ export class PdfGenerator {
11
+ constructor() {
12
+ this.outputDir = '/tmp/squidclaw-files';
13
+ mkdirSync(this.outputDir, { recursive: true });
14
+ }
15
+
16
+ async create(title, content, options = {}) {
17
+ const PDFDocument = (await import('pdfkit')).default;
18
+
19
+ const filename = title.replace(/[^a-zA-Z0-9\u0600-\u06FF ]/g, '').replace(/\s+/g, '_').slice(0, 50) + '.pdf';
20
+ const filepath = join(this.outputDir, filename);
21
+
22
+ return new Promise((resolve, reject) => {
23
+ const doc = new PDFDocument({
24
+ size: 'A4',
25
+ margins: { top: 60, bottom: 60, left: 60, right: 60 },
26
+ info: { Title: title, Author: 'Squidclaw AI πŸ¦‘', Creator: 'Squidclaw' },
27
+ });
28
+
29
+ const stream = createWriteStream(filepath);
30
+ doc.pipe(stream);
31
+
32
+ const colors = {
33
+ title: '#1a1a2e',
34
+ heading: '#2563eb',
35
+ text: '#374151',
36
+ accent: '#2563eb',
37
+ light: '#6b7280',
38
+ bg: '#f8fafc',
39
+ };
40
+
41
+ // Header accent bar
42
+ doc.rect(0, 0, doc.page.width, 8).fill(colors.accent);
43
+
44
+ // Title
45
+ doc.moveDown(1);
46
+ doc.fontSize(28).fillColor(colors.title).font('Helvetica-Bold').text(title, { align: 'left' });
47
+
48
+ // Subtitle line
49
+ doc.moveDown(0.3);
50
+ doc.moveTo(60, doc.y).lineTo(200, doc.y).strokeColor(colors.accent).lineWidth(2).stroke();
51
+ doc.moveDown(0.5);
52
+
53
+ // Date
54
+ const date = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
55
+ doc.fontSize(10).fillColor(colors.light).font('Helvetica').text(date);
56
+ doc.moveDown(1.5);
57
+
58
+ // Parse and render content
59
+ const lines = content.split('\n');
60
+ for (const line of lines) {
61
+ const trimmed = line.trim();
62
+ if (!trimmed) { doc.moveDown(0.5); continue; }
63
+
64
+ // Check for page overflow
65
+ if (doc.y > doc.page.height - 100) {
66
+ doc.addPage();
67
+ doc.rect(0, 0, doc.page.width, 4).fill(colors.accent);
68
+ doc.moveDown(1);
69
+ }
70
+
71
+ // ## Heading
72
+ if (trimmed.startsWith('## ')) {
73
+ doc.moveDown(0.8);
74
+ doc.fontSize(18).fillColor(colors.heading).font('Helvetica-Bold').text(trimmed.slice(3));
75
+ doc.moveDown(0.2);
76
+ doc.moveTo(60, doc.y).lineTo(160, doc.y).strokeColor(colors.accent).lineWidth(1).stroke();
77
+ doc.moveDown(0.5);
78
+ }
79
+ // ### Subheading
80
+ else if (trimmed.startsWith('### ')) {
81
+ doc.moveDown(0.5);
82
+ doc.fontSize(14).fillColor(colors.title).font('Helvetica-Bold').text(trimmed.slice(4));
83
+ doc.moveDown(0.3);
84
+ }
85
+ // - Bullet
86
+ else if (trimmed.startsWith('- ') || trimmed.startsWith('β€’ ') || trimmed.startsWith('* ')) {
87
+ const bullet = trimmed.replace(/^[-β€’*]\s*/, '');
88
+ doc.fontSize(11).fillColor(colors.text).font('Helvetica');
89
+ doc.text('● ' + bullet, { indent: 15, lineGap: 4 });
90
+ }
91
+ // **Bold**
92
+ else if (trimmed.startsWith('**') && trimmed.endsWith('**')) {
93
+ doc.fontSize(12).fillColor(colors.title).font('Helvetica-Bold').text(trimmed.replace(/\*\*/g, ''));
94
+ }
95
+ // > Quote
96
+ else if (trimmed.startsWith('> ')) {
97
+ doc.moveDown(0.3);
98
+ const quoteText = trimmed.slice(2);
99
+ const qx = doc.x;
100
+ doc.rect(qx, doc.y, 3, 40).fill(colors.accent);
101
+ doc.fontSize(11).fillColor(colors.light).font('Helvetica-Oblique').text(quoteText, qx + 15, doc.y - 40 + 5, { width: 420 });
102
+ doc.moveDown(0.5);
103
+ }
104
+ // --- Divider
105
+ else if (trimmed === '---' || trimmed === '***') {
106
+ doc.moveDown(0.5);
107
+ doc.moveTo(60, doc.y).lineTo(doc.page.width - 60, doc.y).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
108
+ doc.moveDown(0.5);
109
+ }
110
+ // Normal text
111
+ else {
112
+ doc.fontSize(11).fillColor(colors.text).font('Helvetica').text(trimmed, { lineGap: 4 });
113
+ }
114
+ }
115
+
116
+ // Footer
117
+ const pages = doc.bufferedPageRange();
118
+ for (let i = 0; i < pages.count; i++) {
119
+ doc.switchToPage(i);
120
+ doc.fontSize(8).fillColor(colors.light).font('Helvetica');
121
+ doc.text('Created with Squidclaw AI πŸ¦‘', 60, doc.page.height - 40, { width: doc.page.width - 120, align: 'center' });
122
+ doc.text(String(i + 1), 60, doc.page.height - 40, { width: doc.page.width - 120, align: 'right' });
123
+ }
124
+
125
+ doc.end();
126
+ stream.on('finish', () => {
127
+ logger.info('pdf', `Created: ${filepath}`);
128
+ resolve({ filepath, filename });
129
+ });
130
+ stream.on('error', reject);
131
+ });
132
+ }
133
+ }
@@ -79,6 +79,37 @@ export class ToolRouter {
79
79
  'Example:',
80
80
  '---TOOL:pptx_slides:AI Report|dark|## Introduction\n- AI is transforming industries\n- Revenue growing 40% YoY\n\n## Growth [chart:bar]\n- 2020: 50\n- 2021: 80\n- 2022: 120\n- 2023: 200\n\n## Key Stats [stats]\n- 🌍 195 β€” Countries using AI\n- πŸ’° $500B β€” Market size\n- πŸš€ 40% β€” Annual growth---');
81
81
 
82
+ tools.push('', '### Create Excel Spreadsheet (SENDS AS FILE!)',
83
+ '---TOOL:excel:Title|theme|sheet content---',
84
+ 'Creates .xlsx file and sends it. Theme: blue, green, dark, red, saudi, corporate.',
85
+ 'Content: use | for columns. First row = headers. Separate sheets with ### SheetName.',
86
+ 'Example: ---TOOL:excel:Sales Report|blue|Product|Revenue|Growth\nWidget A|50000|12%\nWidget B|80000|25%---',
87
+ '', '### Create PDF Document (SENDS AS FILE!)',
88
+ '---TOOL:pdf:Title|content in markdown---',
89
+ 'Creates .pdf file and sends it. Use ## for headings, - for bullets, > for quotes, --- for dividers.',
90
+ 'Example: ---TOOL:pdf:Meeting Notes|## Summary\n- Discussed Q1 results\n- Action items assigned---',
91
+ '', '### Create HTML Page (SENDS AS FILE!)',
92
+ '---TOOL:html:Title|theme|content in markdown---',
93
+ 'Creates .html file and sends it. Themes: light, dark, ocean.',
94
+ 'Example: ---TOOL:html:Report|dark|## Overview\n- Key findings\n- Recommendations---');
95
+
96
+ tools.push('', '### Schedule Cron Job',
97
+ '---TOOL:cron_add:name|schedule|action|data---',
98
+ 'Create a recurring scheduled job. Schedule formats:',
99
+ '- every:5m / every:1h / every:24h (interval)',
100
+ '- daily:09:00 (every day at time UTC)',
101
+ '- weekly:fri:09:00 (every week on day)',
102
+ '- monthly:1:09:00 (every month on date)',
103
+ 'Actions: message (send text), briefing (daily brief), remind (reminder), ai (run AI prompt)',
104
+ 'Example: ---TOOL:cron_add:Morning Briefing|daily:06:00|briefing|---',
105
+ 'Example: ---TOOL:cron_add:Weekly Report Reminder|weekly:fri:09:00|remind|Submit the weekly report!---',
106
+ '', '### List Cron Jobs',
107
+ '---TOOL:cron_list:all---',
108
+ 'Show all scheduled jobs.',
109
+ '', '### Remove Cron Job',
110
+ '---TOOL:cron_remove:job_id---',
111
+ 'Remove a scheduled job by ID.');
112
+
82
113
  tools.push('', '### Allow User',
83
114
  '---TOOL:allow:user_id_or_phone---',
84
115
  'Add someone to the allowlist so they can message you.',
@@ -187,9 +218,16 @@ export class ToolRouter {
187
218
  'You already have vision β€” use it to read text from screenshots, documents, signs, etc.');
188
219
 
189
220
  tools.push('', '### Set Reminder',
190
- '---TOOL:remind:YYYY-MM-DDTHH:MM|Your reminder message---',
191
- 'Set a reminder to message the user at a specific time. Time must be in UTC.',
192
- 'Example: ---TOOL:remind:2026-03-03T08:30|Wake up! Time to start the day!---',
221
+ '---TOOL:remind:TIME|Your reminder message---',
222
+ 'Set a reminder. TIME can be:',
223
+ '- Relative: 5m, 30m, 1h, 2h, 1d (minutes, hours, days from now)',
224
+ '- Absolute: 2026-03-03T08:30 (UTC)',
225
+ '- Natural: tomorrow, tonight',
226
+ 'Examples:',
227
+ '- ---TOOL:remind:30m|Check the oven!---',
228
+ '- ---TOOL:remind:2h|Call Ahmed---',
229
+ '- ---TOOL:remind:2026-03-03T15:00|Meeting time!---',
230
+ 'ALWAYS use this tool when user says "remind me". Do NOT just acknowledge β€” actually set it.',
193
231
  'The user will receive a proactive message at that time even if they are not chatting.');
194
232
 
195
233
  tools.push('', '**Important:** Use tools when needed. The tool result will be injected into the conversation automatically. Only use one tool per response.');
@@ -208,7 +246,7 @@ export class ToolRouter {
208
246
  }
209
247
 
210
248
  async processResponse(response, agentId) {
211
- const toolMatch = response.match(/---TOOL:(\w+):(.+?)---/);
249
+ const toolMatch = response.match(/---TOOL:(\w+):([\s\S]+?)---/);
212
250
  if (!toolMatch) return { toolUsed: false, toolResult: null, cleanResponse: response };
213
251
 
214
252
  const [fullMatch, toolName, toolArg] = toolMatch;
@@ -229,12 +267,31 @@ export class ToolRouter {
229
267
  try {
230
268
  const pipeIdx = toolArg.indexOf('|');
231
269
  if (pipeIdx === -1) {
232
- toolResult = 'Invalid reminder format. Use: YYYY-MM-DDTHH:MM|message';
270
+ toolResult = 'Format: time|message. Example: 30m|Check oven';
233
271
  break;
234
272
  }
235
- const timeStr = toolArg.slice(0, pipeIdx).trim();
273
+ let timeStr = toolArg.slice(0, pipeIdx).trim();
236
274
  const msg = toolArg.slice(pipeIdx + 1).trim();
237
- // Store reminder request in result for engine to pick up
275
+
276
+ // Parse relative times
277
+ const relMatch = timeStr.match(/^(\d+)\s*(m|min|minutes?|h|hr|hours?|d|days?)$/i);
278
+ if (relMatch) {
279
+ const num = parseInt(relMatch[1]);
280
+ const unit = relMatch[2][0].toLowerCase();
281
+ const ms = unit === 'm' ? num * 60000 : unit === 'h' ? num * 3600000 : num * 86400000;
282
+ const fireDate = new Date(Date.now() + ms);
283
+ timeStr = fireDate.toISOString().slice(0, 16);
284
+ } else if (timeStr.toLowerCase() === 'tomorrow') {
285
+ const d = new Date(Date.now() + 86400000);
286
+ d.setUTCHours(9, 0, 0, 0);
287
+ timeStr = d.toISOString().slice(0, 16);
288
+ } else if (timeStr.toLowerCase() === 'tonight') {
289
+ const d = new Date();
290
+ d.setUTCHours(18, 0, 0, 0);
291
+ if (d < new Date()) d.setDate(d.getDate() + 1);
292
+ timeStr = d.toISOString().slice(0, 16);
293
+ }
294
+
238
295
  return { toolUsed: true, toolName: 'remind', reminderTime: timeStr, reminderMessage: msg, cleanResponse };
239
296
  } catch (err) {
240
297
  toolResult = 'Failed to set reminder: ' + err.message;
@@ -291,6 +348,86 @@ export class ToolRouter {
291
348
  }
292
349
  break;
293
350
  }
351
+ case 'excel':
352
+ case 'xlsx':
353
+ case 'spreadsheet': {
354
+ try {
355
+ const { ExcelGenerator } = await import('./excel.js');
356
+ const gen = new ExcelGenerator();
357
+ const parts = toolArg.split('|');
358
+ let title, themeName, sheetContent;
359
+ if (parts.length >= 3) {
360
+ title = parts[0].trim();
361
+ themeName = parts[1].trim();
362
+ sheetContent = parts.slice(2).join('|');
363
+ } else {
364
+ title = parts[0]?.trim() || 'Spreadsheet';
365
+ sheetContent = parts.slice(1).join('|');
366
+ themeName = 'corporate';
367
+ }
368
+ const sheets = ExcelGenerator.parseContent(sheetContent);
369
+ const result = await gen.create(title, sheets, { theme: themeName });
370
+ return { toolUsed: true, toolName: 'excel', toolResult: 'Excel created: ' + result.filename, filePath: result.filepath, fileName: result.filename, cleanResponse };
371
+ } catch (err) { toolResult = 'Excel failed: ' + err.message; }
372
+ break;
373
+ }
374
+ case 'pdf':
375
+ case 'document': {
376
+ try {
377
+ const { PdfGenerator } = await import('./pdf.js');
378
+ const gen = new PdfGenerator();
379
+ const pipeIdx = toolArg.indexOf('|');
380
+ const title = pipeIdx > -1 ? toolArg.slice(0, pipeIdx).trim() : 'Document';
381
+ const content = pipeIdx > -1 ? toolArg.slice(pipeIdx + 1) : toolArg;
382
+ const result = await gen.create(title, content);
383
+ return { toolUsed: true, toolName: 'pdf', toolResult: 'PDF created: ' + result.filename, filePath: result.filepath, fileName: result.filename, cleanResponse };
384
+ } catch (err) { toolResult = 'PDF failed: ' + err.message; }
385
+ break;
386
+ }
387
+ case 'html':
388
+ case 'webpage': {
389
+ try {
390
+ const { HtmlGenerator } = await import('./html.js');
391
+ const gen = new HtmlGenerator();
392
+ const parts = toolArg.split('|');
393
+ const title = parts[0]?.trim() || 'Page';
394
+ const themeName = parts.length >= 3 ? parts[1].trim() : 'light';
395
+ const content = parts.length >= 3 ? parts.slice(2).join('|') : parts.slice(1).join('|');
396
+ const result = gen.create(title, content, { theme: themeName });
397
+ return { toolUsed: true, toolName: 'html', toolResult: 'HTML created: ' + result.filename, filePath: result.filepath, fileName: result.filename, cleanResponse };
398
+ } catch (err) { toolResult = 'HTML failed: ' + err.message; }
399
+ break;
400
+ }
401
+ case 'cron_add': {
402
+ try {
403
+ if (!this._engine?.cron) { toolResult = 'Cron not available'; break; }
404
+ const parts = toolArg.split('|').map(p => p.trim());
405
+ const [name, schedule, action, ...dataParts] = parts;
406
+ const data = dataParts.join('|');
407
+ const result = this._engine.cron.add(agentId, this._currentContactId, name, schedule, action || 'message', data, this._currentPlatform);
408
+ toolResult = 'Cron job created! βœ…\nπŸ“‹ ' + result.name + '\n⏰ Schedule: ' + result.schedule + '\nπŸ”œ Next run: ' + result.nextRun;
409
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
410
+ break;
411
+ }
412
+ case 'cron_list': {
413
+ try {
414
+ if (!this._engine?.cron) { toolResult = 'Cron not available'; break; }
415
+ const jobs = this._engine.cron.listAll(agentId);
416
+ if (jobs.length === 0) { toolResult = 'No scheduled jobs'; break; }
417
+ toolResult = jobs.map(j =>
418
+ (j.enabled ? 'βœ…' : '⏸️') + ' ' + j.name + ' (' + j.schedule + ')\n ID: ' + j.id + '\n Next: ' + (j.next_run || 'N/A')
419
+ ).join('\n\n');
420
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
421
+ break;
422
+ }
423
+ case 'cron_remove': {
424
+ try {
425
+ if (!this._engine?.cron) { toolResult = 'Cron not available'; break; }
426
+ this._engine.cron.remove(toolArg.trim());
427
+ toolResult = 'Cron job removed βœ…';
428
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
429
+ break;
430
+ }
294
431
  case 'allow': {
295
432
  try {
296
433
  const { AllowlistManager } = await import('../features/allowlist-manager.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "πŸ¦‘ AI agent platform β€” human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {
@@ -42,12 +42,14 @@
42
42
  "commander": "^14.0.3",
43
43
  "croner": "^10.0.1",
44
44
  "dotenv": "^17.3.1",
45
+ "exceljs": "^4.4.0",
45
46
  "express": "^5.2.1",
46
47
  "file-type": "^21.3.0",
47
48
  "grammy": "^1.40.1",
48
49
  "linkedom": "^0.18.12",
49
50
  "node-edge-tts": "^1.2.10",
50
51
  "pdfjs-dist": "^5.4.624",
52
+ "pdfkit": "^0.17.2",
51
53
  "pino": "^10.3.1",
52
54
  "pptxgenjs": "^4.0.1",
53
55
  "puppeteer-core": "^24.37.5",