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.
@@ -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
- // Local models first
229
+ // Section 1: Installed local models
183
230
  if (available.local.length > 0) {
184
- console.log(` ${dim('── Local')} ${green('free')} ${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
- // Cloud models
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 either list, add it
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
- if (selected.tier === 'cloud' && available.local.length > 0) {
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('Enable/disable each agent and pick a model.')}`);
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
- console.log(` ${bold(name)} ${dim(agentMd.meta.description || '')}`);
523
- console.log(` ${dim(`Schedule: ${schedule || 'none'}`)}`);
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
 
@@ -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
- if (status.includes('api_key') || status.includes('configured')) {
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
- const list = await execAsync('openclaw', ['models', 'list', '--json']);
192
+ // Try JSON output first, fall back to text parsing
191
193
  try {
192
- const models = JSON.parse(list);
193
- if (Array.isArray(models)) {
194
- result.models = models
195
- .filter(m => m.available && !m.local)
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
- const text = await execAsync('openclaw', ['models', 'list']);
205
- const lines = text.trim().split('\n').filter(l => l.trim() && !l.startsWith('Model'));
206
- result.models = lines.map(l => {
207
- const id = l.trim().split(/\s+/)[0];
208
- if (!id || id.length < 3) return null;
209
- return { id, name: modelShortName(id), provider: id.split('/')[0], tier: 'cloud' };
210
- }).filter(Boolean);
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
5
5
  "type": "module",
6
6
  "bin": {