squidclaw 2.3.0 β†’ 2.5.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.
@@ -73,8 +73,9 @@ export function addToolSupport(agent, toolRouter, knowledgeBase) {
73
73
  return result;
74
74
  }
75
75
 
76
- // File attachment (pptx, etc)
76
+ // File attachment (pptx, excel, pdf, html)
77
77
  if (toolResult.toolUsed && toolResult.filePath) {
78
+ logger.info('agent', 'FILE TOOL: ' + toolResult.toolName + ' -> ' + toolResult.filePath);
78
79
  result.filePath = toolResult.filePath;
79
80
  result.fileName = toolResult.fileName;
80
81
  result.messages = [toolResult.toolResult || 'Here\'s your file! πŸ“Ž'];
package/lib/engine.js CHANGED
@@ -226,6 +226,18 @@ export class SquidclawEngine {
226
226
  if (pending.c > 0) console.log(` ⏰ Reminders: ${pending.c} pending`);
227
227
  } catch {}
228
228
 
229
+ // Sessions
230
+ try {
231
+ const { SessionManager } = await import('./features/sessions.js');
232
+ this.sessions = new SessionManager(this.storage, this);
233
+ } catch (err) { logger.error('engine', 'Sessions init failed: ' + err.message); }
234
+
235
+ // Cron jobs
236
+ try {
237
+ const { CronManager } = await import('./features/cron.js');
238
+ this.cron = new CronManager(this.storage, this);
239
+ } catch (err) { logger.error('engine', 'Cron init failed: ' + err.message); }
240
+
229
241
  // Auto-memory
230
242
  try {
231
243
  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
 
@@ -0,0 +1,269 @@
1
+ /**
2
+ * πŸ¦‘ Session Manager
3
+ * Multiple isolated conversations per agent
4
+ * Types: main (default), isolated (fresh context), thread (linked to parent)
5
+ */
6
+
7
+ import { logger } from '../core/logger.js';
8
+ import crypto from 'crypto';
9
+
10
+ export class SessionManager {
11
+ constructor(storage, engine) {
12
+ this.storage = storage;
13
+ this.engine = engine;
14
+ this.active = new Map(); // sessionKey -> session
15
+ this._initDb();
16
+ }
17
+
18
+ _initDb() {
19
+ this.storage.db.exec(`
20
+ CREATE TABLE IF NOT EXISTS sessions (
21
+ id TEXT PRIMARY KEY,
22
+ agent_id TEXT NOT NULL,
23
+ contact_id TEXT NOT NULL,
24
+ type TEXT DEFAULT 'main',
25
+ label TEXT,
26
+ parent_id TEXT,
27
+ model TEXT,
28
+ system_prompt TEXT,
29
+ status TEXT DEFAULT 'active',
30
+ created_at TEXT DEFAULT (datetime('now')),
31
+ updated_at TEXT DEFAULT (datetime('now')),
32
+ metadata TEXT DEFAULT '{}'
33
+ );
34
+ CREATE TABLE IF NOT EXISTS session_messages (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ session_id TEXT NOT NULL,
37
+ role TEXT NOT NULL,
38
+ content TEXT NOT NULL,
39
+ tokens INTEGER DEFAULT 0,
40
+ created_at TEXT DEFAULT (datetime('now')),
41
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
42
+ );
43
+ CREATE INDEX IF NOT EXISTS idx_session_messages_sid ON session_messages(session_id);
44
+ CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent_id, contact_id);
45
+ `);
46
+ }
47
+
48
+ // ── Session CRUD ──
49
+
50
+ create(agentId, contactId, options = {}) {
51
+ const id = 'sess_' + crypto.randomBytes(6).toString('hex');
52
+ const type = options.type || 'main';
53
+ const label = options.label || null;
54
+ const parentId = options.parentId || null;
55
+ const model = options.model || null;
56
+ const systemPrompt = options.systemPrompt || null;
57
+
58
+ this.storage.db.prepare(
59
+ 'INSERT INTO sessions (id, agent_id, contact_id, type, label, parent_id, model, system_prompt, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
60
+ ).run(id, agentId, contactId, type, label, parentId, model, systemPrompt, JSON.stringify(options.metadata || {}));
61
+
62
+ const session = { id, agentId, contactId, type, label, parentId, model, systemPrompt, status: 'active' };
63
+ this.active.set(id, session);
64
+
65
+ logger.info('sessions', `Created ${type} session ${id}${label ? ' [' + label + ']' : ''}`);
66
+ return session;
67
+ }
68
+
69
+ get(sessionId) {
70
+ const cached = this.active.get(sessionId);
71
+ if (cached) return cached;
72
+
73
+ const row = this.storage.db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId);
74
+ if (row) {
75
+ this.active.set(row.id, row);
76
+ return row;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Get or create main session for a contact
83
+ */
84
+ getMain(agentId, contactId) {
85
+ let session = this.storage.db.prepare(
86
+ "SELECT * FROM sessions WHERE agent_id = ? AND contact_id = ? AND type = 'main' AND status = 'active' LIMIT 1"
87
+ ).get(agentId, contactId);
88
+
89
+ if (!session) {
90
+ session = this.create(agentId, contactId, { type: 'main', label: 'Main' });
91
+ }
92
+ return session;
93
+ }
94
+
95
+ /**
96
+ * Spawn an isolated session
97
+ */
98
+ spawn(agentId, contactId, options = {}) {
99
+ return this.create(agentId, contactId, {
100
+ type: options.type || 'isolated',
101
+ label: options.label || options.task?.slice(0, 50),
102
+ parentId: options.parentId,
103
+ model: options.model,
104
+ systemPrompt: options.systemPrompt || (options.task ?
105
+ 'You are a focused sub-agent. Complete this task concisely:\n\n' + options.task : null),
106
+ metadata: { task: options.task, timeout: options.timeout },
107
+ });
108
+ }
109
+
110
+ /**
111
+ * List sessions for a contact
112
+ */
113
+ list(agentId, contactId, options = {}) {
114
+ let query = 'SELECT * FROM sessions WHERE agent_id = ?';
115
+ const params = [agentId];
116
+
117
+ if (contactId) {
118
+ query += ' AND contact_id = ?';
119
+ params.push(contactId);
120
+ }
121
+ if (options.type) {
122
+ query += ' AND type = ?';
123
+ params.push(options.type);
124
+ }
125
+ if (options.status || !options.includeEnded) {
126
+ query += ' AND status = ?';
127
+ params.push(options.status || 'active');
128
+ }
129
+
130
+ query += ' ORDER BY created_at DESC';
131
+ if (options.limit) {
132
+ query += ' LIMIT ?';
133
+ params.push(options.limit);
134
+ }
135
+
136
+ return this.storage.db.prepare(query).all(...params);
137
+ }
138
+
139
+ /**
140
+ * End a session
141
+ */
142
+ end(sessionId) {
143
+ this.storage.db.prepare("UPDATE sessions SET status = 'ended', updated_at = datetime('now') WHERE id = ?").run(sessionId);
144
+ this.active.delete(sessionId);
145
+ logger.info('sessions', `Ended session ${sessionId}`);
146
+ }
147
+
148
+ // ── Session Messages ──
149
+
150
+ addMessage(sessionId, role, content, tokens = 0) {
151
+ this.storage.db.prepare(
152
+ 'INSERT INTO session_messages (session_id, role, content, tokens) VALUES (?, ?, ?, ?)'
153
+ ).run(sessionId, role, content, tokens);
154
+
155
+ this.storage.db.prepare("UPDATE sessions SET updated_at = datetime('now') WHERE id = ?").run(sessionId);
156
+ }
157
+
158
+ getHistory(sessionId, limit = 50) {
159
+ return this.storage.db.prepare(
160
+ 'SELECT role, content, created_at FROM session_messages WHERE session_id = ? ORDER BY created_at ASC LIMIT ?'
161
+ ).all(sessionId, limit);
162
+ }
163
+
164
+ clearHistory(sessionId) {
165
+ this.storage.db.prepare('DELETE FROM session_messages WHERE session_id = ?').run(sessionId);
166
+ }
167
+
168
+ // ── Session AI Processing ──
169
+
170
+ async process(sessionId, message) {
171
+ const session = this.get(sessionId);
172
+ if (!session) throw new Error('Session not found: ' + sessionId);
173
+
174
+ // Save user message
175
+ this.addMessage(sessionId, 'user', message);
176
+
177
+ // Build messages array
178
+ const history = this.getHistory(sessionId);
179
+ const model = session.model || this.engine.config.ai?.defaultModel;
180
+
181
+ // Get system prompt
182
+ let systemPrompt = session.system_prompt || session.systemPrompt;
183
+ if (!systemPrompt && session.type === 'main') {
184
+ // Use agent's normal prompt for main sessions
185
+ const agent = this.engine.agents?.get(session.agent_id);
186
+ if (agent?.promptBuilder) {
187
+ systemPrompt = await agent.promptBuilder.build(agent, session.contact_id, message);
188
+ }
189
+ }
190
+
191
+ const messages = [];
192
+ if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
193
+ messages.push(...history.map(h => ({ role: h.role === 'system' ? 'user' : h.role, content: h.content })));
194
+
195
+ // Call AI
196
+ const response = await this.engine.aiGateway.chat(messages, {
197
+ model,
198
+ fallbackChain: this.engine.config.ai?.fallbackChain,
199
+ });
200
+
201
+ // Save assistant response
202
+ this.addMessage(sessionId, 'assistant', response.content, response.outputTokens);
203
+
204
+ // Track usage
205
+ await this.storage.trackUsage(session.agent_id, response.model, response.inputTokens, response.outputTokens, response.costUsd);
206
+
207
+ logger.info('sessions', `Session ${sessionId} processed (${response.model}, ${response.outputTokens} tokens)`);
208
+
209
+ return {
210
+ content: response.content,
211
+ model: response.model,
212
+ tokens: response.inputTokens + response.outputTokens,
213
+ cost: response.costUsd,
214
+ sessionId,
215
+ };
216
+ }
217
+
218
+ // ── Sub-agent spawning with auto-completion ──
219
+
220
+ async runTask(agentId, contactId, task, options = {}) {
221
+ const session = this.spawn(agentId, contactId, {
222
+ type: 'isolated',
223
+ task,
224
+ model: options.model,
225
+ label: options.label || task.slice(0, 50),
226
+ });
227
+
228
+ const timeout = options.timeout || 60000;
229
+
230
+ try {
231
+ const result = await Promise.race([
232
+ this.process(session.id, task),
233
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Task timeout')), timeout)),
234
+ ]);
235
+
236
+ this.end(session.id);
237
+ return { ...result, status: 'complete' };
238
+ } catch (err) {
239
+ this.end(session.id);
240
+ return { content: 'Task failed: ' + err.message, status: 'error', sessionId: session.id };
241
+ }
242
+ }
243
+
244
+ // ── Send message to another session ──
245
+
246
+ async send(targetSessionId, message) {
247
+ const session = this.get(targetSessionId);
248
+ if (!session) throw new Error('Session not found');
249
+ return this.process(targetSessionId, message);
250
+ }
251
+
252
+ // ── Stats ──
253
+
254
+ getStats(agentId) {
255
+ const active = this.storage.db.prepare(
256
+ "SELECT COUNT(*) as c FROM sessions WHERE agent_id = ? AND status = 'active'"
257
+ ).get(agentId)?.c || 0;
258
+
259
+ const total = this.storage.db.prepare(
260
+ 'SELECT COUNT(*) as c FROM sessions WHERE agent_id = ?'
261
+ ).get(agentId)?.c || 0;
262
+
263
+ const messages = this.storage.db.prepare(
264
+ 'SELECT COUNT(*) as c FROM session_messages sm JOIN sessions s ON sm.session_id = s.id WHERE s.agent_id = ?'
265
+ ).get(agentId)?.c || 0;
266
+
267
+ return { active, total, messages };
268
+ }
269
+ }
@@ -145,6 +145,64 @@ export async function commandsMiddleware(ctx, next) {
145
145
  return;
146
146
  }
147
147
 
148
+ if (cmd === '/sessions') {
149
+ if (!ctx.engine.sessions) { await ctx.reply('❌ Sessions not available'); return; }
150
+ const args = msg.slice(10).trim();
151
+
152
+ if (args.startsWith('new ')) {
153
+ const task = args.slice(4).trim();
154
+ await ctx.reply('πŸ”„ Spawning session: ' + task.slice(0, 50) + '...');
155
+ const result = await ctx.engine.sessions.runTask(ctx.agentId, ctx.contactId, task);
156
+ await ctx.reply('βœ… *Session Complete*\n\n' + result.content.slice(0, 2000));
157
+ return;
158
+ }
159
+
160
+ if (args === 'stats') {
161
+ const stats = ctx.engine.sessions.getStats(ctx.agentId);
162
+ await ctx.reply('πŸ“Š *Sessions*\n\n🟒 Active: ' + stats.active + '\nπŸ“ Total: ' + stats.total + '\nπŸ’¬ Messages: ' + stats.messages);
163
+ return;
164
+ }
165
+
166
+ if (args.startsWith('end ')) {
167
+ ctx.engine.sessions.end(args.slice(4).trim());
168
+ await ctx.reply('βœ… Session ended');
169
+ return;
170
+ }
171
+
172
+ const sessions = ctx.engine.sessions.list(ctx.agentId, ctx.contactId, { limit: 10 });
173
+ if (sessions.length === 0) { await ctx.reply('πŸ“‹ No active sessions'); return; }
174
+ const lines = sessions.map(s =>
175
+ (s.status === 'active' ? '🟒' : '⚫') + ' *' + (s.label || 'Untitled') + '*\n Type: ' + s.type + '\n ID: `' + s.id + '`'
176
+ );
177
+ await ctx.reply('πŸ“‹ *Sessions*\n\n' + lines.join('\n\n') + '\n\n/sessions new <task> β€” spawn\n/sessions end <id> β€” close\n/sessions stats');
178
+ return;
179
+ }
180
+
181
+ if (cmd === '/cron') {
182
+ const args = msg.slice(6).trim();
183
+ if (!ctx.engine.cron) { await ctx.reply('❌ Cron not available'); return; }
184
+
185
+ if (!args || args === 'list') {
186
+ const jobs = ctx.engine.cron.listAll(ctx.agentId);
187
+ if (jobs.length === 0) { await ctx.reply('⏰ No scheduled jobs'); return; }
188
+ const lines = jobs.map(j =>
189
+ (j.enabled ? 'βœ…' : '⏸️') + ' *' + j.name + '*\n ' + j.schedule + ' β†’ ' + j.action + '\n Next: ' + (j.next_run || '?') + '\n ID: `' + j.id + '`'
190
+ );
191
+ await ctx.reply('⏰ *Cron Jobs*\n\n' + lines.join('\n\n'));
192
+ return;
193
+ }
194
+
195
+ if (args.startsWith('remove ') || args.startsWith('delete ')) {
196
+ const id = args.split(' ')[1];
197
+ ctx.engine.cron.remove(id);
198
+ await ctx.reply('βœ… Removed cron job ' + id);
199
+ return;
200
+ }
201
+
202
+ 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"');
203
+ return;
204
+ }
205
+
148
206
  if (cmd === '/allow') {
149
207
  const args = msg.slice(7).trim();
150
208
  if (!args) { await ctx.reply('Usage: /allow <user_id or phone>'); return; }
@@ -29,8 +29,9 @@ export async function responseSenderMiddleware(ctx, next) {
29
29
 
30
30
  // Send via appropriate channel
31
31
  if (ctx.platform === 'telegram' && tm) {
32
- // Send file attachment (pptx, etc)
32
+ // Send file attachment (pptx, excel, pdf, html)
33
33
  if (response.filePath) {
34
+ logger.info('sender', 'SENDING FILE: ' + response.filePath);
34
35
  try {
35
36
  await tm.sendDocument(agentId, contactId, response.filePath, response.fileName, response.messages?.[0] || '');
36
37
  await next();
@@ -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,49 @@ 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('', '### Spawn Sub-Agent Session',
83
+ '---TOOL:session_spawn:task description---',
84
+ 'Spawn an isolated AI session to work on a task in the background. Returns when complete.',
85
+ 'Use for: research, analysis, writing, any task that needs focused work.',
86
+ 'Example: ---TOOL:session_spawn:Research the top 5 AI companies in Saudi Arabia and summarize their products---',
87
+ '', '### List Sessions',
88
+ '---TOOL:session_list:all---',
89
+ 'Show all active sessions.',
90
+ '', '### Send to Session',
91
+ '---TOOL:session_send:session_id|message---',
92
+ 'Send a follow-up message to an existing session.');
93
+
94
+ tools.push('', '### Create Excel Spreadsheet (SENDS AS FILE!)',
95
+ '---TOOL:excel:Title|theme|sheet content---',
96
+ 'Creates .xlsx file and sends it. Theme: blue, green, dark, red, saudi, corporate.',
97
+ 'Content: use | for columns. First row = headers. Separate sheets with ### SheetName.',
98
+ 'Example: ---TOOL:excel:Sales Report|blue|Product|Revenue|Growth\nWidget A|50000|12%\nWidget B|80000|25%---',
99
+ '', '### Create PDF Document (SENDS AS FILE!)',
100
+ '---TOOL:pdf:Title|content in markdown---',
101
+ 'Creates .pdf file and sends it. Use ## for headings, - for bullets, > for quotes, --- for dividers.',
102
+ 'Example: ---TOOL:pdf:Meeting Notes|## Summary\n- Discussed Q1 results\n- Action items assigned---',
103
+ '', '### Create HTML Page (SENDS AS FILE!)',
104
+ '---TOOL:html:Title|theme|content in markdown---',
105
+ 'Creates .html file and sends it. Themes: light, dark, ocean.',
106
+ 'Example: ---TOOL:html:Report|dark|## Overview\n- Key findings\n- Recommendations---');
107
+
108
+ tools.push('', '### Schedule Cron Job',
109
+ '---TOOL:cron_add:name|schedule|action|data---',
110
+ 'Create a recurring scheduled job. Schedule formats:',
111
+ '- every:5m / every:1h / every:24h (interval)',
112
+ '- daily:09:00 (every day at time UTC)',
113
+ '- weekly:fri:09:00 (every week on day)',
114
+ '- monthly:1:09:00 (every month on date)',
115
+ 'Actions: message (send text), briefing (daily brief), remind (reminder), ai (run AI prompt)',
116
+ 'Example: ---TOOL:cron_add:Morning Briefing|daily:06:00|briefing|---',
117
+ 'Example: ---TOOL:cron_add:Weekly Report Reminder|weekly:fri:09:00|remind|Submit the weekly report!---',
118
+ '', '### List Cron Jobs',
119
+ '---TOOL:cron_list:all---',
120
+ 'Show all scheduled jobs.',
121
+ '', '### Remove Cron Job',
122
+ '---TOOL:cron_remove:job_id---',
123
+ 'Remove a scheduled job by ID.');
124
+
82
125
  tools.push('', '### Allow User',
83
126
  '---TOOL:allow:user_id_or_phone---',
84
127
  'Add someone to the allowlist so they can message you.',
@@ -187,9 +230,16 @@ export class ToolRouter {
187
230
  'You already have vision β€” use it to read text from screenshots, documents, signs, etc.');
188
231
 
189
232
  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!---',
233
+ '---TOOL:remind:TIME|Your reminder message---',
234
+ 'Set a reminder. TIME can be:',
235
+ '- Relative: 5m, 30m, 1h, 2h, 1d (minutes, hours, days from now)',
236
+ '- Absolute: 2026-03-03T08:30 (UTC)',
237
+ '- Natural: tomorrow, tonight',
238
+ 'Examples:',
239
+ '- ---TOOL:remind:30m|Check the oven!---',
240
+ '- ---TOOL:remind:2h|Call Ahmed---',
241
+ '- ---TOOL:remind:2026-03-03T15:00|Meeting time!---',
242
+ 'ALWAYS use this tool when user says "remind me". Do NOT just acknowledge β€” actually set it.',
193
243
  'The user will receive a proactive message at that time even if they are not chatting.');
194
244
 
195
245
  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 +258,21 @@ export class ToolRouter {
208
258
  }
209
259
 
210
260
  async processResponse(response, agentId) {
211
- const toolMatch = response.match(/---TOOL:(\w+):(.+?)---/);
261
+ const toolMatch = (() => {
262
+ const startIdx = response.indexOf('---TOOL:');
263
+ if (startIdx === -1) return null;
264
+ const afterTag = response.slice(startIdx + 8);
265
+ const colonIdx = afterTag.indexOf(':');
266
+ if (colonIdx === -1) return null;
267
+ const toolName = afterTag.slice(0, colonIdx);
268
+ const rest = afterTag.slice(colonIdx + 1);
269
+ // Find the closing --- that's NOT part of markdown (look for last --- or ---END---)
270
+ let endIdx = rest.lastIndexOf('---');
271
+ if (endIdx <= 0) return null;
272
+ const toolArg = rest.slice(0, endIdx);
273
+ const fullMatch = response.slice(startIdx, startIdx + 8 + colonIdx + 1 + endIdx + 3);
274
+ return [fullMatch, toolName, toolArg];
275
+ })();
212
276
  if (!toolMatch) return { toolUsed: false, toolResult: null, cleanResponse: response };
213
277
 
214
278
  const [fullMatch, toolName, toolArg] = toolMatch;
@@ -229,12 +293,31 @@ export class ToolRouter {
229
293
  try {
230
294
  const pipeIdx = toolArg.indexOf('|');
231
295
  if (pipeIdx === -1) {
232
- toolResult = 'Invalid reminder format. Use: YYYY-MM-DDTHH:MM|message';
296
+ toolResult = 'Format: time|message. Example: 30m|Check oven';
233
297
  break;
234
298
  }
235
- const timeStr = toolArg.slice(0, pipeIdx).trim();
299
+ let timeStr = toolArg.slice(0, pipeIdx).trim();
236
300
  const msg = toolArg.slice(pipeIdx + 1).trim();
237
- // Store reminder request in result for engine to pick up
301
+
302
+ // Parse relative times
303
+ const relMatch = timeStr.match(/^(\d+)\s*(m|min|minutes?|h|hr|hours?|d|days?)$/i);
304
+ if (relMatch) {
305
+ const num = parseInt(relMatch[1]);
306
+ const unit = relMatch[2][0].toLowerCase();
307
+ const ms = unit === 'm' ? num * 60000 : unit === 'h' ? num * 3600000 : num * 86400000;
308
+ const fireDate = new Date(Date.now() + ms);
309
+ timeStr = fireDate.toISOString().slice(0, 16);
310
+ } else if (timeStr.toLowerCase() === 'tomorrow') {
311
+ const d = new Date(Date.now() + 86400000);
312
+ d.setUTCHours(9, 0, 0, 0);
313
+ timeStr = d.toISOString().slice(0, 16);
314
+ } else if (timeStr.toLowerCase() === 'tonight') {
315
+ const d = new Date();
316
+ d.setUTCHours(18, 0, 0, 0);
317
+ if (d < new Date()) d.setDate(d.getDate() + 1);
318
+ timeStr = d.toISOString().slice(0, 16);
319
+ }
320
+
238
321
  return { toolUsed: true, toolName: 'remind', reminderTime: timeStr, reminderMessage: msg, cleanResponse };
239
322
  } catch (err) {
240
323
  toolResult = 'Failed to set reminder: ' + err.message;
@@ -291,6 +374,123 @@ export class ToolRouter {
291
374
  }
292
375
  break;
293
376
  }
377
+ case 'session_spawn':
378
+ case 'spawn_session':
379
+ case 'subagent': {
380
+ try {
381
+ if (!this._engine?.sessions) { toolResult = 'Sessions not available'; break; }
382
+ const result = await this._engine.sessions.runTask(
383
+ agentId, this._currentContactId || 'unknown', toolArg, { timeout: 120000 }
384
+ );
385
+ toolResult = result.status === 'complete' ?
386
+ 'Sub-agent result:\n\n' + result.content :
387
+ 'Sub-agent failed: ' + result.content;
388
+ } catch (err) { toolResult = 'Spawn failed: ' + err.message; }
389
+ break;
390
+ }
391
+ case 'session_list': {
392
+ try {
393
+ if (!this._engine?.sessions) { toolResult = 'Sessions not available'; break; }
394
+ const sessions = this._engine.sessions.list(agentId, this._currentContactId);
395
+ if (sessions.length === 0) { toolResult = 'No active sessions'; break; }
396
+ toolResult = sessions.map(s =>
397
+ (s.status === 'active' ? '🟒' : '⚫') + ' ' + (s.label || s.id) + ' (' + s.type + ')\n ID: ' + s.id
398
+ ).join('\n');
399
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
400
+ break;
401
+ }
402
+ case 'session_send': {
403
+ try {
404
+ if (!this._engine?.sessions) { toolResult = 'Sessions not available'; break; }
405
+ const pipeIdx = toolArg.indexOf('|');
406
+ if (pipeIdx === -1) { toolResult = 'Format: session_id|message'; break; }
407
+ const sessId = toolArg.slice(0, pipeIdx).trim();
408
+ const msg = toolArg.slice(pipeIdx + 1).trim();
409
+ const result = await this._engine.sessions.send(sessId, msg);
410
+ toolResult = 'Session response:\n\n' + result.content;
411
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
412
+ break;
413
+ }
414
+ case 'excel':
415
+ case 'xlsx':
416
+ case 'spreadsheet': {
417
+ try {
418
+ const { ExcelGenerator } = await import('./excel.js');
419
+ const gen = new ExcelGenerator();
420
+ const parts = toolArg.split('|');
421
+ let title, themeName, sheetContent;
422
+ if (parts.length >= 3) {
423
+ title = parts[0].trim();
424
+ themeName = parts[1].trim();
425
+ sheetContent = parts.slice(2).join('|');
426
+ } else {
427
+ title = parts[0]?.trim() || 'Spreadsheet';
428
+ sheetContent = parts.slice(1).join('|');
429
+ themeName = 'corporate';
430
+ }
431
+ const sheets = ExcelGenerator.parseContent(sheetContent);
432
+ const result = await gen.create(title, sheets, { theme: themeName });
433
+ return { toolUsed: true, toolName: 'excel', toolResult: 'Excel created: ' + result.filename, filePath: result.filepath, fileName: result.filename, cleanResponse };
434
+ } catch (err) { toolResult = 'Excel failed: ' + err.message; }
435
+ break;
436
+ }
437
+ case 'pdf':
438
+ case 'document': {
439
+ try {
440
+ const { PdfGenerator } = await import('./pdf.js');
441
+ const gen = new PdfGenerator();
442
+ const pipeIdx = toolArg.indexOf('|');
443
+ const title = pipeIdx > -1 ? toolArg.slice(0, pipeIdx).trim() : 'Document';
444
+ const content = pipeIdx > -1 ? toolArg.slice(pipeIdx + 1) : toolArg;
445
+ const result = await gen.create(title, content);
446
+ return { toolUsed: true, toolName: 'pdf', toolResult: 'PDF created: ' + result.filename, filePath: result.filepath, fileName: result.filename, cleanResponse };
447
+ } catch (err) { toolResult = 'PDF failed: ' + err.message; }
448
+ break;
449
+ }
450
+ case 'html':
451
+ case 'webpage': {
452
+ try {
453
+ const { HtmlGenerator } = await import('./html.js');
454
+ const gen = new HtmlGenerator();
455
+ const parts = toolArg.split('|');
456
+ const title = parts[0]?.trim() || 'Page';
457
+ const themeName = parts.length >= 3 ? parts[1].trim() : 'light';
458
+ const content = parts.length >= 3 ? parts.slice(2).join('|') : parts.slice(1).join('|');
459
+ const result = gen.create(title, content, { theme: themeName });
460
+ return { toolUsed: true, toolName: 'html', toolResult: 'HTML created: ' + result.filename, filePath: result.filepath, fileName: result.filename, cleanResponse };
461
+ } catch (err) { toolResult = 'HTML failed: ' + err.message; }
462
+ break;
463
+ }
464
+ case 'cron_add': {
465
+ try {
466
+ if (!this._engine?.cron) { toolResult = 'Cron not available'; break; }
467
+ const parts = toolArg.split('|').map(p => p.trim());
468
+ const [name, schedule, action, ...dataParts] = parts;
469
+ const data = dataParts.join('|');
470
+ const result = this._engine.cron.add(agentId, this._currentContactId, name, schedule, action || 'message', data, this._currentPlatform);
471
+ toolResult = 'Cron job created! βœ…\nπŸ“‹ ' + result.name + '\n⏰ Schedule: ' + result.schedule + '\nπŸ”œ Next run: ' + result.nextRun;
472
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
473
+ break;
474
+ }
475
+ case 'cron_list': {
476
+ try {
477
+ if (!this._engine?.cron) { toolResult = 'Cron not available'; break; }
478
+ const jobs = this._engine.cron.listAll(agentId);
479
+ if (jobs.length === 0) { toolResult = 'No scheduled jobs'; break; }
480
+ toolResult = jobs.map(j =>
481
+ (j.enabled ? 'βœ…' : '⏸️') + ' ' + j.name + ' (' + j.schedule + ')\n ID: ' + j.id + '\n Next: ' + (j.next_run || 'N/A')
482
+ ).join('\n\n');
483
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
484
+ break;
485
+ }
486
+ case 'cron_remove': {
487
+ try {
488
+ if (!this._engine?.cron) { toolResult = 'Cron not available'; break; }
489
+ this._engine.cron.remove(toolArg.trim());
490
+ toolResult = 'Cron job removed βœ…';
491
+ } catch (err) { toolResult = 'Failed: ' + err.message; }
492
+ break;
493
+ }
294
494
  case 'allow': {
295
495
  try {
296
496
  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.5.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",