offgrid-ai 0.4.7 → 0.5.1
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/package.json +1 -1
- package/src/cli.mjs +124 -185
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -79,7 +79,7 @@ export async function mainFlow() {
|
|
|
79
79
|
|
|
80
80
|
// 1. Check what backends are available
|
|
81
81
|
const llamaBinary = await findLlamaServer();
|
|
82
|
-
const { models: ggufModels } = await scanGgufModels();
|
|
82
|
+
const { models: ggufModels, drafters } = await scanGgufModels();
|
|
83
83
|
const managedModels = await scanManagedModels();
|
|
84
84
|
const profiles = await loadProfiles();
|
|
85
85
|
const hasAnyBackend = llamaBinary || managedModels.some((m) => m.models.length > 0);
|
|
@@ -153,7 +153,7 @@ export async function mainFlow() {
|
|
|
153
153
|
|
|
154
154
|
// 6. Interactive: one command center after onboarding.
|
|
155
155
|
startInteractive("offgrid-ai");
|
|
156
|
-
return await modelCommandCenter({ profiles, ggufModels, managedModels });
|
|
156
|
+
return await modelCommandCenter({ profiles, ggufModels, managedModels, drafters });
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
// ── Model command center ────────────────────────────────────────────────────
|
|
@@ -172,27 +172,52 @@ async function modelsCommand(argv) {
|
|
|
172
172
|
return await modelCommandCenter(catalog);
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
async function modelCommandCenter(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
175
|
+
async function modelCommandCenter(initialCatalog) {
|
|
176
|
+
if (!process.stdin.isTTY) {
|
|
177
|
+
const normalized = normalizeCatalog(initialCatalog);
|
|
178
|
+
const allItems = buildCatalogItems(normalized);
|
|
179
|
+
if (allItems.length === 0) return;
|
|
180
|
+
// Non-interactive: just show summary
|
|
181
|
+
for (const item of allItems) console.log(item.label);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
182
184
|
|
|
183
185
|
const prompt = createPrompt();
|
|
184
186
|
try {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
187
|
+
while (true) {
|
|
188
|
+
// Reload catalog each loop to reflect setup/remove changes
|
|
189
|
+
const catalog = await loadModelCatalog();
|
|
190
|
+
const normalized = normalizeCatalog(catalog);
|
|
191
|
+
const allItems = buildCatalogItems(normalized);
|
|
192
|
+
if (allItems.length === 0) { console.log(pc.dim("No models found.")); break; }
|
|
193
|
+
const runningProfilesNow = [];
|
|
194
|
+
for (const profile of normalized.profiles) {
|
|
195
|
+
if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
|
|
196
|
+
}
|
|
197
|
+
const fileMissingCount = normalized.profiles.filter((p) => isProfileFileMissing(p)).length;
|
|
198
|
+
const summaryBorder = fileMissingCount > 0 ? pc.red : normalized.newModels.length > 0 ? pc.yellow : pc.dim;
|
|
199
|
+
console.log("\n" + renderCard("Your local AI workspace", renderRows([
|
|
200
|
+
["Setups", `${normalized.profiles.length} saved`],
|
|
201
|
+
["Need setup", normalized.newModels.length > 0 ? pc.yellow(`${normalized.newModels.length} model${normalized.newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
202
|
+
["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
|
|
203
|
+
["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
204
|
+
]), { formatBorder: summaryBorder }));
|
|
205
|
+
|
|
206
|
+
const options = allItems.map((item) => modelSelectOption(item, { runningProfilesNow, drafters: normalized.drafters }));
|
|
207
|
+
const selected = await prompt.choice("Select a model", options);
|
|
208
|
+
if (!selected) break;
|
|
209
|
+
const item = allItems.find((i) => itemKey(i) === selected);
|
|
210
|
+
if (!item) break;
|
|
211
|
+
|
|
212
|
+
const actions = actionsForItem(item);
|
|
213
|
+
if (actions.length === 1) {
|
|
214
|
+
await performAction(prompt, actions[0].value, item);
|
|
215
|
+
} else {
|
|
216
|
+
const action = await prompt.choice(item.label, actions, actions[0].value);
|
|
217
|
+
if (!action) continue;
|
|
218
|
+
await performAction(prompt, action, item);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
196
221
|
} finally {
|
|
197
222
|
prompt.close();
|
|
198
223
|
}
|
|
@@ -232,167 +257,105 @@ function normalizeCatalog(catalog) {
|
|
|
232
257
|
return { profiles, ggufModels, drafters, managedModels, newModels, managedItems };
|
|
233
258
|
}
|
|
234
259
|
|
|
235
|
-
|
|
236
|
-
const itemNumber = (predicate) => {
|
|
237
|
-
const index = items.findIndex(predicate);
|
|
238
|
-
return index === -1 ? " " : String(index + 1).padStart(2, " ");
|
|
239
|
-
};
|
|
240
|
-
const runningProfilesNow = [];
|
|
241
|
-
for (const profile of profiles) {
|
|
242
|
-
if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const fileMissingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
|
|
246
|
-
|
|
247
|
-
const summaryBorder = fileMissingCount > 0 ? pc.red : newModels.length > 0 ? pc.yellow : pc.dim;
|
|
248
|
-
console.log("\n" + renderCard("Your local AI workspace", renderRows([
|
|
249
|
-
["Setups", `${profiles.length} saved`],
|
|
250
|
-
["Need setup", newModels.length > 0 ? pc.yellow(`${newModels.length} model${newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
251
|
-
["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
|
|
252
|
-
["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
253
|
-
["Next step", fileMissingCount > 0 ? pc.red("Remove or fix missing setups") : profiles.length > 0 ? "Start chatting" : newModels.length > 0 ? pc.yellow("Set up a downloaded model") : "Download a model"],
|
|
254
|
-
]), { formatBorder: summaryBorder }));
|
|
260
|
+
// ── Catalog item helpers ───────────────────────────────────────────────────
|
|
255
261
|
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
for (const profile of profiles) {
|
|
261
|
-
const running = runningProfilesNow.some((item) => item.id === profile.id);
|
|
262
|
-
const fileMissing = isProfileFileMissing(profile);
|
|
263
|
-
const num = itemNumber((item) => item.type === "profile" && item.profile.id === profile.id);
|
|
264
|
-
console.log(profileCatalogCard(num, profile, { running, fileMissing, drafters }));
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
console.log("\n" + pc.bold("Needs one-time setup"));
|
|
269
|
-
if (newModels.length === 0) {
|
|
270
|
-
console.log(renderCard("All set", "Every downloaded local model already has a saved setup.", { formatBorder: pc.dim }));
|
|
271
|
-
} else {
|
|
272
|
-
for (const model of newModels.slice(0, 20)) {
|
|
273
|
-
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
274
|
-
const drafter = matchDrafter(model.path, drafters);
|
|
275
|
-
const num = itemNumber((item) => item.type === "new" && item.model.path === model.path);
|
|
276
|
-
console.log(downloadedModelCard(num, model, caps, { mtpAvailable: caps.mtp || Boolean(drafter), drafter }));
|
|
277
|
-
}
|
|
278
|
-
if (newModels.length > 20) console.log(pc.dim(` ... and ${newModels.length - 20} more`));
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
for (const backendId of ["ollama", "omlx"]) {
|
|
282
|
-
const backendItems = managedItems.filter((item) => item.backendId === backendId);
|
|
283
|
-
if (backendItems.length === 0) continue;
|
|
284
|
-
const be = BACKENDS[backendId];
|
|
285
|
-
console.log("\n" + pc.bold(`Local models via ${be.label}`));
|
|
286
|
-
for (const { model } of backendItems.slice(0, 10)) {
|
|
287
|
-
const num = itemNumber((item) => item.type === "managed" && item.backendId === backendId && item.model.id === model.id);
|
|
288
|
-
console.log(managedModelCard(num, model, be));
|
|
289
|
-
}
|
|
290
|
-
if (backendItems.length > 10) console.log(pc.dim(` ... and ${backendItems.length - 10} more`));
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function isProfileFileMissing(profile) {
|
|
295
|
-
const backend = backendFor(profile.backend);
|
|
296
|
-
if (backend.type === "managed-server") return false;
|
|
297
|
-
if (!profile.modelPath) return true; // no path recorded means we can't verify
|
|
298
|
-
return !existsSync(profile.modelPath);
|
|
262
|
+
function itemKey(item) {
|
|
263
|
+
if (item.type === "profile") return `profile:${item.profile.id}`;
|
|
264
|
+
if (item.type === "new") return `new:${item.model.path}`;
|
|
265
|
+
return `managed:${item.backendId}:${item.model.id}`;
|
|
299
266
|
}
|
|
300
267
|
|
|
301
|
-
function
|
|
302
|
-
const
|
|
303
|
-
const caps = profile.capabilities ?? {};
|
|
304
|
-
let status;
|
|
305
|
-
if (fileMissing) {
|
|
306
|
-
status = pc.red("File missing");
|
|
307
|
-
} else if (running) {
|
|
308
|
-
status = pc.green("Running now");
|
|
309
|
-
} else {
|
|
310
|
-
status = "Ready";
|
|
311
|
-
}
|
|
312
|
-
const border = fileMissing ? pc.red : running ? pc.green : pc.dim;
|
|
313
|
-
const mtpDrafter = profile.drafterPath
|
|
314
|
-
? pc.green("MTP")
|
|
315
|
-
: (drafters ? matchDrafter(profile.modelPath, drafters) : null)
|
|
316
|
-
? pc.yellow("MTP available")
|
|
317
|
-
: (caps.architecture === "gemma4")
|
|
318
|
-
? pc.yellow("MTP: needs drafter")
|
|
319
|
-
: null;
|
|
320
|
-
const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
|
|
321
|
-
const capLabel = fileMissing ? pc.red("File not found") : humanCapabilitySummary(caps);
|
|
322
|
-
const detailParts = [capLabel];
|
|
323
|
-
if (mtpDrafter) detailParts.push(mtpDrafter);
|
|
324
|
-
if (ctxLabel) detailParts.push(ctxLabel);
|
|
325
|
-
const detailLine = detailParts.join(pc.dim(" · "));
|
|
326
|
-
return renderCard(`${num}. ${profile.label}`, renderRows([
|
|
327
|
-
["Status", status],
|
|
328
|
-
["Details", detailLine],
|
|
329
|
-
["Runs with", backend.label],
|
|
330
|
-
]), { formatBorder: border });
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function downloadedModelCard(num, model, caps, { mtpAvailable } = {}) {
|
|
334
|
-
const mtpLabel = mtpAvailable
|
|
335
|
-
? pc.green("MTP ✓")
|
|
336
|
-
: (caps.architecture === "gemma4")
|
|
337
|
-
? pc.yellow("MTP: needs drafter")
|
|
338
|
-
: null;
|
|
339
|
-
const detailParts = [humanCapabilitySummary(caps)];
|
|
340
|
-
if (mtpLabel) detailParts.push(mtpLabel);
|
|
341
|
-
detailParts.push(formatBytes(model.sizeBytes));
|
|
342
|
-
return renderCard(`${num}. ${model.label}`, renderRows([
|
|
343
|
-
["Status", pc.yellow("Needs setup")],
|
|
344
|
-
["Details", detailParts.join(pc.dim(" · "))],
|
|
345
|
-
]), { formatBorder: pc.yellow });
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function managedModelCard(num, model, backend) {
|
|
349
|
-
return renderCard(`${num}. ${model.label}`, renderRows([
|
|
350
|
-
["Status", pc.dim(`Via ${backend.label}`)],
|
|
351
|
-
["Details", [model.id, model.quant].filter(Boolean).join(pc.dim(" · "))],
|
|
352
|
-
]), { formatBorder: pc.dim });
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function modelCatalogItems({ profiles, newModels, managedItems, drafters }) {
|
|
268
|
+
function buildCatalogItems(normalized) {
|
|
269
|
+
const { profiles, newModels, managedItems, drafters } = normalized;
|
|
356
270
|
return [
|
|
357
|
-
...profiles.map((profile) => ({ type: "profile", profile, label: profile.label,
|
|
271
|
+
...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) })),
|
|
358
272
|
...newModels.map((model) => {
|
|
359
273
|
const drafter = matchDrafter(model.path, drafters);
|
|
360
|
-
return { type: "new", model, label: model.label,
|
|
274
|
+
return { type: "new", model, label: model.label, drafter };
|
|
361
275
|
}),
|
|
362
|
-
...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label
|
|
276
|
+
...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label })),
|
|
363
277
|
];
|
|
364
278
|
}
|
|
365
279
|
|
|
366
|
-
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
280
|
+
function modelSelectOption(item, { runningProfilesNow }) {
|
|
281
|
+
if (item.type === "profile") {
|
|
282
|
+
const { profile } = item;
|
|
283
|
+
const running = runningProfilesNow.some((r) => r.id === profile.id);
|
|
284
|
+
const fileMissing = item.fileMissing;
|
|
285
|
+
const caps = profile.capabilities ?? {};
|
|
286
|
+
let status;
|
|
287
|
+
if (fileMissing) status = pc.red("⚠ File missing");
|
|
288
|
+
else if (running) status = pc.green("● Running");
|
|
289
|
+
else status = pc.dim("● Ready");
|
|
290
|
+
const labelParts = [profile.label, status];
|
|
291
|
+
if (profile.drafterPath) labelParts.push(pc.green("MTP"));
|
|
292
|
+
if (profile.flags?.ctxSize) labelParts.push(`${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx`);
|
|
293
|
+
return { value: itemKey(item), label: labelParts.join(" "), hint: humanCapabilitySummary(caps) || profile.modelAlias };
|
|
370
294
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
295
|
+
if (item.type === "new") {
|
|
296
|
+
const { model, drafter } = item;
|
|
297
|
+
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
298
|
+
const labelParts = [model.label, pc.yellow("Needs setup")];
|
|
299
|
+
if (caps.mtp || drafter) labelParts.push(pc.green("MTP ✓"));
|
|
300
|
+
else if (caps.architecture === "gemma4") labelParts.push(pc.yellow("MTP"));
|
|
301
|
+
labelParts.push(formatBytes(model.sizeBytes));
|
|
302
|
+
return { value: itemKey(item), label: labelParts.join(" "), hint: humanCapabilitySummary(caps) || model.quant };
|
|
378
303
|
}
|
|
304
|
+
// managed
|
|
305
|
+
const { model, backendId } = item;
|
|
306
|
+
const backend = BACKENDS[backendId];
|
|
307
|
+
return { value: itemKey(item), label: `${model.label} ${pc.dim(`via ${backend.label}`)}`, hint: model.id };
|
|
308
|
+
}
|
|
379
309
|
|
|
380
|
-
|
|
381
|
-
if (
|
|
382
|
-
|
|
383
|
-
|
|
310
|
+
function actionsForItem(item) {
|
|
311
|
+
if (item.type === "profile") {
|
|
312
|
+
const actions = [
|
|
313
|
+
{ value: "run", label: "Start chatting", hint: "Launch and open Pi" },
|
|
314
|
+
{ value: "reconfigure", label: "Reconfigure", hint: "Change context, MTP, settings" },
|
|
315
|
+
{ value: "inspect", label: "Details", hint: "Paths, ports, flags" },
|
|
316
|
+
];
|
|
317
|
+
if (!item.fileMissing) {
|
|
318
|
+
actions.push({ value: "remove", label: "Remove", hint: "Delete this setup" });
|
|
319
|
+
}
|
|
320
|
+
return actions;
|
|
384
321
|
}
|
|
385
|
-
|
|
322
|
+
if (item.type === "new") {
|
|
323
|
+
return [
|
|
324
|
+
{ value: "setup", label: "Set up", hint: "Configure and save" },
|
|
325
|
+
{ value: "inspect", label: "Details", hint: "Model info" },
|
|
326
|
+
];
|
|
327
|
+
}
|
|
328
|
+
// managed
|
|
329
|
+
return [
|
|
330
|
+
{ value: "setup", label: "Set up", hint: `Connect via ${BACKENDS[item.backendId].label}` },
|
|
331
|
+
{ value: "inspect", label: "Details", hint: "Model info" },
|
|
332
|
+
];
|
|
386
333
|
}
|
|
387
334
|
|
|
388
|
-
|
|
335
|
+
function isProfileFileMissing(profile) {
|
|
336
|
+
const backend = backendFor(profile.backend);
|
|
337
|
+
if (backend.type === "managed-server") return false;
|
|
338
|
+
if (!profile.modelPath) return true;
|
|
339
|
+
return !existsSync(profile.modelPath);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function performAction(prompt, action, item) {
|
|
389
343
|
if (action === "inspect") {
|
|
390
344
|
if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
|
|
391
345
|
if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
|
|
392
346
|
return printGgufModelDetails(item.model, item.drafter);
|
|
393
347
|
}
|
|
394
|
-
|
|
395
|
-
|
|
348
|
+
if (action === "run") {
|
|
349
|
+
if (item.type === "profile") return await runProfile(await readProfile(item.profile.id));
|
|
350
|
+
// Shouldn't reach here for new/managed, but handle gracefully
|
|
351
|
+
const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
|
|
352
|
+
const configured = await configureLocalProfile(prompt, profile);
|
|
353
|
+
if (!configured) return;
|
|
354
|
+
await saveProfile(configured);
|
|
355
|
+
await syncPiConfig(configured);
|
|
356
|
+
return await runProfile(configured);
|
|
357
|
+
}
|
|
358
|
+
if (action === "reconfigure" || action === "setup") {
|
|
396
359
|
if (item.type === "profile") {
|
|
397
360
|
const profile = await readProfile(item.profile.id);
|
|
398
361
|
const configured = await configureLocalProfile(prompt, profile);
|
|
@@ -411,26 +374,10 @@ async function handleCatalogAction(prompt, action, item) {
|
|
|
411
374
|
await saveProfile(configured);
|
|
412
375
|
return await syncPiConfig(configured);
|
|
413
376
|
}
|
|
414
|
-
|
|
415
|
-
if (action === "run") {
|
|
416
|
-
if (item.type === "profile") return await runProfile(await readProfile(item.profile.id));
|
|
417
|
-
if (item.type === "managed") {
|
|
418
|
-
const profile = createManagedProfile(item.model, item.backendId);
|
|
419
|
-
await saveProfile(profile);
|
|
420
|
-
await syncPiConfig(profile);
|
|
421
|
-
return await runProfile(profile);
|
|
422
|
-
}
|
|
423
|
-
const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
|
|
424
|
-
const configured = await configureLocalProfile(prompt, profile);
|
|
425
|
-
if (!configured) return;
|
|
426
|
-
await saveProfile(configured);
|
|
427
|
-
await syncPiConfig(configured);
|
|
428
|
-
return await runProfile(configured);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
377
|
if (action === "remove" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
|
|
432
378
|
}
|
|
433
379
|
|
|
380
|
+
|
|
434
381
|
async function printProfileDetails(profile) {
|
|
435
382
|
const backend = backendFor(profile.backend);
|
|
436
383
|
const isManaged = backend.type === "managed-server";
|
|
@@ -694,14 +641,6 @@ async function removeProfileInteractive(id) {
|
|
|
694
641
|
|
|
695
642
|
// ── Benchmark (stub) ────────────────────────────────────────────────────────
|
|
696
643
|
|
|
697
|
-
async function benchmarkFlow() {
|
|
698
|
-
console.log("\n" + renderCard("Benchmark", renderRows([
|
|
699
|
-
["Status", pc.yellow("Coming soon")],
|
|
700
|
-
["What it will do", "Compare local models with repeatable prompts"],
|
|
701
|
-
["For now", "Start a model with offgrid-ai, then run benchmarks manually"],
|
|
702
|
-
]), { formatBorder: pc.yellow }));
|
|
703
|
-
}
|
|
704
|
-
|
|
705
644
|
// ── Status ──────────────────────────────────────────────────────────────────
|
|
706
645
|
|
|
707
646
|
async function statusCommand() {
|