serpentstack 0.2.13 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commands/persistent.js +163 -12
- package/lib/utils/models.js +53 -13
- package/package.json +1 -1
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
getEffectiveModel,
|
|
24
24
|
isAgentEnabled,
|
|
25
25
|
} from '../utils/config.js';
|
|
26
|
-
import { detectModels, modelShortName } from '../utils/models.js';
|
|
26
|
+
import { detectModels, modelShortName, detectSystemCapabilities } from '../utils/models.js';
|
|
27
27
|
|
|
28
28
|
// ─── Helpers ────────────────────────────────────────────────
|
|
29
29
|
|
|
@@ -174,18 +174,65 @@ function printPreflightStatus(hasOpenClaw, available) {
|
|
|
174
174
|
return hasOpenClaw;
|
|
175
175
|
}
|
|
176
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
|
+
|
|
177
224
|
// ─── Model Picker ───────────────────────────────────────────
|
|
178
225
|
|
|
179
226
|
async function pickModel(rl, agentName, currentModel, available) {
|
|
180
227
|
const choices = [];
|
|
181
228
|
|
|
182
|
-
//
|
|
229
|
+
// Section 1: Installed local models
|
|
183
230
|
if (available.local.length > 0) {
|
|
184
|
-
console.log(` ${dim('──
|
|
231
|
+
console.log(` ${dim('── Installed')} ${green('ready')} ${dim('────────────────────')}`);
|
|
185
232
|
for (const m of available.local) {
|
|
186
233
|
const isCurrent = m.id === currentModel;
|
|
187
234
|
const idx = choices.length;
|
|
188
|
-
choices.push(m);
|
|
235
|
+
choices.push({ ...m, action: 'use' });
|
|
189
236
|
const marker = isCurrent ? green('>') : ' ';
|
|
190
237
|
const num = dim(`${idx + 1}.`);
|
|
191
238
|
const label = isCurrent ? bold(m.name) : m.name;
|
|
@@ -197,14 +244,48 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
197
244
|
}
|
|
198
245
|
}
|
|
199
246
|
|
|
200
|
-
//
|
|
247
|
+
// Section 2: Downloadable models — always shown if we have recommendations
|
|
248
|
+
if (available.recommended.length > 0) {
|
|
249
|
+
const liveTag = available.recommendedLive
|
|
250
|
+
? dim(`live from ollama.com`)
|
|
251
|
+
: dim(`cached list`);
|
|
252
|
+
const needsOllama = !available.ollamaInstalled ? dim(' · requires Ollama') : '';
|
|
253
|
+
console.log(` ${dim('── Download')} ${cyan('free')} ${dim('(')}${liveTag}${needsOllama}${dim(') ──')}`);
|
|
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
|
|
201
282
|
if (available.cloud.length > 0) {
|
|
202
283
|
const apiNote = available.hasApiKey ? green('key ✓') : yellow('needs API key');
|
|
203
284
|
console.log(` ${dim('── Cloud')} ${apiNote} ${dim('─────────────────────')}`);
|
|
204
285
|
for (const m of available.cloud) {
|
|
205
286
|
const isCurrent = m.id === currentModel;
|
|
206
287
|
const idx = choices.length;
|
|
207
|
-
choices.push(m);
|
|
288
|
+
choices.push({ ...m, action: 'use' });
|
|
208
289
|
const marker = isCurrent ? green('>') : ' ';
|
|
209
290
|
const num = dim(`${idx + 1}.`);
|
|
210
291
|
const label = isCurrent ? bold(m.name) : m.name;
|
|
@@ -216,13 +297,14 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
216
297
|
|
|
217
298
|
if (choices.length === 0) {
|
|
218
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')}`);
|
|
219
301
|
console.log(` ${dim('$')} ${bold('ollama pull llama3.2')}`);
|
|
220
302
|
return currentModel;
|
|
221
303
|
}
|
|
222
304
|
|
|
223
|
-
// If current model isn't in
|
|
305
|
+
// If current model isn't in any list, add it at the top
|
|
224
306
|
if (!choices.some(c => c.id === currentModel)) {
|
|
225
|
-
choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom' });
|
|
307
|
+
choices.unshift({ id: currentModel, name: modelShortName(currentModel), tier: 'custom', action: 'use' });
|
|
226
308
|
console.log(` ${dim(`Current: ${modelShortName(currentModel)} (not in detected models)`)}`);
|
|
227
309
|
}
|
|
228
310
|
|
|
@@ -234,7 +316,45 @@ async function pickModel(rl, agentName, currentModel, available) {
|
|
|
234
316
|
|
|
235
317
|
const selected = (idx >= 0 && idx < choices.length) ? choices[idx] : choices[Math.max(0, currentIdx)];
|
|
236
318
|
|
|
237
|
-
|
|
319
|
+
// If they selected a downloadable model, handle Ollama install + pull
|
|
320
|
+
if (selected.action === 'download') {
|
|
321
|
+
if (!available.ollamaInstalled) {
|
|
322
|
+
console.log();
|
|
323
|
+
warn('Ollama is required to run local models.');
|
|
324
|
+
console.log();
|
|
325
|
+
console.log(` ${dim('Install Ollama (free, open-source):')}`);
|
|
326
|
+
console.log(` ${dim('$')} ${bold('curl -fsSL https://ollama.com/install.sh | sh')}`);
|
|
327
|
+
console.log(` ${dim('$')} ${bold('ollama serve')}`);
|
|
328
|
+
console.log();
|
|
329
|
+
info(`After installing, re-run ${bold('serpentstack persistent --agents')} to download and select ${bold(selected.name)}.`);
|
|
330
|
+
console.log();
|
|
331
|
+
|
|
332
|
+
// Save the selection anyway so it's remembered
|
|
333
|
+
return selected.id;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!available.ollamaRunning) {
|
|
337
|
+
console.log();
|
|
338
|
+
warn('Ollama is installed but not running.');
|
|
339
|
+
console.log(` ${dim('$')} ${bold('ollama serve')}`);
|
|
340
|
+
console.log();
|
|
341
|
+
info(`Start Ollama, then re-run ${bold('serpentstack persistent --agents')} to download ${bold(selected.name)}.`);
|
|
342
|
+
console.log();
|
|
343
|
+
return selected.id;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
rl.pause();
|
|
347
|
+
const pulled = await ollamaPull(selected.name);
|
|
348
|
+
rl.resume();
|
|
349
|
+
|
|
350
|
+
if (!pulled) {
|
|
351
|
+
warn(`Download failed. Keeping previous model: ${bold(modelShortName(currentModel))}`);
|
|
352
|
+
return currentModel;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Warn about cloud model costs
|
|
357
|
+
if (selected.tier === 'cloud' && (available.local.length > 0 || available.recommended.length > 0)) {
|
|
238
358
|
warn(`Cloud models cost tokens per heartbeat. Consider a local model for persistent agents.`);
|
|
239
359
|
}
|
|
240
360
|
if (selected.tier === 'cloud' && !available.hasApiKey) {
|
|
@@ -505,9 +625,25 @@ async function runConfigure(projectDir, config, soulPath) {
|
|
|
505
625
|
|
|
506
626
|
// ─── Agents Flow ────────────────────────────────────────────
|
|
507
627
|
|
|
628
|
+
// Agent description summaries for the enable/disable flow
|
|
629
|
+
const AGENT_SUMMARIES = {
|
|
630
|
+
'log-watcher': 'Monitors your dev server health and log output every 30–60s. Catches backend crashes, frontend build errors, and import failures — reports them with file paths and suggested fixes.',
|
|
631
|
+
'test-runner': 'Runs your test suite every 5 min and lint/typecheck every 15 min. Catches regressions before you commit — shows which test failed, what changed, and whether the test or source needs fixing.',
|
|
632
|
+
'skill-maintainer': 'Checks every hour whether your .skills/ files still match the actual codebase. When code patterns drift from what skills describe, it proposes exact updates so IDE agents stay accurate.',
|
|
633
|
+
};
|
|
634
|
+
|
|
508
635
|
async function runAgents(projectDir, config, parsed, available) {
|
|
636
|
+
// Show system capabilities so users know what models they can run
|
|
637
|
+
const sys = detectSystemCapabilities();
|
|
638
|
+
|
|
639
|
+
divider('Your System');
|
|
640
|
+
console.log(` ${dim('RAM:')} ${bold(sys.totalGB + ' GB')} total, ${sys.freeGB} GB free`);
|
|
641
|
+
console.log(` ${dim(sys.recommendation)}`);
|
|
642
|
+
console.log();
|
|
643
|
+
|
|
509
644
|
divider('Agents');
|
|
510
|
-
console.log(` ${dim('
|
|
645
|
+
console.log(` ${dim('Each agent runs in its own terminal on a schedule.')}`);
|
|
646
|
+
console.log(` ${dim('Enable the ones you want, then pick a model for each.')}`);
|
|
511
647
|
console.log();
|
|
512
648
|
|
|
513
649
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
@@ -519,8 +655,23 @@ async function runAgents(projectDir, config, parsed, available) {
|
|
|
519
655
|
const currentModel = existingAgent?.model || 'ollama/llama3.2';
|
|
520
656
|
const schedule = (agentMd.meta.schedule || []).map(s => s.every).join(', ');
|
|
521
657
|
|
|
522
|
-
|
|
523
|
-
console.log(`
|
|
658
|
+
// Show rich description
|
|
659
|
+
console.log(` ${bold(name)} ${dim(`(${schedule || 'manual'})`)}`);
|
|
660
|
+
const summary = AGENT_SUMMARIES[name] || agentMd.meta.description || '';
|
|
661
|
+
if (summary) {
|
|
662
|
+
// Word-wrap summary to ~70 chars, indented
|
|
663
|
+
const words = summary.split(' ');
|
|
664
|
+
let line = '';
|
|
665
|
+
for (const word of words) {
|
|
666
|
+
if (line.length + word.length + 1 > 68) {
|
|
667
|
+
console.log(` ${dim(line)}`);
|
|
668
|
+
line = word;
|
|
669
|
+
} else {
|
|
670
|
+
line = line ? `${line} ${word}` : word;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (line) console.log(` ${dim(line)}`);
|
|
674
|
+
}
|
|
524
675
|
|
|
525
676
|
const enabled = await askYesNo(rl, `Enable ${bold(name)}?`, currentEnabled);
|
|
526
677
|
|
package/lib/utils/models.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { freemem, totalmem } from 'node:os';
|
|
2
3
|
|
|
3
4
|
// ─── Fallback Recommendations ───────────────────────────────
|
|
4
5
|
// Used only when the Ollama library API is unreachable.
|
|
@@ -183,16 +184,20 @@ async function detectOpenClawAuth() {
|
|
|
183
184
|
|
|
184
185
|
try {
|
|
185
186
|
const status = await execAsync('openclaw', ['models', 'status']);
|
|
186
|
-
|
|
187
|
+
// Check for API key in status output (e.g. "api_key=1" or "Configured models")
|
|
188
|
+
if (status.includes('api_key=') || status.includes('Configured models')) {
|
|
187
189
|
result.hasApiKey = true;
|
|
188
190
|
}
|
|
189
191
|
|
|
190
|
-
|
|
192
|
+
// Try JSON output first, fall back to text parsing
|
|
191
193
|
try {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
const list = await execAsync('openclaw', ['models', 'list', '--json']);
|
|
195
|
+
const parsed = JSON.parse(list);
|
|
196
|
+
// Handle both { models: [...] } and bare array
|
|
197
|
+
const modelsArr = Array.isArray(parsed) ? parsed : (parsed.models || []);
|
|
198
|
+
if (modelsArr.length > 0) {
|
|
199
|
+
result.models = modelsArr
|
|
200
|
+
.filter(m => !m.local) // only cloud models
|
|
196
201
|
.map(m => ({
|
|
197
202
|
id: m.key || m.name,
|
|
198
203
|
name: modelShortName(m.key || m.name),
|
|
@@ -201,13 +206,16 @@ async function detectOpenClawAuth() {
|
|
|
201
206
|
}));
|
|
202
207
|
}
|
|
203
208
|
} catch {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
// Fall back to text output parsing
|
|
210
|
+
try {
|
|
211
|
+
const text = await execAsync('openclaw', ['models', 'list']);
|
|
212
|
+
const lines = text.trim().split('\n').filter(l => l.trim() && !l.startsWith('Model'));
|
|
213
|
+
result.models = lines.map(l => {
|
|
214
|
+
const id = l.trim().split(/\s+/)[0];
|
|
215
|
+
if (!id || id.length < 3) return null;
|
|
216
|
+
return { id, name: modelShortName(id), provider: id.split('/')[0], tier: 'cloud' };
|
|
217
|
+
}).filter(Boolean);
|
|
218
|
+
} catch { /* use fallback below */ }
|
|
211
219
|
}
|
|
212
220
|
} catch {
|
|
213
221
|
result.models = [
|
|
@@ -266,6 +274,38 @@ export function modelShortName(model) {
|
|
|
266
274
|
return model;
|
|
267
275
|
}
|
|
268
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Detect system capabilities for model recommendations.
|
|
279
|
+
* Returns { totalRAM, freeRAM, maxModelSize, recommendation }.
|
|
280
|
+
*/
|
|
281
|
+
export function detectSystemCapabilities() {
|
|
282
|
+
const total = totalmem();
|
|
283
|
+
const free = freemem();
|
|
284
|
+
const totalGB = total / (1024 ** 3);
|
|
285
|
+
const freeGB = free / (1024 ** 3);
|
|
286
|
+
|
|
287
|
+
// Ollama needs ~2GB overhead; model needs to fit in remaining RAM
|
|
288
|
+
const availableForModel = Math.max(0, freeGB - 2);
|
|
289
|
+
|
|
290
|
+
let recommendation;
|
|
291
|
+
if (totalGB >= 32) {
|
|
292
|
+
recommendation = 'Your system can handle large models (up to 24B parameters)';
|
|
293
|
+
} else if (totalGB >= 16) {
|
|
294
|
+
recommendation = 'Good for medium models (up to 8B parameters)';
|
|
295
|
+
} else if (totalGB >= 8) {
|
|
296
|
+
recommendation = 'Best with small models (3B–4B parameters)';
|
|
297
|
+
} else {
|
|
298
|
+
recommendation = 'Limited RAM — use cloud models or very small local models';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
totalGB: totalGB.toFixed(0),
|
|
303
|
+
freeGB: freeGB.toFixed(1),
|
|
304
|
+
availableGB: availableForModel.toFixed(1),
|
|
305
|
+
recommendation,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
269
309
|
function execAsync(cmd, args) {
|
|
270
310
|
return new Promise((resolve, reject) => {
|
|
271
311
|
execFile(cmd, args, { timeout: 5000 }, (err, stdout) => {
|