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.
@@ -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 (free, fast, recommended)
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 (require API key, cost money)
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 cycle. Consider a local model for persistent agents.`);
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
- const alive = child.pid && !child.killed;
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
- // ─── Configure Flow (project settings) ─────────────────────
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 with project context
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 (enable/disable + model selection) ─────────
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); // -1 = terminal-managed
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
- // ── Preflight checks ──
486
- const soulPath = join(projectDir, '.openclaw/SOUL.md');
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
- // ── Explicit flag: --configure ──
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
- // ── Explicit flag: --agents ──
684
+ // ── --agents: edit agent models and enabled state ──
546
685
  if (agents) {
547
- config = readConfig(projectDir) || config; // re-read in case --configure was run first
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
- // ── Explicit flag: --start ──
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
- // ── Default: bare `serpentstack persistent` ──
697
+ // ── Bare `serpentstack persistent` ──
559
698
  if (isConfigured) {
560
- // Already set up — show dashboard
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: full walkthrough ──
587
- info('First-time setup — let\'s configure your project and agents.');
588
- console.log();
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
- await runStart(projectDir, parsed, config, soulPath);
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
  }
@@ -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
- * Local models are preferred for persistent agents — they're free and fast.
6
- * Cloud models require API keys and cost money per token.
19
+ * Also fetches recommended models from the Ollama library.
7
20
  *
8
- * Returns { local: [...], cloud: [...], hasApiKey: bool }
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 [ollamaModels, openclawInfo] = await Promise.all([
12
- detectOllamaModels(),
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: ollamaModels,
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
- * Detect locally installed Ollama models via the REST API.
25
- * GET http://localhost:11434/api/tags returns structured JSON with real
26
- * parameter counts, sizes, and quantization levels no CLI parsing needed.
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 detectOllamaModels() {
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
- return data.models.map(m => {
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 not running or not installed
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
- // Check if any API key is configured via openclaw models status
113
- const status = await execAsync('openclaw', ['models', 'status']);
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
- // Look for "api_key" or "configured" in the output
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serpentstack",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "description": "CLI for SerpentStack — AI-driven development standards with project-specific skills and persistent agents",
5
5
  "type": "module",
6
6
  "bin": {