squidclaw 3.1.1 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/ai/gateway.js CHANGED
@@ -31,8 +31,10 @@ export class AIGateway {
31
31
  // Smart routing: use best model for the task
32
32
  let routedModel = options.model;
33
33
  if (!routedModel && options.taskHint) {
34
- const route = getRouteForTask(options.taskHint, options.toolName, this.config);
35
- if (route) routedModel = route.model;
34
+ try {
35
+ const route = getRouteForTask(options.taskHint, options.toolName, this.config);
36
+ if (route) routedModel = route.model;
37
+ } catch {}
36
38
  }
37
39
  const model = routedModel || this.config.ai?.defaultModel;
38
40
  const fallbackChain = options.fallbackChain || this.config.ai?.fallbackChain || [];
@@ -74,11 +74,15 @@ export class PromptBuilder {
74
74
 
75
75
  // 4. Contact context
76
76
  const contact = await this.storage.getContact(agent.id, contactId);
77
+ parts.push('\n## About This Person');
77
78
  if (contact) {
78
- parts.push(`\n## About This Person`);
79
- if (contact.name) parts.push(`- Name: ${contact.name}`);
80
- parts.push(`- Messages exchanged: ${contact.message_count || 0}`);
81
- parts.push(`- First seen: ${contact.first_seen || 'just now'}`);
79
+ if (contact.name) parts.push('- Name: ' + contact.name);
80
+ parts.push('- Messages exchanged: ' + (contact.message_count || 0));
81
+ }
82
+ // Also check memories for name (more reliable)
83
+ const memName = memories.find(m => m.key === 'name');
84
+ if (memName && memName.value) {
85
+ parts.push('- Confirmed name: ' + memName.value + ' (USE THIS NAME)');
82
86
  }
83
87
 
84
88
  // 5. Knowledge base (RAG)
@@ -157,6 +157,13 @@ export class TelegramManager {
157
157
  /**
158
158
  * Send a reaction
159
159
  */
160
+ async sendChatAction(agentId, contactId, action, metadata = {}) {
161
+ const botInfo = this.bots.get(agentId);
162
+ if (!botInfo?.bot) return;
163
+ const chatId = metadata.chatId || contactId.replace('tg_', '');
164
+ try { await botInfo.bot.api.sendChatAction(chatId, action); } catch {}
165
+ }
166
+
160
167
  async sendReaction(agentId, contactId, messageId, emoji, metadata = {}) {
161
168
  const botInfo = this.bots.get(agentId);
162
169
  if (!botInfo?.bot) return;
package/lib/core/agent.js CHANGED
@@ -66,12 +66,17 @@ export class Agent {
66
66
  const systemPrompt = await this.promptBuilder.build(this, contactId, message);
67
67
 
68
68
  // Get conversation history
69
- const history = await this.storage.getConversation(this.id, contactId, 50);
69
+ const history = await this.storage.getConversation(this.id, contactId, 20);
70
70
 
71
71
  // Build messages array for AI
72
72
  const messages = [
73
73
  { role: 'system', content: systemPrompt },
74
- ...history.map(h => ({ role: h.role, content: h.content })),
74
+ ...history.map(h => ({
75
+ role: h.role,
76
+ content: h.role === 'assistant'
77
+ ? h.content.replace(/---TOOL:\w+:.{0,200}---/gs, '[tool used]').slice(0, 500)
78
+ : h.content.slice(0, 500),
79
+ })),
75
80
  ];
76
81
 
77
82
  // If the last message in history is already the current message, don't add again
package/lib/engine.js CHANGED
@@ -117,7 +117,7 @@ export class SquidclawEngine {
117
117
  initLogger(this.config.engine?.logLevel || 'info');
118
118
 
119
119
  console.log(`
120
- 🦑 Squidclaw Engine v1.1.0
120
+ 🦑 Squidclaw Engine v${require('./package.json').version}
121
121
  ──────────────────────────`);
122
122
 
123
123
  // 1. Storage
@@ -1,47 +1,73 @@
1
1
  /**
2
- * 🦑 Auto Memory — Extracts facts from conversations automatically
3
- * No AI tags needed scans messages for personal info, preferences, decisions
2
+ * 🦑 Auto Memory v2 Smarter fact extraction
3
+ * Fixes: no more saving "ur" as a name, better filtering
4
4
  */
5
5
 
6
6
  import { logger } from '../core/logger.js';
7
7
 
8
+ // Words that should NEVER be saved as names
9
+ const NAME_BLACKLIST = new Set([
10
+ 'ur', 'u', 'im', 'me', 'my', 'i', 'the', 'a', 'an', 'is', 'am', 'are',
11
+ 'here', 'there', 'this', 'that', 'it', 'he', 'she', 'they', 'we', 'you',
12
+ 'not', 'no', 'yes', 'ok', 'okay', 'hi', 'hey', 'hello', 'bye',
13
+ 'just', 'very', 'really', 'actually', 'basically', 'literally',
14
+ 'new', 'old', 'good', 'bad', 'great', 'nice', 'cool', 'fine',
15
+ 'going', 'doing', 'working', 'looking', 'trying', 'getting',
16
+ 'available', 'interested', 'confused', 'tired', 'busy', 'free',
17
+ 'happy', 'sad', 'angry', 'bored', 'excited', 'worried',
18
+ 'still', 'also', 'too', 'now', 'then', 'so', 'but',
19
+ ]);
20
+
21
+ // Must look like a real name: 2+ chars, starts with letter, no pure numbers
22
+ function isValidName(name) {
23
+ if (!name || name.length < 2 || name.length > 30) return false;
24
+ if (NAME_BLACKLIST.has(name.toLowerCase())) return false;
25
+ if (/^\d+$/.test(name)) return false;
26
+ if (/^[a-z]/.test(name) && !/[\u0600-\u06FF]/.test(name)) return false; // English names must be capitalized
27
+ return true;
28
+ }
29
+
30
+ // Must look like a real location
31
+ function isValidLocation(loc) {
32
+ if (!loc || loc.length < 2 || loc.length > 40) return false;
33
+ if (/^(here|there|home|work|office|bed|room|outside)$/i.test(loc.trim())) return false;
34
+ return true;
35
+ }
36
+
8
37
  const FACT_PATTERNS = [
9
- // Names
10
- { regex: /(?:my name is|call me|i'?m|ana|اسمي|اسم)\s+([A-Z\u0600-\u06FF][a-z\u0600-\u06FF]+)/i, key: 'name', extract: 1 },
38
+ // Names — stricter matching
39
+ { regex: /(?:my name is|call me|اسمي)\s+([A-Z\u0600-\u06FF][\w\u0600-\u06FF]{1,25})/i, key: 'name', extract: 1, validate: isValidName },
40
+
41
+ // "I'm Tamer" — but ONLY if the word after is a capitalized proper noun (not "I'm fine")
42
+ { regex: /^(?:i'?m|ana)\s+([A-Z\u0600-\u06FF][\w\u0600-\u06FF]{1,25})$/im, key: 'name', extract: 1, validate: isValidName },
11
43
 
12
44
  // Location
13
- { regex: /(?:i live in|i'?m from|i'?m in|based in|located in|ساكن في|من)\s+([A-Z\u0600-\u06FF][\w\u0600-\u06FF\s]{2,20})/i, key: 'location', extract: 1 },
45
+ { regex: /(?:i live in|i'?m from|based in|located in|ساكن في|من مدينة)\s+([A-Z\u0600-\u06FF][\w\u0600-\u06FF\s]{2,30})/i, key: 'location', extract: 1, validate: isValidLocation },
14
46
 
15
47
  // Job
16
- { regex: /(?:i work (?:as|at|in|for)|my job is|i'?m a|i'?m an|اشتغل|شغلي)\s+(.{3,40}?)(?:\.|,|!|\?|$)/i, key: 'job', extract: 1 },
48
+ { regex: /(?:i work (?:as|at|in|for)|my job is|اشتغل|شغلي)\s+(.{3,40}?)(?:\.|,|!|\?|$)/i, key: 'job', extract: 1 },
17
49
 
18
- // Age
19
- { regex: /(?:i'?m|i am|عمري)\s+(\d{1,3})\s*(?:years? old|سنة|سنه)?/i, key: 'age', extract: 1 },
50
+ // Age — must be reasonable
51
+ { regex: /(?:i'?m|i am|عمري)\s+(\d{1,3})\s*(?:years? old|سنة|سنه)/i, key: 'age', extract: 1, validate: v => { const n = parseInt(v); return n > 5 && n < 120; } },
20
52
 
21
53
  // Family
22
- { regex: /(?:my (?:wife|husband|son|daughter|brother|sister|mom|dad|father|mother)(?:'s name)? is)\s+(\w+)/i, key: (m) => m[0].match(/wife|husband|son|daughter|brother|sister|mom|dad|father|mother/i)[0], extract: 1 },
54
+ { regex: /(?:my (?:wife|husband|son|daughter|brother|sister|mom|dad|father|mother)(?:'s name)? is)\s+([A-Z\u0600-\u06FF]\w+)/i, key: (m) => m[0].match(/wife|husband|son|daughter|brother|sister|mom|dad|father|mother/i)[0], extract: 1, validate: isValidName },
23
55
 
24
56
  // Favorites
25
- { regex: /(?:my fav(?:orite|ourite)?\s+(\w+)\s+is)\s+(.+?)(?:\.|,|!|$)/i, key: (m) => 'favorite_' + m[1], extract: 2 },
26
- { regex: /(?:i (?:love|like|prefer|enjoy))\s+(.{3,30}?)(?:\.|,|!|\?|$)/i, key: 'likes', extract: 1, append: true },
27
- { regex: /(?:i (?:hate|dislike|don'?t like))\s+(.{3,30}?)(?:\.|,|!|\?|$)/i, key: 'dislikes', extract: 1, append: true },
28
-
29
- // Timezone
30
- { regex: /(?:my time(?:zone)? is|i'?m in)\s+(UTC[+-]\d+|GMT[+-]\d+|[A-Z]{2,4}\/[A-Za-z_]+)/i, key: 'timezone', extract: 1 },
57
+ { regex: /(?:my fav(?:orite|ourite)?\s+(\w+)\s+is)\s+(.{2,30}?)(?:\.|,|!|$)/i, key: (m) => 'favorite_' + m[1], extract: 2 },
31
58
 
32
59
  // Birthday
33
- { regex: /(?:my birthday is|born on|ميلادي)\s+(.{5,20})/i, key: 'birthday', extract: 1 },
60
+ { regex: /(?:my birthday is|born on|ميلادي)\s+(.{5,25})/i, key: 'birthday', extract: 1 },
34
61
 
35
62
  // Language
36
- { regex: /(?:i speak|my language is|لغتي)\s+(\w+)/i, key: 'language', extract: 1 },
63
+ { regex: /(?:i speak|my language is|لغتي)\s+([A-Z\u0600-\u06FF]\w+)/i, key: 'language', extract: 1 },
37
64
 
38
65
  // Pet
39
- { regex: /(?:my (?:cat|dog|pet)(?:'s name)? is)\s+(\w+)/i, key: (m) => m[0].match(/cat|dog|pet/i)[0] + '_name', extract: 1 },
66
+ { regex: /(?:my (?:cat|dog|pet)(?:'s name)? is)\s+([A-Z\u0600-\u06FF]\w+)/i, key: (m) => m[0].match(/cat|dog|pet/i)[0] + '_name', extract: 1, validate: isValidName },
40
67
  ];
41
68
 
42
- // Extract per-contact facts
43
69
  const CONTACT_PATTERNS = [
44
- { regex: /(?:remember|don'?t forget|note|تذكر|لا تنسى)\s+(?:that\s+)?(.{5,100})/i, key: 'noted', extract: 1 },
70
+ { regex: /(?:remember|don'?t forget|note that|تذكر|لا تنسى)\s+(?:that\s+)?(.{5,100})/i, key: 'noted', extract: 1 },
45
71
  ];
46
72
 
47
73
  export class AutoMemory {
@@ -49,10 +75,10 @@ export class AutoMemory {
49
75
  this.storage = storage;
50
76
  }
51
77
 
52
- /**
53
- * Scan a user message for facts and auto-save them
54
- */
55
78
  async extract(agentId, contactId, message) {
79
+ // Skip very short messages or commands
80
+ if (!message || message.length < 4 || message.startsWith('/')) return [];
81
+
56
82
  const extracted = [];
57
83
 
58
84
  for (const pattern of FACT_PATTERNS) {
@@ -62,23 +88,20 @@ export class AutoMemory {
62
88
  const key = typeof pattern.key === 'function' ? pattern.key(match) : pattern.key;
63
89
  const value = match[pattern.extract].trim();
64
90
 
91
+ // Validate
65
92
  if (value.length < 2 || value.length > 100) continue;
93
+ if (pattern.validate && !pattern.validate(value)) continue;
66
94
 
67
- if (pattern.append) {
68
- // Append to existing
69
- const existing = await this._getMemory(agentId, key);
70
- if (existing && existing.includes(value)) continue;
71
- const newValue = existing ? existing + ', ' + value : value;
72
- await this.storage.saveMemory(agentId, key, newValue, 'auto');
73
- } else {
74
- await this.storage.saveMemory(agentId, key, value, 'auto');
75
- }
95
+ // Don't overwrite with worse data
96
+ const existing = await this._getMemory(agentId, key);
97
+ if (existing && key === 'name' && isValidName(existing) && existing.length > value.length) continue;
76
98
 
99
+ await this.storage.saveMemory(agentId, key, value, 'auto');
77
100
  extracted.push({ key, value });
78
101
  logger.info('auto-memory', `Extracted: ${key} = ${value}`);
79
102
  }
80
103
 
81
- // Check "remember this" patterns
104
+ // "Remember this" patterns
82
105
  for (const pattern of CONTACT_PATTERNS) {
83
106
  const match = message.match(pattern.regex);
84
107
  if (!match) continue;
@@ -88,7 +111,6 @@ export class AutoMemory {
88
111
  const key = 'user_note_' + Date.now().toString(36);
89
112
  await this.storage.saveMemory(agentId, key, value, 'noted');
90
113
  extracted.push({ key, value });
91
- logger.info('auto-memory', `User note: ${value}`);
92
114
  }
93
115
 
94
116
  return extracted;
@@ -96,12 +118,7 @@ export class AutoMemory {
96
118
 
97
119
  async _getMemory(agentId, key) {
98
120
  try {
99
- const row = this.storage.db.prepare(
100
- 'SELECT value FROM memories WHERE agent_id = ? AND key = ?'
101
- ).get(agentId, key);
102
- return row?.value;
103
- } catch {
104
- return null;
105
- }
121
+ return this.storage.db.prepare('SELECT value FROM memories WHERE agent_id = ? AND key = ?').get(agentId, key)?.value;
122
+ } catch { return null; }
106
123
  }
107
124
  }
@@ -212,11 +212,12 @@ export class ConfigManager {
212
212
  static parseConfigCommand(message) {
213
213
  const lower = message.toLowerCase().trim();
214
214
 
215
- // "switch to gpt-4o" / "use gemini"
216
- const modelMatch = lower.match(/(?:switch|change|use)\s+(?:to\s+)?(?:model\s+)?(.+)/);
215
+ // "switch to gpt-4o" / "use gemini" — must be the MAIN intent (start of message)
216
+ const modelMatch = lower.match(/^(?:switch|change|use)\s+(?:to\s+)?(?:model\s+)?(\S+(?:\s+\S+){0,3})$/);
217
217
  if (modelMatch) {
218
218
  const model = modelMatch[1].trim();
219
- // Map common names to actual model IDs
219
+ // Only match if it looks like a model name (known alias or contains provider-like patterns)
220
+ const knownPatterns = /^(gpt|claude|sonnet|opus|haiku|gemini|flash|llama|deepseek|mistral|groq|ollama|o[1-4]|\S+-\d)/i;
220
221
  const aliases = {
221
222
  'gpt4': 'gpt-4o', 'gpt-4': 'gpt-4o', 'gpt4o': 'gpt-4o',
222
223
  'claude': 'claude-sonnet-4-20250514', 'sonnet': 'claude-sonnet-4-20250514',
@@ -224,6 +225,8 @@ export class ConfigManager {
224
225
  'gemini': 'gemini-2.5-flash', 'flash': 'gemini-2.5-flash',
225
226
  'llama': 'llama-3.3-70b-versatile', 'deepseek': 'deepseek-chat',
226
227
  };
228
+ if (!knownPatterns.test(model) && !aliases[model]) return null; // Not a model name
229
+ // Map common names to actual model IDs
227
230
  return { action: 'set_model', model: aliases[model] || model };
228
231
  }
229
232
 
@@ -1,8 +1,27 @@
1
1
  /**
2
2
  * Send the response back to the user via the appropriate channel
3
+ * With human-like timing — small delays to feel natural
3
4
  */
4
5
  import { logger } from '../core/logger.js';
5
6
 
7
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
8
+
9
+ // Calculate human-like "typing" delay based on message length
10
+ function typingDelay(text) {
11
+ const len = (text || '').length;
12
+ if (len < 20) return 400 + Math.random() * 300; // Short: 0.4-0.7s
13
+ if (len < 100) return 700 + Math.random() * 500; // Medium: 0.7-1.2s
14
+ if (len < 300) return 1000 + Math.random() * 700; // Long: 1.0-1.7s
15
+ return 1500 + Math.random() * 800; // Very long: 1.5-2.3s
16
+ }
17
+
18
+ // Delay between split messages
19
+ function splitDelay(text) {
20
+ const len = (text || '').length;
21
+ if (len < 50) return 300 + Math.random() * 400; // 0.3-0.7s
22
+ return 500 + Math.random() * 700; // 0.5-1.2s
23
+ }
24
+
6
25
  export async function responseSenderMiddleware(ctx, next) {
7
26
  if (!ctx.response) return;
8
27
 
@@ -15,9 +34,10 @@ export async function responseSenderMiddleware(ctx, next) {
15
34
  ctx.engine.reminders.add(agentId, contactId, response._reminder.message, response._reminder.time, ctx.platform, metadata);
16
35
  }
17
36
 
18
- // Send reaction
37
+ // Send reaction first (with tiny natural delay)
19
38
  if (response.reaction && metadata.messageId) {
20
39
  try {
40
+ await sleep(200 + Math.random() * 400); // 0.2-0.6s to "see" the message
21
41
  if (ctx.platform === 'telegram' && tm) {
22
42
  await tm.sendReaction(agentId, contactId, metadata.messageId, response.reaction, metadata);
23
43
  }
@@ -27,9 +47,18 @@ export async function responseSenderMiddleware(ctx, next) {
27
47
  // No messages to send
28
48
  if (!response.messages?.length && !response.image) return;
29
49
 
50
+ // Human-like delay before first reply (thinking time)
51
+ const firstMsg = response.messages?.[0] || '';
52
+ await sleep(typingDelay(firstMsg));
53
+
54
+ // Send typing indicator if available
55
+ if (ctx.platform === 'telegram' && tm) {
56
+ try { await tm.sendChatAction(agentId, contactId, 'typing', metadata); } catch {}
57
+ }
58
+
30
59
  // Send via appropriate channel
31
60
  if (ctx.platform === 'telegram' && tm) {
32
- // Send file attachment (pptx, excel, pdf, html)
61
+ // Send file attachment
33
62
  if (response.filePath) {
34
63
  logger.info('sender', 'SENDING FILE: ' + response.filePath);
35
64
  try {
@@ -38,7 +67,6 @@ export async function responseSenderMiddleware(ctx, next) {
38
67
  return;
39
68
  } catch (err) {
40
69
  logger.error('sender', 'File send failed: ' + err.message);
41
- // Fall through to text
42
70
  }
43
71
  }
44
72
 
@@ -53,14 +81,28 @@ export async function responseSenderMiddleware(ctx, next) {
53
81
  const audio = await vr.generate(response.messages[0], { language: lang });
54
82
  await tm.sendVoice(agentId, contactId, audio, metadata);
55
83
  } catch {
56
- await tm.sendMessages(agentId, contactId, response.messages, metadata);
84
+ // Fallback: send one by one with timing
85
+ for (let i = 0; i < response.messages.length; i++) {
86
+ if (i > 0) { try { await tm.sendChatAction(agentId, contactId, "typing", metadata); } catch {} await sleep(splitDelay(response.messages[i])); }
87
+ await tm.sendMessage(agentId, contactId, response.messages[i], metadata);
88
+ }
57
89
  }
58
90
  } else {
59
- await tm.sendMessages(agentId, contactId, response.messages, metadata);
91
+ // Send messages with human-like gaps between splits
92
+ for (let i = 0; i < response.messages.length; i++) {
93
+ if (i > 0) {
94
+ // Typing indicator + delay between messages
95
+ try { await tm.sendChatAction(agentId, contactId, 'typing', metadata); } catch {}
96
+ await sleep(splitDelay(response.messages[i]));
97
+ }
98
+ await tm.sendMessage(agentId, contactId, response.messages[i], metadata);
99
+ }
60
100
  }
61
101
  } else if (wm) {
62
- for (const msg of response.messages) {
63
- await wm.sendMessage(agentId, contactId, msg);
102
+ // WhatsApp same human timing
103
+ for (let i = 0; i < response.messages.length; i++) {
104
+ if (i > 0) await sleep(splitDelay(response.messages[i]));
105
+ await wm.sendMessage(agentId, contactId, response.messages[i]);
64
106
  }
65
107
  }
66
108
 
@@ -382,10 +382,12 @@ export class ToolRouter {
382
382
  case 'pptx_slides':
383
383
  case 'pptx_pro':
384
384
  case 'powerpoint':
385
+ case 'slides':
385
386
  case 'presentation': {
386
387
  try {
387
- const { generatePresentation } = await import('./pptx-pro.js');
388
- const parts = toolArg.split('|');
388
+ const { generateSlides } = await import('./slides-engine.js');
389
+ const unescaped = toolArg.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
390
+ const parts = unescaped.split('|');
389
391
  let title, theme, content;
390
392
  if (parts.length >= 3) {
391
393
  title = parts[0].trim();
@@ -396,22 +398,23 @@ export class ToolRouter {
396
398
  content = parts[1];
397
399
  theme = 'executive';
398
400
  } else {
399
- const firstH = toolArg.match(/^#\s+(.+)/m);
401
+ const firstH = unescaped.match(/^#\s+(.+)/m);
400
402
  title = firstH ? firstH[1] : 'Presentation';
401
- content = toolArg;
403
+ content = unescaped;
402
404
  theme = 'executive';
403
405
  }
404
- const result = await generatePresentation({ title, theme, content });
406
+ const result = await generateSlides({ title, theme, content });
405
407
  return {
406
408
  toolUsed: true,
407
409
  toolName: 'pptx',
408
- toolResult: 'PowerPoint created: ' + result.filename + ' (' + result.slides + ' slides, types: ' + result.types.join(', ') + ')',
410
+ toolResult: 'Presentation created: ' + result.filename + ' (' + result.slides + ' slides, ' + result.images + ' photos, types: ' + result.types.join(', ') + ')',
409
411
  filePath: result.filepath,
410
412
  fileName: result.filename,
411
413
  cleanResponse
412
414
  };
413
415
  } catch (err) {
414
- toolResult = 'PowerPoint failed: ' + err.message;
416
+ toolResult = 'Slides failed: ' + err.message;
417
+ logger.error('tools', 'Slides error: ' + err.stack);
415
418
  }
416
419
  break;
417
420
  }
@@ -709,7 +712,7 @@ export class ToolRouter {
709
712
  case 'dashboard': {
710
713
  try {
711
714
  const { renderDashboard } = await import('./dashboard-pro.js');
712
- const result = await renderDashboard(toolArg);
715
+ const result = await renderDashboard(toolArg.replace(/\\n/g, '\n').replace(/\\t/g, '\t'));
713
716
  if (result.buffer) {
714
717
  return { toolUsed: true, toolName: 'dashboard', toolResult: 'Dashboard rendered', imageBase64: result.buffer.toString('base64'), mimeType: 'image/png', cleanResponse };
715
718
  } else {
@@ -0,0 +1,660 @@
1
+ /**
2
+ * 🦑 Slides Engine v2 — Genspark-style
3
+ *
4
+ * Flow:
5
+ * 1. AI generates structured slide data (JSON or markdown)
6
+ * 2. Engine fetches real images from Unsplash/Pexels
7
+ * 3. Renders as HTML slides (Reveal.js-style)
8
+ * 4. Screenshots each slide via Puppeteer
9
+ * 5. Assembles into PPTX (image-per-slide) or PDF
10
+ * 6. Optionally serves as web presentation via API
11
+ *
12
+ * Result: Professional slides with real photos, proper typography, smooth gradients
13
+ */
14
+
15
+ import PptxGenJS from 'pptxgenjs';
16
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
17
+ import { join } from 'path';
18
+ import { logger } from '../core/logger.js';
19
+
20
+ const OUTPUT_DIR = '/tmp/squidclaw-slides';
21
+ const CACHE_DIR = '/tmp/squidclaw-slides/cache';
22
+
23
+ // ── Image fetcher (Unsplash free API) ──
24
+
25
+ async function fetchImage(query, width = 1200, height = 800) {
26
+ try {
27
+ // Unsplash Source (no API key needed, direct redirect)
28
+ const url = `https://source.unsplash.com/${width}x${height}/?${encodeURIComponent(query)}`;
29
+ const resp = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(8000) });
30
+ if (resp.ok) {
31
+ const buffer = Buffer.from(await resp.arrayBuffer());
32
+ if (buffer.length > 5000) { // Valid image
33
+ const cached = join(CACHE_DIR, query.replace(/[^a-z0-9]/gi, '_').slice(0, 30) + '.jpg');
34
+ writeFileSync(cached, buffer);
35
+ return { buffer, url: resp.url, cached };
36
+ }
37
+ }
38
+ } catch {}
39
+
40
+ // Fallback: Picsum (always works, random quality images)
41
+ try {
42
+ const url = `https://picsum.photos/${width}/${height}`;
43
+ const resp = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(5000) });
44
+ if (resp.ok) {
45
+ const buffer = Buffer.from(await resp.arrayBuffer());
46
+ return { buffer, url: resp.url };
47
+ }
48
+ } catch {}
49
+
50
+ return null;
51
+ }
52
+
53
+ // ── Slide Templates (HTML) ──
54
+
55
+ const CSS_BASE = `
56
+ /* Fonts: system stack (no external loads) */
57
+
58
+ * { margin: 0; padding: 0; box-sizing: border-box; }
59
+
60
+ .slide {
61
+ width: 1280px; height: 720px;
62
+ position: relative; overflow: hidden;
63
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
64
+ }
65
+
66
+ /* Dark gradient overlay for text readability on images */
67
+ .overlay {
68
+ position: absolute; top: 0; left: 0; right: 0; bottom: 0;
69
+ z-index: 1;
70
+ }
71
+ .overlay-dark { background: linear-gradient(135deg, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 100%); }
72
+ .overlay-left { background: linear-gradient(90deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.7) 55%, transparent 100%); }
73
+ .overlay-bottom { background: linear-gradient(0deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.3) 50%, transparent 100%); }
74
+ .overlay-gradient { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
75
+ .overlay-saudi { background: linear-gradient(135deg, rgba(0,108,53,0.92) 0%, rgba(0,77,37,0.85) 100%); }
76
+ .overlay-ocean { background: linear-gradient(135deg, rgba(10,25,47,0.95) 0%, rgba(17,34,64,0.9) 100%); }
77
+ .overlay-fire { background: linear-gradient(135deg, rgba(139,0,0,0.9) 0%, rgba(26,0,0,0.85) 100%); }
78
+
79
+ .bg-image {
80
+ position: absolute; top: 0; left: 0; width: 100%; height: 100%;
81
+ object-fit: cover; z-index: 0;
82
+ }
83
+
84
+ .content { position: relative; z-index: 2; padding: 60px; height: 100%; display: flex; flex-direction: column; }
85
+ .content-split { display: flex; height: 100%; }
86
+ .content-left { width: 55%; padding: 60px; display: flex; flex-direction: column; justify-content: center; position: relative; z-index: 2; }
87
+ .content-right { width: 45%; position: relative; }
88
+ .content-right img { width: 100%; height: 100%; object-fit: cover; }
89
+
90
+ /* Typography */
91
+ .title-xl { font-family: Georgia, 'Times New Roman', serif; font-size: 56px; font-weight: 800; color: #fff; line-height: 1.1; letter-spacing: -1px; }
92
+ .title-lg { font-family: Georgia, 'Times New Roman', serif; font-size: 42px; font-weight: 700; color: #fff; line-height: 1.15; }
93
+ .title-md { font-size: 28px; font-weight: 700; color: #fff; }
94
+ .subtitle { font-size: 18px; color: rgba(255,255,255,0.7); margin-top: 16px; line-height: 1.6; font-weight: 300; }
95
+ .label { font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.5); text-transform: uppercase; letter-spacing: 3px; margin-bottom: 20px; }
96
+ .accent-line { width: 60px; height: 4px; background: linear-gradient(90deg, #58a6ff, #3fb950); border-radius: 2px; margin: 20px 0; }
97
+ .accent-line-gold { background: linear-gradient(90deg, #c8a951, #daa520); }
98
+
99
+ /* Stats Grid */
100
+ .stats-grid { display: grid; gap: 20px; margin-top: 30px; }
101
+ .stats-grid-2 { grid-template-columns: 1fr 1fr; }
102
+ .stats-grid-3 { grid-template-columns: 1fr 1fr 1fr; }
103
+ .stats-grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
104
+ .stat-card {
105
+ background: rgba(255,255,255,0.08); backdrop-filter: blur(20px);
106
+ border: 1px solid rgba(255,255,255,0.12); border-radius: 16px;
107
+ padding: 24px; text-align: center;
108
+ }
109
+ .stat-icon { font-size: 28px; margin-bottom: 8px; }
110
+ .stat-value { font-size: 36px; font-weight: 800; background: linear-gradient(135deg, #58a6ff, #3fb950); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
111
+ .stat-label { font-size: 12px; color: rgba(255,255,255,0.6); margin-top: 6px; font-weight: 500; }
112
+ .stat-change { font-size: 11px; margin-top: 4px; }
113
+ .stat-up { color: #3fb950; }
114
+ .stat-down { color: #f85149; }
115
+
116
+ /* Bullets */
117
+ .bullets { list-style: none; margin-top: 24px; }
118
+ .bullets li { display: flex; align-items: flex-start; gap: 14px; margin: 14px 0; font-size: 16px; color: rgba(255,255,255,0.85); line-height: 1.5; }
119
+ .bullet-dot { width: 8px; height: 8px; border-radius: 50%; background: #58a6ff; margin-top: 7px; flex-shrink: 0; }
120
+ .bullet-num { width: 28px; height: 28px; border-radius: 50%; background: rgba(88,166,255,0.2); color: #58a6ff; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; flex-shrink: 0; }
121
+
122
+ /* Table */
123
+ .slide-table { width: 100%; border-collapse: separate; border-spacing: 0; margin-top: 20px; border-radius: 12px; overflow: hidden; }
124
+ .slide-table th { background: rgba(88,166,255,0.15); color: #58a6ff; padding: 14px 18px; text-align: left; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; }
125
+ .slide-table td { padding: 12px 18px; font-size: 14px; color: rgba(255,255,255,0.8); border-bottom: 1px solid rgba(255,255,255,0.06); }
126
+ .slide-table tr:nth-child(even) td { background: rgba(255,255,255,0.03); }
127
+
128
+ /* Bar chart */
129
+ .bar-chart { margin-top: 20px; }
130
+ .bar-row { display: flex; align-items: center; margin: 10px 0; }
131
+ .bar-label { width: 110px; font-size: 13px; color: rgba(255,255,255,0.7); flex-shrink: 0; }
132
+ .bar-track { flex: 1; height: 32px; background: rgba(255,255,255,0.06); border-radius: 8px; overflow: hidden; }
133
+ .bar-fill { height: 100%; border-radius: 8px; display: flex; align-items: center; padding: 0 12px; }
134
+ .bar-value { font-size: 12px; color: #fff; font-weight: 700; }
135
+
136
+ /* Donut */
137
+ .donut-wrap { display: flex; align-items: center; gap: 32px; margin-top: 20px; }
138
+ .donut-circle { width: 180px; height: 180px; border-radius: 50%; position: relative; flex-shrink: 0; }
139
+ .donut-hole { position: absolute; top: 18%; left: 18%; width: 64%; height: 64%; border-radius: 50%; background: rgba(13,17,23,0.95); display: flex; align-items: center; justify-content: center; flex-direction: column; }
140
+ .donut-total { font-size: 24px; font-weight: 800; color: #fff; }
141
+ .donut-sub { font-size: 10px; color: rgba(255,255,255,0.5); }
142
+ .legend { display: flex; flex-direction: column; gap: 10px; }
143
+ .legend-item { display: flex; align-items: center; gap: 10px; font-size: 13px; color: rgba(255,255,255,0.8); }
144
+ .legend-dot { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
145
+
146
+ /* Timeline */
147
+ .timeline { margin-top: 30px; position: relative; padding-left: 30px; }
148
+ .timeline::before { content: ''; position: absolute; left: 8px; top: 0; bottom: 0; width: 2px; background: linear-gradient(180deg, #58a6ff, #3fb950); }
149
+ .timeline-item { position: relative; margin: 24px 0; }
150
+ .timeline-dot { position: absolute; left: -26px; top: 4px; width: 14px; height: 14px; border-radius: 50%; background: #58a6ff; border: 3px solid #0d1117; }
151
+ .timeline-year { font-size: 13px; font-weight: 700; color: #58a6ff; }
152
+ .timeline-title { font-size: 16px; font-weight: 600; color: #fff; margin-top: 2px; }
153
+ .timeline-desc { font-size: 13px; color: rgba(255,255,255,0.6); margin-top: 4px; line-height: 1.4; }
154
+
155
+ /* Quote */
156
+ .quote-mark { font-family: Georgia, 'Times New Roman', serif; font-size: 120px; color: rgba(88,166,255,0.2); line-height: 0.8; position: absolute; top: 40px; left: 50px; }
157
+ .quote-text { font-family: Georgia, 'Times New Roman', serif; font-size: 28px; color: #fff; line-height: 1.5; font-style: italic; max-width: 800px; }
158
+ .quote-author { font-size: 14px; color: rgba(255,255,255,0.5); margin-top: 24px; }
159
+
160
+ /* Progress */
161
+ .progress-list { margin-top: 20px; }
162
+ .progress-row { display: flex; align-items: center; gap: 16px; margin: 14px 0; }
163
+ .progress-label { width: 140px; font-size: 13px; color: rgba(255,255,255,0.7); }
164
+ .progress-track { flex: 1; height: 12px; background: rgba(255,255,255,0.08); border-radius: 6px; overflow: hidden; }
165
+ .progress-fill { height: 100%; border-radius: 6px; }
166
+ .progress-pct { font-size: 13px; font-weight: 700; width: 45px; text-align: right; }
167
+
168
+ /* Footer */
169
+ .slide-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: 14px 60px; display: flex; justify-content: space-between; align-items: center; z-index: 3; background: linear-gradient(0deg, rgba(0,0,0,0.5), transparent); }
170
+ .slide-footer span { font-size: 10px; color: rgba(255,255,255,0.35); }
171
+
172
+ /* Comparison */
173
+ .compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px; }
174
+ .compare-card { background: rgba(255,255,255,0.06); border-radius: 16px; padding: 28px; border: 1px solid rgba(255,255,255,0.08); }
175
+ .compare-title { font-size: 18px; font-weight: 700; margin-bottom: 16px; }
176
+ `;
177
+
178
+ // ── Chart Colors ──
179
+ const COLORS = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#f778ba', '#a5d6ff', '#7ee787'];
180
+ const GRADIENTS = [
181
+ 'linear-gradient(135deg, #58a6ff, #3fb950)',
182
+ 'linear-gradient(135deg, #f85149, #d29922)',
183
+ 'linear-gradient(135deg, #bc8cff, #f778ba)',
184
+ 'linear-gradient(135deg, #3fb950, #58a6ff)',
185
+ 'linear-gradient(135deg, #d29922, #f85149)',
186
+ 'linear-gradient(135deg, #a5d6ff, #58a6ff)',
187
+ ];
188
+
189
+ // ── Overlay picker by theme ──
190
+ function getOverlay(theme) {
191
+ const map = { saudi: 'overlay-saudi', ocean: 'overlay-ocean', fire: 'overlay-fire', gradient: 'overlay-gradient' };
192
+ return map[theme] || 'overlay-dark';
193
+ }
194
+ function getAccentLine(theme) {
195
+ return theme === 'saudi' ? 'accent-line accent-line-gold' : 'accent-line';
196
+ }
197
+
198
+ // ── Parse markdown to structured slide data ──
199
+
200
+ function parseToSlides(content) {
201
+ const slides = [];
202
+ const lines = content.split('\n');
203
+ let current = null;
204
+
205
+ for (const rawLine of lines) {
206
+ const line = rawLine.trim();
207
+ if (!line) continue;
208
+
209
+ if (line.startsWith('## ') || line.startsWith('# ')) {
210
+ if (current) slides.push(current);
211
+ current = {
212
+ title: line.replace(/^#+\s*/, '').replace(/\*\*/g, ''),
213
+ type: 'content', imageQuery: '', stats: [], table: [], chart: [],
214
+ bullets: [], timeline: [], quote: null, quoteAuthor: null,
215
+ progress: [], comparison: [], body: [],
216
+ };
217
+ // Auto image query from title
218
+ current.imageQuery = current.title.toLowerCase().replace(/[^a-z\s]/g, '').trim();
219
+ continue;
220
+ }
221
+
222
+ if (!current) {
223
+ current = { title: '', type: 'content', imageQuery: 'abstract technology', stats: [], table: [], chart: [], bullets: [], timeline: [], quote: null, quoteAuthor: null, progress: [], comparison: [], body: [] };
224
+ }
225
+
226
+ // Section markers
227
+ if (line.match(/^\[stats?\]/i)) { current.type = 'stats'; continue; }
228
+ if (line.match(/^\[chart\]/i) || line.match(/^\[bar\]/i)) { current.type = 'chart'; continue; }
229
+ if (line.match(/^\[donut\]/i) || line.match(/^\[pie\]/i)) { current.type = 'donut'; continue; }
230
+ if (line.match(/^\[table\]/i)) { current.type = 'table'; continue; }
231
+ if (line.match(/^\[timeline\]/i)) { current.type = 'timeline'; continue; }
232
+ if (line.match(/^\[progress\]/i)) { current.type = 'progress'; continue; }
233
+ if (line.match(/^\[compare\]/i) || line.match(/^\[comparison\]/i)) { current.type = 'comparison'; continue; }
234
+ if (line.match(/^\[quote\]/i)) { current.type = 'quote'; continue; }
235
+ if (line.match(/^\[image:\s*(.+?)\]/i)) { current.imageQuery = line.match(/\[image:\s*(.+?)\]/i)[1]; continue; }
236
+
237
+ // Stats: - icon value — label (change)
238
+ if (current.type === 'stats' && line.startsWith('-')) {
239
+ const m = line.match(/^-\s*(.+?)\s+(.+?)\s*[—–-]\s*(.+?)(?:\s*\((.+?)\))?$/);
240
+ if (m) { current.stats.push({ icon: m[1], value: m[2], label: m[3].trim(), change: m[4] || null }); continue; }
241
+ }
242
+
243
+ // Table
244
+ if (line.startsWith('|') && line.endsWith('|')) {
245
+ if (line.match(/^\|[\s-:|]+\|$/)) continue;
246
+ current.table.push(line.split('|').filter(c => c.trim()).map(c => c.trim()));
247
+ if (current.type === 'content') current.type = 'table';
248
+ continue;
249
+ }
250
+
251
+ // Chart: - label: value
252
+ if ((current.type === 'chart' || current.type === 'donut') && line.startsWith('-')) {
253
+ const m = line.match(/^-\s*(.+?):\s*(\d+)/);
254
+ if (m) { current.chart.push({ label: m[1].trim(), value: parseInt(m[2]) }); continue; }
255
+ }
256
+
257
+ // Progress: - label: value%
258
+ if (current.type === 'progress' && line.startsWith('-')) {
259
+ const m = line.match(/^-\s*(.+?):\s*(\d+)%?/);
260
+ if (m) { current.progress.push({ label: m[1].trim(), value: parseInt(m[2]) }); continue; }
261
+ }
262
+
263
+ // Timeline: N. Title — desc
264
+ if (current.type === 'timeline' && line.match(/^\d+\./)) {
265
+ const m = line.match(/^(\d+)\.\s*\*?\*?(.+?)\*?\*?\s*[—–-]\s*(.+)/);
266
+ if (m) { current.timeline.push({ year: m[1], title: m[2].trim(), desc: m[3].trim() }); continue; }
267
+ }
268
+
269
+ // Quote
270
+ if (line.startsWith('>') || current.type === 'quote') {
271
+ const q = line.replace(/^>\s*/, '').replace(/\*\*/g, '');
272
+ if (q.startsWith('—') || q.startsWith('- ')) {
273
+ current.quoteAuthor = q.replace(/^[—-]\s*/, '');
274
+ } else {
275
+ current.quote = (current.quote || '') + q + ' ';
276
+ }
277
+ current.type = 'quote';
278
+ continue;
279
+ }
280
+
281
+ // Bullets
282
+ if (line.startsWith('-') || line.startsWith('•') || line.startsWith('*')) {
283
+ const b = line.replace(/^[-•*]\s*/, '').replace(/\*\*/g, '');
284
+ current.bullets.push(b);
285
+ continue;
286
+ }
287
+
288
+ current.body.push(line.replace(/\*\*/g, ''));
289
+ }
290
+ if (current) slides.push(current);
291
+
292
+ // Auto-type detection
293
+ for (const s of slides) {
294
+ if (s.stats.length >= 2 && s.type === 'content') s.type = 'stats';
295
+ if (s.table.length >= 2 && s.type === 'content') s.type = 'table';
296
+ if (s.chart.length >= 2 && s.type === 'content') s.type = 'chart';
297
+ if (s.timeline.length >= 2 && s.type === 'content') s.type = 'timeline';
298
+ if (s.progress.length >= 2 && s.type === 'content') s.type = 'progress';
299
+ if (s.quote && s.type === 'content') s.type = 'quote';
300
+ if (s.bullets.length >= 2 && s.type === 'content') s.type = 'bullets';
301
+ }
302
+
303
+ return slides;
304
+ }
305
+
306
+ // ── HTML Renderers ──
307
+
308
+ function renderTitleSlide(title, subtitle, theme, imgB64) {
309
+ const overlay = getOverlay(theme);
310
+ return `<div class="slide">
311
+ ${imgB64 ? `<img class="bg-image" src="data:image/jpeg;base64,${imgB64}" />` : ''}
312
+ <div class="overlay overlay-left"></div>
313
+ <div class="content" style="justify-content:center">
314
+ <div class="label">PRESENTATION</div>
315
+ <div class="title-xl">${title}</div>
316
+ <div class="${getAccentLine(theme)}"></div>
317
+ ${subtitle ? `<div class="subtitle">${subtitle}</div>` : ''}
318
+ </div>
319
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span></span></div>
320
+ </div>`;
321
+ }
322
+
323
+ function renderStatsSlide(slide, theme, num, total, imgB64) {
324
+ const cols = Math.min(slide.stats.length, 4);
325
+ const statsHtml = slide.stats.map(s => {
326
+ const changeHtml = s.change ? `<div class="stat-change ${s.change.includes('+') || s.change.includes('↑') ? 'stat-up' : 'stat-down'}">${s.change}</div>` : '';
327
+ return `<div class="stat-card"><div class="stat-icon">${s.icon}</div><div class="stat-value">${s.value}</div><div class="stat-label">${s.label}</div>${changeHtml}</div>`;
328
+ }).join('');
329
+
330
+ return `<div class="slide">
331
+ ${imgB64 ? `<img class="bg-image" src="data:image/jpeg;base64,${imgB64}" />` : ''}
332
+ <div class="overlay ${getOverlay(theme)}"></div>
333
+ <div class="content">
334
+ <div class="label">KEY METRICS</div>
335
+ <div class="title-lg">${slide.title}</div>
336
+ <div class="${getAccentLine(theme)}"></div>
337
+ <div class="stats-grid stats-grid-${cols}" style="flex:1;align-content:center">${statsHtml}</div>
338
+ </div>
339
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
340
+ </div>`;
341
+ }
342
+
343
+ function renderBulletsSlide(slide, theme, num, total, imgB64) {
344
+ // Split layout: text left, image right
345
+ if (imgB64) {
346
+ const bulletsHtml = slide.bullets.map((b, i) => `<li><div class="bullet-num">${i + 1}</div><span>${b}</span></li>`).join('');
347
+ return `<div class="slide">
348
+ <div class="content-split">
349
+ <div class="content-left">
350
+ <div class="label">OVERVIEW</div>
351
+ <div class="title-lg">${slide.title}</div>
352
+ <div class="${getAccentLine(theme)}"></div>
353
+ <ul class="bullets">${bulletsHtml}</ul>
354
+ </div>
355
+ <div class="content-right"><img src="data:image/jpeg;base64,${imgB64}" /></div>
356
+ </div>
357
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
358
+ </div>`;
359
+ }
360
+
361
+ const bulletsHtml = slide.bullets.map(b => `<li><div class="bullet-dot"></div><span>${b}</span></li>`).join('');
362
+ return `<div class="slide">
363
+ <div class="overlay ${getOverlay(theme)}"></div>
364
+ <div class="content">
365
+ <div class="label">OVERVIEW</div>
366
+ <div class="title-lg">${slide.title}</div>
367
+ <div class="${getAccentLine(theme)}"></div>
368
+ <ul class="bullets">${bulletsHtml}</ul>
369
+ </div>
370
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
371
+ </div>`;
372
+ }
373
+
374
+ function renderTableSlide(slide, theme, num, total) {
375
+ if (slide.table.length < 2) return '';
376
+ const headers = slide.table[0];
377
+ const rows = slide.table.slice(1);
378
+ const headerHtml = headers.map(h => `<th>${h}</th>`).join('');
379
+ const rowsHtml = rows.map(r => `<tr>${r.map(c => `<td>${c}</td>`).join('')}</tr>`).join('');
380
+
381
+ return `<div class="slide">
382
+ <div class="overlay ${getOverlay(theme)}"></div>
383
+ <div class="content">
384
+ <div class="label">DATA</div>
385
+ <div class="title-lg">${slide.title}</div>
386
+ <div class="${getAccentLine(theme)}"></div>
387
+ <table class="slide-table"><thead><tr>${headerHtml}</tr></thead><tbody>${rowsHtml}</tbody></table>
388
+ </div>
389
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
390
+ </div>`;
391
+ }
392
+
393
+ function renderChartSlide(slide, theme, num, total) {
394
+ const max = Math.max(...slide.chart.map(i => i.value));
395
+ const barsHtml = slide.chart.map((item, idx) => {
396
+ const pct = (item.value / max * 100).toFixed(0);
397
+ const gradient = GRADIENTS[idx % GRADIENTS.length];
398
+ return `<div class="bar-row"><div class="bar-label">${item.label}</div><div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${gradient}"><span class="bar-value">${item.value}</span></div></div></div>`;
399
+ }).join('');
400
+
401
+ return `<div class="slide">
402
+ <div class="overlay ${getOverlay(theme)}"></div>
403
+ <div class="content">
404
+ <div class="label">ANALYSIS</div>
405
+ <div class="title-lg">${slide.title}</div>
406
+ <div class="${getAccentLine(theme)}"></div>
407
+ <div class="bar-chart">${barsHtml}</div>
408
+ </div>
409
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
410
+ </div>`;
411
+ }
412
+
413
+ function renderDonutSlide(slide, theme, num, total) {
414
+ const items = slide.chart;
415
+ const chartTotal = items.reduce((s, i) => s + i.value, 0);
416
+ let rotation = 0;
417
+ const gradParts = [];
418
+ items.forEach((item, idx) => {
419
+ const deg = item.value / chartTotal * 360;
420
+ gradParts.push(`${COLORS[idx % COLORS.length]} ${rotation}deg ${rotation + deg}deg`);
421
+ rotation += deg;
422
+ });
423
+ const legendHtml = items.map((item, idx) =>
424
+ `<div class="legend-item"><div class="legend-dot" style="background:${COLORS[idx % COLORS.length]}"></div>${item.label}: <strong>${item.value}</strong> (${(item.value / chartTotal * 100).toFixed(0)}%)</div>`
425
+ ).join('');
426
+
427
+ return `<div class="slide">
428
+ <div class="overlay ${getOverlay(theme)}"></div>
429
+ <div class="content">
430
+ <div class="label">DISTRIBUTION</div>
431
+ <div class="title-lg">${slide.title}</div>
432
+ <div class="${getAccentLine(theme)}"></div>
433
+ <div class="donut-wrap">
434
+ <div class="donut-circle" style="background:conic-gradient(${gradParts.join(',')})">
435
+ <div class="donut-hole"><div class="donut-total">${chartTotal}</div><div class="donut-sub">Total</div></div>
436
+ </div>
437
+ <div class="legend">${legendHtml}</div>
438
+ </div>
439
+ </div>
440
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
441
+ </div>`;
442
+ }
443
+
444
+ function renderTimelineSlide(slide, theme, num, total) {
445
+ const itemsHtml = slide.timeline.map(t =>
446
+ `<div class="timeline-item"><div class="timeline-dot"></div><div class="timeline-year">${t.year}</div><div class="timeline-title">${t.title}</div><div class="timeline-desc">${t.desc}</div></div>`
447
+ ).join('');
448
+
449
+ return `<div class="slide">
450
+ <div class="overlay ${getOverlay(theme)}"></div>
451
+ <div class="content">
452
+ <div class="label">TIMELINE</div>
453
+ <div class="title-lg">${slide.title}</div>
454
+ <div class="${getAccentLine(theme)}"></div>
455
+ <div class="timeline">${itemsHtml}</div>
456
+ </div>
457
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
458
+ </div>`;
459
+ }
460
+
461
+ function renderQuoteSlide(slide, theme, num, total, imgB64) {
462
+ return `<div class="slide">
463
+ ${imgB64 ? `<img class="bg-image" src="data:image/jpeg;base64,${imgB64}" />` : ''}
464
+ <div class="overlay ${getOverlay(theme)}"></div>
465
+ <div class="content" style="justify-content:center;align-items:center">
466
+ <div class="quote-mark">"</div>
467
+ <div class="quote-text" style="margin-top:40px">${(slide.quote || '').trim()}</div>
468
+ ${slide.quoteAuthor ? `<div class="quote-author">— ${slide.quoteAuthor}</div>` : ''}
469
+ <div class="${getAccentLine(theme)}" style="margin-top:30px"></div>
470
+ </div>
471
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
472
+ </div>`;
473
+ }
474
+
475
+ function renderProgressSlide(slide, theme, num, total) {
476
+ const itemsHtml = slide.progress.map((item, idx) => {
477
+ const color = COLORS[idx % COLORS.length];
478
+ return `<div class="progress-row"><div class="progress-label">${item.label}</div><div class="progress-track"><div class="progress-fill" style="width:${item.value}%;background:${GRADIENTS[idx % GRADIENTS.length]}"></div></div><div class="progress-pct" style="color:${color}">${item.value}%</div></div>`;
479
+ }).join('');
480
+
481
+ return `<div class="slide">
482
+ <div class="overlay ${getOverlay(theme)}"></div>
483
+ <div class="content">
484
+ <div class="label">PROGRESS</div>
485
+ <div class="title-lg">${slide.title}</div>
486
+ <div class="${getAccentLine(theme)}"></div>
487
+ <div class="progress-list">${itemsHtml}</div>
488
+ </div>
489
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
490
+ </div>`;
491
+ }
492
+
493
+ function renderContentSlide(slide, theme, num, total, imgB64) {
494
+ const bodyHtml = slide.body.map(t => `<p style="font-size:16px;color:rgba(255,255,255,0.8);line-height:1.7;margin:8px 0">${t}</p>`).join('');
495
+
496
+ if (imgB64) {
497
+ return `<div class="slide">
498
+ <div class="content-split">
499
+ <div class="content-left">
500
+ <div class="title-lg">${slide.title}</div>
501
+ <div class="${getAccentLine(theme)}"></div>
502
+ ${bodyHtml}
503
+ ${slide.bullets.length ? `<ul class="bullets">${slide.bullets.map(b => `<li><div class="bullet-dot"></div><span>${b}</span></li>`).join('')}</ul>` : ''}
504
+ </div>
505
+ <div class="content-right"><img src="data:image/jpeg;base64,${imgB64}" /></div>
506
+ </div>
507
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
508
+ </div>`;
509
+ }
510
+
511
+ return `<div class="slide">
512
+ <div class="overlay ${getOverlay(theme)}"></div>
513
+ <div class="content">
514
+ <div class="title-lg">${slide.title}</div>
515
+ <div class="${getAccentLine(theme)}"></div>
516
+ ${bodyHtml}
517
+ ${slide.bullets.length ? `<ul class="bullets">${slide.bullets.map(b => `<li><div class="bullet-dot"></div><span>${b}</span></li>`).join('')}</ul>` : ''}
518
+ </div>
519
+ <div class="slide-footer"><span>Squidclaw AI 🦑</span><span>${num} / ${total}</span></div>
520
+ </div>`;
521
+ }
522
+
523
+ function renderThankYouSlide(theme, brand, imgB64) {
524
+ return `<div class="slide">
525
+ ${imgB64 ? `<img class="bg-image" src="data:image/jpeg;base64,${imgB64}" />` : ''}
526
+ <div class="overlay ${getOverlay(theme)}"></div>
527
+ <div class="content" style="justify-content:center;align-items:center">
528
+ <div class="title-xl">Thank You</div>
529
+ <div class="${getAccentLine(theme)}" style="width:80px;margin:30px auto"></div>
530
+ <div class="subtitle" style="text-align:center">Created with ${brand || 'Squidclaw AI 🦑'}</div>
531
+ </div>
532
+ </div>`;
533
+ }
534
+
535
+ // ── Main Export ──
536
+
537
+ export async function generateSlides(input) {
538
+ mkdirSync(OUTPUT_DIR, { recursive: true });
539
+ mkdirSync(CACHE_DIR, { recursive: true });
540
+
541
+ const { title = 'Presentation', subtitle = '', content = '', theme = 'executive', brand = 'Squidclaw AI 🦑' } =
542
+ typeof input === 'string' ? { content: input, title: 'Presentation' } : input;
543
+
544
+ const slides = parseToSlides(content);
545
+ const totalSlides = slides.length + 2; // + title + thank you
546
+
547
+ logger.info('slides-engine', `Generating ${totalSlides} slides, theme: ${theme}`);
548
+
549
+ // Fetch images in parallel (title + content slides that benefit from images)
550
+ const imageSlideTypes = ['content', 'bullets', 'quote'];
551
+ const imageQueries = [
552
+ title.toLowerCase().replace(/[^a-z\s]/g, '').trim() || 'presentation business',
553
+ ...slides.filter(s => imageSlideTypes.includes(s.type)).map(s => s.imageQuery || s.title.toLowerCase()),
554
+ 'thank you success',
555
+ ];
556
+
557
+ // Fetch up to 6 images (title + 4 content + thank you)
558
+ const imagePromises = imageQueries.slice(0, 6).map(q => fetchImage(q));
559
+ const images = await Promise.allSettled(imagePromises);
560
+ const imageBuffers = images.map(r => r.status === 'fulfilled' && r.value ? r.value.buffer.toString('base64') : null);
561
+
562
+ // Build HTML for each slide
563
+ const slideHtmls = [];
564
+ let imgIdx = 1; // 0 = title
565
+
566
+ // Title slide
567
+ slideHtmls.push(renderTitleSlide(title, subtitle || new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }), theme, imageBuffers[0]));
568
+
569
+ // Content slides
570
+ for (let i = 0; i < slides.length; i++) {
571
+ const s = slides[i];
572
+ const num = i + 2;
573
+ const needsImage = imageSlideTypes.includes(s.type);
574
+ const img = needsImage && imgIdx < imageBuffers.length ? imageBuffers[imgIdx++] : null;
575
+
576
+ switch (s.type) {
577
+ case 'stats': slideHtmls.push(renderStatsSlide(s, theme, num, totalSlides, img)); break;
578
+ case 'bullets': slideHtmls.push(renderBulletsSlide(s, theme, num, totalSlides, img)); break;
579
+ case 'table': slideHtmls.push(renderTableSlide(s, theme, num, totalSlides)); break;
580
+ case 'chart': slideHtmls.push(renderChartSlide(s, theme, num, totalSlides)); break;
581
+ case 'donut': slideHtmls.push(renderDonutSlide(s, theme, num, totalSlides)); break;
582
+ case 'timeline': slideHtmls.push(renderTimelineSlide(s, theme, num, totalSlides)); break;
583
+ case 'quote': slideHtmls.push(renderQuoteSlide(s, theme, num, totalSlides, img)); break;
584
+ case 'progress': slideHtmls.push(renderProgressSlide(s, theme, num, totalSlides)); break;
585
+ default: slideHtmls.push(renderContentSlide(s, theme, num, totalSlides, img)); break;
586
+ }
587
+ }
588
+
589
+ // Thank you slide
590
+ slideHtmls.push(renderThankYouSlide(theme, brand, imageBuffers[imageBuffers.length - 1]));
591
+
592
+ // Render each slide via Puppeteer → screenshot → assemble PPTX
593
+ const puppeteer = await import('puppeteer-core');
594
+ const paths = ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium-browser', '/usr/bin/chromium', '/snap/bin/chromium'];
595
+ let execPath = null;
596
+ for (const p of paths) { if (existsSync(p)) { execPath = p; break; } }
597
+ if (!execPath) throw new Error('No Chrome/Chromium found');
598
+
599
+ const browser = await puppeteer.default.launch({
600
+ executablePath: execPath, headless: 'new',
601
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--font-render-hinting=none'],
602
+ });
603
+
604
+ const pptx = new PptxGenJS();
605
+ pptx.layout = 'LAYOUT_WIDE';
606
+ pptx.author = brand;
607
+ pptx.title = title;
608
+
609
+ const page = await browser.newPage();
610
+ await page.setViewport({ width: 1280, height: 720 });
611
+
612
+ for (let i = 0; i < slideHtmls.length; i++) {
613
+ const html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><style>${CSS_BASE}</style></head><body style="margin:0;padding:0;background:#000">${slideHtmls[i]}</body></html>`;
614
+
615
+ await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 10000 });
616
+ // Wait for fonts to load
617
+ // fonts are system, no wait needed
618
+ await new Promise(r => setTimeout(r, 100));
619
+
620
+ const screenshot = await page.screenshot({ type: 'jpeg', quality: 95 });
621
+
622
+ // Add as full-bleed image slide
623
+ const slide = pptx.addSlide();
624
+ const imgB64 = screenshot.toString('base64');
625
+ slide.addImage({
626
+ data: `image/jpeg;base64,${imgB64}`,
627
+ x: 0, y: 0, w: '100%', h: '100%',
628
+ });
629
+
630
+ logger.debug('slides-engine', `Slide ${i + 1}/${slideHtmls.length} rendered`);
631
+ }
632
+
633
+ await browser.close();
634
+
635
+ // Save PPTX
636
+ const filename = title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '_').slice(0, 50) + '.pptx';
637
+ const filepath = join(OUTPUT_DIR, filename);
638
+ await pptx.writeFile({ fileName: filepath });
639
+
640
+ // Also save HTML version for web viewing
641
+ const webHtml = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${title}</title>
642
+ <style>${CSS_BASE}
643
+ body { background: #000; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 20px; }
644
+ .slide { flex-shrink: 0; border-radius: 4px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); }
645
+ </style></head><body>${slideHtmls.join('\n')}</body></html>`;
646
+ const htmlPath = filepath.replace('.pptx', '.html');
647
+ writeFileSync(htmlPath, webHtml);
648
+
649
+ logger.info('slides-engine', `Done: ${filepath} (${slideHtmls.length} slides)`);
650
+
651
+ return {
652
+ filepath, filename,
653
+ htmlPath,
654
+ slides: slideHtmls.length,
655
+ types: [...new Set(slides.map(s => s.type))],
656
+ images: imageBuffers.filter(Boolean).length,
657
+ };
658
+ }
659
+
660
+ export { parseToSlides, fetchImage };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "3.1.1",
3
+ "version": "3.2.1",
4
4
  "description": "🦑 AI agent platform — human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {