squidclaw 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/engine.js CHANGED
@@ -136,6 +136,13 @@ export class SquidclawEngine {
136
136
  // 5. Features (reminders, auto-memory, usage alerts)
137
137
  await this._initFeatures();
138
138
 
139
+ // 5b. Sub-agents
140
+ try {
141
+ const { SubAgentManager } = await import('./features/sub-agents.js');
142
+ this.subAgents = new SubAgentManager(this);
143
+ if (this.toolRouter) this.toolRouter._subAgents = this.subAgents;
144
+ } catch {}
145
+
139
146
  // 6. Message Pipeline
140
147
  this.pipeline = await this._buildPipeline();
141
148
  console.log(` 🔧 Pipeline: ${this.pipeline.middleware.length} middleware`);
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 🦑 Sub-Agents
3
+ * Spawn isolated agent tasks that run in the background
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+
8
+ export class SubAgentManager {
9
+ constructor(engine) {
10
+ this.engine = engine;
11
+ this.running = new Map(); // id -> { task, status, result, startedAt }
12
+ this.counter = 0;
13
+ }
14
+
15
+ /**
16
+ * Spawn a sub-agent task
17
+ */
18
+ async spawn(agentId, task, options = {}) {
19
+ const id = 'sub_' + (++this.counter) + '_' + Date.now().toString(36);
20
+ const model = options.model || this.engine.config.ai?.defaultModel;
21
+ const timeout = options.timeout || 60000;
22
+
23
+ this.running.set(id, {
24
+ id,
25
+ task,
26
+ status: 'running',
27
+ result: null,
28
+ startedAt: new Date(),
29
+ agentId,
30
+ });
31
+
32
+ logger.info('sub-agents', `Spawned ${id}: ${task.slice(0, 80)}`);
33
+
34
+ // Run in background
35
+ this._execute(id, agentId, task, model, timeout).catch(err => {
36
+ const sub = this.running.get(id);
37
+ if (sub) {
38
+ sub.status = 'error';
39
+ sub.result = err.message;
40
+ }
41
+ });
42
+
43
+ return id;
44
+ }
45
+
46
+ async _execute(id, agentId, task, model, timeout) {
47
+ const sub = this.running.get(id);
48
+
49
+ try {
50
+ const messages = [
51
+ { role: 'system', content: 'You are a sub-agent. Complete this task concisely and return the result. Do not ask questions.' },
52
+ { role: 'user', content: task },
53
+ ];
54
+
55
+ const response = await Promise.race([
56
+ this.engine.aiGateway.chat(messages, { model }),
57
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeout)),
58
+ ]);
59
+
60
+ sub.status = 'complete';
61
+ sub.result = response.content;
62
+ sub.completedAt = new Date();
63
+
64
+ logger.info('sub-agents', `Completed ${id}: ${response.content.slice(0, 50)}`);
65
+ } catch (err) {
66
+ sub.status = 'error';
67
+ sub.result = err.message;
68
+ logger.error('sub-agents', `Failed ${id}: ${err.message}`);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Get sub-agent status
74
+ */
75
+ get(id) {
76
+ return this.running.get(id);
77
+ }
78
+
79
+ /**
80
+ * List all sub-agents
81
+ */
82
+ list(agentId) {
83
+ return Array.from(this.running.values())
84
+ .filter(s => !agentId || s.agentId === agentId)
85
+ .map(s => ({
86
+ id: s.id,
87
+ task: s.task.slice(0, 80),
88
+ status: s.status,
89
+ result: s.result?.slice(0, 200),
90
+ duration: s.completedAt ? (s.completedAt - s.startedAt) / 1000 + 's' : 'running',
91
+ }));
92
+ }
93
+
94
+ /**
95
+ * Kill a running sub-agent
96
+ */
97
+ kill(id) {
98
+ const sub = this.running.get(id);
99
+ if (sub) {
100
+ sub.status = 'killed';
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+ }
@@ -16,7 +16,10 @@ export async function commandsMiddleware(ctx, next) {
16
16
  '/memories — what I remember about you',
17
17
  '/tasks — your todo list',
18
18
  '/usage — spending report',
19
- '/helpthis message',
19
+ '/exec <cmd> run a shell command',
20
+ '/files — list sandbox files',
21
+ '/subagents — list background tasks',
22
+ '/help — this message',
20
23
  '', 'Just chat normally! 🦑',
21
24
  ].join('\n'));
22
25
  return;
@@ -117,5 +120,51 @@ export async function commandsMiddleware(ctx, next) {
117
120
  return;
118
121
  }
119
122
 
123
+ if (cmd === '/exec') {
124
+ const command = msg.slice(6).trim();
125
+ if (!command) { await ctx.reply('Usage: /exec <command>'); return; }
126
+ try {
127
+ const { ShellTool } = await import('../tools/shell.js');
128
+ const sh = new ShellTool();
129
+ const result = sh.exec(command);
130
+ const output = result.error ? '❌ ' + result.error : '```\n' + (result.output || '(no output)') + '\n```';
131
+ await ctx.reply(output);
132
+ } catch (err) {
133
+ await ctx.reply('❌ ' + err.message);
134
+ }
135
+ return;
136
+ }
137
+
138
+ if (cmd === '/files') {
139
+ try {
140
+ const { ShellTool } = await import('../tools/shell.js');
141
+ const sh = new ShellTool();
142
+ const result = sh.listDir('.');
143
+ const list = result.files?.map(f => (f.type === 'dir' ? '📁 ' : '📄 ') + f.name).join('\n') || 'Empty';
144
+ await ctx.reply('📂 *Sandbox Files*\n\n' + list);
145
+ } catch (err) {
146
+ await ctx.reply('❌ ' + err.message);
147
+ }
148
+ return;
149
+ }
150
+
151
+ if (cmd === '/subagents') {
152
+ if (ctx.engine.subAgents) {
153
+ const list = ctx.engine.subAgents.list(ctx.agentId);
154
+ if (list.length === 0) {
155
+ await ctx.reply('🤖 No sub-agents running');
156
+ } else {
157
+ const lines = list.map(s =>
158
+ (s.status === 'complete' ? '✅' : s.status === 'error' ? '❌' : '⏳') +
159
+ ' ' + s.id + '\n ' + s.task + '\n ' + s.status + (s.duration ? ' (' + s.duration + ')' : '')
160
+ );
161
+ await ctx.reply('🤖 *Sub-Agents*\n\n' + lines.join('\n\n'));
162
+ }
163
+ } else {
164
+ await ctx.reply('🤖 Sub-agents not available');
165
+ }
166
+ return;
167
+ }
168
+
120
169
  await next();
121
170
  }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * 🦑 Browser Control
3
+ * Headless browser for browsing, screenshots, form filling, scraping
4
+ */
5
+
6
+ import { logger } from '../core/logger.js';
7
+
8
+ let browser = null;
9
+
10
+ async function getBrowser() {
11
+ if (browser?.isConnected()) return browser;
12
+
13
+ const puppeteer = await import('puppeteer-core');
14
+
15
+ // Try common Chrome/Chromium locations
16
+ const paths = [
17
+ '/usr/bin/google-chrome',
18
+ '/usr/bin/google-chrome-stable',
19
+ '/usr/bin/chromium-browser',
20
+ '/usr/bin/chromium',
21
+ '/snap/bin/chromium',
22
+ '/usr/bin/brave-browser',
23
+ ];
24
+
25
+ let execPath = null;
26
+ const { existsSync } = await import('fs');
27
+ for (const p of paths) {
28
+ if (existsSync(p)) { execPath = p; break; }
29
+ }
30
+
31
+ if (!execPath) {
32
+ // Try installing chromium
33
+ try {
34
+ const { execSync } = await import('child_process');
35
+ execSync('which chromium || which chromium-browser || apt-get install -y chromium-browser 2>/dev/null', { stdio: 'ignore' });
36
+ for (const p of paths) {
37
+ if (existsSync(p)) { execPath = p; break; }
38
+ }
39
+ } catch {}
40
+ }
41
+
42
+ if (!execPath) throw new Error('No Chrome/Chromium found. Install with: apt install chromium-browser');
43
+
44
+ browser = await puppeteer.default.launch({
45
+ executablePath: execPath,
46
+ headless: 'new',
47
+ args: [
48
+ '--no-sandbox',
49
+ '--disable-setuid-sandbox',
50
+ '--disable-dev-shm-usage',
51
+ '--disable-gpu',
52
+ '--single-process',
53
+ ],
54
+ });
55
+
56
+ logger.info('browser', 'Browser launched: ' + execPath);
57
+ return browser;
58
+ }
59
+
60
+ export class BrowserControl {
61
+
62
+ /**
63
+ * Take a screenshot of a URL
64
+ */
65
+ async screenshot(url, options = {}) {
66
+ const b = await getBrowser();
67
+ const page = await b.newPage();
68
+
69
+ try {
70
+ await page.setViewport({ width: options.width || 1280, height: options.height || 800 });
71
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 15000 });
72
+
73
+ if (options.waitFor) {
74
+ await page.waitForSelector(options.waitFor, { timeout: 5000 }).catch(() => {});
75
+ }
76
+
77
+ const screenshot = await page.screenshot({
78
+ type: 'jpeg',
79
+ quality: 80,
80
+ fullPage: options.fullPage || false,
81
+ });
82
+
83
+ const title = await page.title();
84
+ return { buffer: screenshot, title, url };
85
+ } finally {
86
+ await page.close();
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Extract page content (more thorough than simple fetch)
92
+ */
93
+ async readPage(url, options = {}) {
94
+ const b = await getBrowser();
95
+ const page = await b.newPage();
96
+
97
+ try {
98
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 15000 });
99
+
100
+ const content = await page.evaluate(() => {
101
+ // Remove scripts, styles, nav, footer
102
+ const remove = document.querySelectorAll('script, style, nav, footer, header, aside, iframe, .ad, [class*="cookie"]');
103
+ remove.forEach(el => el.remove());
104
+
105
+ const main = document.querySelector('main, article, [role="main"], .content, #content');
106
+ const el = main || document.body;
107
+ return el.innerText.trim().slice(0, 5000);
108
+ });
109
+
110
+ const title = await page.title();
111
+ return { title, content, url };
112
+ } finally {
113
+ await page.close();
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Fill a form on a page
119
+ */
120
+ async fillForm(url, fields) {
121
+ const b = await getBrowser();
122
+ const page = await b.newPage();
123
+
124
+ try {
125
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 15000 });
126
+
127
+ for (const field of fields) {
128
+ if (field.selector && field.value) {
129
+ await page.type(field.selector, field.value, { delay: 50 });
130
+ }
131
+ if (field.click) {
132
+ await page.click(field.click);
133
+ }
134
+ }
135
+
136
+ // Wait for result
137
+ await new Promise(r => setTimeout(r, 2000));
138
+
139
+ const screenshot = await page.screenshot({ type: 'jpeg', quality: 80 });
140
+ const content = await page.evaluate(() => document.body.innerText.trim().slice(0, 3000));
141
+
142
+ return { screenshot, content, url };
143
+ } finally {
144
+ await page.close();
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Click an element on a page
150
+ */
151
+ async click(url, selector) {
152
+ const b = await getBrowser();
153
+ const page = await b.newPage();
154
+
155
+ try {
156
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 15000 });
157
+ await page.click(selector);
158
+ await new Promise(r => setTimeout(r, 2000));
159
+
160
+ const screenshot = await page.screenshot({ type: 'jpeg', quality: 80 });
161
+ const newUrl = page.url();
162
+ const content = await page.evaluate(() => document.body.innerText.trim().slice(0, 3000));
163
+
164
+ return { screenshot, content, url: newUrl };
165
+ } finally {
166
+ await page.close();
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Get all links on a page
172
+ */
173
+ async getLinks(url) {
174
+ const b = await getBrowser();
175
+ const page = await b.newPage();
176
+
177
+ try {
178
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 15000 });
179
+
180
+ const links = await page.evaluate(() => {
181
+ return Array.from(document.querySelectorAll('a[href]'))
182
+ .map(a => ({ text: a.innerText.trim(), href: a.href }))
183
+ .filter(l => l.text && l.href.startsWith('http'))
184
+ .slice(0, 30);
185
+ });
186
+
187
+ return links;
188
+ } finally {
189
+ await page.close();
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Execute JavaScript on a page and return result
195
+ */
196
+ async evaluate(url, script) {
197
+ const b = await getBrowser();
198
+ const page = await b.newPage();
199
+
200
+ try {
201
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 15000 });
202
+ const result = await page.evaluate(script);
203
+ return { result, url };
204
+ } finally {
205
+ await page.close();
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Close browser
211
+ */
212
+ async close() {
213
+ if (browser) {
214
+ await browser.close();
215
+ browser = null;
216
+ }
217
+ }
218
+ }
@@ -59,6 +59,35 @@ export class ToolRouter {
59
59
  'Send an email.');
60
60
  }
61
61
 
62
+ tools.push('', '### Run Command',
63
+ '---TOOL:exec:ls -la---',
64
+ 'Execute a shell command. Output is returned. Sandboxed for safety.',
65
+ '', '### Read File',
66
+ '---TOOL:readfile:filename.txt---',
67
+ 'Read contents of a file in the sandbox.',
68
+ '', '### Write File',
69
+ '---TOOL:writefile:filename.txt|file contents here---',
70
+ 'Create or overwrite a file. Use pipe (|) to separate filename from content.',
71
+ '', '### List Files',
72
+ '---TOOL:ls:path---',
73
+ 'List files in a directory.',
74
+ '', '### Spawn Sub-Agent',
75
+ '---TOOL:spawn:Research the latest AI news and summarize top 5 stories---',
76
+ 'Spawn a background task. Runs independently and returns result when done.',
77
+ '', '### Run Python',
78
+ '---TOOL:python:print(2+2)---',
79
+ 'Execute Python code and return output.');
80
+
81
+ tools.push('', '### Screenshot',
82
+ '---TOOL:screenshot:https://example.com---',
83
+ 'Take a screenshot of a website. Returns the screenshot as an image.',
84
+ '', '### Browse Page (Full)',
85
+ '---TOOL:browse:https://example.com---',
86
+ 'Open a page in a real browser (handles JavaScript). Better than read for dynamic sites.',
87
+ '', '### Get Links',
88
+ '---TOOL:links:https://example.com---',
89
+ 'Get all links on a webpage.');
90
+
62
91
  tools.push('', '### Weather',
63
92
  '---TOOL:weather:city name---',
64
93
  'Get current weather and 3-day forecast for any city.',
@@ -139,6 +168,93 @@ export class ToolRouter {
139
168
  }
140
169
  break;
141
170
  }
171
+ case 'exec':
172
+ case 'shell':
173
+ case 'run': {
174
+ const { ShellTool } = await import('./shell.js');
175
+ const sh = new ShellTool();
176
+ const result = sh.exec(toolArg);
177
+ toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
178
+ break;
179
+ }
180
+ case 'readfile': {
181
+ const { ShellTool } = await import('./shell.js');
182
+ const sh = new ShellTool();
183
+ const result = sh.readFile(toolArg);
184
+ toolResult = result.error || result.content;
185
+ break;
186
+ }
187
+ case 'writefile': {
188
+ const pipeIdx = toolArg.indexOf('|');
189
+ if (pipeIdx === -1) { toolResult = 'Format: filename|content'; break; }
190
+ const fname = toolArg.slice(0, pipeIdx).trim();
191
+ const fcontent = toolArg.slice(pipeIdx + 1);
192
+ const { ShellTool } = await import('./shell.js');
193
+ const sh = new ShellTool();
194
+ const result = sh.writeFile(fname, fcontent);
195
+ toolResult = result.error || 'File written: ' + fname;
196
+ break;
197
+ }
198
+ case 'ls':
199
+ case 'listdir': {
200
+ const { ShellTool } = await import('./shell.js');
201
+ const sh = new ShellTool();
202
+ const result = sh.listDir(toolArg || '.');
203
+ toolResult = result.error || result.files.map(f => (f.type === 'dir' ? '📁 ' : '📄 ') + f.name).join('\n');
204
+ break;
205
+ }
206
+ case 'python':
207
+ case 'py': {
208
+ const { ShellTool } = await import('./shell.js');
209
+ const sh = new ShellTool();
210
+ // Write Python script then execute
211
+ sh.writeFile('_run.py', toolArg);
212
+ const result = sh.exec('python3 _run.py');
213
+ toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
214
+ break;
215
+ }
216
+ case 'spawn': {
217
+ if (this._subAgents) {
218
+ const id = await this._subAgents.spawn(agentId, toolArg);
219
+ toolResult = 'Sub-agent spawned: ' + id + '. It will complete in the background.';
220
+ } else {
221
+ toolResult = 'Sub-agents not available';
222
+ }
223
+ break;
224
+ }
225
+ case 'screenshot': {
226
+ try {
227
+ const { BrowserControl } = await import('./browser-control.js');
228
+ const bc = new BrowserControl();
229
+ const result = await bc.screenshot(toolArg);
230
+ return { toolUsed: true, toolName: 'screenshot', toolResult: '[Screenshot taken]', imageBase64: result.buffer.toString('base64'), mimeType: 'image/jpeg', cleanResponse };
231
+ } catch (err) {
232
+ toolResult = 'Screenshot failed: ' + err.message;
233
+ }
234
+ break;
235
+ }
236
+ case 'browse': {
237
+ try {
238
+ const { BrowserControl } = await import('./browser-control.js');
239
+ const bc = new BrowserControl();
240
+ const result = await bc.readPage(toolArg);
241
+ toolResult = 'Title: ' + result.title + '\n\n' + result.content;
242
+ } catch (err) {
243
+ toolResult = 'Browse failed: ' + err.message;
244
+ }
245
+ break;
246
+ }
247
+ case 'links': {
248
+ try {
249
+ const { BrowserControl } = await import('./browser-control.js');
250
+ const bc = new BrowserControl();
251
+ const links = await bc.getLinks(toolArg);
252
+ toolResult = links.map(l => '• ' + l.text + ' → ' + l.href).join('\n');
253
+ } catch (err) {
254
+ toolResult = 'Failed: ' + err.message;
255
+ }
256
+ break;
257
+ }
142
258
  case 'weather': {
143
259
  const { getWeather } = await import('./weather.js');
144
260
  toolResult = await getWeather(toolArg);
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 🦑 Shell & File System
3
+ * Sandboxed command execution and file operations
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync } from 'fs';
8
+ import { join, resolve } from 'path';
9
+ import { logger } from '../core/logger.js';
10
+
11
+ // Sandboxed base directory
12
+ const SANDBOX_DIR = process.env.SQUIDCLAW_SANDBOX || '/tmp/squidclaw-sandbox';
13
+
14
+ // Blocked commands for safety
15
+ const BLOCKED = [
16
+ 'rm -rf /', 'mkfs', 'dd if=', ':(){', 'fork bomb',
17
+ 'chmod 777 /', 'chown', 'passwd', 'userdel', 'useradd',
18
+ 'shutdown', 'reboot', 'halt', 'poweroff',
19
+ 'iptables', 'ufw', 'firewall',
20
+ ];
21
+
22
+ export class ShellTool {
23
+ constructor(home) {
24
+ this.sandbox = SANDBOX_DIR;
25
+ mkdirSync(this.sandbox, { recursive: true });
26
+ }
27
+
28
+ /**
29
+ * Execute a shell command (sandboxed)
30
+ */
31
+ exec(command, options = {}) {
32
+ // Safety check
33
+ const lower = command.toLowerCase();
34
+ for (const blocked of BLOCKED) {
35
+ if (lower.includes(blocked)) {
36
+ return { error: 'Blocked for safety: ' + blocked };
37
+ }
38
+ }
39
+
40
+ const timeout = options.timeout || 10000;
41
+ const cwd = options.cwd || this.sandbox;
42
+
43
+ try {
44
+ const output = execSync(command, {
45
+ cwd,
46
+ timeout,
47
+ maxBuffer: 1024 * 1024,
48
+ encoding: 'utf8',
49
+ env: { ...process.env, HOME: this.sandbox },
50
+ });
51
+ logger.info('shell', `Executed: ${command.slice(0, 80)}`);
52
+ return { output: output.trim().slice(0, 3000), exitCode: 0 };
53
+ } catch (err) {
54
+ return {
55
+ output: (err.stdout || '').trim().slice(0, 1000),
56
+ error: (err.stderr || err.message).trim().slice(0, 1000),
57
+ exitCode: err.status || 1,
58
+ };
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Read a file (sandboxed)
64
+ */
65
+ readFile(path) {
66
+ const safePath = this._safePath(path);
67
+ if (!existsSync(safePath)) return { error: 'File not found: ' + path };
68
+
69
+ const stat = statSync(safePath);
70
+ if (stat.size > 100000) return { error: 'File too large: ' + (stat.size / 1024).toFixed(0) + ' KB' };
71
+
72
+ return { content: readFileSync(safePath, 'utf8').slice(0, 5000) };
73
+ }
74
+
75
+ /**
76
+ * Write a file (sandboxed)
77
+ */
78
+ writeFile(path, content) {
79
+ const safePath = this._safePath(path);
80
+ const dir = resolve(safePath, '..');
81
+ mkdirSync(dir, { recursive: true });
82
+ writeFileSync(safePath, content);
83
+ logger.info('shell', `Wrote file: ${path}`);
84
+ return { success: true, path: safePath };
85
+ }
86
+
87
+ /**
88
+ * List directory (sandboxed)
89
+ */
90
+ listDir(path) {
91
+ const safePath = this._safePath(path || '.');
92
+ if (!existsSync(safePath)) return { error: 'Directory not found' };
93
+
94
+ const entries = readdirSync(safePath, { withFileTypes: true });
95
+ return {
96
+ files: entries.map(e => ({
97
+ name: e.name,
98
+ type: e.isDirectory() ? 'dir' : 'file',
99
+ })),
100
+ };
101
+ }
102
+
103
+ _safePath(p) {
104
+ // Prevent path traversal
105
+ const resolved = resolve(this.sandbox, p);
106
+ if (!resolved.startsWith(this.sandbox)) {
107
+ throw new Error('Path outside sandbox');
108
+ }
109
+ return resolved;
110
+ }
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "🦑 AI agent platform — human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {
@@ -49,6 +49,7 @@
49
49
  "node-edge-tts": "^1.2.10",
50
50
  "pdfjs-dist": "^5.4.624",
51
51
  "pino": "^10.3.1",
52
+ "puppeteer-core": "^24.37.5",
52
53
  "qrcode-terminal": "^0.12.0",
53
54
  "sharp": "^0.34.5",
54
55
  "undici": "^7.22.0",