squidclaw 2.2.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/channels/telegram/bot.js +12 -0
- package/lib/engine.js +6 -0
- package/lib/features/cron.js +217 -0
- package/lib/features/reminders.js +2 -1
- package/lib/middleware/commands.js +53 -15
- package/lib/middleware/response-sender.js +3 -12
- package/lib/tools/excel.js +136 -0
- package/lib/tools/html.js +148 -0
- package/lib/tools/pdf.js +133 -0
- package/lib/tools/pptx.js +681 -83
- package/lib/tools/router.js +162 -19
- package/package.json +3 -1
|
@@ -202,6 +202,18 @@ export class TelegramManager {
|
|
|
202
202
|
/**
|
|
203
203
|
* Send voice note
|
|
204
204
|
*/
|
|
205
|
+
async sendDocument(agentId, contactId, filePath, fileName, caption, metadata = {}) {
|
|
206
|
+
const botInfo = this.bots.get(agentId);
|
|
207
|
+
if (!botInfo?.bot) throw new Error('Bot not running');
|
|
208
|
+
const chatId = contactId.replace('tg_', '');
|
|
209
|
+
const { readFileSync } = await import('fs');
|
|
210
|
+
const buffer = readFileSync(filePath);
|
|
211
|
+
await botInfo.bot.api.sendDocument(chatId, new InputFile(buffer, fileName || 'file'), {
|
|
212
|
+
caption: caption || '',
|
|
213
|
+
});
|
|
214
|
+
logger.info('telegram', 'Sent document: ' + fileName);
|
|
215
|
+
}
|
|
216
|
+
|
|
205
217
|
async sendVoiceNote(agentId, contactId, audioBuffer, metadata = {}) {
|
|
206
218
|
const botInfo = this.bots.get(agentId);
|
|
207
219
|
if (!botInfo?.bot) return;
|
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',
|
|
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
|
|
|
@@ -39,27 +39,32 @@ export async function commandsMiddleware(ctx, next) {
|
|
|
39
39
|
|
|
40
40
|
if (cmd === '/status' || textCmd === 'status') {
|
|
41
41
|
const uptime = process.uptime();
|
|
42
|
-
const
|
|
42
|
+
const d = Math.floor(uptime / 86400);
|
|
43
|
+
const h = Math.floor((uptime % 86400) / 3600);
|
|
43
44
|
const m = Math.floor((uptime % 3600) / 60);
|
|
45
|
+
const uptimeStr = d > 0 ? d + 'd ' + h + 'h' : h + 'h ' + m + 'm';
|
|
44
46
|
const usage = await ctx.storage.getUsage(ctx.agentId) || {};
|
|
45
47
|
const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
|
|
46
|
-
const fmtT =
|
|
48
|
+
const fmtT = (n) => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'K' : String(n || 0);
|
|
47
49
|
const waOn = Object.values(ctx.engine.whatsappManager?.getStatuses() || {}).some(s => s.connected);
|
|
50
|
+
const memCount = ctx.storage.db.prepare('SELECT COUNT(*) as c FROM memories WHERE agent_id = ?').get(ctx.agentId)?.c || 0;
|
|
51
|
+
const convCount = ctx.storage.db.prepare('SELECT COUNT(*) as c FROM conversations WHERE agent_id = ?').get(ctx.agentId)?.c || 0;
|
|
52
|
+
const reminderCount = ctx.storage.db.prepare("SELECT COUNT(*) as c FROM reminders WHERE agent_id = ? AND fired = 0").get(ctx.agentId)?.c || 0;
|
|
53
|
+
const ram = (process.memoryUsage().rss / 1024 / 1024).toFixed(0);
|
|
48
54
|
|
|
49
55
|
await ctx.reply([
|
|
50
|
-
|
|
51
|
-
'
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
'
|
|
61
|
-
|
|
62
|
-
`🗣️ Language: ${ctx.agent?.language || 'bilingual'}`,
|
|
56
|
+
'🦑 *Squidclaw v2.2.0*',
|
|
57
|
+
'*' + (ctx.agent?.name || 'Agent') + '* — Status',
|
|
58
|
+
'🧠 Model: ' + (ctx.agent?.model || '?'),
|
|
59
|
+
'⚡ Pipeline: 14 middleware · 40+ skills',
|
|
60
|
+
'🗣️ Language: Bilingual (AR/EN)',
|
|
61
|
+
'💬 Messages: ' + convCount,
|
|
62
|
+
'🪙 Tokens: ' + fmtT(tokens) + ' (↑' + fmtT(usage.input_tokens || 0) + ' ↓' + fmtT(usage.output_tokens || 0) + ')',
|
|
63
|
+
'💰 Cost: $' + (usage.cost_usd || 0).toFixed(4),
|
|
64
|
+
'✈️ Telegram: ' + (ctx.engine.telegramManager ? '🟢' : '🔴') + ' · WhatsApp: ' + (waOn ? '🟢' : '🔴'),
|
|
65
|
+
'💾 Memories: ' + memCount + ' · Reminders: ' + reminderCount,
|
|
66
|
+
'⏱️ Uptime: ' + uptimeStr + ' · RAM: ' + ram + 'MB',
|
|
67
|
+
'💚 Heartbeat: active',
|
|
63
68
|
].join('\n'));
|
|
64
69
|
return;
|
|
65
70
|
}
|
|
@@ -132,6 +137,39 @@ export async function commandsMiddleware(ctx, next) {
|
|
|
132
137
|
return;
|
|
133
138
|
}
|
|
134
139
|
|
|
140
|
+
if (cmd === '/reset' || cmd === '/restart') {
|
|
141
|
+
await ctx.reply('🔄 Restarting Squidclaw...');
|
|
142
|
+
setTimeout(() => {
|
|
143
|
+
process.exit(0); // Process manager (pm2/systemd) will restart it
|
|
144
|
+
}, 1000);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
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
|
+
|
|
135
173
|
if (cmd === '/allow') {
|
|
136
174
|
const args = msg.slice(7).trim();
|
|
137
175
|
if (!args) { await ctx.reply('Usage: /allow <user_id or phone>'); return; }
|
|
@@ -32,18 +32,9 @@ export async function responseSenderMiddleware(ctx, next) {
|
|
|
32
32
|
// Send file attachment (pptx, etc)
|
|
33
33
|
if (response.filePath) {
|
|
34
34
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (bot) {
|
|
39
|
-
const buffer = readFileSync(response.filePath);
|
|
40
|
-
await bot.api.sendDocument(contactId.replace('tg_', ''), new InputFile(buffer, response.fileName || 'file'), {
|
|
41
|
-
caption: response.messages?.[0] || '',
|
|
42
|
-
});
|
|
43
|
-
// Skip normal message sending
|
|
44
|
-
await next();
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
35
|
+
await tm.sendDocument(agentId, contactId, response.filePath, response.fileName, response.messages?.[0] || '');
|
|
36
|
+
await next();
|
|
37
|
+
return;
|
|
47
38
|
} catch (err) {
|
|
48
39
|
logger.error('sender', 'File send failed: ' + err.message);
|
|
49
40
|
// Fall through to text
|
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
147
|
+
}
|
|
148
|
+
}
|