squidclaw 2.4.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,12 @@ 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
+
229
235
  // Cron jobs
230
236
  try {
231
237
  const { CronManager } = await import('./features/cron.js');
@@ -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,39 @@ 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
+
148
181
  if (cmd === '/cron') {
149
182
  const args = msg.slice(6).trim();
150
183
  if (!ctx.engine.cron) { await ctx.reply('āŒ Cron not available'); 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();
@@ -79,6 +79,18 @@ 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
+
82
94
  tools.push('', '### Create Excel Spreadsheet (SENDS AS FILE!)',
83
95
  '---TOOL:excel:Title|theme|sheet content---',
84
96
  'Creates .xlsx file and sends it. Theme: blue, green, dark, red, saudi, corporate.',
@@ -246,7 +258,21 @@ export class ToolRouter {
246
258
  }
247
259
 
248
260
  async processResponse(response, agentId) {
249
- const toolMatch = response.match(/---TOOL:(\w+):([\s\S]+?)---/);
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
+ })();
250
276
  if (!toolMatch) return { toolUsed: false, toolResult: null, cleanResponse: response };
251
277
 
252
278
  const [fullMatch, toolName, toolArg] = toolMatch;
@@ -348,6 +374,43 @@ export class ToolRouter {
348
374
  }
349
375
  break;
350
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
+ }
351
414
  case 'excel':
352
415
  case 'xlsx':
353
416
  case 'spreadsheet': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "2.4.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": {