serpentstack 0.2.13 → 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/lib/commands/persistent.js +104 -8
- package/lib/utils/models.js +20 -13
- package/package.json +1 -1
|
@@ -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: 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
|
|
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,21 @@ 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, 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
|
+
|
|
332
|
+
// Warn about cloud model costs
|
|
333
|
+
if (selected.tier === 'cloud' && (available.local.length > 0 || available.recommended.length > 0)) {
|
|
238
334
|
warn(`Cloud models cost tokens per heartbeat. Consider a local model for persistent agents.`);
|
|
239
335
|
}
|
|
240
336
|
if (selected.tier === 'cloud' && !available.hasApiKey) {
|
package/lib/utils/models.js
CHANGED
|
@@ -183,16 +183,20 @@ async function detectOpenClawAuth() {
|
|
|
183
183
|
|
|
184
184
|
try {
|
|
185
185
|
const status = await execAsync('openclaw', ['models', 'status']);
|
|
186
|
-
|
|
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')) {
|
|
187
188
|
result.hasApiKey = true;
|
|
188
189
|
}
|
|
189
190
|
|
|
190
|
-
|
|
191
|
+
// Try JSON output first, fall back to text parsing
|
|
191
192
|
try {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
196
200
|
.map(m => ({
|
|
197
201
|
id: m.key || m.name,
|
|
198
202
|
name: modelShortName(m.key || m.name),
|
|
@@ -201,13 +205,16 @@ async function detectOpenClawAuth() {
|
|
|
201
205
|
}));
|
|
202
206
|
}
|
|
203
207
|
} catch {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 */ }
|
|
211
218
|
}
|
|
212
219
|
} catch {
|
|
213
220
|
result.models = [
|