serpentstack 0.2.12 → 0.2.14
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 +369 -108
- package/lib/utils/models.js +186 -84
- 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,18 +47,192 @@ 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
|
+
|
|
177
|
+
// ─── Model Install ──────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Run `ollama pull <model>` with live progress output.
|
|
181
|
+
* Returns true if the pull succeeded.
|
|
182
|
+
*/
|
|
183
|
+
function ollamaPull(modelName) {
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
console.log();
|
|
186
|
+
info(`Downloading ${bold(modelName)}... ${dim('(this may take a few minutes)')}`);
|
|
187
|
+
console.log();
|
|
188
|
+
|
|
189
|
+
const child = spawn('ollama', ['pull', modelName], {
|
|
190
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Stream progress to the terminal
|
|
194
|
+
child.stdout.on('data', (data) => {
|
|
195
|
+
const line = data.toString().trim();
|
|
196
|
+
if (line) process.stderr.write(` ${line}\r`);
|
|
197
|
+
});
|
|
198
|
+
child.stderr.on('data', (data) => {
|
|
199
|
+
const line = data.toString().trim();
|
|
200
|
+
if (line) process.stderr.write(` ${line}\r`);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
child.on('close', (code) => {
|
|
204
|
+
process.stderr.write('\x1b[K'); // clear the progress line
|
|
205
|
+
if (code === 0) {
|
|
206
|
+
success(`${bold(modelName)} installed`);
|
|
207
|
+
console.log();
|
|
208
|
+
resolve(true);
|
|
209
|
+
} else {
|
|
210
|
+
error(`Failed to download ${bold(modelName)} (exit code ${code})`);
|
|
211
|
+
console.log();
|
|
212
|
+
resolve(false);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
child.on('error', (err) => {
|
|
217
|
+
error(`Could not run ollama pull: ${err.message}`);
|
|
218
|
+
console.log();
|
|
219
|
+
resolve(false);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
50
224
|
// ─── Model Picker ───────────────────────────────────────────
|
|
51
225
|
|
|
52
226
|
async function pickModel(rl, agentName, currentModel, available) {
|
|
53
227
|
const choices = [];
|
|
54
228
|
|
|
55
|
-
//
|
|
229
|
+
// Section 1: Installed local models
|
|
56
230
|
if (available.local.length > 0) {
|
|
57
|
-
console.log(` ${dim('──
|
|
231
|
+
console.log(` ${dim('── Installed')} ${green('ready')} ${dim('────────────────────')}`);
|
|
58
232
|
for (const m of available.local) {
|
|
59
233
|
const isCurrent = m.id === currentModel;
|
|
60
234
|
const idx = choices.length;
|
|
61
|
-
choices.push(m);
|
|
235
|
+
choices.push({ ...m, action: 'use' });
|
|
62
236
|
const marker = isCurrent ? green('>') : ' ';
|
|
63
237
|
const num = dim(`${idx + 1}.`);
|
|
64
238
|
const label = isCurrent ? bold(m.name) : m.name;
|
|
@@ -70,14 +244,48 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
70
244
|
}
|
|
71
245
|
}
|
|
72
246
|
|
|
73
|
-
//
|
|
247
|
+
// Section 2: Recommended models (not installed, auto-download on select)
|
|
248
|
+
if (available.ollamaInstalled && available.recommended.length > 0) {
|
|
249
|
+
const liveTag = available.recommendedLive
|
|
250
|
+
? dim(`fetched from ollama.com`)
|
|
251
|
+
: dim(`cached list`);
|
|
252
|
+
console.log(` ${dim('── Download')} ${cyan('free')} ${dim('(')}${liveTag}${dim(') ──')}`);
|
|
253
|
+
// Show a reasonable subset (not 50 models)
|
|
254
|
+
const toShow = available.recommended.slice(0, 8);
|
|
255
|
+
for (const r of toShow) {
|
|
256
|
+
const idx = choices.length;
|
|
257
|
+
const isCurrent = `ollama/${r.name}` === currentModel;
|
|
258
|
+
choices.push({
|
|
259
|
+
id: `ollama/${r.name}`,
|
|
260
|
+
name: r.name,
|
|
261
|
+
params: r.params,
|
|
262
|
+
size: r.size,
|
|
263
|
+
description: r.description,
|
|
264
|
+
tier: 'downloadable',
|
|
265
|
+
action: 'download',
|
|
266
|
+
});
|
|
267
|
+
const marker = isCurrent ? green('>') : ' ';
|
|
268
|
+
const num = dim(`${idx + 1}.`);
|
|
269
|
+
const label = isCurrent ? bold(r.name) : r.name;
|
|
270
|
+
const params = r.params ? dim(` ${r.params}`) : '';
|
|
271
|
+
const size = r.size ? dim(` (${r.size})`) : '';
|
|
272
|
+
const desc = r.description ? dim(` — ${r.description}`) : '';
|
|
273
|
+
const tag = isCurrent ? green(' ← current') : '';
|
|
274
|
+
console.log(` ${marker} ${num} ${label}${params}${size}${desc}${tag}`);
|
|
275
|
+
}
|
|
276
|
+
if (available.recommended.length > toShow.length) {
|
|
277
|
+
console.log(` ${dim(`... and ${available.recommended.length - toShow.length} more at`)} ${cyan('ollama.com/library')}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Section 3: Cloud models
|
|
74
282
|
if (available.cloud.length > 0) {
|
|
75
283
|
const apiNote = available.hasApiKey ? green('key ✓') : yellow('needs API key');
|
|
76
284
|
console.log(` ${dim('── Cloud')} ${apiNote} ${dim('─────────────────────')}`);
|
|
77
285
|
for (const m of available.cloud) {
|
|
78
286
|
const isCurrent = m.id === currentModel;
|
|
79
287
|
const idx = choices.length;
|
|
80
|
-
choices.push(m);
|
|
288
|
+
choices.push({ ...m, action: 'use' });
|
|
81
289
|
const marker = isCurrent ? green('>') : ' ';
|
|
82
290
|
const num = dim(`${idx + 1}.`);
|
|
83
291
|
const label = isCurrent ? bold(m.name) : m.name;
|
|
@@ -87,9 +295,16 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
87
295
|
}
|
|
88
296
|
}
|
|
89
297
|
|
|
90
|
-
|
|
298
|
+
if (choices.length === 0) {
|
|
299
|
+
warn('No models available. Install Ollama and pull a model first.');
|
|
300
|
+
console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
|
|
301
|
+
console.log(` ${dim('$')} ${bold('ollama pull llama3.2')}`);
|
|
302
|
+
return currentModel;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// If current model isn't in any list, add it at the top
|
|
91
306
|
if (!choices.some(c => c.id === currentModel)) {
|
|
92
|
-
choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom' });
|
|
307
|
+
choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom', action: 'use' });
|
|
93
308
|
console.log(` ${dim(`Current: ${modelShortName(currentModel)} (not in detected models)`)}`);
|
|
94
309
|
}
|
|
95
310
|
|
|
@@ -101,9 +316,22 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
101
316
|
|
|
102
317
|
const selected = (idx >= 0 && idx < choices.length) ? choices[idx] : choices[Math.max(0, currentIdx)];
|
|
103
318
|
|
|
319
|
+
// If they selected a downloadable model, pull it now
|
|
320
|
+
if (selected.action === 'download') {
|
|
321
|
+
// Close rl temporarily so ollama pull can use the terminal
|
|
322
|
+
rl.pause();
|
|
323
|
+
const pulled = await ollamaPull(selected.name);
|
|
324
|
+
rl.resume();
|
|
325
|
+
|
|
326
|
+
if (!pulled) {
|
|
327
|
+
warn(`Download failed. Keeping previous model: ${bold(modelShortName(currentModel))}`);
|
|
328
|
+
return currentModel;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
104
332
|
// Warn about cloud model costs
|
|
105
|
-
if (selected.tier === 'cloud' && available.local.length > 0) {
|
|
106
|
-
warn(`Cloud models cost tokens per heartbeat
|
|
333
|
+
if (selected.tier === 'cloud' && (available.local.length > 0 || available.recommended.length > 0)) {
|
|
334
|
+
warn(`Cloud models cost tokens per heartbeat. Consider a local model for persistent agents.`);
|
|
107
335
|
}
|
|
108
336
|
if (selected.tier === 'cloud' && !available.hasApiKey) {
|
|
109
337
|
warn(`No API key detected. Run ${bold('openclaw configure')} to set up authentication.`);
|
|
@@ -156,8 +384,7 @@ end tell`;
|
|
|
156
384
|
try {
|
|
157
385
|
const child = spawn(bin, args, { stdio: 'ignore', detached: true });
|
|
158
386
|
child.unref();
|
|
159
|
-
|
|
160
|
-
if (alive) return bin;
|
|
387
|
+
if (child.pid && !child.killed) return bin;
|
|
161
388
|
} catch { continue; }
|
|
162
389
|
}
|
|
163
390
|
}
|
|
@@ -237,6 +464,8 @@ function printStatusDashboard(config, parsed, projectDir) {
|
|
|
237
464
|
console.log(` ${dim(`Dev: ${config.project.devCmd} · Test: ${config.project.testCmd}`)}`);
|
|
238
465
|
console.log();
|
|
239
466
|
|
|
467
|
+
divider('Agents');
|
|
468
|
+
console.log();
|
|
240
469
|
for (const { name, agentMd } of parsed) {
|
|
241
470
|
const statusInfo = getAgentStatus(projectDir, name, config);
|
|
242
471
|
printAgentLine(name, agentMd, config, statusInfo);
|
|
@@ -244,7 +473,66 @@ function printStatusDashboard(config, parsed, projectDir) {
|
|
|
244
473
|
console.log();
|
|
245
474
|
}
|
|
246
475
|
|
|
247
|
-
// ───
|
|
476
|
+
// ─── Models Command ─────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
async function runModels(available) {
|
|
479
|
+
divider('Installed Models');
|
|
480
|
+
console.log();
|
|
481
|
+
|
|
482
|
+
if (available.local.length > 0) {
|
|
483
|
+
for (const m of available.local) {
|
|
484
|
+
const params = m.params ? dim(` ${m.params}`) : '';
|
|
485
|
+
const quant = m.quant ? dim(` ${m.quant}`) : '';
|
|
486
|
+
const size = m.size ? dim(` (${m.size})`) : '';
|
|
487
|
+
console.log(` ${green('●')} ${bold(m.name)}${params}${quant}${size}`);
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
console.log(` ${dim('No local models installed.')}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (available.cloud.length > 0) {
|
|
494
|
+
console.log();
|
|
495
|
+
const apiNote = available.hasApiKey ? green('key ✓') : yellow('needs API key');
|
|
496
|
+
console.log(` ${dim('Cloud models')} ${apiNote}`);
|
|
497
|
+
for (const m of available.cloud) {
|
|
498
|
+
console.log(` ${dim('●')} ${m.name} ${dim(`(${m.provider})`)}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
console.log();
|
|
503
|
+
|
|
504
|
+
// Show recommended models to install
|
|
505
|
+
if (available.recommended.length > 0) {
|
|
506
|
+
divider('Recommended Models');
|
|
507
|
+
console.log();
|
|
508
|
+
if (available.recommendedLive) {
|
|
509
|
+
success(`Fetched latest models from ${cyan('ollama.com/library')}`);
|
|
510
|
+
} else {
|
|
511
|
+
warn(`Could not reach ollama.com — showing cached recommendations`);
|
|
512
|
+
}
|
|
513
|
+
console.log();
|
|
514
|
+
for (const r of available.recommended) {
|
|
515
|
+
console.log(` ${dim('$')} ${bold(`ollama pull ${r.name}`)} ${dim(`${r.params} — ${r.description}`)}`);
|
|
516
|
+
}
|
|
517
|
+
console.log();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Status summary
|
|
521
|
+
if (!available.ollamaInstalled) {
|
|
522
|
+
console.log(` ${dim('Install Ollama for free local models:')}`);
|
|
523
|
+
console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
|
|
524
|
+
console.log();
|
|
525
|
+
} else if (!available.ollamaRunning) {
|
|
526
|
+
console.log(` ${dim('Start Ollama to use local models:')}`);
|
|
527
|
+
console.log(` ${dim('$')} ${bold('ollama serve')}`);
|
|
528
|
+
console.log();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
console.log(` ${dim('Browse all models:')} ${cyan('https://ollama.com/library')}`);
|
|
532
|
+
console.log();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ─── Configure Flow ─────────────────────────────────────────
|
|
248
536
|
|
|
249
537
|
async function runConfigure(projectDir, config, soulPath) {
|
|
250
538
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
@@ -280,7 +568,7 @@ async function runConfigure(projectDir, config, soulPath) {
|
|
|
280
568
|
conventions: await ask(rl, 'Key conventions', defaults.conventions),
|
|
281
569
|
};
|
|
282
570
|
|
|
283
|
-
// Update SOUL.md
|
|
571
|
+
// Update SOUL.md
|
|
284
572
|
if (existsSync(soulPath)) {
|
|
285
573
|
let soul = readFileSync(soulPath, 'utf8');
|
|
286
574
|
const ctx = [
|
|
@@ -305,29 +593,15 @@ async function runConfigure(projectDir, config, soulPath) {
|
|
|
305
593
|
rl.close();
|
|
306
594
|
}
|
|
307
595
|
|
|
308
|
-
// Mark as user-confirmed
|
|
309
596
|
config._configured = true;
|
|
310
597
|
writeConfig(projectDir, config);
|
|
311
598
|
success(`Saved ${bold('.openclaw/config.json')}`);
|
|
312
599
|
console.log();
|
|
313
600
|
}
|
|
314
601
|
|
|
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();
|
|
602
|
+
// ─── Agents Flow ────────────────────────────────────────────
|
|
330
603
|
|
|
604
|
+
async function runAgents(projectDir, config, parsed, available) {
|
|
331
605
|
divider('Agents');
|
|
332
606
|
console.log(` ${dim('Enable/disable each agent and pick a model.')}`);
|
|
333
607
|
console.log();
|
|
@@ -371,7 +645,15 @@ async function runAgents(projectDir, config, parsed) {
|
|
|
371
645
|
|
|
372
646
|
// ─── Start Flow ─────────────────────────────────────────────
|
|
373
647
|
|
|
374
|
-
async function runStart(projectDir, parsed, config, soulPath) {
|
|
648
|
+
async function runStart(projectDir, parsed, config, soulPath, hasOpenClaw) {
|
|
649
|
+
if (!hasOpenClaw) {
|
|
650
|
+
error('Cannot launch agents — OpenClaw is not installed.');
|
|
651
|
+
console.log();
|
|
652
|
+
console.log(` ${dim('$')} ${bold('npm install -g openclaw@latest')}`);
|
|
653
|
+
console.log();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
375
657
|
const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
|
|
376
658
|
const runningNames = new Set(listPids(projectDir).map(p => p.name));
|
|
377
659
|
const startable = enabledAgents.filter(a => !runningNames.has(a.name));
|
|
@@ -430,11 +712,10 @@ async function runStart(projectDir, parsed, config, soulPath) {
|
|
|
430
712
|
const absProject = resolve(projectDir);
|
|
431
713
|
|
|
432
714
|
const openclawCmd = `OPENCLAW_STATE_DIR='${join(absWorkspace, '.state')}' openclaw start --workspace '${absWorkspace}'`;
|
|
433
|
-
|
|
434
715
|
const method = openInTerminal(`SerpentStack: ${name}`, openclawCmd, absProject);
|
|
435
716
|
|
|
436
717
|
if (method) {
|
|
437
|
-
writePid(projectDir, name, -1);
|
|
718
|
+
writePid(projectDir, name, -1);
|
|
438
719
|
success(`${bold(name)} opened in ${method} ${dim(`(${modelShortName(effectiveModel)})`)}`);
|
|
439
720
|
started++;
|
|
440
721
|
} else {
|
|
@@ -459,105 +740,59 @@ async function runStart(projectDir, parsed, config, soulPath) {
|
|
|
459
740
|
if (started > 0) {
|
|
460
741
|
success(`${started} agent(s) launched — fangs out 🐍`);
|
|
461
742
|
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
743
|
}
|
|
470
744
|
}
|
|
471
745
|
|
|
472
746
|
// ─── Main Entry Point ───────────────────────────────────────
|
|
473
747
|
|
|
474
|
-
export async function persistent({ stop = false, configure = false, agents = false, start = false } = {}) {
|
|
748
|
+
export async function persistent({ stop = false, configure = false, agents = false, start = false, models = false } = {}) {
|
|
475
749
|
const projectDir = process.cwd();
|
|
476
750
|
|
|
477
751
|
printHeader();
|
|
478
752
|
|
|
479
|
-
// ── Stop ──
|
|
753
|
+
// ── Stop (doesn't need full preflight) ──
|
|
480
754
|
if (stop) {
|
|
755
|
+
cleanStalePids(projectDir);
|
|
481
756
|
stopAllAgents(projectDir);
|
|
482
757
|
return;
|
|
483
758
|
}
|
|
484
759
|
|
|
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
|
-
|
|
760
|
+
// ── Full preflight (checks workspace, agents, runtime) ──
|
|
761
|
+
const { soulPath, parsed, hasOpenClaw, available } = await preflight(projectDir);
|
|
517
762
|
cleanStalePids(projectDir);
|
|
518
763
|
|
|
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
764
|
// Load config
|
|
536
765
|
let config = readConfig(projectDir) || { project: {}, agents: {} };
|
|
537
766
|
const isConfigured = !!config._configured;
|
|
538
767
|
|
|
539
|
-
// ──
|
|
768
|
+
// ── --models: list installed and recommended models ──
|
|
769
|
+
if (models) {
|
|
770
|
+
await runModels(available);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ── --configure: edit project settings ──
|
|
540
775
|
if (configure) {
|
|
541
776
|
await runConfigure(projectDir, config, soulPath);
|
|
542
777
|
return;
|
|
543
778
|
}
|
|
544
779
|
|
|
545
|
-
// ──
|
|
780
|
+
// ── --agents: edit agent models and enabled state ──
|
|
546
781
|
if (agents) {
|
|
547
|
-
config = readConfig(projectDir) || config;
|
|
548
|
-
await runAgents(projectDir, config, parsed);
|
|
782
|
+
config = readConfig(projectDir) || config;
|
|
783
|
+
await runAgents(projectDir, config, parsed, available);
|
|
549
784
|
return;
|
|
550
785
|
}
|
|
551
786
|
|
|
552
|
-
// ──
|
|
787
|
+
// ── --start: launch agents ──
|
|
553
788
|
if (start) {
|
|
554
|
-
await runStart(projectDir, parsed, config, soulPath);
|
|
789
|
+
await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
|
|
555
790
|
return;
|
|
556
791
|
}
|
|
557
792
|
|
|
558
|
-
// ──
|
|
793
|
+
// ── Bare `serpentstack persistent` ──
|
|
559
794
|
if (isConfigured) {
|
|
560
|
-
//
|
|
795
|
+
// Show dashboard
|
|
561
796
|
printStatusDashboard(config, parsed, projectDir);
|
|
562
797
|
|
|
563
798
|
const enabledAgents = parsed.filter(a => isAgentEnabled(a.name, config));
|
|
@@ -578,27 +813,53 @@ export async function persistent({ stop = false, configure = false, agents = fal
|
|
|
578
813
|
`${dim('$')} ${bold('serpentstack persistent --stop')} ${dim('# stop all')}`,
|
|
579
814
|
`${dim('$')} ${bold('serpentstack persistent --configure')} ${dim('# edit project settings')}`,
|
|
580
815
|
`${dim('$')} ${bold('serpentstack persistent --agents')} ${dim('# change models')}`,
|
|
816
|
+
`${dim('$')} ${bold('serpentstack persistent --models')} ${dim('# list & install models')}`,
|
|
581
817
|
]);
|
|
582
818
|
console.log();
|
|
583
819
|
return;
|
|
584
820
|
}
|
|
585
821
|
|
|
586
|
-
// ── First-time setup:
|
|
587
|
-
|
|
588
|
-
|
|
822
|
+
// ── First-time setup: guided walkthrough ──
|
|
823
|
+
|
|
824
|
+
// Step 0: Show runtime status
|
|
825
|
+
const canLaunch = printPreflightStatus(hasOpenClaw, available);
|
|
826
|
+
|
|
827
|
+
if (!canLaunch) {
|
|
828
|
+
console.log(` ${dim('Install the missing dependencies above, then run:')}`);
|
|
829
|
+
console.log(` ${dim('$')} ${bold('serpentstack persistent')}`);
|
|
830
|
+
console.log();
|
|
831
|
+
|
|
832
|
+
// Still let them configure even without OpenClaw
|
|
833
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
834
|
+
let proceed;
|
|
835
|
+
try {
|
|
836
|
+
proceed = await askYesNo(rl, `Continue with project configuration anyway?`, true);
|
|
837
|
+
} finally {
|
|
838
|
+
rl.close();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (!proceed) {
|
|
842
|
+
console.log();
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
console.log();
|
|
846
|
+
}
|
|
589
847
|
|
|
590
848
|
// Step 1: Project settings
|
|
591
849
|
await runConfigure(projectDir, config, soulPath);
|
|
592
|
-
|
|
593
|
-
// Re-read config (runConfigure saved it)
|
|
594
850
|
config = readConfig(projectDir) || config;
|
|
595
851
|
|
|
596
852
|
// Step 2: Agent settings
|
|
597
|
-
await runAgents(projectDir, config, parsed);
|
|
598
|
-
|
|
599
|
-
// Re-read config (runAgents saved it)
|
|
853
|
+
await runAgents(projectDir, config, parsed, available);
|
|
600
854
|
config = readConfig(projectDir) || config;
|
|
601
855
|
|
|
602
|
-
// Step 3: Launch
|
|
603
|
-
|
|
856
|
+
// Step 3: Launch (only if OpenClaw is installed)
|
|
857
|
+
if (canLaunch) {
|
|
858
|
+
await runStart(projectDir, parsed, config, soulPath, hasOpenClaw);
|
|
859
|
+
} else {
|
|
860
|
+
console.log();
|
|
861
|
+
info('Skipping launch — install OpenClaw first, then run:');
|
|
862
|
+
console.log(` ${dim('$')} ${bold('serpentstack persistent --start')}`);
|
|
863
|
+
console.log();
|
|
864
|
+
}
|
|
604
865
|
}
|
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,77 +159,44 @@ 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
|
-
|
|
116
|
-
|
|
184
|
+
try {
|
|
185
|
+
const status = await execAsync('openclaw', ['models', 'status']);
|
|
186
|
+
// Check for API key in status output (e.g. "api_key=1" or "Configured models")
|
|
187
|
+
if (status.includes('api_key=') || status.includes('Configured models')) {
|
|
117
188
|
result.hasApiKey = true;
|
|
118
189
|
}
|
|
119
190
|
|
|
120
|
-
//
|
|
121
|
-
const list = await execAsync('openclaw', ['models', 'list', '--json']);
|
|
191
|
+
// Try JSON output first, fall back to text parsing
|
|
122
192
|
try {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
193
|
+
const list = await execAsync('openclaw', ['models', 'list', '--json']);
|
|
194
|
+
const parsed = JSON.parse(list);
|
|
195
|
+
// Handle both { models: [...] } and bare array
|
|
196
|
+
const modelsArr = Array.isArray(parsed) ? parsed : (parsed.models || []);
|
|
197
|
+
if (modelsArr.length > 0) {
|
|
198
|
+
result.models = modelsArr
|
|
199
|
+
.filter(m => !m.local) // only cloud models
|
|
127
200
|
.map(m => ({
|
|
128
201
|
id: m.key || m.name,
|
|
129
202
|
name: modelShortName(m.key || m.name),
|
|
@@ -132,17 +205,18 @@ async function detectOpenClawAuth() {
|
|
|
132
205
|
}));
|
|
133
206
|
}
|
|
134
207
|
} catch {
|
|
135
|
-
// Fall back to text parsing
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
208
|
+
// Fall back to text output parsing
|
|
209
|
+
try {
|
|
210
|
+
const text = await execAsync('openclaw', ['models', 'list']);
|
|
211
|
+
const lines = text.trim().split('\n').filter(l => l.trim() && !l.startsWith('Model'));
|
|
212
|
+
result.models = lines.map(l => {
|
|
213
|
+
const id = l.trim().split(/\s+/)[0];
|
|
214
|
+
if (!id || id.length < 3) return null;
|
|
215
|
+
return { id, name: modelShortName(id), provider: id.split('/')[0], tier: 'cloud' };
|
|
216
|
+
}).filter(Boolean);
|
|
217
|
+
} catch { /* use fallback below */ }
|
|
143
218
|
}
|
|
144
219
|
} catch {
|
|
145
|
-
// OpenClaw not installed or no models configured — use defaults
|
|
146
220
|
result.models = [
|
|
147
221
|
{ id: 'anthropic/claude-haiku-4-20250414', name: 'Haiku', provider: 'anthropic', tier: 'cloud' },
|
|
148
222
|
{ id: 'anthropic/claude-sonnet-4-20250514', name: 'Sonnet', provider: 'anthropic', tier: 'cloud' },
|
|
@@ -152,21 +226,49 @@ async function detectOpenClawAuth() {
|
|
|
152
226
|
return result;
|
|
153
227
|
}
|
|
154
228
|
|
|
229
|
+
// ─── Formatting Helpers ─────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
function formatParamCount(paramSize) {
|
|
232
|
+
if (!paramSize) return '';
|
|
233
|
+
const s = String(paramSize).trim();
|
|
234
|
+
if (/^\d+\.?\d*[BbMm]$/i.test(s)) return s.toUpperCase();
|
|
235
|
+
return s;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function guessParamsFromSize(bytes) {
|
|
239
|
+
if (!bytes || bytes <= 0) return '';
|
|
240
|
+
const gb = bytes / (1024 ** 3);
|
|
241
|
+
const billions = Math.round(gb * 2);
|
|
242
|
+
if (billions > 0) return `~${billions}B`;
|
|
243
|
+
return '';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function formatBytes(bytes) {
|
|
247
|
+
if (!bytes || bytes <= 0) return '';
|
|
248
|
+
const gb = bytes / (1024 ** 3);
|
|
249
|
+
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
|
250
|
+
const mb = bytes / (1024 ** 2);
|
|
251
|
+
return `${Math.round(mb)} MB`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function fetchWithTimeout(url, timeoutMs) {
|
|
255
|
+
const controller = new AbortController();
|
|
256
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
257
|
+
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
|
|
258
|
+
}
|
|
259
|
+
|
|
155
260
|
/**
|
|
156
261
|
* Short display name for a model ID.
|
|
157
262
|
*/
|
|
158
263
|
export function modelShortName(model) {
|
|
159
264
|
if (!model) return 'unknown';
|
|
160
|
-
// Anthropic models
|
|
161
265
|
if (model.startsWith('anthropic/')) {
|
|
162
266
|
if (model.includes('haiku')) return 'Haiku';
|
|
163
267
|
if (model.includes('sonnet')) return 'Sonnet';
|
|
164
268
|
if (model.includes('opus')) return 'Opus';
|
|
165
269
|
return model.slice('anthropic/'.length);
|
|
166
270
|
}
|
|
167
|
-
// Ollama models
|
|
168
271
|
if (model.startsWith('ollama/')) return model.slice('ollama/'.length);
|
|
169
|
-
// Other: strip provider prefix
|
|
170
272
|
if (model.includes('/')) return model.split('/').pop();
|
|
171
273
|
return model;
|
|
172
274
|
}
|