serpentstack 0.2.12 → 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 +2 -0
- package/lib/commands/persistent.js +268 -103
- package/lib/utils/models.js +166 -71
- package/package.json +1 -1
package/bin/serpentstack.js
CHANGED
|
@@ -71,6 +71,7 @@ function showHelp() {
|
|
|
71
71
|
console.log(` ${cyan('persistent')} Status dashboard (first run = full setup)`);
|
|
72
72
|
console.log(` ${cyan('persistent')} ${dim('--configure')} Edit project settings`);
|
|
73
73
|
console.log(` ${cyan('persistent')} ${dim('--agents')} Change agent models, enable/disable`);
|
|
74
|
+
console.log(` ${cyan('persistent')} ${dim('--models')} List installed & recommended models`);
|
|
74
75
|
console.log(` ${cyan('persistent')} ${dim('--start')} Launch enabled agents`);
|
|
75
76
|
console.log(` ${cyan('persistent')} ${dim('--stop')} Stop all running agents`);
|
|
76
77
|
console.log();
|
|
@@ -141,6 +142,7 @@ async function main() {
|
|
|
141
142
|
configure: !!flags.configure,
|
|
142
143
|
agents: !!flags.agents,
|
|
143
144
|
start: !!flags.start,
|
|
145
|
+
models: !!flags.models,
|
|
144
146
|
});
|
|
145
147
|
} else {
|
|
146
148
|
error(`Unknown command: ${bold(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) {
|
|
@@ -70,7 +197,7 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
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('─────────────────────')}`);
|
|
@@ -87,6 +214,12 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
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' });
|
|
@@ -101,9 +234,8 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
101
234
|
|
|
102
235
|
const selected = (idx >= 0 && idx < choices.length) ? choices[idx] : choices[Math.max(0, currentIdx)];
|
|
103
236
|
|
|
104
|
-
// Warn about cloud model costs
|
|
105
237
|
if (selected.tier === 'cloud' && available.local.length > 0) {
|
|
106
|
-
warn(`Cloud models cost tokens per heartbeat
|
|
238
|
+
warn(`Cloud models cost tokens per heartbeat. Consider a local model for persistent agents.`);
|
|
107
239
|
}
|
|
108
240
|
if (selected.tier === 'cloud' && !available.hasApiKey) {
|
|
109
241
|
warn(`No API key detected. Run ${bold('openclaw configure')} to set up authentication.`);
|
|
@@ -156,8 +288,7 @@ end tell`;
|
|
|
156
288
|
try {
|
|
157
289
|
const child = spawn(bin, args, { stdio: 'ignore', detached: true });
|
|
158
290
|
child.unref();
|
|
159
|
-
|
|
160
|
-
if (alive) return bin;
|
|
291
|
+
if (child.pid && !child.killed) return bin;
|
|
161
292
|
} catch { continue; }
|
|
162
293
|
}
|
|
163
294
|
}
|
|
@@ -237,6 +368,8 @@ function printStatusDashboard(config, parsed, projectDir) {
|
|
|
237
368
|
console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
|
|
238
369
|
console.log();
|
|
239
370
|
|
|
371
|
+
divider('Agents');
|
|
372
|
+
console.log();
|
|
240
373
|
for (const { name, agentMd } of parsed) {
|
|
241
374
|
const statusInfo = getAgentStatus(projectDir, name, config);
|
|
242
375
|
printAgentLine(name, agentMd, config, statusInfo);
|
|
@@ -244,7 +377,66 @@ function printStatusDashboard(config, parsed, projectDir) {
|
|
|
244
377
|
console.log();
|
|
245
378
|
}
|
|
246
379
|
|
|
247
|
-
// ───
|
|
380
|
+
// ─── Models Command ─────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
async function runModels(available) {
|
|
383
|
+
divider('Installed Models');
|
|
384
|
+
console.log();
|
|
385
|
+
|
|
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}`);
|
|
392
|
+
}
|
|
393
|
+
} else {
|
|
394
|
+
console.log(` ${dim('No local models installed.')}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (available.cloud.length > 0) {
|
|
398
|
+
console.log();
|
|
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
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
console.log();
|
|
407
|
+
|
|
408
|
+
// Show recommended models to install
|
|
409
|
+
if (available.recommended.length > 0) {
|
|
410
|
+
divider('Recommended Models');
|
|
411
|
+
console.log();
|
|
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`);
|
|
416
|
+
}
|
|
417
|
+
console.log();
|
|
418
|
+
for (const r of available.recommended) {
|
|
419
|
+
console.log(` ${dim('$')} ${bold(`ollama pull ${r.name}`)} ${dim(`${r.params} — ${r.description}`)}`);
|
|
420
|
+
}
|
|
421
|
+
console.log();
|
|
422
|
+
}
|
|
423
|
+
|
|
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')}`);
|
|
432
|
+
console.log();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
console.log(` ${dim('Browse all models:')} ${cyan('https://ollama.com/library')}`);
|
|
436
|
+
console.log();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── Configure Flow ─────────────────────────────────────────
|
|
248
440
|
|
|
249
441
|
async function runConfigure(projectDir, config, soulPath) {
|
|
250
442
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
@@ -280,7 +472,7 @@ async function runConfigure(projectDir, config, soulPath) {
|
|
|
280
472
|
conventions: await ask(rl, 'Key conventions', defaults.conventions),
|
|
281
473
|
};
|
|
282
474
|
|
|
283
|
-
// Update SOUL.md
|
|
475
|
+
// Update SOUL.md
|
|
284
476
|
if (existsSync(soulPath)) {
|
|
285
477
|
let soul = readFileSync(soulPath, 'utf8');
|
|
286
478
|
const ctx = [
|
|
@@ -305,29 +497,15 @@ async function runConfigure(projectDir, config, soulPath) {
|
|
|
305
497
|
rl.close();
|
|
306
498
|
}
|
|
307
499
|
|
|
308
|
-
// Mark as user-confirmed
|
|
309
500
|
config._configured = true;
|
|
310
501
|
writeConfig(projectDir, config);
|
|
311
502
|
success(`Saved ${bold('.openclaw/config.json')}`);
|
|
312
503
|
console.log();
|
|
313
504
|
}
|
|
314
505
|
|
|
315
|
-
// ─── Agents Flow
|
|
316
|
-
|
|
317
|
-
async function runAgents(projectDir, config, parsed) {
|
|
318
|
-
const available = await detectModels();
|
|
319
|
-
|
|
320
|
-
if (available.local.length > 0) {
|
|
321
|
-
info(`${available.local.length} local model(s) detected via Ollama`);
|
|
322
|
-
} else {
|
|
323
|
-
warn('No local models found. Install Ollama and pull a model for free persistent agents:');
|
|
324
|
-
console.log(` ${dim('$')} ${bold('ollama pull llama3.2')}`);
|
|
325
|
-
}
|
|
326
|
-
if (available.hasApiKey) {
|
|
327
|
-
info('API key configured for cloud models');
|
|
328
|
-
}
|
|
329
|
-
console.log();
|
|
506
|
+
// ─── Agents Flow ────────────────────────────────────────────
|
|
330
507
|
|
|
508
|
+
async function runAgents(projectDir, config, parsed, available) {
|
|
331
509
|
divider('Agents');
|
|
332
510
|
console.log(` ${dim('Enable/disable each agent and pick a model.')}`);
|
|
333
511
|
console.log();
|
|
@@ -371,7 +549,15 @@ async function runAgents(projectDir, config, parsed) {
|
|
|
371
549
|
|
|
372
550
|
// ─── Start Flow ─────────────────────────────────────────────
|
|
373
551
|
|
|
374
|
-
async function runStart(projectDir, parsed, config, soulPath) {
|
|
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')}`);
|
|
557
|
+
console.log();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
375
561
|
const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
|
|
376
562
|
const runningNames = new Set(listPids(projectDir).map(p => p.name));
|
|
377
563
|
const startable = enabledAgents.filter(a => !runningNames.has(a.name));
|
|
@@ -430,11 +616,10 @@ async function runStart(projectDir, parsed, config, soulPath) {
|
|
|
430
616
|
const absProject = resolve(projectDir);
|
|
431
617
|
|
|
432
618
|
const openclawCmd = `OPENCLAW_STATE_DIR='${join(absWorkspace, '.state')}' openclaw start --workspace '${absWorkspace}'`;
|
|
433
|
-
|
|
434
619
|
const method = openInTerminal(`SerpentStack: ${name}`, openclawCmd, absProject);
|
|
435
620
|
|
|
436
621
|
if (method) {
|
|
437
|
-
writePid(projectDir, name, -1);
|
|
622
|
+
writePid(projectDir, name, -1);
|
|
438
623
|
success(`${bold(name)} opened in ${method} ${dim(`(${modelShortName(effectiveModel)})`)}`);
|
|
439
624
|
started++;
|
|
440
625
|
} else {
|
|
@@ -459,105 +644,59 @@ async function runStart(projectDir, parsed, config, soulPath) {
|
|
|
459
644
|
if (started > 0) {
|
|
460
645
|
success(`${started} agent(s) launched — fangs out 🐍`);
|
|
461
646
|
console.log();
|
|
462
|
-
printBox('Manage agents', [
|
|
463
|
-
`${dim('$')} ${bold('serpentstack persistent')} ${dim('# status dashboard')}`,
|
|
464
|
-
`${dim('$')} ${bold('serpentstack persistent --start')} ${dim('# launch agents')}`,
|
|
465
|
-
`${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all')}`,
|
|
466
|
-
`${dim('$')} ${bold('serpentstack persistent --configure')} ${dim('# edit project settings')}`,
|
|
467
|
-
`${dim('$')} ${bold('serpentstack persistent --agents')} ${dim('# change models')}`,
|
|
468
|
-
]);
|
|
469
647
|
}
|
|
470
648
|
}
|
|
471
649
|
|
|
472
650
|
// ─── Main Entry Point ───────────────────────────────────────
|
|
473
651
|
|
|
474
|
-
export async function persistent({ stop = false, configure = false, agents = false, start = false } = {}) {
|
|
652
|
+
export async function persistent({ stop = false, configure = false, agents = false, start = false, models = false } = {}) {
|
|
475
653
|
const projectDir = process.cwd();
|
|
476
654
|
|
|
477
655
|
printHeader();
|
|
478
656
|
|
|
479
|
-
// ── Stop ──
|
|
657
|
+
// ── Stop (doesn't need full preflight) ──
|
|
480
658
|
if (stop) {
|
|
659
|
+
cleanStalePids(projectDir);
|
|
481
660
|
stopAllAgents(projectDir);
|
|
482
661
|
return;
|
|
483
662
|
}
|
|
484
663
|
|
|
485
|
-
// ──
|
|
486
|
-
const soulPath =
|
|
487
|
-
if (!existsSync(soulPath)) {
|
|
488
|
-
error('No .openclaw/ workspace found.');
|
|
489
|
-
console.log(` Run ${bold('serpentstack skills')} first to download the workspace files.`);
|
|
490
|
-
console.log();
|
|
491
|
-
process.exit(1);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
const agentDirs = discoverAgents(projectDir);
|
|
495
|
-
if (agentDirs.length === 0) {
|
|
496
|
-
error('No agents found in .openclaw/agents/');
|
|
497
|
-
console.log(` Run ${bold('serpentstack skills')} to download the default agents,`);
|
|
498
|
-
console.log(` or create your own at ${bold('.openclaw/agents/<name>/AGENT.md')}`);
|
|
499
|
-
console.log();
|
|
500
|
-
process.exit(1);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// Check OpenClaw early
|
|
504
|
-
const hasOpenClaw = await which('openclaw');
|
|
505
|
-
if (!hasOpenClaw) {
|
|
506
|
-
warn('OpenClaw is not installed.');
|
|
507
|
-
console.log();
|
|
508
|
-
console.log(` ${dim('OpenClaw is the persistent agent runtime.')}`);
|
|
509
|
-
console.log(` ${dim('Install it first, then re-run this command:')}`);
|
|
510
|
-
console.log();
|
|
511
|
-
console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
|
|
512
|
-
console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
|
|
513
|
-
console.log();
|
|
514
|
-
process.exit(1);
|
|
515
|
-
}
|
|
516
|
-
|
|
664
|
+
// ── Full preflight (checks workspace, agents, runtime) ──
|
|
665
|
+
const { soulPath, parsed, hasOpenClaw, available } = await preflight(projectDir);
|
|
517
666
|
cleanStalePids(projectDir);
|
|
518
667
|
|
|
519
|
-
// Parse agent definitions
|
|
520
|
-
const parsed = [];
|
|
521
|
-
for (const agent of agentDirs) {
|
|
522
|
-
try {
|
|
523
|
-
const agentMd = parseAgentMd(agent.agentMdPath);
|
|
524
|
-
parsed.push({ ...agent, agentMd });
|
|
525
|
-
} catch (err) {
|
|
526
|
-
warn(`Skipping ${bold(agent.name)}: ${err.message}`);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
if (parsed.length === 0) {
|
|
530
|
-
error('No valid AGENT.md files found.');
|
|
531
|
-
console.log();
|
|
532
|
-
process.exit(1);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
668
|
// Load config
|
|
536
669
|
let config = readConfig(projectDir) || { project: {}, agents: {} };
|
|
537
670
|
const isConfigured = !!config._configured;
|
|
538
671
|
|
|
539
|
-
// ──
|
|
672
|
+
// ── --models: list installed and recommended models ──
|
|
673
|
+
if (models) {
|
|
674
|
+
await runModels(available);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ── --configure: edit project settings ──
|
|
540
679
|
if (configure) {
|
|
541
680
|
await runConfigure(projectDir, config, soulPath);
|
|
542
681
|
return;
|
|
543
682
|
}
|
|
544
683
|
|
|
545
|
-
// ──
|
|
684
|
+
// ── --agents: edit agent models and enabled state ──
|
|
546
685
|
if (agents) {
|
|
547
|
-
config = readConfig(projectDir) || config;
|
|
548
|
-
await runAgents(projectDir, config, parsed);
|
|
686
|
+
config = readConfig(projectDir) || config;
|
|
687
|
+
await runAgents(projectDir, config, parsed, available);
|
|
549
688
|
return;
|
|
550
689
|
}
|
|
551
690
|
|
|
552
|
-
// ──
|
|
691
|
+
// ── --start: launch agents ──
|
|
553
692
|
if (start) {
|
|
554
|
-
await runStart(projectDir, parsed, config, soulPath);
|
|
693
|
+
await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
|
|
555
694
|
return;
|
|
556
695
|
}
|
|
557
696
|
|
|
558
|
-
// ──
|
|
697
|
+
// ── Bare `serpentstack persistent` ──
|
|
559
698
|
if (isConfigured) {
|
|
560
|
-
//
|
|
699
|
+
// Show dashboard
|
|
561
700
|
printStatusDashboard(config, parsed, projectDir);
|
|
562
701
|
|
|
563
702
|
const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
|
|
@@ -578,27 +717,53 @@ export async function persistent({ stop = false, configure = false, agents = fal
|
|
|
578
717
|
`${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all')}`,
|
|
579
718
|
`${dim('$')} ${bold('serpentstack persistent --configure')} ${dim('# edit project settings')}`,
|
|
580
719
|
`${dim('$')} ${bold('serpentstack persistent --agents')} ${dim('# change models')}`,
|
|
720
|
+
`${dim('$')} ${bold('serpentstack persistent --models')} ${dim('# list & install models')}`,
|
|
581
721
|
]);
|
|
582
722
|
console.log();
|
|
583
723
|
return;
|
|
584
724
|
}
|
|
585
725
|
|
|
586
|
-
// ── First-time setup:
|
|
587
|
-
|
|
588
|
-
|
|
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
|
+
}
|
|
589
751
|
|
|
590
752
|
// Step 1: Project settings
|
|
591
753
|
await runConfigure(projectDir, config, soulPath);
|
|
592
|
-
|
|
593
|
-
// Re-read config (runConfigure saved it)
|
|
594
754
|
config = readConfig(projectDir) || config;
|
|
595
755
|
|
|
596
756
|
// Step 2: Agent settings
|
|
597
|
-
await runAgents(projectDir, config, parsed);
|
|
598
|
-
|
|
599
|
-
// Re-read config (runAgents saved it)
|
|
757
|
+
await runAgents(projectDir, config, parsed, available);
|
|
600
758
|
config = readConfig(projectDir) || config;
|
|
601
759
|
|
|
602
|
-
// Step 3: Launch
|
|
603
|
-
|
|
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();
|
|
768
|
+
}
|
|
604
769
|
}
|
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
|
}
|