offgrid-ai 0.4.6 → 0.5.0

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/cli.mjs +133 -186
  3. package/src/logs.mjs +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
4
4
  "description": "Privacy-first CLI for running local LLMs — discover, configure, run, benchmark",
5
5
  "author": "Eeshan Srivastava (https://eeshans.com)",
6
6
  "type": "module",
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 ────────────────────────────────────────────────────
@@ -174,25 +174,53 @@ async function modelsCommand(argv) {
174
174
 
175
175
  async function modelCommandCenter(catalog) {
176
176
  const normalized = normalizeCatalog(catalog);
177
- const items = modelCatalogItems(normalized);
178
- await printModelCatalog(normalized, items);
179
- if (!process.stdin.isTTY) return;
177
+ const { profiles } = normalized;
178
+ const runningProfilesNow = [];
179
+ for (const profile of profiles) {
180
+ if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
181
+ }
182
+ const fileMissingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
183
+
184
+ // Summary card
185
+ const summaryBorder = fileMissingCount > 0 ? pc.red : normalized.newModels.length > 0 ? pc.yellow : pc.dim;
186
+ console.log("\n" + renderCard("Your local AI workspace", renderRows([
187
+ ["Setups", `${profiles.length} saved`],
188
+ ["Need setup", normalized.newModels.length > 0 ? pc.yellow(`${normalized.newModels.length} model${normalized.newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
189
+ ["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
190
+ ["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
191
+ ]), { formatBorder: summaryBorder }));
180
192
 
181
- if (items.length === 0) return;
193
+ if (!process.stdin.isTTY) return;
194
+ const allItems = buildCatalogItems(normalized);
195
+ if (allItems.length === 0) return;
182
196
 
183
197
  const prompt = createPrompt();
184
198
  try {
185
- const action = await prompt.choice("What would you like to do?", [
186
- { value: "run", label: "Start chatting", hint: "Start a local model and open Pi" },
187
- { value: "setup", label: "Set up", hint: "Configure settings, MTP, context window" },
188
- { value: "inspect", label: "See model details", hint: "Show advanced paths, ports, and flags" },
189
- { value: "benchmark", label: "Benchmark", hint: "Coming soon" },
190
- { value: "remove", label: "Remove a saved setup", hint: "Delete a model setup from offgrid-ai" },
191
- ], "run");
192
- if (action === "benchmark") return await benchmarkFlow();
193
- const item = await chooseCatalogItem(prompt, items, action);
194
- if (!item) return;
195
- return await handleCatalogAction(prompt, action, item);
199
+ while (true) {
200
+ // Build model select options
201
+ const options = allItems.map((item) => modelSelectOption(item, { runningProfilesNow, drafters: normalized.drafters }));
202
+ const selected = await prompt.choice("Select a model", options);
203
+ if (!selected) break;
204
+ const item = allItems.find((i) => itemKey(i) === selected);
205
+ if (!item) break;
206
+
207
+ // Build contextual actions
208
+ const actions = actionsForItem(item);
209
+ if (actions.length === 1) {
210
+ // Only one action — just do it
211
+ await performAction(prompt, actions[0].value, item);
212
+ } else {
213
+ const action = await prompt.choice(item.label, actions, actions[0].value);
214
+ if (!action) continue;
215
+ if (action === "back") continue;
216
+ await performAction(prompt, action, item);
217
+ }
218
+ // After action, refresh running state and loop back
219
+ runningProfilesNow.length = 0;
220
+ for (const profile of profiles) {
221
+ if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
222
+ }
223
+ }
196
224
  } finally {
197
225
  prompt.close();
198
226
  }
@@ -232,167 +260,107 @@ function normalizeCatalog(catalog) {
232
260
  return { profiles, ggufModels, drafters, managedModels, newModels, managedItems };
233
261
  }
234
262
 
235
- async function printModelCatalog({ profiles, newModels, managedItems, drafters }, items = modelCatalogItems({ profiles, newModels, managedItems })) {
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 }));
255
-
256
- console.log("\n" + pc.bold("Ready to chat"));
257
- if (profiles.length === 0) {
258
- console.log(renderCard("No saved setups yet", "Downloaded models will appear below. Set one up once, then it will be ready from here.", { formatBorder: pc.yellow }));
259
- } else {
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);
299
- }
300
-
301
- function profileCatalogCard(num, profile, { running, fileMissing, drafters }) {
302
- const backend = backendFor(profile.backend);
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
- }
263
+ // ── Catalog item helpers ───────────────────────────────────────────────────
347
264
 
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 });
265
+ function itemKey(item) {
266
+ if (item.type === "profile") return `profile:${item.profile.id}`;
267
+ if (item.type === "new") return `new:${item.model.path}`;
268
+ return `managed:${item.backendId}:${item.model.id}`;
353
269
  }
354
270
 
355
- function modelCatalogItems({ profiles, newModels, managedItems, drafters }) {
271
+ function buildCatalogItems(normalized) {
272
+ const { profiles, newModels, managedItems, drafters } = normalized;
356
273
  return [
357
- ...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, hint: `${profile.modelAlias} · ${profile.baseUrl}`, fileMissing: isProfileFileMissing(profile) })),
274
+ ...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) })),
358
275
  ...newModels.map((model) => {
359
276
  const drafter = matchDrafter(model.path, drafters);
360
- return { type: "new", model, label: model.label, hint: `${model.quant ?? "GGUF"} · ${formatBytes(model.sizeBytes)}`, drafter };
277
+ return { type: "new", model, label: model.label, drafter };
361
278
  }),
362
- ...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label, hint: BACKENDS[backendId].label })),
279
+ ...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label })),
363
280
  ];
364
281
  }
365
282
 
366
- async function chooseCatalogItem(prompt, items, action) {
367
- if (action === "remove" && !items.some((item) => item.type === "profile")) {
368
- console.log(pc.yellow("No saved profiles to remove."));
369
- return null;
283
+ function modelSelectOption(item, { runningProfilesNow }) {
284
+ if (item.type === "profile") {
285
+ const { profile } = item;
286
+ const running = runningProfilesNow.some((r) => r.id === profile.id);
287
+ const fileMissing = item.fileMissing;
288
+ const caps = profile.capabilities ?? {};
289
+ let status;
290
+ if (fileMissing) status = pc.red("File missing");
291
+ else if (running) status = pc.green("Running");
292
+ else status = pc.dim("Ready");
293
+ const detailParts = [status];
294
+ if (humanCapabilitySummary(caps)) detailParts.push(humanCapabilitySummary(caps));
295
+ if (profile.drafterPath) detailParts.push(pc.green("MTP"));
296
+ if (profile.flags?.ctxSize) detailParts.push(`${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx`);
297
+ return { value: itemKey(item), label: profile.label, hint: detailParts.join(pc.dim(" · ")) };
370
298
  }
371
-
372
- const input = await prompt.text(action === "remove" ? "Which saved setup should be removed? Enter its number" : "Which model? Enter its number", "");
373
- if (!input) return null;
374
- const index = Number(input) - 1;
375
- if (!Number.isInteger(index) || index < 0 || index >= items.length) {
376
- console.log(pc.yellow(`No item ${input}.`));
377
- return null;
299
+ if (item.type === "new") {
300
+ const { model, drafter } = item;
301
+ const caps = detectCapabilities(model.path, model.mmprojPath);
302
+ const detailParts = [pc.yellow("Needs setup")];
303
+ if (humanCapabilitySummary(caps)) detailParts.push(humanCapabilitySummary(caps));
304
+ if (caps.mtp || drafter) detailParts.push(pc.green("MTP ✓"));
305
+ else if (caps.architecture === "gemma4") detailParts.push(pc.yellow("MTP: needs drafter"));
306
+ detailParts.push(formatBytes(model.sizeBytes));
307
+ return { value: itemKey(item), label: model.label, hint: detailParts.join(pc.dim(" · ")) };
378
308
  }
309
+ // managed
310
+ const { model, backendId } = item;
311
+ const backend = BACKENDS[backendId];
312
+ return { value: itemKey(item), label: model.label, hint: `${backend.label} · ${model.id}` };
313
+ }
379
314
 
380
- const item = items[index];
381
- if (action === "remove" && item.type !== "profile") {
382
- console.log(pc.yellow("Only saved profiles can be removed."));
383
- return null;
315
+ function actionsForItem(item) {
316
+ if (item.type === "profile") {
317
+ const actions = [
318
+ { value: "run", label: "Start chatting", hint: "Launch and open Pi" },
319
+ { value: "reconfigure", label: "Reconfigure", hint: "Change context, MTP, settings" },
320
+ { value: "inspect", label: "Details", hint: "Paths, ports, flags" },
321
+ ];
322
+ if (!item.fileMissing) {
323
+ actions.push({ value: "remove", label: "Remove", hint: "Delete this setup" });
324
+ }
325
+ return actions;
384
326
  }
385
- return item;
327
+ if (item.type === "new") {
328
+ return [
329
+ { value: "setup", label: "Set up", hint: "Configure and save" },
330
+ { value: "inspect", label: "Details", hint: "Model info" },
331
+ ];
332
+ }
333
+ // managed
334
+ return [
335
+ { value: "setup", label: "Set up", hint: `Connect via ${BACKENDS[item.backendId].label}` },
336
+ { value: "inspect", label: "Details", hint: "Model info" },
337
+ ];
386
338
  }
387
339
 
388
- async function handleCatalogAction(prompt, action, item) {
340
+ function isProfileFileMissing(profile) {
341
+ const backend = backendFor(profile.backend);
342
+ if (backend.type === "managed-server") return false;
343
+ if (!profile.modelPath) return true;
344
+ return !existsSync(profile.modelPath);
345
+ }
346
+
347
+ async function performAction(prompt, action, item) {
389
348
  if (action === "inspect") {
390
349
  if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
391
350
  if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
392
351
  return printGgufModelDetails(item.model, item.drafter);
393
352
  }
394
-
395
- if (action === "setup") {
353
+ if (action === "run") {
354
+ if (item.type === "profile") return await runProfile(await readProfile(item.profile.id));
355
+ // Shouldn't reach here for new/managed, but handle gracefully
356
+ const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
357
+ const configured = await configureLocalProfile(prompt, profile);
358
+ if (!configured) return;
359
+ await saveProfile(configured);
360
+ await syncPiConfig(configured);
361
+ return await runProfile(configured);
362
+ }
363
+ if (action === "reconfigure" || action === "setup") {
396
364
  if (item.type === "profile") {
397
365
  const profile = await readProfile(item.profile.id);
398
366
  const configured = await configureLocalProfile(prompt, profile);
@@ -411,26 +379,10 @@ async function handleCatalogAction(prompt, action, item) {
411
379
  await saveProfile(configured);
412
380
  return await syncPiConfig(configured);
413
381
  }
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
382
  if (action === "remove" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
432
383
  }
433
384
 
385
+
434
386
  async function printProfileDetails(profile) {
435
387
  const backend = backendFor(profile.backend);
436
388
  const isManaged = backend.type === "managed-server";
@@ -637,12 +589,15 @@ async function runProfile(profile, options = {}) {
637
589
  // Show memory estimate for local models
638
590
  if (!isManaged && profile.modelPath && existsSync(profile.modelPath)) {
639
591
  try {
640
- const est = estimateMemory(profile.modelPath, profile.mmprojPath, null, profile.flags);
641
- console.log(renderSection("Memory estimate", renderRows([
592
+ const est = estimateMemory(profile.modelPath, profile.mmprojPath, profile.drafterPath, profile.flags);
593
+ const rows = [
642
594
  ["Estimated total", pc.bold(`~${formatBytes(est.totalBytes)}`)],
643
595
  ["Model file", formatBytes(est.modelBytes)],
644
- ["Conversation memory", est.kvBytes ? `~${formatBytes(est.kvBytes)}` : "unknown"],
645
- ])));
596
+ ];
597
+ if (est.draftBytes) rows.push(["Drafter", formatBytes(est.draftBytes)]);
598
+ if (est.mmprojBytes) rows.push(["Vision projector", formatBytes(est.mmprojBytes)]);
599
+ rows.push(["Conversation memory", est.kvBytes ? `~${formatBytes(est.kvBytes)}` : "unknown"]);
600
+ console.log(renderSection("Memory estimate", renderRows(rows)));
646
601
  } catch { /* estimate failed, skip */ }
647
602
  }
648
603
 
@@ -691,14 +646,6 @@ async function removeProfileInteractive(id) {
691
646
 
692
647
  // ── Benchmark (stub) ────────────────────────────────────────────────────────
693
648
 
694
- async function benchmarkFlow() {
695
- console.log("\n" + renderCard("Benchmark", renderRows([
696
- ["Status", pc.yellow("Coming soon")],
697
- ["What it will do", "Compare local models with repeatable prompts"],
698
- ["For now", "Start a model with offgrid-ai, then run benchmarks manually"],
699
- ]), { formatBorder: pc.yellow }));
700
- }
701
-
702
649
  // ── Status ──────────────────────────────────────────────────────────────────
703
650
 
704
651
  async function statusCommand() {
package/src/logs.mjs CHANGED
@@ -32,6 +32,13 @@ export function tailFriendly(rawLogPath, friendlyLogPath) {
32
32
  function friendlyLine(line) {
33
33
  const lower = line.toLowerCase();
34
34
  const trimmed = line.trim();
35
+ // Known-harmless errors during MTP memory estimation — llama.cpp tries to measure
36
+ // the draft model's memory usage before allocating the shared KV cache, which
37
+ // fails because the assistant architecture needs the trunk context. This is
38
+ // expected and the server proceeds to load the draft model correctly afterward.
39
+ if (lower.includes("ctx_other") || lower.includes("failed to measure draft model memory")) {
40
+ return pc.dim(`[mtp] ${trimmed}`);
41
+ }
35
42
  if (lower.includes("error") || lower.includes("failed")) return pc.red(`[error] ${trimmed}`);
36
43
  if (lower.includes("listening") || lower.includes("http server")) return pc.green(`[server] ${trimmed}`);
37
44
  if (lower.includes("llm_load") || lower.includes("load_model") || lower.includes("loading model")) return pc.cyan(`[load] ${trimmed}`);