squidclaw 2.5.0 → 2.7.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.
@@ -0,0 +1,23 @@
1
+ let messageCount = 0;
2
+ let startTime = Date.now();
3
+
4
+ export async function onMessage({ message, contactId, agentId, engine }) {
5
+ messageCount++;
6
+ return null; // Don't intercept, just count
7
+ }
8
+
9
+ export function getTools() {
10
+ return [
11
+ '### Analytics Report',
12
+ '---TOOL:analytics:report---',
13
+ 'Show messaging analytics and stats for this session.',
14
+ ];
15
+ }
16
+
17
+ export async function onTool({ toolName, engine }) {
18
+ if (toolName !== 'analytics') return null;
19
+ const uptime = ((Date.now() - startTime) / 3600000).toFixed(1);
20
+ return {
21
+ result: `šŸ“Š Session Analytics:\n• Messages this session: ${messageCount}\n• Session uptime: ${uptime}h\n• Engine uptime: ${(process.uptime() / 3600).toFixed(1)}h\n• Memory: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(0)} MB`,
22
+ };
23
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "analytics",
3
+ "version": "1.0.0",
4
+ "description": "Track message patterns, response times, popular topics",
5
+ "author": "Squidclaw",
6
+ "hooks": ["onMessage", "onTool", "getTools"]
7
+ }
@@ -0,0 +1,16 @@
1
+ let responses = {};
2
+
3
+ export async function onLoad({ config }) {
4
+ responses = config.responses || {
5
+ 'ping': 'pong! šŸ“',
6
+ 'marco': 'polo! šŸŠ',
7
+ };
8
+ }
9
+
10
+ export async function onMessage({ message }) {
11
+ const lower = message.toLowerCase().trim();
12
+ if (responses[lower]) {
13
+ return { handled: true, response: responses[lower] };
14
+ }
15
+ return null;
16
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "auto-responder",
3
+ "version": "1.0.0",
4
+ "description": "Auto-respond to common phrases (customizable)",
5
+ "author": "Squidclaw",
6
+ "hooks": ["onLoad", "onMessage"]
7
+ }
@@ -0,0 +1,22 @@
1
+ export function getTools() {
2
+ return [
3
+ '### Translate Text',
4
+ '---TOOL:translate:target_language|text to translate---',
5
+ 'Translate text to another language. Examples: translate:arabic|Hello world, translate:english|Ł…Ų±Ų­ŲØŲ§',
6
+ ];
7
+ }
8
+
9
+ export async function onTool({ toolName, toolArg, engine }) {
10
+ if (toolName !== 'translate') return null;
11
+ const pipeIdx = toolArg.indexOf('|');
12
+ if (pipeIdx === -1) return { result: 'Format: language|text' };
13
+ const lang = toolArg.slice(0, pipeIdx).trim();
14
+ const text = toolArg.slice(pipeIdx + 1).trim();
15
+
16
+ const response = await engine.aiGateway.chat([
17
+ { role: 'system', content: `Translate the following text to ${lang}. Return ONLY the translation, nothing else.` },
18
+ { role: 'user', content: text },
19
+ ], { model: engine.config.ai?.defaultModel });
20
+
21
+ return { result: response.content };
22
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "translator",
3
+ "version": "1.0.0",
4
+ "description": "Auto-detect and translate messages between languages",
5
+ "author": "Squidclaw",
6
+ "hooks": ["onTool", "getTools"]
7
+ }
@@ -208,7 +208,8 @@ export class TelegramManager {
208
208
  const chatId = contactId.replace('tg_', '');
209
209
  const { readFileSync } = await import('fs');
210
210
  const buffer = readFileSync(filePath);
211
- await botInfo.bot.api.sendDocument(chatId, new InputFile(buffer, fileName || 'file'), {
211
+ const { InputFile: IF } = await import("grammy");
212
+ await botInfo.bot.api.sendDocument(chatId, new IF(buffer, fileName || 'file'), {
212
213
  caption: caption || '',
213
214
  });
214
215
  logger.info('telegram', 'Sent document: ' + fileName);
package/lib/engine.js CHANGED
@@ -63,6 +63,8 @@ export class SquidclawEngine {
63
63
  pipeline.use('auto-links', autoLinksMiddleware);
64
64
  pipeline.use('auto-memory', autoMemoryMiddleware);
65
65
  const { configChatMiddleware } = await import('./middleware/config-chat.js');
66
+ const { pluginMiddleware } = await import('./middleware/plugins.js');
67
+ pipeline.use('plugins', pluginMiddleware);
66
68
  pipeline.use('config-chat', configChatMiddleware);
67
69
  pipeline.use('skill-check', skillCheckMiddleware);
68
70
  pipeline.use('typing', typingMiddleware); // wraps AI call with typing indicator
@@ -226,6 +228,27 @@ export class SquidclawEngine {
226
228
  if (pending.c > 0) console.log(` ā° Reminders: ${pending.c} pending`);
227
229
  } catch {}
228
230
 
231
+ // Sandbox
232
+ try {
233
+ const { Sandbox } = await import('./features/sandbox.js');
234
+ this.sandbox = new Sandbox({
235
+ timeout: 15000,
236
+ maxMemory: 64,
237
+ maxFiles: 100,
238
+ });
239
+ // Cleanup old files every hour
240
+ setInterval(() => this.sandbox.cleanup(), 3600000);
241
+ } catch (err) { logger.error('engine', 'Sandbox init failed: ' + err.message); }
242
+
243
+ // Plugins
244
+ try {
245
+ const { PluginManager } = await import('./features/plugins.js');
246
+ this.plugins = new PluginManager(this);
247
+ const count = await this.plugins.loadAll();
248
+ if (count > 0) console.log(' šŸ”Œ Plugins: ' + count + ' loaded');
249
+ await this.plugins.fireHook('onStart', { engine: this });
250
+ } catch (err) { logger.error('engine', 'Plugins init failed: ' + err.message); }
251
+
229
252
  // Sessions
230
253
  try {
231
254
  const { SessionManager } = await import('./features/sessions.js');
@@ -0,0 +1,278 @@
1
+ /**
2
+ * šŸ¦‘ Plugin System
3
+ * Load/unload plugins dynamically from ~/.squidclaw/plugins/
4
+ *
5
+ * Plugin structure:
6
+ * plugins/my-plugin/
7
+ * plugin.json — manifest (name, version, description, hooks)
8
+ * index.js — main entry (exports hooks)
9
+ * README.md — optional docs
10
+ */
11
+
12
+ import { logger } from '../core/logger.js';
13
+ import { readdirSync, existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
14
+ import { join } from 'path';
15
+ import { getHome } from '../core/config.js';
16
+ import { pathToFileURL } from 'url';
17
+
18
+ export class PluginManager {
19
+ constructor(engine) {
20
+ this.engine = engine;
21
+ this.plugins = new Map(); // name -> { manifest, module, status }
22
+ this.pluginsDir = join(getHome(), 'plugins');
23
+ mkdirSync(this.pluginsDir, { recursive: true });
24
+ this._initDb();
25
+ }
26
+
27
+ _initDb() {
28
+ this.engine.storage.db.exec(`
29
+ CREATE TABLE IF NOT EXISTS plugins (
30
+ name TEXT PRIMARY KEY,
31
+ enabled INTEGER DEFAULT 1,
32
+ config TEXT DEFAULT '{}',
33
+ installed_at TEXT DEFAULT (datetime('now'))
34
+ )
35
+ `);
36
+ }
37
+
38
+ // ── Load all plugins ──
39
+
40
+ async loadAll() {
41
+ if (!existsSync(this.pluginsDir)) return;
42
+
43
+ const dirs = readdirSync(this.pluginsDir, { withFileTypes: true })
44
+ .filter(d => d.isDirectory())
45
+ .map(d => d.name);
46
+
47
+ let loaded = 0;
48
+ for (const dir of dirs) {
49
+ try {
50
+ await this.load(dir);
51
+ loaded++;
52
+ } catch (err) {
53
+ logger.error('plugins', `Failed to load ${dir}: ${err.message}`);
54
+ }
55
+ }
56
+
57
+ if (loaded > 0) {
58
+ logger.info('plugins', `Loaded ${loaded}/${dirs.length} plugins`);
59
+ }
60
+ return loaded;
61
+ }
62
+
63
+ // ── Load single plugin ──
64
+
65
+ async load(name) {
66
+ const pluginDir = join(this.pluginsDir, name);
67
+ const manifestPath = join(pluginDir, 'plugin.json');
68
+ const indexPath = join(pluginDir, 'index.js');
69
+
70
+ if (!existsSync(manifestPath)) {
71
+ throw new Error('No plugin.json found');
72
+ }
73
+
74
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
75
+ manifest.name = manifest.name || name;
76
+
77
+ // Check if disabled in DB
78
+ const dbEntry = this.engine.storage.db.prepare('SELECT * FROM plugins WHERE name = ?').get(manifest.name);
79
+ if (dbEntry && !dbEntry.enabled) {
80
+ this.plugins.set(manifest.name, { manifest, module: null, status: 'disabled' });
81
+ return;
82
+ }
83
+
84
+ // Load module
85
+ if (!existsSync(indexPath)) {
86
+ throw new Error('No index.js found');
87
+ }
88
+
89
+ // Dynamic import with cache busting for hot reload
90
+ const moduleUrl = pathToFileURL(indexPath).href + '?t=' + Date.now();
91
+ const module = await import(moduleUrl);
92
+
93
+ // Register in DB
94
+ this.engine.storage.db.prepare(
95
+ 'INSERT OR REPLACE INTO plugins (name, enabled, config) VALUES (?, 1, ?)'
96
+ ).run(manifest.name, JSON.stringify(dbEntry?.config ? JSON.parse(dbEntry.config) : {}));
97
+
98
+ // Call onLoad hook
99
+ if (module.onLoad) {
100
+ const pluginConfig = dbEntry ? JSON.parse(dbEntry.config || '{}') : {};
101
+ await module.onLoad({ engine: this.engine, config: pluginConfig, logger });
102
+ }
103
+
104
+ this.plugins.set(manifest.name, { manifest, module, status: 'active', dir: pluginDir });
105
+ logger.info('plugins', `Loaded: ${manifest.name} v${manifest.version || '?'}`);
106
+ }
107
+
108
+ // ── Unload plugin ──
109
+
110
+ async unload(name) {
111
+ const plugin = this.plugins.get(name);
112
+ if (!plugin) throw new Error('Plugin not found: ' + name);
113
+
114
+ if (plugin.module?.onUnload) {
115
+ await plugin.module.onUnload({ engine: this.engine });
116
+ }
117
+
118
+ this.plugins.delete(name);
119
+ logger.info('plugins', `Unloaded: ${name}`);
120
+ }
121
+
122
+ // ── Enable/Disable ──
123
+
124
+ enable(name) {
125
+ this.engine.storage.db.prepare('UPDATE plugins SET enabled = 1 WHERE name = ?').run(name);
126
+ logger.info('plugins', `Enabled: ${name}`);
127
+ }
128
+
129
+ disable(name) {
130
+ this.engine.storage.db.prepare('UPDATE plugins SET enabled = 0 WHERE name = ?').run(name);
131
+ this.unload(name).catch(() => {});
132
+ logger.info('plugins', `Disabled: ${name}`);
133
+ }
134
+
135
+ // ── Hot reload ──
136
+
137
+ async reload(name) {
138
+ await this.unload(name).catch(() => {});
139
+ await this.load(name);
140
+ logger.info('plugins', `Reloaded: ${name}`);
141
+ }
142
+
143
+ // ── Fire hooks ──
144
+
145
+ async fireHook(hookName, context) {
146
+ const results = [];
147
+ for (const [name, plugin] of this.plugins) {
148
+ if (plugin.status !== 'active' || !plugin.module) continue;
149
+
150
+ const hook = plugin.module[hookName];
151
+ if (typeof hook === 'function') {
152
+ try {
153
+ const result = await hook(context);
154
+ if (result) results.push({ plugin: name, result });
155
+ } catch (err) {
156
+ logger.error('plugins', `${name}.${hookName} failed: ${err.message}`);
157
+ }
158
+ }
159
+ }
160
+ return results;
161
+ }
162
+
163
+ /**
164
+ * Hook types:
165
+ * - onLoad({ engine, config, logger }) — plugin loaded
166
+ * - onUnload({ engine }) — plugin unloading
167
+ * - onMessage({ message, contactId, agentId, metadata, engine }) — incoming message, return { handled, response } to intercept
168
+ * - onResponse({ response, contactId, agentId, engine }) — before sending response, can modify
169
+ * - onTool({ toolName, toolArg, agentId, engine }) — custom tool handler, return { result } to handle
170
+ * - onStart({ engine }) — engine started
171
+ * - onStop({ engine }) — engine stopping
172
+ * - getTools() — return array of tool descriptions for AI prompt
173
+ */
174
+
175
+ // ── Get plugin tool descriptions ──
176
+
177
+ getToolDescriptions() {
178
+ const tools = [];
179
+ for (const [name, plugin] of this.plugins) {
180
+ if (plugin.status !== 'active' || !plugin.module?.getTools) continue;
181
+ try {
182
+ const pluginTools = plugin.module.getTools();
183
+ if (Array.isArray(pluginTools)) {
184
+ tools.push(...pluginTools);
185
+ }
186
+ } catch {}
187
+ }
188
+ return tools;
189
+ }
190
+
191
+ // ── Handle custom tool calls ──
192
+
193
+ async handleTool(toolName, toolArg, agentId) {
194
+ const results = await this.fireHook('onTool', { toolName, toolArg, agentId, engine: this.engine });
195
+ if (results.length > 0 && results[0].result) {
196
+ return results[0].result;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ // ── List plugins ──
202
+
203
+ list() {
204
+ return Array.from(this.plugins.entries()).map(([name, p]) => ({
205
+ name,
206
+ version: p.manifest.version || '?',
207
+ description: p.manifest.description || '',
208
+ status: p.status,
209
+ hooks: p.module ? Object.keys(p.module).filter(k => k.startsWith('on') || k === 'getTools') : [],
210
+ author: p.manifest.author || '',
211
+ }));
212
+ }
213
+
214
+ // ── Create plugin scaffold ──
215
+
216
+ scaffold(name, description) {
217
+ const dir = join(this.pluginsDir, name);
218
+ mkdirSync(dir, { recursive: true });
219
+
220
+ writeFileSync(join(dir, 'plugin.json'), JSON.stringify({
221
+ name,
222
+ version: '1.0.0',
223
+ description: description || 'A Squidclaw plugin',
224
+ author: '',
225
+ hooks: ['onMessage', 'onTool', 'getTools'],
226
+ }, null, 2));
227
+
228
+ writeFileSync(join(dir, 'index.js'), `/**
229
+ * ${name} — Squidclaw Plugin
230
+ * ${description || ''}
231
+ */
232
+
233
+ // Called when plugin loads
234
+ export async function onLoad({ engine, config, logger }) {
235
+ logger.info('${name}', 'Plugin loaded!');
236
+ }
237
+
238
+ // Called when plugin unloads
239
+ export async function onUnload({ engine }) {}
240
+
241
+ // Called on every incoming message — return { handled: true, response } to intercept
242
+ export async function onMessage({ message, contactId, agentId, engine }) {
243
+ // Example: respond to a specific keyword
244
+ // if (message.toLowerCase().includes('hello plugin')) {
245
+ // return { handled: true, response: 'Hello from ${name} plugin! šŸ”Œ' };
246
+ // }
247
+ return null;
248
+ }
249
+
250
+ // Called before response is sent — can modify
251
+ export async function onResponse({ response, contactId, agentId, engine }) {
252
+ return response; // return modified response or null
253
+ }
254
+
255
+ // Handle custom tool calls
256
+ export async function onTool({ toolName, toolArg, agentId, engine }) {
257
+ // if (toolName === 'my_custom_tool') {
258
+ // return { result: 'Custom tool result!' };
259
+ // }
260
+ return null;
261
+ }
262
+
263
+ // Return tool descriptions for the AI prompt
264
+ export function getTools() {
265
+ return [
266
+ // '### My Custom Tool',
267
+ // '---TOOL:my_custom_tool:argument---',
268
+ // 'Description of what this tool does.',
269
+ ];
270
+ }
271
+ `);
272
+
273
+ writeFileSync(join(dir, 'README.md'), `# ${name}\n\n${description || 'A Squidclaw plugin.'}\n\n## Installation\n\nCopy to \`~/.squidclaw/plugins/${name}/\`\n`);
274
+
275
+ logger.info('plugins', `Scaffolded: ${dir}`);
276
+ return dir;
277
+ }
278
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * šŸ¦‘ Proper Sandbox
3
+ * Isolated code execution with resource limits
4
+ * - VM isolation (vm module)
5
+ * - CPU timeout
6
+ * - Memory limits
7
+ * - File system jail
8
+ * - Network restrictions
9
+ * - Safe eval for user code
10
+ */
11
+
12
+ import { logger } from '../core/logger.js';
13
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync, unlinkSync } from 'fs';
14
+ import { join, resolve } from 'path';
15
+ import { execSync, spawn } from 'child_process';
16
+ import vm from 'vm';
17
+
18
+ export class Sandbox {
19
+ constructor(options = {}) {
20
+ this.jailDir = options.jailDir || '/tmp/squidclaw-sandbox';
21
+ this.timeout = options.timeout || 10000; // 10s default
22
+ this.maxMemory = options.maxMemory || 64; // 64MB
23
+ this.maxFileSize = options.maxFileSize || 1024 * 1024; // 1MB
24
+ this.maxFiles = options.maxFiles || 50;
25
+ this.maxOutputSize = options.maxOutputSize || 50000; // 50KB
26
+ this.allowNetwork = options.allowNetwork || false;
27
+
28
+ mkdirSync(this.jailDir, { recursive: true });
29
+
30
+ // Blocked patterns for shell commands
31
+ this.blockedCommands = [
32
+ /rm\s+(-rf?|--recursive)\s+\//, /mkfs/, /dd\s+if=/, /:\(\)\{/,
33
+ /chmod\s+777\s+\//, /chown\s.*\//, /passwd/, /userdel/, /useradd/,
34
+ /shutdown/, /reboot/, /halt/, /poweroff/,
35
+ /iptables/, /ufw\s/, /firewall/,
36
+ /wget.*\|.*sh/, /curl.*\|.*sh/, /eval\s*\(/, // download & execute
37
+ /\/etc\/shadow/, /\/etc\/passwd/,
38
+ /ssh\s/, /scp\s/, /rsync\s/, // no remote access
39
+ /npm\s+install\s+-g/, /pip\s+install/, // no global installs
40
+ /systemctl/, /service\s/, // no service control
41
+ ];
42
+ }
43
+
44
+ // ── JavaScript VM Execution ──
45
+
46
+ evalJS(code, context = {}) {
47
+ const sandbox = {
48
+ console: {
49
+ log: (...args) => { output.push(args.map(String).join(' ')); },
50
+ error: (...args) => { output.push('ERROR: ' + args.map(String).join(' ')); },
51
+ warn: (...args) => { output.push('WARN: ' + args.map(String).join(' ')); },
52
+ },
53
+ Math, Date, JSON, parseInt, parseFloat, isNaN, isFinite,
54
+ String, Number, Boolean, Array, Object, Map, Set, RegExp,
55
+ setTimeout: undefined, setInterval: undefined, // blocked
56
+ fetch: undefined, // blocked unless allowNetwork
57
+ require: undefined, // blocked
58
+ process: { env: {} }, // sanitized
59
+ Buffer: undefined, // blocked
60
+ ...context,
61
+ };
62
+
63
+ const output = [];
64
+
65
+ try {
66
+ const vmContext = vm.createContext(sandbox, {
67
+ codeGeneration: { strings: false, wasm: false },
68
+ });
69
+
70
+ const script = new vm.Script(code, {
71
+ timeout: this.timeout,
72
+ filename: 'sandbox.js',
73
+ });
74
+
75
+ const result = script.runInContext(vmContext, {
76
+ timeout: this.timeout,
77
+ breakOnSigint: true,
78
+ });
79
+
80
+ if (result !== undefined && !output.length) {
81
+ output.push(String(result));
82
+ }
83
+
84
+ const finalOutput = output.join('\n').slice(0, this.maxOutputSize);
85
+ logger.info('sandbox', `JS eval OK (${code.length} chars)`);
86
+ return { success: true, output: finalOutput, type: 'javascript' };
87
+
88
+ } catch (err) {
89
+ const errMsg = err.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT'
90
+ ? 'Execution timeout (' + (this.timeout / 1000) + 's limit)'
91
+ : err.message;
92
+ return { success: false, error: errMsg, output: output.join('\n'), type: 'javascript' };
93
+ }
94
+ }
95
+
96
+ // ── Python Execution (process-isolated) ──
97
+
98
+ async runPython(code, options = {}) {
99
+ const filename = 'run_' + Date.now() + '.py';
100
+ const filepath = join(this.jailDir, filename);
101
+ const timeout = options.timeout || this.timeout;
102
+
103
+ // Safety: wrap in resource limits
104
+ const wrappedCode = `
105
+ import sys, os, signal
106
+ signal.alarm(${Math.ceil(timeout / 1000)})
107
+ sys.path = ['.']
108
+ os.chdir('${this.jailDir}')
109
+ ${code}
110
+ `;
111
+
112
+ writeFileSync(filepath, wrappedCode);
113
+
114
+ try {
115
+ const result = await this._execProcess('python3', [filepath], {
116
+ timeout,
117
+ cwd: this.jailDir,
118
+ maxOutput: this.maxOutputSize,
119
+ });
120
+ return { ...result, type: 'python' };
121
+ } finally {
122
+ try { unlinkSync(filepath); } catch {}
123
+ }
124
+ }
125
+
126
+ // ── Shell Execution (sandboxed) ──
127
+
128
+ async runShell(command, options = {}) {
129
+ // Security check
130
+ for (const pattern of this.blockedCommands) {
131
+ if (pattern.test(command)) {
132
+ return { success: false, error: 'Blocked: dangerous command pattern', type: 'shell' };
133
+ }
134
+ }
135
+
136
+ const timeout = options.timeout || this.timeout;
137
+
138
+ try {
139
+ const output = execSync(command, {
140
+ cwd: options.cwd || this.jailDir,
141
+ timeout,
142
+ maxBuffer: this.maxOutputSize,
143
+ encoding: 'utf8',
144
+ env: {
145
+ PATH: '/usr/local/bin:/usr/bin:/bin',
146
+ HOME: this.jailDir,
147
+ LANG: 'en_US.UTF-8',
148
+ // No AWS keys, no tokens, no secrets
149
+ },
150
+ });
151
+
152
+ logger.info('sandbox', `Shell OK: ${command.slice(0, 50)}`);
153
+ return { success: true, output: output.trim().slice(0, this.maxOutputSize), type: 'shell' };
154
+ } catch (err) {
155
+ return {
156
+ success: false,
157
+ output: (err.stdout || '').trim().slice(0, 5000),
158
+ error: (err.stderr || err.message).trim().slice(0, 5000),
159
+ exitCode: err.status || 1,
160
+ type: 'shell',
161
+ };
162
+ }
163
+ }
164
+
165
+ // ── File System (jailed) ──
166
+
167
+ writeFile(name, content) {
168
+ const safePath = this._safePath(name);
169
+
170
+ if (content.length > this.maxFileSize) {
171
+ throw new Error('File too large: ' + (content.length / 1024).toFixed(0) + 'KB (max ' + (this.maxFileSize / 1024) + 'KB)');
172
+ }
173
+
174
+ const fileCount = this._countFiles();
175
+ if (fileCount >= this.maxFiles) {
176
+ throw new Error('Too many files in sandbox (max ' + this.maxFiles + ')');
177
+ }
178
+
179
+ const dir = resolve(safePath, '..');
180
+ mkdirSync(dir, { recursive: true });
181
+ writeFileSync(safePath, content);
182
+ return { path: safePath, size: content.length };
183
+ }
184
+
185
+ readFile(name) {
186
+ const safePath = this._safePath(name);
187
+ if (!existsSync(safePath)) throw new Error('File not found: ' + name);
188
+
189
+ const stat = statSync(safePath);
190
+ if (stat.size > this.maxFileSize) throw new Error('File too large to read');
191
+
192
+ return readFileSync(safePath, 'utf8');
193
+ }
194
+
195
+ listFiles(subdir = '.') {
196
+ const safePath = this._safePath(subdir);
197
+ if (!existsSync(safePath)) return [];
198
+
199
+ return readdirSync(safePath, { withFileTypes: true }).map(d => ({
200
+ name: d.name,
201
+ type: d.isDirectory() ? 'dir' : 'file',
202
+ size: d.isFile() ? statSync(join(safePath, d.name)).size : 0,
203
+ }));
204
+ }
205
+
206
+ deleteFile(name) {
207
+ const safePath = this._safePath(name);
208
+ if (existsSync(safePath)) unlinkSync(safePath);
209
+ }
210
+
211
+ // ── Cleanup ──
212
+
213
+ cleanup() {
214
+ try {
215
+ execSync(`find ${this.jailDir} -type f -mmin +60 -delete 2>/dev/null`, { timeout: 5000 });
216
+ logger.info('sandbox', 'Cleaned old files');
217
+ } catch {}
218
+ }
219
+
220
+ // ── Stats ──
221
+
222
+ getStats() {
223
+ const files = this._countFiles();
224
+ let totalSize = 0;
225
+ try {
226
+ const output = execSync(`du -sb ${this.jailDir} 2>/dev/null`, { encoding: 'utf8' });
227
+ totalSize = parseInt(output.split('\t')[0]) || 0;
228
+ } catch {}
229
+
230
+ return {
231
+ files,
232
+ maxFiles: this.maxFiles,
233
+ totalSize,
234
+ maxFileSize: this.maxFileSize,
235
+ timeout: this.timeout,
236
+ maxMemory: this.maxMemory,
237
+ jailDir: this.jailDir,
238
+ allowNetwork: this.allowNetwork,
239
+ };
240
+ }
241
+
242
+ // ── Internal ──
243
+
244
+ _safePath(name) {
245
+ const resolved = resolve(this.jailDir, name);
246
+ if (!resolved.startsWith(this.jailDir)) {
247
+ throw new Error('Path traversal blocked');
248
+ }
249
+ return resolved;
250
+ }
251
+
252
+ _countFiles() {
253
+ try {
254
+ const output = execSync(`find ${this.jailDir} -type f | wc -l`, { encoding: 'utf8', timeout: 3000 });
255
+ return parseInt(output.trim()) || 0;
256
+ } catch { return 0; }
257
+ }
258
+
259
+ async _execProcess(cmd, args, options = {}) {
260
+ return new Promise((resolve) => {
261
+ let stdout = '';
262
+ let stderr = '';
263
+ const timeout = options.timeout || this.timeout;
264
+ const maxOutput = options.maxOutput || this.maxOutputSize;
265
+
266
+ const proc = spawn(cmd, args, {
267
+ cwd: options.cwd || this.jailDir,
268
+ timeout,
269
+ env: {
270
+ PATH: '/usr/local/bin:/usr/bin:/bin',
271
+ HOME: this.jailDir,
272
+ LANG: 'en_US.UTF-8',
273
+ },
274
+ });
275
+
276
+ const timer = setTimeout(() => {
277
+ proc.kill('SIGKILL');
278
+ resolve({ success: false, error: 'Timeout (' + (timeout / 1000) + 's)', output: stdout.slice(0, maxOutput) });
279
+ }, timeout);
280
+
281
+ proc.stdout.on('data', d => { stdout += d; if (stdout.length > maxOutput) proc.kill(); });
282
+ proc.stderr.on('data', d => { stderr += d; });
283
+
284
+ proc.on('close', (code) => {
285
+ clearTimeout(timer);
286
+ if (code === 0) {
287
+ resolve({ success: true, output: stdout.trim().slice(0, maxOutput) });
288
+ } else {
289
+ resolve({ success: false, output: stdout.trim().slice(0, 5000), error: stderr.trim().slice(0, 5000), exitCode: code });
290
+ }
291
+ });
292
+
293
+ proc.on('error', (err) => {
294
+ clearTimeout(timer);
295
+ resolve({ success: false, error: err.message });
296
+ });
297
+ });
298
+ }
299
+ }
@@ -145,6 +145,106 @@ export async function commandsMiddleware(ctx, next) {
145
145
  return;
146
146
  }
147
147
 
148
+ if (cmd === '/sandbox') {
149
+ if (!ctx.engine.sandbox) { await ctx.reply('āŒ Sandbox not available'); return; }
150
+ const args = msg.slice(9).trim();
151
+
152
+ if (!args || args === 'stats') {
153
+ const stats = ctx.engine.sandbox.getStats();
154
+ await ctx.reply('šŸ”’ *Sandbox*\n\nšŸ“ Files: ' + stats.files + '/' + stats.maxFiles + '\nšŸ’¾ Size: ' + (stats.totalSize / 1024).toFixed(0) + ' KB\nā±ļø Timeout: ' + (stats.timeout / 1000) + 's\n🧠 Max memory: ' + stats.maxMemory + ' MB\n🌐 Network: ' + (stats.allowNetwork ? 'āœ…' : '🚫'));
155
+ return;
156
+ }
157
+
158
+ if (args === 'files') {
159
+ const files = ctx.engine.sandbox.listFiles();
160
+ if (files.length === 0) { await ctx.reply('šŸ“ Sandbox is empty'); return; }
161
+ const lines = files.map(f => (f.type === 'dir' ? 'šŸ“ ' : 'šŸ“„ ') + f.name + (f.size ? ' (' + (f.size / 1024).toFixed(1) + ' KB)' : ''));
162
+ await ctx.reply('šŸ“ *Sandbox Files*\n\n' + lines.join('\n'));
163
+ return;
164
+ }
165
+
166
+ if (args === 'clean') {
167
+ ctx.engine.sandbox.cleanup();
168
+ await ctx.reply('🧹 Sandbox cleaned');
169
+ return;
170
+ }
171
+
172
+ if (args.startsWith('js ')) {
173
+ const code = args.slice(3);
174
+ const result = ctx.engine.sandbox.evalJS(code);
175
+ await ctx.reply(result.success ? '```\n' + (result.output || '(no output)') + '\n```' : 'āŒ ' + result.error);
176
+ return;
177
+ }
178
+
179
+ if (args.startsWith('py ')) {
180
+ const code = args.slice(3);
181
+ const result = await ctx.engine.sandbox.runPython(code);
182
+ await ctx.reply(result.success ? '```\n' + (result.output || '(no output)') + '\n```' : 'āŒ ' + (result.error || 'Failed'));
183
+ return;
184
+ }
185
+
186
+ await ctx.reply('šŸ”’ *Sandbox Commands*\n\n/sandbox — stats\n/sandbox files — list files\n/sandbox clean — remove old files\n/sandbox js <code> — run JavaScript\n/sandbox py <code> — run Python');
187
+ return;
188
+ }
189
+
190
+ if (cmd === '/plugins' || cmd === '/plugin') {
191
+ if (!ctx.engine.plugins) { await ctx.reply('āŒ Plugin system not available'); return; }
192
+ const args = msg.split(/\s+/).slice(1);
193
+ const sub = args[0];
194
+
195
+ if (!sub || sub === 'list') {
196
+ const plugins = ctx.engine.plugins.list();
197
+ if (plugins.length === 0) {
198
+ await ctx.reply('šŸ”Œ No plugins installed\n\nCreate one: /plugin new <name>\nOr drop a plugin folder in ~/.squidclaw/plugins/');
199
+ return;
200
+ }
201
+ const lines = plugins.map(p =>
202
+ (p.status === 'active' ? '🟢' : 'āøļø') + ' *' + p.name + '* v' + p.version + '\n ' + p.description + '\n Hooks: ' + p.hooks.join(', ')
203
+ );
204
+ await ctx.reply('šŸ”Œ *Plugins*\n\n' + lines.join('\n\n'));
205
+ return;
206
+ }
207
+
208
+ if (sub === 'new' || sub === 'create') {
209
+ const name = args[1] || 'my-plugin';
210
+ const desc = args.slice(2).join(' ') || '';
211
+ const dir = ctx.engine.plugins.scaffold(name, desc);
212
+ await ctx.reply('šŸ”Œ Plugin scaffolded!\n\nšŸ“ ' + dir + '\n\nEdit index.js to add your logic, then /plugin reload ' + name);
213
+ return;
214
+ }
215
+
216
+ if (sub === 'reload') {
217
+ const name = args[1];
218
+ if (!name) { await ctx.reply('Usage: /plugin reload <name>'); return; }
219
+ try {
220
+ await ctx.engine.plugins.reload(name);
221
+ await ctx.reply('šŸ”Œ Reloaded: ' + name);
222
+ } catch (err) { await ctx.reply('āŒ ' + err.message); }
223
+ return;
224
+ }
225
+
226
+ if (sub === 'enable') {
227
+ ctx.engine.plugins.enable(args[1]);
228
+ await ctx.reply('āœ… Enabled: ' + args[1]);
229
+ return;
230
+ }
231
+
232
+ if (sub === 'disable') {
233
+ ctx.engine.plugins.disable(args[1]);
234
+ await ctx.reply('āøļø Disabled: ' + args[1]);
235
+ return;
236
+ }
237
+
238
+ if (sub === 'unload') {
239
+ await ctx.engine.plugins.unload(args[1]);
240
+ await ctx.reply('šŸ”Œ Unloaded: ' + args[1]);
241
+ return;
242
+ }
243
+
244
+ await ctx.reply('šŸ”Œ *Plugin Commands*\n\n/plugin list — show plugins\n/plugin new <name> — create plugin\n/plugin reload <name> — hot reload\n/plugin enable <name>\n/plugin disable <name>');
245
+ return;
246
+ }
247
+
148
248
  if (cmd === '/sessions') {
149
249
  if (!ctx.engine.sessions) { await ctx.reply('āŒ Sessions not available'); return; }
150
250
  const args = msg.slice(10).trim();
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Plugin middleware — fires onMessage hook before AI processing
3
+ */
4
+ export async function pluginMiddleware(ctx, next) {
5
+ if (!ctx.engine.plugins) { await next(); return; }
6
+
7
+ // Fire onMessage hooks
8
+ const results = await ctx.engine.plugins.fireHook('onMessage', {
9
+ message: ctx.message,
10
+ contactId: ctx.contactId,
11
+ agentId: ctx.agentId,
12
+ metadata: ctx.metadata,
13
+ engine: ctx.engine,
14
+ });
15
+
16
+ // Check if any plugin handled it
17
+ for (const r of results) {
18
+ if (r.result?.handled) {
19
+ ctx.response = {
20
+ messages: [r.result.response],
21
+ reaction: r.result.reaction || null,
22
+ };
23
+ ctx.handled = true;
24
+ // Skip to response sender
25
+ await next();
26
+ return;
27
+ }
28
+ }
29
+
30
+ await next();
31
+
32
+ // Fire onResponse hooks (post-AI)
33
+ if (ctx.response) {
34
+ await ctx.engine.plugins.fireHook('onResponse', {
35
+ response: ctx.response,
36
+ contactId: ctx.contactId,
37
+ agentId: ctx.agentId,
38
+ engine: ctx.engine,
39
+ });
40
+ }
41
+ }
@@ -169,6 +169,13 @@ export class ToolRouter {
169
169
  '---TOOL:handoff:reason---',
170
170
  'Transfer the conversation to a human agent. Use when you cannot help further.');
171
171
 
172
+ tools.push('', '### Run JavaScript (Sandboxed)',
173
+ '---TOOL:js:console.log(2 + 2)---',
174
+ 'Execute JavaScript in a secure VM sandbox. No network, no filesystem, no require. Safe for math, logic, data processing.',
175
+ '', '### Sandbox Info',
176
+ '---TOOL:sandbox_info:stats---',
177
+ 'Show sandbox stats (files, size, limits).');
178
+
172
179
  tools.push('', '### Run Command',
173
180
  '---TOOL:exec:ls -la---',
174
181
  'Execute a shell command. Output is returned. Sandboxed for safety.',
@@ -244,6 +251,15 @@ export class ToolRouter {
244
251
 
245
252
  tools.push('', '**Important:** Use tools when needed. The tool result will be injected into the conversation automatically. Only use one tool per response.');
246
253
 
254
+ // Plugin tools
255
+ if (this._engine?.plugins) {
256
+ const pluginTools = this._engine.plugins.getToolDescriptions();
257
+ if (pluginTools.length > 0) {
258
+ tools.push('', '## Plugin Tools');
259
+ tools.push(...pluginTools);
260
+ }
261
+ }
262
+
247
263
  return tools.join('\n');
248
264
  }
249
265
 
@@ -602,10 +618,15 @@ export class ToolRouter {
602
618
  case 'exec':
603
619
  case 'shell':
604
620
  case 'run': {
605
- const { ShellTool } = await import('./shell.js');
606
- const sh = new ShellTool();
607
- const result = sh.exec(toolArg);
608
- toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
621
+ if (this._engine?.sandbox) {
622
+ const result = await this._engine.sandbox.runShell(toolArg);
623
+ toolResult = result.success ? (result.output || '(no output)') : 'Error: ' + (result.error || 'Unknown');
624
+ } else {
625
+ const { ShellTool } = await import('./shell.js');
626
+ const sh = new ShellTool();
627
+ const result = sh.exec(toolArg);
628
+ toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
629
+ }
609
630
  break;
610
631
  }
611
632
  case 'readfile': {
@@ -634,14 +655,44 @@ export class ToolRouter {
634
655
  toolResult = result.error || result.files.map(f => (f.type === 'dir' ? 'šŸ“ ' : 'šŸ“„ ') + f.name).join('\n');
635
656
  break;
636
657
  }
658
+ case 'js':
659
+ case 'javascript':
660
+ case 'eval': {
661
+ if (this._engine?.sandbox) {
662
+ const result = this._engine.sandbox.evalJS(toolArg);
663
+ toolResult = result.success ? (result.output || '(no output)') : 'Error: ' + (result.error || 'Unknown');
664
+ } else {
665
+ toolResult = 'Sandbox not available';
666
+ }
667
+ break;
668
+ }
637
669
  case 'python':
638
670
  case 'py': {
639
- const { ShellTool } = await import('./shell.js');
640
- const sh = new ShellTool();
641
- // Write Python script then execute
642
- sh.writeFile('_run.py', toolArg);
643
- const result = sh.exec('python3 _run.py');
644
- toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
671
+ if (this._engine?.sandbox) {
672
+ const result = await this._engine.sandbox.runPython(toolArg);
673
+ toolResult = result.success ? (result.output || '(no output)') : 'Error: ' + (result.error || 'Unknown');
674
+ } else {
675
+ const { ShellTool } = await import('./shell.js');
676
+ const sh = new ShellTool();
677
+ sh.writeFile('_run.py', toolArg);
678
+ const result = sh.exec('python3 _run.py');
679
+ toolResult = result.error ? 'Error: ' + result.error : result.output || '(no output)';
680
+ }
681
+ break;
682
+ }
683
+ case 'sandbox_info': {
684
+ if (this._engine?.sandbox) {
685
+ const stats = this._engine.sandbox.getStats();
686
+ toolResult = 'šŸ”’ Sandbox Stats:\n' +
687
+ 'šŸ“ Files: ' + stats.files + '/' + stats.maxFiles + '\n' +
688
+ 'šŸ’¾ Size: ' + (stats.totalSize / 1024).toFixed(0) + ' KB\n' +
689
+ 'ā±ļø Timeout: ' + (stats.timeout / 1000) + 's\n' +
690
+ '🧠 Max memory: ' + stats.maxMemory + ' MB\n' +
691
+ '🌐 Network: ' + (stats.allowNetwork ? 'allowed' : 'blocked') + '\n' +
692
+ 'šŸ“‚ Jail: ' + stats.jailDir;
693
+ } else {
694
+ toolResult = 'Sandbox not available';
695
+ }
645
696
  break;
646
697
  }
647
698
  case 'spawn': {
@@ -805,7 +856,19 @@ export class ToolRouter {
805
856
  toolResult = `Email sent to ${to}`;
806
857
  break;
807
858
 
808
- default:
859
+ default: {
860
+ // Check plugins for custom tool handlers
861
+ if (this._engine?.plugins) {
862
+ const pluginResult = await this._engine.plugins.handleTool(toolName, toolArg, agentId);
863
+ if (pluginResult) {
864
+ if (pluginResult.filePath) {
865
+ return { toolUsed: true, toolName, toolResult: pluginResult.result || 'Done', filePath: pluginResult.filePath, fileName: pluginResult.fileName, cleanResponse };
866
+ }
867
+ toolResult = pluginResult.result || pluginResult;
868
+ break;
869
+ }
870
+ }
871
+ }
809
872
  toolResult = `Unknown tool: ${toolName}`;
810
873
  }
811
874
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squidclaw",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "description": "šŸ¦‘ AI agent platform — human-like agents for WhatsApp, Telegram & more",
5
5
  "main": "lib/engine.js",
6
6
  "bin": {