serpentstack 0.2.11 → 0.2.13
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/bin/serpentstack.js +12 -3
- package/lib/commands/persistent.js +363 -156
- package/lib/commands/skills-init.js +3 -4
- package/lib/utils/models.js +166 -71
- package/package.json +1 -1
package/bin/serpentstack.js
CHANGED
|
@@ -68,9 +68,12 @@ function showHelp() {
|
|
|
68
68
|
divider('Any project');
|
|
69
69
|
console.log(` ${cyan('skills')} Download all skills + persistent agent configs`);
|
|
70
70
|
console.log(` ${cyan('skills update')} Update base skills to latest versions`);
|
|
71
|
-
console.log(` ${cyan('persistent')}
|
|
71
|
+
console.log(` ${cyan('persistent')} Status dashboard (first run = full setup)`);
|
|
72
|
+
console.log(` ${cyan('persistent')} ${dim('--configure')} Edit project settings`);
|
|
73
|
+
console.log(` ${cyan('persistent')} ${dim('--agents')} Change agent models, enable/disable`);
|
|
74
|
+
console.log(` ${cyan('persistent')} ${dim('--models')} List installed & recommended models`);
|
|
75
|
+
console.log(` ${cyan('persistent')} ${dim('--start')} Launch enabled agents`);
|
|
72
76
|
console.log(` ${cyan('persistent')} ${dim('--stop')} Stop all running agents`);
|
|
73
|
-
console.log(` ${cyan('persistent')} ${dim('--reconfigure')} Change models, enable/disable agents`);
|
|
74
77
|
console.log();
|
|
75
78
|
|
|
76
79
|
divider('Options');
|
|
@@ -134,7 +137,13 @@ async function main() {
|
|
|
134
137
|
}
|
|
135
138
|
} else if (noun === 'persistent') {
|
|
136
139
|
const { persistent } = await import('../lib/commands/persistent.js');
|
|
137
|
-
await persistent({
|
|
140
|
+
await persistent({
|
|
141
|
+
stop: !!flags.stop,
|
|
142
|
+
configure: !!flags.configure,
|
|
143
|
+
agents: !!flags.agents,
|
|
144
|
+
start: !!flags.start,
|
|
145
|
+
models: !!flags.models,
|
|
146
|
+
});
|
|
138
147
|
} else {
|
|
139
148
|
error(`Unknown command: ${bold(noun)}`);
|
|
140
149
|
const suggestion = suggestCommand(noun);
|
|
@@ -3,7 +3,7 @@ import { join, resolve } from 'node:path';
|
|
|
3
3
|
import { execFile, spawn } from 'node:child_process';
|
|
4
4
|
import { createInterface } from 'node:readline/promises';
|
|
5
5
|
import { stdin, stdout } from 'node:process';
|
|
6
|
-
import { info, success, warn, error, bold, dim, green, cyan, yellow, red, divider, printBox, printHeader } from '../utils/ui.js';
|
|
6
|
+
import { info, success, warn, error, bold, dim, green, cyan, yellow, red, divider, printBox, printHeader, spinner } from '../utils/ui.js';
|
|
7
7
|
import {
|
|
8
8
|
parseAgentMd,
|
|
9
9
|
discoverAgents,
|
|
@@ -47,12 +47,139 @@ async function askYesNo(rl, label, defaultYes = true) {
|
|
|
47
47
|
return val === 'y' || val === 'yes';
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// ─── Preflight ──────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check all prerequisites and return a status object.
|
|
54
|
+
* Exits the process with helpful guidance if anything critical is missing.
|
|
55
|
+
*/
|
|
56
|
+
async function preflight(projectDir) {
|
|
57
|
+
const soulPath = join(projectDir, '.openclaw/SOUL.md');
|
|
58
|
+
|
|
59
|
+
// Check for .openclaw workspace
|
|
60
|
+
if (!existsSync(soulPath)) {
|
|
61
|
+
error('No .openclaw/ workspace found.');
|
|
62
|
+
console.log();
|
|
63
|
+
console.log(` ${dim('Run')} ${bold('serpentstack skills')} ${dim('first to download skills and agent configs.')}`);
|
|
64
|
+
console.log();
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check for agent definitions
|
|
69
|
+
const agentDirs = discoverAgents(projectDir);
|
|
70
|
+
if (agentDirs.length === 0) {
|
|
71
|
+
error('No agents found in .openclaw/agents/');
|
|
72
|
+
console.log();
|
|
73
|
+
console.log(` ${dim('Run')} ${bold('serpentstack skills')} ${dim('to download the default agents,')}`);
|
|
74
|
+
console.log(` ${dim('or create your own at')} ${bold('.openclaw/agents/<name>/AGENT.md')}`);
|
|
75
|
+
console.log();
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Parse agent definitions
|
|
80
|
+
const parsed = [];
|
|
81
|
+
for (const agent of agentDirs) {
|
|
82
|
+
try {
|
|
83
|
+
const agentMd = parseAgentMd(agent.agentMdPath);
|
|
84
|
+
parsed.push({ ...agent, agentMd });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
warn(`Skipping ${bold(agent.name)}: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (parsed.length === 0) {
|
|
90
|
+
error('No valid AGENT.md files found.');
|
|
91
|
+
console.log();
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Detect runtime dependencies in parallel
|
|
96
|
+
const spin = spinner('Checking runtime...');
|
|
97
|
+
const [hasOpenClaw, available] = await Promise.all([
|
|
98
|
+
which('openclaw'),
|
|
99
|
+
detectModels(),
|
|
100
|
+
]);
|
|
101
|
+
spin.stop();
|
|
102
|
+
|
|
103
|
+
return { soulPath, parsed, hasOpenClaw, available };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Print a summary of what's installed and what's missing.
|
|
108
|
+
* Returns true if everything needed to launch is present.
|
|
109
|
+
*/
|
|
110
|
+
function printPreflightStatus(hasOpenClaw, available) {
|
|
111
|
+
divider('Runtime');
|
|
112
|
+
console.log();
|
|
113
|
+
|
|
114
|
+
// OpenClaw
|
|
115
|
+
if (hasOpenClaw) {
|
|
116
|
+
console.log(` ${green('✓')} OpenClaw ${dim('— persistent agent runtime')}`);
|
|
117
|
+
} else {
|
|
118
|
+
console.log(` ${red('✗')} OpenClaw ${dim('— not installed')}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Ollama
|
|
122
|
+
if (available.ollamaRunning) {
|
|
123
|
+
console.log(` ${green('✓')} Ollama ${dim(`— running, ${available.local.length} model(s) installed`)}`);
|
|
124
|
+
} else if (available.ollamaInstalled) {
|
|
125
|
+
console.log(` ${yellow('△')} Ollama ${dim('— installed but not running')}`);
|
|
126
|
+
} else {
|
|
127
|
+
console.log(` ${yellow('○')} Ollama ${dim('— not installed (optional, for free local models)')}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// API key
|
|
131
|
+
if (available.hasApiKey) {
|
|
132
|
+
console.log(` ${green('✓')} API key ${dim('— configured for cloud models')}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log();
|
|
136
|
+
|
|
137
|
+
// Actionable guidance for missing pieces
|
|
138
|
+
const issues = [];
|
|
139
|
+
|
|
140
|
+
if (!hasOpenClaw) {
|
|
141
|
+
issues.push({
|
|
142
|
+
label: 'Install OpenClaw (required to run agents)',
|
|
143
|
+
cmd: 'npm install -g openclaw@latest',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!available.ollamaInstalled) {
|
|
148
|
+
issues.push({
|
|
149
|
+
label: 'Install Ollama for free local models (recommended)',
|
|
150
|
+
cmd: 'curl -fsSL https://ollama.com/install.sh | sh',
|
|
151
|
+
});
|
|
152
|
+
} else if (!available.ollamaRunning) {
|
|
153
|
+
issues.push({
|
|
154
|
+
label: 'Start Ollama',
|
|
155
|
+
cmd: 'ollama serve',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (available.ollamaRunning && available.local.length === 0) {
|
|
160
|
+
issues.push({
|
|
161
|
+
label: 'Pull a model (Ollama is running but has no models)',
|
|
162
|
+
cmd: 'ollama pull llama3.2',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (issues.length > 0) {
|
|
167
|
+
for (const issue of issues) {
|
|
168
|
+
console.log(` ${dim(issue.label + ':')}`);
|
|
169
|
+
console.log(` ${dim('$')} ${bold(issue.cmd)}`);
|
|
170
|
+
console.log();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return hasOpenClaw;
|
|
175
|
+
}
|
|
176
|
+
|
|
50
177
|
// ─── Model Picker ───────────────────────────────────────────
|
|
51
178
|
|
|
52
179
|
async function pickModel(rl, agentName, currentModel, available) {
|
|
53
180
|
const choices = [];
|
|
54
181
|
|
|
55
|
-
// Local models first
|
|
182
|
+
// Local models first
|
|
56
183
|
if (available.local.length > 0) {
|
|
57
184
|
console.log(` ${dim('── Local')} ${green('free')} ${dim('──────────────────────')}`);
|
|
58
185
|
for (const m of available.local) {
|
|
@@ -65,12 +192,12 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
65
192
|
const params = m.params ? dim(` ${m.params}`) : '';
|
|
66
193
|
const quant = m.quant ? dim(` ${m.quant}`) : '';
|
|
67
194
|
const size = m.size ? dim(` (${m.size})`) : '';
|
|
68
|
-
const tag = isCurrent ? green('
|
|
195
|
+
const tag = isCurrent ? green(' ← current') : '';
|
|
69
196
|
console.log(` ${marker} ${num} ${label}${params}${quant}${size}${tag}`);
|
|
70
197
|
}
|
|
71
198
|
}
|
|
72
199
|
|
|
73
|
-
// Cloud models
|
|
200
|
+
// Cloud models
|
|
74
201
|
if (available.cloud.length > 0) {
|
|
75
202
|
const apiNote = available.hasApiKey ? green('key ✓') : yellow('needs API key');
|
|
76
203
|
console.log(` ${dim('── Cloud')} ${apiNote} ${dim('─────────────────────')}`);
|
|
@@ -82,15 +209,20 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
82
209
|
const num = dim(`${idx + 1}.`);
|
|
83
210
|
const label = isCurrent ? bold(m.name) : m.name;
|
|
84
211
|
const provider = m.provider ? dim(` (${m.provider})`) : '';
|
|
85
|
-
const tag = isCurrent ? green('
|
|
212
|
+
const tag = isCurrent ? green(' ← current') : '';
|
|
86
213
|
console.log(` ${marker} ${num} ${label}${provider}${tag}`);
|
|
87
214
|
}
|
|
88
215
|
}
|
|
89
216
|
|
|
217
|
+
if (choices.length === 0) {
|
|
218
|
+
warn('No models available. Install Ollama and pull a model first.');
|
|
219
|
+
console.log(` ${dim('$')} ${bold('ollama pull llama3.2')}`);
|
|
220
|
+
return currentModel;
|
|
221
|
+
}
|
|
222
|
+
|
|
90
223
|
// If current model isn't in either list, add it
|
|
91
224
|
if (!choices.some(c => c.id === currentModel)) {
|
|
92
225
|
choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom' });
|
|
93
|
-
// Re-render isn't needed since we'll just note it
|
|
94
226
|
console.log(` ${dim(`Current: ${modelShortName(currentModel)} (not in detected models)`)}`);
|
|
95
227
|
}
|
|
96
228
|
|
|
@@ -102,9 +234,8 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
102
234
|
|
|
103
235
|
const selected = (idx >= 0 && idx < choices.length) ? choices[idx] : choices[Math.max(0, currentIdx)];
|
|
104
236
|
|
|
105
|
-
// Warn about cloud model costs
|
|
106
237
|
if (selected.tier === 'cloud' && available.local.length > 0) {
|
|
107
|
-
warn(`Cloud models cost tokens per heartbeat
|
|
238
|
+
warn(`Cloud models cost tokens per heartbeat. Consider a local model for persistent agents.`);
|
|
108
239
|
}
|
|
109
240
|
if (selected.tier === 'cloud' && !available.hasApiKey) {
|
|
110
241
|
warn(`No API key detected. Run ${bold('openclaw configure')} to set up authentication.`);
|
|
@@ -157,9 +288,7 @@ end tell`;
|
|
|
157
288
|
try {
|
|
158
289
|
const child = spawn(bin, args, { stdio: 'ignore', detached: true });
|
|
159
290
|
child.unref();
|
|
160
|
-
|
|
161
|
-
const alive = child.pid && !child.killed;
|
|
162
|
-
if (alive) return bin;
|
|
291
|
+
if (child.pid && !child.killed) return bin;
|
|
163
292
|
} catch { continue; }
|
|
164
293
|
}
|
|
165
294
|
}
|
|
@@ -197,7 +326,6 @@ function stopAllAgents(projectDir) {
|
|
|
197
326
|
} catch (err) {
|
|
198
327
|
if (err.code === 'ESRCH') {
|
|
199
328
|
removePid(projectDir, name);
|
|
200
|
-
// Don't count already-dead processes as "stopped"
|
|
201
329
|
} else {
|
|
202
330
|
error(`Failed to stop ${bold(name)}: ${err.message}`);
|
|
203
331
|
}
|
|
@@ -235,124 +363,85 @@ function printAgentLine(name, agentMd, config, statusInfo) {
|
|
|
235
363
|
}
|
|
236
364
|
}
|
|
237
365
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
printHeader();
|
|
244
|
-
|
|
245
|
-
// ── Stop ──
|
|
246
|
-
if (stop) {
|
|
247
|
-
stopAllAgents(projectDir);
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// ── Preflight checks ──
|
|
252
|
-
const soulPath = join(projectDir, '.openclaw/SOUL.md');
|
|
253
|
-
if (!existsSync(soulPath)) {
|
|
254
|
-
error('No .openclaw/ workspace found.');
|
|
255
|
-
console.log(` Run ${bold('serpentstack skills')} first to download the workspace files.`);
|
|
256
|
-
console.log();
|
|
257
|
-
process.exit(1);
|
|
258
|
-
}
|
|
366
|
+
function printStatusDashboard(config, parsed, projectDir) {
|
|
367
|
+
console.log(` ${bold(config.project.name)} ${dim(`— ${config.project.framework}`)}`);
|
|
368
|
+
console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
|
|
369
|
+
console.log();
|
|
259
370
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
console.log();
|
|
266
|
-
process.exit(1);
|
|
371
|
+
divider('Agents');
|
|
372
|
+
console.log();
|
|
373
|
+
for (const { name, agentMd } of parsed) {
|
|
374
|
+
const statusInfo = getAgentStatus(projectDir, name, config);
|
|
375
|
+
printAgentLine(name, agentMd, config, statusInfo);
|
|
267
376
|
}
|
|
377
|
+
console.log();
|
|
378
|
+
}
|
|
268
379
|
|
|
269
|
-
|
|
270
|
-
const hasOpenClaw = await which('openclaw');
|
|
271
|
-
if (!hasOpenClaw) {
|
|
272
|
-
warn('OpenClaw is not installed.');
|
|
273
|
-
console.log();
|
|
274
|
-
console.log(` ${dim('OpenClaw is the persistent agent runtime.')}`);
|
|
275
|
-
console.log(` ${dim('Install it first, then re-run this command:')}`);
|
|
276
|
-
console.log();
|
|
277
|
-
console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
|
|
278
|
-
console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
|
|
279
|
-
console.log();
|
|
280
|
-
process.exit(1);
|
|
281
|
-
}
|
|
380
|
+
// ─── Models Command ─────────────────────────────────────────
|
|
282
381
|
|
|
283
|
-
|
|
382
|
+
async function runModels(available) {
|
|
383
|
+
divider('Installed Models');
|
|
384
|
+
console.log();
|
|
284
385
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
} catch (err) {
|
|
292
|
-
warn(`Skipping ${bold(agent.name)}: ${err.message}`);
|
|
386
|
+
if (available.local.length > 0) {
|
|
387
|
+
for (const m of available.local) {
|
|
388
|
+
const params = m.params ? dim(` ${m.params}`) : '';
|
|
389
|
+
const quant = m.quant ? dim(` ${m.quant}`) : '';
|
|
390
|
+
const size = m.size ? dim(` (${m.size})`) : '';
|
|
391
|
+
console.log(` ${green('●')} ${bold(m.name)}${params}${quant}${size}`);
|
|
293
392
|
}
|
|
393
|
+
} else {
|
|
394
|
+
console.log(` ${dim('No local models installed.')}`);
|
|
294
395
|
}
|
|
295
|
-
|
|
296
|
-
|
|
396
|
+
|
|
397
|
+
if (available.cloud.length > 0) {
|
|
297
398
|
console.log();
|
|
298
|
-
|
|
399
|
+
const apiNote = available.hasApiKey ? green('key ✓') : yellow('needs API key');
|
|
400
|
+
console.log(` ${dim('Cloud models')} ${apiNote}`);
|
|
401
|
+
for (const m of available.cloud) {
|
|
402
|
+
console.log(` ${dim('●')} ${m.name} ${dim(`(${m.provider})`)}`);
|
|
403
|
+
}
|
|
299
404
|
}
|
|
300
405
|
|
|
301
|
-
|
|
302
|
-
let config = readConfig(projectDir) || { project: {}, agents: {} };
|
|
303
|
-
const needsSetup = !config.project?.name || reconfigure;
|
|
304
|
-
|
|
305
|
-
// Detect models in background while we show status
|
|
306
|
-
const modelsPromise = detectModels();
|
|
406
|
+
console.log();
|
|
307
407
|
|
|
308
|
-
//
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
|
|
408
|
+
// Show recommended models to install
|
|
409
|
+
if (available.recommended.length > 0) {
|
|
410
|
+
divider('Recommended Models');
|
|
312
411
|
console.log();
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
412
|
+
if (available.recommendedLive) {
|
|
413
|
+
success(`Fetched latest models from ${cyan('ollama.com/library')}`);
|
|
414
|
+
} else {
|
|
415
|
+
warn(`Could not reach ollama.com — showing cached recommendations`);
|
|
317
416
|
}
|
|
318
417
|
console.log();
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
|
|
322
|
-
const runningNames = new Set(listPids(projectDir).map(p => p.name));
|
|
323
|
-
const startable = enabledAgents.filter(a => !runningNames.has(a.name));
|
|
324
|
-
|
|
325
|
-
if (startable.length === 0 && runningNames.size > 0) {
|
|
326
|
-
info('All enabled agents are running.');
|
|
327
|
-
console.log(` ${dim('Run')} ${bold('serpentstack persistent --stop')} ${dim('to stop them.')}`);
|
|
328
|
-
console.log(` ${dim('Run')} ${bold('serpentstack persistent --reconfigure')} ${dim('to change settings.')}`);
|
|
329
|
-
console.log();
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if (startable.length === 0) {
|
|
334
|
-
info('No agents are enabled.');
|
|
335
|
-
console.log(` ${dim('Run')} ${bold('serpentstack persistent --reconfigure')} ${dim('to enable agents.')}`);
|
|
336
|
-
console.log();
|
|
337
|
-
return;
|
|
418
|
+
for (const r of available.recommended) {
|
|
419
|
+
console.log(` ${dim('$')} ${bold(`ollama pull ${r.name}`)} ${dim(`${r.params} — ${r.description}`)}`);
|
|
338
420
|
}
|
|
339
|
-
|
|
340
|
-
// Start startable agents
|
|
341
|
-
await launchAgents(projectDir, startable, config, soulPath);
|
|
342
|
-
return;
|
|
421
|
+
console.log();
|
|
343
422
|
}
|
|
344
423
|
|
|
345
|
-
//
|
|
346
|
-
if (
|
|
347
|
-
|
|
424
|
+
// Status summary
|
|
425
|
+
if (!available.ollamaInstalled) {
|
|
426
|
+
console.log(` ${dim('Install Ollama for free local models:')}`);
|
|
427
|
+
console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
|
|
428
|
+
console.log();
|
|
429
|
+
} else if (!available.ollamaRunning) {
|
|
430
|
+
console.log(` ${dim('Start Ollama to use local models:')}`);
|
|
431
|
+
console.log(` ${dim('$')} ${bold('ollama serve')}`);
|
|
348
432
|
console.log();
|
|
349
433
|
}
|
|
350
434
|
|
|
435
|
+
console.log(` ${dim('Browse all models:')} ${cyan('https://ollama.com/library')}`);
|
|
436
|
+
console.log();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── Configure Flow ─────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
async function runConfigure(projectDir, config, soulPath) {
|
|
351
442
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
352
|
-
let configDirty = false;
|
|
353
443
|
|
|
354
444
|
try {
|
|
355
|
-
// ── Project configuration ──
|
|
356
445
|
const detected = detectProjectDefaults(projectDir);
|
|
357
446
|
const template = detectTemplateDefaults(projectDir);
|
|
358
447
|
const existing = config.project || {};
|
|
@@ -382,13 +471,12 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
|
|
|
382
471
|
testCmd: await ask(rl, 'Test command', defaults.testCmd),
|
|
383
472
|
conventions: await ask(rl, 'Key conventions', defaults.conventions),
|
|
384
473
|
};
|
|
385
|
-
configDirty = true;
|
|
386
474
|
|
|
387
475
|
// Update SOUL.md
|
|
388
476
|
if (existsSync(soulPath)) {
|
|
389
477
|
let soul = readFileSync(soulPath, 'utf8');
|
|
390
478
|
const ctx = [
|
|
391
|
-
`# ${config.project.name}
|
|
479
|
+
`# ${config.project.name} — Persistent Development Agents`,
|
|
392
480
|
'',
|
|
393
481
|
`**Project:** ${config.project.name}`,
|
|
394
482
|
`**Language:** ${config.project.language}`,
|
|
@@ -405,25 +493,26 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
|
|
|
405
493
|
console.log();
|
|
406
494
|
success(`Updated ${bold('.openclaw/SOUL.md')}`);
|
|
407
495
|
console.log();
|
|
496
|
+
} finally {
|
|
497
|
+
rl.close();
|
|
498
|
+
}
|
|
408
499
|
|
|
409
|
-
|
|
410
|
-
|
|
500
|
+
config._configured = true;
|
|
501
|
+
writeConfig(projectDir, config);
|
|
502
|
+
success(`Saved ${bold('.openclaw/config.json')}`);
|
|
503
|
+
console.log();
|
|
504
|
+
}
|
|
411
505
|
|
|
412
|
-
|
|
413
|
-
info(`${available.local.length} local model(s) detected via Ollama`);
|
|
414
|
-
} else {
|
|
415
|
-
warn('No local models found. Install Ollama and pull a model for free persistent agents:');
|
|
416
|
-
console.log(` ${dim('$')} ${bold('ollama pull llama3.2')}`);
|
|
417
|
-
}
|
|
418
|
-
if (available.hasApiKey) {
|
|
419
|
-
info('API key configured for cloud models');
|
|
420
|
-
}
|
|
421
|
-
console.log();
|
|
506
|
+
// ─── Agents Flow ────────────────────────────────────────────
|
|
422
507
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
508
|
+
async function runAgents(projectDir, config, parsed, available) {
|
|
509
|
+
divider('Agents');
|
|
510
|
+
console.log(` ${dim('Enable/disable each agent and pick a model.')}`);
|
|
511
|
+
console.log();
|
|
512
|
+
|
|
513
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
426
514
|
|
|
515
|
+
try {
|
|
427
516
|
for (const { name, agentMd } of parsed) {
|
|
428
517
|
const existingAgent = config.agents?.[name];
|
|
429
518
|
const currentEnabled = existingAgent?.enabled !== false;
|
|
@@ -443,43 +532,49 @@ export async function persistent({ stop = false, reconfigure = false } = {}) {
|
|
|
443
532
|
|
|
444
533
|
config.agents[name] = { enabled, model };
|
|
445
534
|
|
|
446
|
-
const status = enabled ? green('
|
|
535
|
+
const status = enabled ? green('✓ enabled') : dim('✗ disabled');
|
|
447
536
|
const modelLabel = enabled ? `, ${modelShortName(model)}` : '';
|
|
448
537
|
console.log(` ${status}${modelLabel}`);
|
|
449
538
|
console.log();
|
|
450
539
|
}
|
|
451
|
-
|
|
452
|
-
configDirty = true;
|
|
453
540
|
} finally {
|
|
454
541
|
rl.close();
|
|
455
|
-
// Only save if we completed configuration
|
|
456
|
-
if (configDirty) {
|
|
457
|
-
writeConfig(projectDir, config);
|
|
458
|
-
success(`Saved ${bold('.openclaw/config.json')}`);
|
|
459
|
-
console.log();
|
|
460
|
-
}
|
|
461
542
|
}
|
|
462
543
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
printAgentLine(name, agentMd, config, statusInfo);
|
|
467
|
-
}
|
|
544
|
+
config._configured = true;
|
|
545
|
+
writeConfig(projectDir, config);
|
|
546
|
+
success(`Saved ${bold('.openclaw/config.json')}`);
|
|
468
547
|
console.log();
|
|
548
|
+
}
|
|
469
549
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
550
|
+
// ─── Start Flow ─────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
553
|
+
if (!hasOpenClaw) {
|
|
554
|
+
error('Cannot launch agents — OpenClaw is not installed.');
|
|
555
|
+
console.log();
|
|
556
|
+
console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
|
|
473
557
|
console.log();
|
|
474
558
|
return;
|
|
475
559
|
}
|
|
476
560
|
|
|
477
|
-
|
|
478
|
-
|
|
561
|
+
const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
|
|
562
|
+
const runningNames = new Set(listPids(projectDir).map(p => p.name));
|
|
563
|
+
const startable = enabledAgents.filter(a => !runningNames.has(a.name));
|
|
479
564
|
|
|
480
|
-
|
|
565
|
+
if (startable.length === 0 && runningNames.size > 0) {
|
|
566
|
+
info('All enabled agents are already running.');
|
|
567
|
+
console.log();
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (startable.length === 0) {
|
|
572
|
+
info('No agents are enabled.');
|
|
573
|
+
console.log(` ${dim('Run')} ${bold('serpentstack persistent --agents')} ${dim('to enable agents.')}`);
|
|
574
|
+
console.log();
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
481
577
|
|
|
482
|
-
async function launchAgents(projectDir, agentsToStart, config, soulPath) {
|
|
483
578
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
484
579
|
const toStart = [];
|
|
485
580
|
|
|
@@ -487,7 +582,7 @@ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
|
|
|
487
582
|
divider('Launch');
|
|
488
583
|
console.log();
|
|
489
584
|
|
|
490
|
-
for (const agent of
|
|
585
|
+
for (const agent of startable) {
|
|
491
586
|
const model = getEffectiveModel(agent.name, agent.agentMd.meta, config);
|
|
492
587
|
const yes = await askYesNo(rl, `Start ${bold(agent.name)} ${dim(`(${modelShortName(model)})`)}?`, true);
|
|
493
588
|
if (yes) toStart.push(agent);
|
|
@@ -521,18 +616,14 @@ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
|
|
|
521
616
|
const absProject = resolve(projectDir);
|
|
522
617
|
|
|
523
618
|
const openclawCmd = `OPENCLAW_STATE_DIR='${join(absWorkspace, '.state')}' openclaw start --workspace '${absWorkspace}'`;
|
|
524
|
-
|
|
525
619
|
const method = openInTerminal(`SerpentStack: ${name}`, openclawCmd, absProject);
|
|
526
620
|
|
|
527
621
|
if (method) {
|
|
528
|
-
|
|
529
|
-
// The terminal process will create its own PID — we record ours as a marker
|
|
530
|
-
writePid(projectDir, name, -1); // -1 = terminal-managed
|
|
622
|
+
writePid(projectDir, name, -1);
|
|
531
623
|
success(`${bold(name)} opened in ${method} ${dim(`(${modelShortName(effectiveModel)})`)}`);
|
|
532
624
|
started++;
|
|
533
625
|
} else {
|
|
534
|
-
|
|
535
|
-
warn(`No terminal detected \u2014 starting ${bold(name)} in background`);
|
|
626
|
+
warn(`No terminal detected — starting ${bold(name)} in background`);
|
|
536
627
|
const child = spawn('openclaw', ['start', '--workspace', absWorkspace], {
|
|
537
628
|
stdio: 'ignore',
|
|
538
629
|
detached: true,
|
|
@@ -553,10 +644,126 @@ async function launchAgents(projectDir, agentsToStart, config, soulPath) {
|
|
|
553
644
|
if (started > 0) {
|
|
554
645
|
success(`${started} agent(s) launched — fangs out 🐍`);
|
|
555
646
|
console.log();
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ─── Main Entry Point ───────────────────────────────────────
|
|
651
|
+
|
|
652
|
+
export async function persistent({ stop = false, configure = false, agents = false, start = false, models = false } = {}) {
|
|
653
|
+
const projectDir = process.cwd();
|
|
654
|
+
|
|
655
|
+
printHeader();
|
|
656
|
+
|
|
657
|
+
// ── Stop (doesn't need full preflight) ──
|
|
658
|
+
if (stop) {
|
|
659
|
+
cleanStalePids(projectDir);
|
|
660
|
+
stopAllAgents(projectDir);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── Full preflight (checks workspace, agents, runtime) ──
|
|
665
|
+
const { soulPath, parsed, hasOpenClaw, available } = await preflight(projectDir);
|
|
666
|
+
cleanStalePids(projectDir);
|
|
667
|
+
|
|
668
|
+
// Load config
|
|
669
|
+
let config = readConfig(projectDir) || { project: {}, agents: {} };
|
|
670
|
+
const isConfigured = !!config._configured;
|
|
671
|
+
|
|
672
|
+
// ── --models: list installed and recommended models ──
|
|
673
|
+
if (models) {
|
|
674
|
+
await runModels(available);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ── --configure: edit project settings ──
|
|
679
|
+
if (configure) {
|
|
680
|
+
await runConfigure(projectDir, config, soulPath);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ── --agents: edit agent models and enabled state ──
|
|
685
|
+
if (agents) {
|
|
686
|
+
config = readConfig(projectDir) || config;
|
|
687
|
+
await runAgents(projectDir, config, parsed, available);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ── --start: launch agents ──
|
|
692
|
+
if (start) {
|
|
693
|
+
await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ── Bare `serpentstack persistent` ──
|
|
698
|
+
if (isConfigured) {
|
|
699
|
+
// Show dashboard
|
|
700
|
+
printStatusDashboard(config, parsed, projectDir);
|
|
701
|
+
|
|
702
|
+
const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
|
|
703
|
+
const runningNames = new Set(listPids(projectDir).map(p => p.name));
|
|
704
|
+
const startable = enabledAgents.filter(a => !runningNames.has(a.name));
|
|
705
|
+
|
|
706
|
+
if (startable.length === 0 && runningNames.size > 0) {
|
|
707
|
+
info('All enabled agents are running.');
|
|
708
|
+
} else if (startable.length === 0) {
|
|
709
|
+
info('No agents are enabled.');
|
|
710
|
+
} else {
|
|
711
|
+
info(`${startable.length} agent(s) ready to start.`);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
console.log();
|
|
715
|
+
printBox('Commands', [
|
|
716
|
+
`${dim('$')} ${bold('serpentstack persistent --start')} ${dim('# launch agents')}`,
|
|
717
|
+
`${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all')}`,
|
|
718
|
+
`${dim('$')} ${bold('serpentstack persistent --configure')} ${dim('# edit project settings')}`,
|
|
719
|
+
`${dim('$')} ${bold('serpentstack persistent --agents')} ${dim('# change models')}`,
|
|
720
|
+
`${dim('$')} ${bold('serpentstack persistent --models')} ${dim('# list & install models')}`,
|
|
560
721
|
]);
|
|
722
|
+
console.log();
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ── First-time setup: guided walkthrough ──
|
|
727
|
+
|
|
728
|
+
// Step 0: Show runtime status
|
|
729
|
+
const canLaunch = printPreflightStatus(hasOpenClaw, available);
|
|
730
|
+
|
|
731
|
+
if (!canLaunch) {
|
|
732
|
+
console.log(` ${dim('Install the missing dependencies above, then run:')}`);
|
|
733
|
+
console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
|
|
734
|
+
console.log();
|
|
735
|
+
|
|
736
|
+
// Still let them configure even without OpenClaw
|
|
737
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
738
|
+
let proceed;
|
|
739
|
+
try {
|
|
740
|
+
proceed = await askYesNo(rl, `Continue with project configuration anyway?`, true);
|
|
741
|
+
} finally {
|
|
742
|
+
rl.close();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (!proceed) {
|
|
746
|
+
console.log();
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
console.log();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Step 1: Project settings
|
|
753
|
+
await runConfigure(projectDir, config, soulPath);
|
|
754
|
+
config = readConfig(projectDir) || config;
|
|
755
|
+
|
|
756
|
+
// Step 2: Agent settings
|
|
757
|
+
await runAgents(projectDir, config, parsed, available);
|
|
758
|
+
config = readConfig(projectDir) || config;
|
|
759
|
+
|
|
760
|
+
// Step 3: Launch (only if OpenClaw is installed)
|
|
761
|
+
if (canLaunch) {
|
|
762
|
+
await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
|
|
763
|
+
} else {
|
|
764
|
+
console.log();
|
|
765
|
+
info('Skipping launch — install OpenClaw first, then run:');
|
|
766
|
+
console.log(` ${dim('$')} ${bold('serpentstack persistent --start')}`);
|
|
767
|
+
console.log();
|
|
561
768
|
}
|
|
562
769
|
}
|
|
@@ -139,11 +139,10 @@ export async function skillsInit({ force = false } = {}) {
|
|
|
139
139
|
]);
|
|
140
140
|
|
|
141
141
|
printBox('Want persistent background agents too?', [
|
|
142
|
-
`${dim('$')} ${bold('serpentstack persistent')}`,
|
|
142
|
+
`${dim('$')} ${bold('serpentstack persistent')} ${dim('# first-time setup walkthrough')}`,
|
|
143
143
|
'',
|
|
144
|
-
`${dim('
|
|
145
|
-
`${dim('
|
|
146
|
-
`${dim('run and choose local or cloud models.')}`,
|
|
144
|
+
`${dim('Configures your project, picks models, and launches')}`,
|
|
145
|
+
`${dim('agents — each in its own terminal window.')}`,
|
|
147
146
|
]);
|
|
148
147
|
console.log();
|
|
149
148
|
}
|
package/lib/utils/models.js
CHANGED
|
@@ -1,43 +1,149 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
|
|
3
|
+
// ─── Fallback Recommendations ───────────────────────────────
|
|
4
|
+
// Used only when the Ollama library API is unreachable.
|
|
5
|
+
// When online, we fetch fresh models from ollama.com/api/tags.
|
|
6
|
+
|
|
7
|
+
const FALLBACK_RECOMMENDATIONS = [
|
|
8
|
+
{ name: 'gemma3:4b', params: '4B', size: '8.0 GB', description: 'Small and fast — great for log watching' },
|
|
9
|
+
{ name: 'ministral-3:3b', params: '3B', size: '4.3 GB', description: 'Lightweight, good for simple tasks' },
|
|
10
|
+
{ name: 'ministral-3:8b', params: '8B', size: '9.7 GB', description: 'Balanced — good default for most agents' },
|
|
11
|
+
{ name: 'devstral-small-2:24b', params: '24B', size: '14.6 GB', description: 'Code-focused, strong for test runner agents' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
// Max model size to recommend (16 GB — fits on most dev machines)
|
|
15
|
+
const MAX_RECOMMEND_SIZE = 16 * 1024 ** 3;
|
|
16
|
+
|
|
3
17
|
/**
|
|
4
18
|
* Detect all available models: local (Ollama) and cloud (via OpenClaw auth).
|
|
5
|
-
*
|
|
6
|
-
* Cloud models require API keys and cost money per token.
|
|
19
|
+
* Also fetches recommended models from the Ollama library.
|
|
7
20
|
*
|
|
8
|
-
* Returns {
|
|
21
|
+
* Returns {
|
|
22
|
+
* local: [...],
|
|
23
|
+
* cloud: [...],
|
|
24
|
+
* hasApiKey: bool,
|
|
25
|
+
* ollamaRunning: bool,
|
|
26
|
+
* ollamaInstalled: bool,
|
|
27
|
+
* openclawInstalled: bool,
|
|
28
|
+
* recommended: [...], // models user doesn't have yet
|
|
29
|
+
* }
|
|
9
30
|
*/
|
|
10
31
|
export async function detectModels() {
|
|
11
|
-
const [
|
|
12
|
-
|
|
32
|
+
const [ollamaStatus, openclawInfo, libraryResult] = await Promise.all([
|
|
33
|
+
detectOllamaStatus(),
|
|
13
34
|
detectOpenClawAuth(),
|
|
35
|
+
fetchOllamaLibrary(),
|
|
14
36
|
]);
|
|
15
37
|
|
|
38
|
+
// Filter out models the user already has installed
|
|
39
|
+
const installedNames = new Set(ollamaStatus.models.map(m => m.name.split(':')[0]));
|
|
40
|
+
const recommended = libraryResult.models.filter(r => {
|
|
41
|
+
const baseName = r.name.split(':')[0];
|
|
42
|
+
return !installedNames.has(baseName);
|
|
43
|
+
});
|
|
44
|
+
|
|
16
45
|
return {
|
|
17
|
-
local:
|
|
46
|
+
local: ollamaStatus.models,
|
|
18
47
|
cloud: openclawInfo.models,
|
|
19
48
|
hasApiKey: openclawInfo.hasApiKey,
|
|
49
|
+
ollamaRunning: ollamaStatus.running,
|
|
50
|
+
ollamaInstalled: ollamaStatus.installed,
|
|
51
|
+
openclawInstalled: openclawInfo.installed,
|
|
52
|
+
recommended,
|
|
53
|
+
recommendedLive: libraryResult.live,
|
|
20
54
|
};
|
|
21
55
|
}
|
|
22
56
|
|
|
57
|
+
// ─── Ollama Library (live from ollama.com) ───────────────────
|
|
58
|
+
|
|
23
59
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
60
|
+
* Fetch available models from the Ollama library API.
|
|
61
|
+
* Filters to models suitable for persistent agents (< MAX_RECOMMEND_SIZE).
|
|
62
|
+
* Falls back to a hardcoded list if the API is unreachable.
|
|
27
63
|
*/
|
|
28
|
-
async function
|
|
64
|
+
async function fetchOllamaLibrary() {
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetchWithTimeout('https://ollama.com/api/tags', 5000);
|
|
67
|
+
if (!response.ok) return { models: FALLBACK_RECOMMENDATIONS, live: false };
|
|
68
|
+
|
|
69
|
+
const data = await response.json();
|
|
70
|
+
if (!data.models || !Array.isArray(data.models)) return { models: FALLBACK_RECOMMENDATIONS, live: false };
|
|
71
|
+
|
|
72
|
+
// Filter to models that fit on a dev machine
|
|
73
|
+
const suitable = data.models
|
|
74
|
+
.filter(m => m.size > 0 && m.size <= MAX_RECOMMEND_SIZE)
|
|
75
|
+
.sort((a, b) => new Date(b.modified_at) - new Date(a.modified_at))
|
|
76
|
+
.map(m => {
|
|
77
|
+
const name = m.name || '';
|
|
78
|
+
const params = extractParams(name, m.size);
|
|
79
|
+
return {
|
|
80
|
+
name,
|
|
81
|
+
params,
|
|
82
|
+
size: formatBytes(m.size),
|
|
83
|
+
description: describeModel(name),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return suitable.length > 0
|
|
88
|
+
? { models: suitable, live: true }
|
|
89
|
+
: { models: FALLBACK_RECOMMENDATIONS, live: false };
|
|
90
|
+
} catch {
|
|
91
|
+
return { models: FALLBACK_RECOMMENDATIONS, live: false };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generate a short description for a model based on its name.
|
|
97
|
+
*/
|
|
98
|
+
function describeModel(name) {
|
|
99
|
+
const n = name.toLowerCase();
|
|
100
|
+
if (n.includes('devstral') || n.includes('codellama') || n.includes('deepseek-coder') || n.includes('coder'))
|
|
101
|
+
return 'Code-focused';
|
|
102
|
+
if (n.includes('gemma')) return 'Google, general purpose';
|
|
103
|
+
if (n.includes('llama')) return 'Meta, general purpose';
|
|
104
|
+
if (n.includes('mistral') || n.includes('ministral')) return 'Mistral AI';
|
|
105
|
+
if (n.includes('qwen')) return 'Alibaba, multilingual';
|
|
106
|
+
if (n.includes('nemotron')) return 'NVIDIA';
|
|
107
|
+
if (n.includes('gpt-oss')) return 'Open-source GPT variant';
|
|
108
|
+
return 'General purpose';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Extract parameter count from model name tag or estimate from size.
|
|
113
|
+
*/
|
|
114
|
+
function extractParams(name, size) {
|
|
115
|
+
// Check for explicit param count in the name (e.g., "gemma3:4b", "ministral-3:8b")
|
|
116
|
+
const tagMatch = name.match(/:(\d+\.?\d*)([bBmM])/);
|
|
117
|
+
if (tagMatch) return `${tagMatch[1]}${tagMatch[2].toUpperCase()}`;
|
|
118
|
+
|
|
119
|
+
// Estimate from size
|
|
120
|
+
return guessParamsFromSize(size);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── Ollama Local Status ────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
async function detectOllamaStatus() {
|
|
126
|
+
const result = { installed: false, running: false, models: [] };
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await execAsync('which', ['ollama']);
|
|
130
|
+
result.installed = true;
|
|
131
|
+
} catch {
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
29
135
|
try {
|
|
30
136
|
const response = await fetchWithTimeout('http://localhost:11434/api/tags', 3000);
|
|
31
|
-
if (!response.ok) return
|
|
137
|
+
if (!response.ok) return result;
|
|
32
138
|
|
|
139
|
+
result.running = true;
|
|
33
140
|
const data = await response.json();
|
|
34
|
-
if (!data.models || !Array.isArray(data.models)) return
|
|
141
|
+
if (!data.models || !Array.isArray(data.models)) return result;
|
|
35
142
|
|
|
36
|
-
|
|
143
|
+
result.models = data.models.map(m => {
|
|
37
144
|
const name = (m.name || '').replace(':latest', '');
|
|
38
145
|
if (!name) return null;
|
|
39
146
|
|
|
40
|
-
// Use the real parameter count from the API when available
|
|
41
147
|
const details = m.details || {};
|
|
42
148
|
const params = formatParamCount(details.parameter_size) || guessParamsFromSize(m.size);
|
|
43
149
|
const quant = details.quantization_level || '';
|
|
@@ -53,71 +159,34 @@ async function detectOllamaModels() {
|
|
|
53
159
|
};
|
|
54
160
|
}).filter(Boolean);
|
|
55
161
|
} catch {
|
|
56
|
-
// Ollama
|
|
57
|
-
return [];
|
|
162
|
+
// Ollama installed but not running
|
|
58
163
|
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Format parameter count from Ollama API (e.g., "7B", "3.2B", "70B").
|
|
63
|
-
* The API returns strings like "7B", "3.21B", etc. in details.parameter_size.
|
|
64
|
-
*/
|
|
65
|
-
function formatParamCount(paramSize) {
|
|
66
|
-
if (!paramSize) return '';
|
|
67
|
-
// Already formatted like "7B" or "3.2B"
|
|
68
|
-
const s = String(paramSize).trim();
|
|
69
|
-
if (/^\d+\.?\d*[BbMm]$/i.test(s)) return s.toUpperCase();
|
|
70
|
-
return s;
|
|
71
|
-
}
|
|
72
164
|
|
|
73
|
-
|
|
74
|
-
* Fallback: estimate parameter count from file size.
|
|
75
|
-
* Rough heuristic: ~0.5GB per billion parameters for Q4 quantization.
|
|
76
|
-
*/
|
|
77
|
-
function guessParamsFromSize(bytes) {
|
|
78
|
-
if (!bytes || bytes <= 0) return '';
|
|
79
|
-
const gb = bytes / (1024 ** 3);
|
|
80
|
-
const billions = Math.round(gb * 2); // Q4 ≈ 0.5GB/B
|
|
81
|
-
if (billions > 0) return `~${billions}B`;
|
|
82
|
-
return '';
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Format bytes into human-readable size (e.g., "4.7 GB").
|
|
87
|
-
*/
|
|
88
|
-
function formatBytes(bytes) {
|
|
89
|
-
if (!bytes || bytes <= 0) return '';
|
|
90
|
-
const gb = bytes / (1024 ** 3);
|
|
91
|
-
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
|
92
|
-
const mb = bytes / (1024 ** 2);
|
|
93
|
-
return `${Math.round(mb)} MB`;
|
|
165
|
+
return result;
|
|
94
166
|
}
|
|
95
167
|
|
|
96
|
-
|
|
97
|
-
* Fetch with timeout using AbortController (Node 18+).
|
|
98
|
-
*/
|
|
99
|
-
function fetchWithTimeout(url, timeoutMs) {
|
|
100
|
-
const controller = new AbortController();
|
|
101
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
102
|
-
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
|
|
103
|
-
}
|
|
168
|
+
// ─── OpenClaw Auth & Cloud Models ───────────────────────────
|
|
104
169
|
|
|
105
|
-
/**
|
|
106
|
-
* Check OpenClaw for configured models and API key status.
|
|
107
|
-
*/
|
|
108
170
|
async function detectOpenClawAuth() {
|
|
109
|
-
const result = { models: [], hasApiKey: false };
|
|
171
|
+
const result = { models: [], hasApiKey: false, installed: false };
|
|
110
172
|
|
|
111
173
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
174
|
+
await execAsync('which', ['openclaw']);
|
|
175
|
+
result.installed = true;
|
|
176
|
+
} catch {
|
|
177
|
+
result.models = [
|
|
178
|
+
{ id: 'anthropic/claude-haiku-4-20250414', name: 'Haiku', provider: 'anthropic', tier: 'cloud' },
|
|
179
|
+
{ id: 'anthropic/claude-sonnet-4-20250514', name: 'Sonnet', provider: 'anthropic', tier: 'cloud' },
|
|
180
|
+
];
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
114
183
|
|
|
115
|
-
|
|
184
|
+
try {
|
|
185
|
+
const status = await execAsync('openclaw', ['models', 'status']);
|
|
116
186
|
if (status.includes('api_key') || status.includes('configured')) {
|
|
117
187
|
result.hasApiKey = true;
|
|
118
188
|
}
|
|
119
189
|
|
|
120
|
-
// Get the model catalog for cloud options
|
|
121
190
|
const list = await execAsync('openclaw', ['models', 'list', '--json']);
|
|
122
191
|
try {
|
|
123
192
|
const models = JSON.parse(list);
|
|
@@ -132,7 +201,6 @@ async function detectOpenClawAuth() {
|
|
|
132
201
|
}));
|
|
133
202
|
}
|
|
134
203
|
} catch {
|
|
135
|
-
// Fall back to text parsing
|
|
136
204
|
const text = await execAsync('openclaw', ['models', 'list']);
|
|
137
205
|
const lines = text.trim().split('\n').filter(l => l.trim() && !l.startsWith('Model'));
|
|
138
206
|
result.models = lines.map(l => {
|
|
@@ -142,7 +210,6 @@ async function detectOpenClawAuth() {
|
|
|
142
210
|
}).filter(Boolean);
|
|
143
211
|
}
|
|
144
212
|
} catch {
|
|
145
|
-
// OpenClaw not installed or no models configured — use defaults
|
|
146
213
|
result.models = [
|
|
147
214
|
{ id: 'anthropic/claude-haiku-4-20250414', name: 'Haiku', provider: 'anthropic', tier: 'cloud' },
|
|
148
215
|
{ id: 'anthropic/claude-sonnet-4-20250514', name: 'Sonnet', provider: 'anthropic', tier: 'cloud' },
|
|
@@ -152,21 +219,49 @@ async function detectOpenClawAuth() {
|
|
|
152
219
|
return result;
|
|
153
220
|
}
|
|
154
221
|
|
|
222
|
+
// ─── Formatting Helpers ─────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
function formatParamCount(paramSize) {
|
|
225
|
+
if (!paramSize) return '';
|
|
226
|
+
const s = String(paramSize).trim();
|
|
227
|
+
if (/^\d+\.?\d*[BbMm]$/i.test(s)) return s.toUpperCase();
|
|
228
|
+
return s;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function guessParamsFromSize(bytes) {
|
|
232
|
+
if (!bytes || bytes <= 0) return '';
|
|
233
|
+
const gb = bytes / (1024 ** 3);
|
|
234
|
+
const billions = Math.round(gb * 2);
|
|
235
|
+
if (billions > 0) return `~${billions}B`;
|
|
236
|
+
return '';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function formatBytes(bytes) {
|
|
240
|
+
if (!bytes || bytes <= 0) return '';
|
|
241
|
+
const gb = bytes / (1024 ** 3);
|
|
242
|
+
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
|
243
|
+
const mb = bytes / (1024 ** 2);
|
|
244
|
+
return `${Math.round(mb)} MB`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function fetchWithTimeout(url, timeoutMs) {
|
|
248
|
+
const controller = new AbortController();
|
|
249
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
250
|
+
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
|
|
251
|
+
}
|
|
252
|
+
|
|
155
253
|
/**
|
|
156
254
|
* Short display name for a model ID.
|
|
157
255
|
*/
|
|
158
256
|
export function modelShortName(model) {
|
|
159
257
|
if (!model) return 'unknown';
|
|
160
|
-
// Anthropic models
|
|
161
258
|
if (model.startsWith('anthropic/')) {
|
|
162
259
|
if (model.includes('haiku')) return 'Haiku';
|
|
163
260
|
if (model.includes('sonnet')) return 'Sonnet';
|
|
164
261
|
if (model.includes('opus')) return 'Opus';
|
|
165
262
|
return model.slice('anthropic/'.length);
|
|
166
263
|
}
|
|
167
|
-
// Ollama models
|
|
168
264
|
if (model.startsWith('ollama/')) return model.slice('ollama/'.length);
|
|
169
|
-
// Other: strip provider prefix
|
|
170
265
|
if (model.includes('/')) return model.split('/').pop();
|
|
171
266
|
return model;
|
|
172
267
|
}
|