squidclaw 1.4.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 +9 -1
- package/lib/features/config-manager.js +238 -0
- package/lib/middleware/commands.js +67 -1
- package/lib/middleware/config-chat.js +35 -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,7 +138,13 @@ export class SquidclawEngine {
|
|
|
136
138
|
// 5. Features (reminders, auto-memory, usage alerts)
|
|
137
139
|
await this._initFeatures();
|
|
138
140
|
|
|
139
|
-
// 5b.
|
|
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
|
|
140
148
|
try {
|
|
141
149
|
const { SubAgentManager } = await import('./features/sub-agents.js');
|
|
142
150
|
this.subAgents = new SubAgentManager(this);
|
|
@@ -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
|
+
}
|
|
@@ -16,7 +16,8 @@ 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',
|
|
20
21
|
'/files ā list sandbox files',
|
|
21
22
|
'/subagents ā list background tasks',
|
|
22
23
|
'/help ā this message',
|
|
@@ -120,6 +121,71 @@ export async function commandsMiddleware(ctx, next) {
|
|
|
120
121
|
return;
|
|
121
122
|
}
|
|
122
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
|
+
|
|
123
189
|
if (cmd === '/exec') {
|
|
124
190
|
const command = msg.slice(6).trim();
|
|
125
191
|
if (!command) { await ctx.reply('Usage: /exec <command>'); return; }
|
|
@@ -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
|
+
}
|