squidclaw 1.3.0 → 1.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.
- package/lib/engine.js +15 -0
- package/lib/features/config-manager.js +238 -0
- package/lib/features/sub-agents.js +105 -0
- package/lib/middleware/commands.js +116 -1
- package/lib/middleware/config-chat.js +35 -0
- package/lib/tools/router.js +73 -0
- package/lib/tools/shell.js +111 -0
- package/package.json +1 -1
package/lib/engine.js
CHANGED
|
@@ -62,6 +62,8 @@ export class SquidclawEngine {
|
|
|
62
62
|
pipeline.use('media', mediaMiddleware);
|
|
63
63
|
pipeline.use('auto-links', autoLinksMiddleware);
|
|
64
64
|
pipeline.use('auto-memory', autoMemoryMiddleware);
|
|
65
|
+
const { configChatMiddleware } = await import('./middleware/config-chat.js');
|
|
66
|
+
pipeline.use('config-chat', configChatMiddleware);
|
|
65
67
|
pipeline.use('skill-check', skillCheckMiddleware);
|
|
66
68
|
pipeline.use('typing', typingMiddleware); // wraps AI call with typing indicator
|
|
67
69
|
pipeline.use('ai-process', aiProcessMiddleware);
|
|
@@ -136,6 +138,19 @@ export class SquidclawEngine {
|
|
|
136
138
|
// 5. Features (reminders, auto-memory, usage alerts)
|
|
137
139
|
await this._initFeatures();
|
|
138
140
|
|
|
141
|
+
// 5b. Config Manager
|
|
142
|
+
try {
|
|
143
|
+
const { ConfigManager } = await import('./features/config-manager.js');
|
|
144
|
+
this.configManager = new ConfigManager(this);
|
|
145
|
+
} catch {}
|
|
146
|
+
|
|
147
|
+
// 5c. Sub-agents
|
|
148
|
+
try {
|
|
149
|
+
const { SubAgentManager } = await import('./features/sub-agents.js');
|
|
150
|
+
this.subAgents = new SubAgentManager(this);
|
|
151
|
+
if (this.toolRouter) this.toolRouter._subAgents = this.subAgents;
|
|
152
|
+
} catch {}
|
|
153
|
+
|
|
139
154
|
// 6. Message Pipeline
|
|
140
155
|
this.pipeline = await this._buildPipeline();
|
|
141
156
|
console.log(` 🔧 Pipeline: ${this.pipeline.middleware.length} middleware`);
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🦑 Config Manager
|
|
3
|
+
* Hot reload, chat commands, .env support, profiles, validation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { loadConfig, saveConfig, getHome } from '../core/config.js';
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { logger } from '../core/logger.js';
|
|
10
|
+
|
|
11
|
+
export class ConfigManager {
|
|
12
|
+
constructor(engine) {
|
|
13
|
+
this.engine = engine;
|
|
14
|
+
this.home = getHome();
|
|
15
|
+
this._loadEnv();
|
|
16
|
+
this._watchConfig();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── .env support ──
|
|
20
|
+
|
|
21
|
+
_loadEnv() {
|
|
22
|
+
const envPath = join(this.home, '.env');
|
|
23
|
+
if (!existsSync(envPath)) return;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const lines = readFileSync(envPath, 'utf8').split('\n');
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
const trimmed = line.trim();
|
|
29
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
30
|
+
const eqIdx = trimmed.indexOf('=');
|
|
31
|
+
if (eqIdx === -1) continue;
|
|
32
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
33
|
+
const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
|
|
34
|
+
process.env[key] = value;
|
|
35
|
+
}
|
|
36
|
+
logger.info('config', 'Loaded .env file');
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Hot reload ──
|
|
41
|
+
|
|
42
|
+
_watchConfig() {
|
|
43
|
+
const configPath = join(this.home, 'config.json');
|
|
44
|
+
try {
|
|
45
|
+
const { watch } = require('fs');
|
|
46
|
+
let debounce = null;
|
|
47
|
+
watch(configPath, () => {
|
|
48
|
+
if (debounce) clearTimeout(debounce);
|
|
49
|
+
debounce = setTimeout(() => {
|
|
50
|
+
try {
|
|
51
|
+
this.engine.config = loadConfig();
|
|
52
|
+
logger.info('config', 'Config hot-reloaded');
|
|
53
|
+
} catch (err) {
|
|
54
|
+
logger.error('config', 'Hot reload failed: ' + err.message);
|
|
55
|
+
}
|
|
56
|
+
}, 1000);
|
|
57
|
+
});
|
|
58
|
+
} catch {
|
|
59
|
+
// fs.watch not available — use polling
|
|
60
|
+
this._pollConfig(configPath);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_pollConfig(configPath) {
|
|
65
|
+
let lastMtime = 0;
|
|
66
|
+
try { lastMtime = readFileSync(configPath).length; } catch {}
|
|
67
|
+
|
|
68
|
+
setInterval(() => {
|
|
69
|
+
try {
|
|
70
|
+
const content = readFileSync(configPath, 'utf8');
|
|
71
|
+
if (content.length !== lastMtime) {
|
|
72
|
+
lastMtime = content.length;
|
|
73
|
+
this.engine.config = loadConfig();
|
|
74
|
+
logger.info('config', 'Config hot-reloaded (poll)');
|
|
75
|
+
}
|
|
76
|
+
} catch {}
|
|
77
|
+
}, 5000);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Set config value ──
|
|
81
|
+
|
|
82
|
+
set(path, value) {
|
|
83
|
+
const config = loadConfig();
|
|
84
|
+
const parts = path.split('.');
|
|
85
|
+
let obj = config;
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
88
|
+
if (!obj[parts[i]]) obj[parts[i]] = {};
|
|
89
|
+
obj = obj[parts[i]];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Auto-convert types
|
|
93
|
+
if (value === 'true') value = true;
|
|
94
|
+
else if (value === 'false') value = false;
|
|
95
|
+
else if (!isNaN(value) && value !== '') value = Number(value);
|
|
96
|
+
|
|
97
|
+
obj[parts[parts.length - 1]] = value;
|
|
98
|
+
saveConfig(config);
|
|
99
|
+
this.engine.config = config;
|
|
100
|
+
|
|
101
|
+
logger.info('config', `Set ${path} = ${JSON.stringify(value)}`);
|
|
102
|
+
return { path, value };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Get config value ──
|
|
106
|
+
|
|
107
|
+
get(path) {
|
|
108
|
+
const config = loadConfig();
|
|
109
|
+
const parts = path.split('.');
|
|
110
|
+
let obj = config;
|
|
111
|
+
|
|
112
|
+
for (const part of parts) {
|
|
113
|
+
if (obj === undefined) return undefined;
|
|
114
|
+
obj = obj[part];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return obj;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Profiles ──
|
|
121
|
+
|
|
122
|
+
saveProfile(name) {
|
|
123
|
+
const configPath = join(this.home, 'config.json');
|
|
124
|
+
const profilePath = join(this.home, `config.${name}.json`);
|
|
125
|
+
copyFileSync(configPath, profilePath);
|
|
126
|
+
return profilePath;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
loadProfile(name) {
|
|
130
|
+
const profilePath = join(this.home, `config.${name}.json`);
|
|
131
|
+
if (!existsSync(profilePath)) throw new Error('Profile not found: ' + name);
|
|
132
|
+
|
|
133
|
+
const configPath = join(this.home, 'config.json');
|
|
134
|
+
copyFileSync(profilePath, configPath);
|
|
135
|
+
this.engine.config = loadConfig();
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
listProfiles() {
|
|
140
|
+
const { readdirSync } = require('fs');
|
|
141
|
+
return readdirSync(this.home)
|
|
142
|
+
.filter(f => f.startsWith('config.') && f.endsWith('.json') && f !== 'config.json')
|
|
143
|
+
.map(f => f.replace('config.', '').replace('.json', ''));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Validation ──
|
|
147
|
+
|
|
148
|
+
async validate() {
|
|
149
|
+
const config = loadConfig();
|
|
150
|
+
const results = [];
|
|
151
|
+
|
|
152
|
+
// Check AI providers
|
|
153
|
+
const providers = config.ai?.providers || {};
|
|
154
|
+
for (const [name, prov] of Object.entries(providers)) {
|
|
155
|
+
if (!prov.key || prov.key === 'local') continue;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const valid = await this._testProvider(name, prov.key);
|
|
159
|
+
results.push({ name, status: valid ? '✅' : '❌', message: valid ? 'Valid' : 'Invalid key' });
|
|
160
|
+
} catch (err) {
|
|
161
|
+
results.push({ name, status: '❌', message: err.message });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Check Telegram
|
|
166
|
+
if (config.channels?.telegram?.token) {
|
|
167
|
+
try {
|
|
168
|
+
const res = await fetch(`https://api.telegram.org/bot${config.channels.telegram.token}/getMe`);
|
|
169
|
+
const data = await res.json();
|
|
170
|
+
results.push({ name: 'Telegram', status: data.ok ? '✅' : '❌', message: data.ok ? '@' + data.result.username : 'Invalid token' });
|
|
171
|
+
} catch (err) {
|
|
172
|
+
results.push({ name: 'Telegram', status: '❌', message: err.message });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Check agents
|
|
177
|
+
const agentsDir = join(this.home, 'agents');
|
|
178
|
+
if (existsSync(agentsDir)) {
|
|
179
|
+
const { readdirSync } = require('fs');
|
|
180
|
+
const count = readdirSync(agentsDir, { withFileTypes: true }).filter(d => d.isDirectory()).length;
|
|
181
|
+
results.push({ name: 'Agents', status: '✅', message: count + ' configured' });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return results;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async _testProvider(name, key) {
|
|
188
|
+
const urls = {
|
|
189
|
+
anthropic: 'https://api.anthropic.com/v1/models',
|
|
190
|
+
openai: 'https://api.openai.com/v1/models',
|
|
191
|
+
google: `https://generativelanguage.googleapis.com/v1beta/models?key=${key}`,
|
|
192
|
+
groq: 'https://api.groq.com/openai/v1/models',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const url = urls[name];
|
|
196
|
+
if (!url) return true; // Can't validate, assume OK
|
|
197
|
+
|
|
198
|
+
const headers = {};
|
|
199
|
+
if (name === 'anthropic') {
|
|
200
|
+
headers['x-api-key'] = key;
|
|
201
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
202
|
+
} else if (name !== 'google') {
|
|
203
|
+
headers['Authorization'] = 'Bearer ' + key;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const res = await fetch(url, { headers });
|
|
207
|
+
return res.ok;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Chat command parsing ──
|
|
211
|
+
|
|
212
|
+
static parseConfigCommand(message) {
|
|
213
|
+
const lower = message.toLowerCase().trim();
|
|
214
|
+
|
|
215
|
+
// "switch to gpt-4o" / "use gemini"
|
|
216
|
+
const modelMatch = lower.match(/(?:switch|change|use)\s+(?:to\s+)?(?:model\s+)?(.+)/);
|
|
217
|
+
if (modelMatch) {
|
|
218
|
+
const model = modelMatch[1].trim();
|
|
219
|
+
// Map common names to actual model IDs
|
|
220
|
+
const aliases = {
|
|
221
|
+
'gpt4': 'gpt-4o', 'gpt-4': 'gpt-4o', 'gpt4o': 'gpt-4o',
|
|
222
|
+
'claude': 'claude-sonnet-4-20250514', 'sonnet': 'claude-sonnet-4-20250514',
|
|
223
|
+
'opus': 'claude-opus-4', 'haiku': 'claude-haiku-3-5',
|
|
224
|
+
'gemini': 'gemini-2.5-flash', 'flash': 'gemini-2.5-flash',
|
|
225
|
+
'llama': 'llama-3.3-70b-versatile', 'deepseek': 'deepseek-chat',
|
|
226
|
+
};
|
|
227
|
+
return { action: 'set_model', model: aliases[model] || model };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// "set temperature to 0.8"
|
|
231
|
+
const setMatch = lower.match(/set\s+(\w[\w.]*)\s+(?:to\s+)?(.+)/);
|
|
232
|
+
if (setMatch) {
|
|
233
|
+
return { action: 'set', path: setMatch[1], value: setMatch[2] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -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,11 @@ 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
|
-
'/
|
|
19
|
+
'/config — manage settings',
|
|
20
|
+
'/exec <cmd> — run a shell command',
|
|
21
|
+
'/files — list sandbox files',
|
|
22
|
+
'/subagents — list background tasks',
|
|
23
|
+
'/help — this message',
|
|
20
24
|
'', 'Just chat normally! 🦑',
|
|
21
25
|
].join('\n'));
|
|
22
26
|
return;
|
|
@@ -117,5 +121,116 @@ export async function commandsMiddleware(ctx, next) {
|
|
|
117
121
|
return;
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
if (cmd === '/config') {
|
|
125
|
+
const args = msg.slice(8).trim();
|
|
126
|
+
if (!args || args === 'help') {
|
|
127
|
+
await ctx.reply([
|
|
128
|
+
'⚙️ *Config Commands*', '',
|
|
129
|
+
'/config get <path> — show value',
|
|
130
|
+
'/config set <path> <value> — set value',
|
|
131
|
+
'/config test — validate all connections',
|
|
132
|
+
'/config profiles — list saved profiles',
|
|
133
|
+
'/config save <name> — save current config',
|
|
134
|
+
'/config load <name> — switch profile',
|
|
135
|
+
'', 'Or just say: "switch to gemini" 🦑',
|
|
136
|
+
].join('\n'));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const cm = ctx.engine.configManager;
|
|
141
|
+
if (!cm) { await ctx.reply('❌ Config manager not available'); return; }
|
|
142
|
+
|
|
143
|
+
if (args.startsWith('get ')) {
|
|
144
|
+
const val = cm.get(args.slice(4).trim());
|
|
145
|
+
await ctx.reply('⚙️ ' + args.slice(4).trim() + ' = ' + JSON.stringify(val));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (args.startsWith('set ')) {
|
|
150
|
+
const parts = args.slice(4).trim().split(/\s+/);
|
|
151
|
+
const path = parts[0];
|
|
152
|
+
const value = parts.slice(1).join(' ');
|
|
153
|
+
cm.set(path, value);
|
|
154
|
+
await ctx.reply('✅ Set *' + path + '* = ' + value);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (args === 'test' || args === 'validate') {
|
|
159
|
+
await ctx.reply('🔍 Validating connections...');
|
|
160
|
+
const results = await cm.validate();
|
|
161
|
+
const lines = results.map(r => r.status + ' *' + r.name + '* — ' + r.message);
|
|
162
|
+
await ctx.reply('⚙️ *Config Validation*\n\n' + lines.join('\n'));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (args === 'profiles') {
|
|
167
|
+
const profiles = cm.listProfiles();
|
|
168
|
+
await ctx.reply(profiles.length ? '📋 Profiles: ' + profiles.join(', ') : '📋 No saved profiles');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (args.startsWith('save ')) {
|
|
173
|
+
cm.saveProfile(args.slice(5).trim());
|
|
174
|
+
await ctx.reply('✅ Profile saved: ' + args.slice(5).trim());
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (args.startsWith('load ')) {
|
|
179
|
+
try {
|
|
180
|
+
cm.loadProfile(args.slice(5).trim());
|
|
181
|
+
await ctx.reply('✅ Profile loaded: ' + args.slice(5).trim() + '\n⚠️ Restart recommended');
|
|
182
|
+
} catch (err) {
|
|
183
|
+
await ctx.reply('❌ ' + err.message);
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (cmd === '/exec') {
|
|
190
|
+
const command = msg.slice(6).trim();
|
|
191
|
+
if (!command) { await ctx.reply('Usage: /exec <command>'); return; }
|
|
192
|
+
try {
|
|
193
|
+
const { ShellTool } = await import('../tools/shell.js');
|
|
194
|
+
const sh = new ShellTool();
|
|
195
|
+
const result = sh.exec(command);
|
|
196
|
+
const output = result.error ? '❌ ' + result.error : '```\n' + (result.output || '(no output)') + '\n```';
|
|
197
|
+
await ctx.reply(output);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
await ctx.reply('❌ ' + err.message);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (cmd === '/files') {
|
|
205
|
+
try {
|
|
206
|
+
const { ShellTool } = await import('../tools/shell.js');
|
|
207
|
+
const sh = new ShellTool();
|
|
208
|
+
const result = sh.listDir('.');
|
|
209
|
+
const list = result.files?.map(f => (f.type === 'dir' ? '📁 ' : '📄 ') + f.name).join('\n') || 'Empty';
|
|
210
|
+
await ctx.reply('📂 *Sandbox Files*\n\n' + list);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
await ctx.reply('❌ ' + err.message);
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (cmd === '/subagents') {
|
|
218
|
+
if (ctx.engine.subAgents) {
|
|
219
|
+
const list = ctx.engine.subAgents.list(ctx.agentId);
|
|
220
|
+
if (list.length === 0) {
|
|
221
|
+
await ctx.reply('🤖 No sub-agents running');
|
|
222
|
+
} else {
|
|
223
|
+
const lines = list.map(s =>
|
|
224
|
+
(s.status === 'complete' ? '✅' : s.status === 'error' ? '❌' : '⏳') +
|
|
225
|
+
' ' + s.id + '\n ' + s.task + '\n ' + s.status + (s.duration ? ' (' + s.duration + ')' : '')
|
|
226
|
+
);
|
|
227
|
+
await ctx.reply('🤖 *Sub-Agents*\n\n' + lines.join('\n\n'));
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
await ctx.reply('🤖 Sub-agents not available');
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
120
235
|
await next();
|
|
121
236
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handle config changes via natural language
|
|
3
|
+
* "switch to gemini", "use gpt-4o", "set temperature to 0.8"
|
|
4
|
+
*/
|
|
5
|
+
import { logger } from '../core/logger.js';
|
|
6
|
+
|
|
7
|
+
export async function configChatMiddleware(ctx, next) {
|
|
8
|
+
if (!ctx.engine.configManager) { await next(); return; }
|
|
9
|
+
|
|
10
|
+
const { ConfigManager } = await import('../features/config-manager.js');
|
|
11
|
+
const parsed = ConfigManager.parseConfigCommand(ctx.message);
|
|
12
|
+
|
|
13
|
+
if (!parsed) { await next(); return; }
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
if (parsed.action === 'set_model') {
|
|
17
|
+
ctx.engine.configManager.set('ai.defaultModel', parsed.model);
|
|
18
|
+
// Update agent's model too
|
|
19
|
+
if (ctx.agent) ctx.agent.model = parsed.model;
|
|
20
|
+
await ctx.reply('✅ Model switched to *' + parsed.model + '*\n\nI will use this model from now on 🦑');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (parsed.action === 'set') {
|
|
25
|
+
ctx.engine.configManager.set(parsed.path, parsed.value);
|
|
26
|
+
await ctx.reply('✅ Set *' + parsed.path + '* = ' + parsed.value);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
} catch (err) {
|
|
30
|
+
await ctx.reply('❌ Config error: ' + err.message);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await next();
|
|
35
|
+
}
|
package/lib/tools/router.js
CHANGED
|
@@ -59,6 +59,25 @@ 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
|
+
|
|
62
81
|
tools.push('', '### Screenshot',
|
|
63
82
|
'---TOOL:screenshot:https://example.com---',
|
|
64
83
|
'Take a screenshot of a website. Returns the screenshot as an image.',
|
|
@@ -149,6 +168,60 @@ export class ToolRouter {
|
|
|
149
168
|
}
|
|
150
169
|
break;
|
|
151
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
|
+
}
|
|
152
225
|
case 'screenshot': {
|
|
153
226
|
try {
|
|
154
227
|
const { BrowserControl } = await import('./browser-control.js');
|
|
@@ -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
|
+
}
|