offgrid-ai 0.4.4 → 0.4.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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
@@ -184,7 +184,7 @@ async function modelCommandCenter(catalog) {
184
184
  try {
185
185
  const action = await prompt.choice("What would you like to do?", [
186
186
  { value: "run", label: "Start chatting", hint: "Start a local model and open Pi" },
187
- { value: "setup", label: "Set up a downloaded model", hint: "One-time setup or Pi sync" },
187
+ { value: "setup", label: "Set up", hint: "Configure settings, MTP, context window" },
188
188
  { value: "inspect", label: "See model details", hint: "Show advanced paths, ports, and flags" },
189
189
  { value: "benchmark", label: "Benchmark", hint: "Coming soon" },
190
190
  { value: "remove", label: "Remove a saved setup", hint: "Delete a model setup from offgrid-ai" },
@@ -259,10 +259,9 @@ async function printModelCatalog({ profiles, newModels, managedItems, drafters }
259
259
  } else {
260
260
  for (const profile of profiles) {
261
261
  const running = runningProfilesNow.some((item) => item.id === profile.id);
262
- const piConfigured = await hasPiModel(profile);
263
262
  const fileMissing = isProfileFileMissing(profile);
264
263
  const num = itemNumber((item) => item.type === "profile" && item.profile.id === profile.id);
265
- console.log(profileCatalogCard(num, profile, { running, piConfigured, fileMissing, drafters }));
264
+ console.log(profileCatalogCard(num, profile, { running, fileMissing, drafters }));
266
265
  }
267
266
  }
268
267
 
@@ -299,7 +298,7 @@ function isProfileFileMissing(profile) {
299
298
  return !existsSync(profile.modelPath);
300
299
  }
301
300
 
302
- function profileCatalogCard(num, profile, { running, piConfigured, fileMissing, drafters }) {
301
+ function profileCatalogCard(num, profile, { running, fileMissing, drafters }) {
303
302
  const backend = backendFor(profile.backend);
304
303
  const caps = profile.capabilities ?? {};
305
304
  let status;
@@ -312,42 +311,44 @@ function profileCatalogCard(num, profile, { running, piConfigured, fileMissing,
312
311
  }
313
312
  const border = fileMissing ? pc.red : running ? pc.green : pc.dim;
314
313
  const mtpDrafter = profile.drafterPath
315
- ? pc.green("enabled")
314
+ ? pc.green("MTP")
316
315
  : (drafters ? matchDrafter(profile.modelPath, drafters) : null)
317
- ? pc.yellow("available — re-setup to enable")
316
+ ? pc.yellow("MTP available")
318
317
  : (caps.architecture === "gemma4")
319
- ? pc.yellow("drafter not found")
318
+ ? pc.yellow("MTP: needs drafter")
320
319
  : null;
321
- const rows = [
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([
322
327
  ["Status", status],
323
- ["Good for", fileMissing ? pc.red("Model file not found") : humanCapabilitySummary(caps)],
324
- ];
325
- if (mtpDrafter) rows.push(["MTP", mtpDrafter]);
326
- rows.push(["Pi", piConfigured ? pc.dim("synced") : pc.yellow("Needs sync")]);
327
- rows.push(["Runs with", backend.label]);
328
- return renderCard(`${num}. ${profile.label}`, renderRows(rows), { formatBorder: border });
328
+ ["Details", detailLine],
329
+ ["Runs with", backend.label],
330
+ ]), { formatBorder: border });
329
331
  }
330
332
 
331
333
  function downloadedModelCard(num, model, caps, { mtpAvailable } = {}) {
332
- const mtpRow = mtpAvailable
333
- ? [["MTP", pc.green("Drafter found — setup will enable 2× speedup")]]
334
- : (caps.architecture === "gemma4" || caps.architecture === "gemma4")
335
- ? [["MTP", pc.yellow("Drafter not found — download MTP model for 2× speedup")]]
336
- : [];
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));
337
342
  return renderCard(`${num}. ${model.label}`, renderRows([
338
343
  ["Status", pc.yellow("Needs setup")],
339
- ["Good for", humanCapabilitySummary(caps)],
340
- ...mtpRow,
341
- ["Size", formatBytes(model.sizeBytes)],
344
+ ["Details", detailParts.join(pc.dim(" · "))],
342
345
  ]), { formatBorder: pc.yellow });
343
346
  }
344
347
 
345
348
  function managedModelCard(num, model, backend) {
346
349
  return renderCard(`${num}. ${model.label}`, renderRows([
347
350
  ["Status", pc.dim(`Via ${backend.label}`)],
348
- ["Runs with", backend.label],
349
- ["Model ID", model.id],
350
- ...(model.quant ? [["Size/type", model.quant]] : []),
351
+ ["Details", [model.id, model.quant].filter(Boolean).join(pc.dim(" · "))],
351
352
  ]), { formatBorder: pc.dim });
352
353
  }
353
354
 
@@ -392,7 +393,13 @@ async function handleCatalogAction(prompt, action, item) {
392
393
  }
393
394
 
394
395
  if (action === "setup") {
395
- if (item.type === "profile") return await syncPiConfig(await readProfile(item.profile.id));
396
+ if (item.type === "profile") {
397
+ const profile = await readProfile(item.profile.id);
398
+ const configured = await configureLocalProfile(prompt, profile);
399
+ if (!configured) return;
400
+ await saveProfile(configured);
401
+ return await syncPiConfig(configured);
402
+ }
396
403
  if (item.type === "managed") {
397
404
  const profile = createManagedProfile(item.model, item.backendId);
398
405
  await saveProfile(profile);
@@ -427,7 +434,6 @@ async function handleCatalogAction(prompt, action, item) {
427
434
  async function printProfileDetails(profile) {
428
435
  const backend = backendFor(profile.backend);
429
436
  const isManaged = backend.type === "managed-server";
430
- const piConfigured = await hasPiModel(profile);
431
437
  const running = await isProfileRunning(profile);
432
438
  const fileMissing = !isManaged && isProfileFileMissing(profile);
433
439
  const statusRow = fileMissing
@@ -438,14 +444,16 @@ async function printProfileDetails(profile) {
438
444
  : (profile.capabilities?.architecture === "gemma4")
439
445
  ? pc.yellow("MTP available — download a drafter model to enable 2× speedup")
440
446
  : null;
447
+ const detailParts = [fileMissing ? pc.red("File not found") : humanCapabilitySummary(profile.capabilities ?? {})];
448
+ if (mtpStatus) detailParts.push(mtpStatus);
449
+ const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
450
+ if (ctxLabel) detailParts.push(ctxLabel);
441
451
  const overviewRows = [
442
452
  ["Name", pc.bold(profile.label)],
443
453
  ["Status", statusRow],
444
- ["Good for", fileMissing ? pc.red("Model file not found — remove or fix this setup") : humanCapabilitySummary(profile.capabilities ?? {})],
445
- ["Pi", piConfigured ? pc.dim("synced") : pc.yellow("Needs sync")],
454
+ ["Details", detailParts.join(pc.dim(" · "))],
446
455
  ["Server", fileMissing ? pc.red(profile.baseUrl) : profile.baseUrl],
447
456
  ];
448
- if (mtpStatus) overviewRows.push(["MTP", mtpStatus]);
449
457
  console.log("\n" + renderSection("Model overview", renderRows(overviewRows)));
450
458
 
451
459
  const detailRows = [
@@ -479,18 +487,21 @@ async function printProfileDetails(profile) {
479
487
  function printGgufModelDetails(model, drafter) {
480
488
  const caps = detectCapabilities(model.path, model.mmprojPath);
481
489
  const mtpAvailable = caps.mtp || Boolean(drafter);
482
- const mtpRow = mtpAvailable
483
- ? [["MTP", pc.green("Drafter found — setup will enable 2× speedup")]]
490
+ const mtpLabel = mtpAvailable
491
+ ? pc.green("MTP ")
484
492
  : (caps.architecture === "gemma4")
485
- ? [["MTP", pc.yellow("Drafter not found — download MTP model for 2× speedup")]]
486
- : [];
493
+ ? pc.yellow("MTP: needs drafter")
494
+ : null;
495
+ const detailParts = [humanCapabilitySummary(caps)];
496
+ if (mtpLabel) detailParts.push(mtpLabel);
497
+ const ctxLabel = caps.ctxSize ? `${(caps.ctxSize / 1000).toFixed(0)}k ctx` : null;
498
+ if (ctxLabel) detailParts.push(ctxLabel);
499
+ detailParts.push(formatBytes(model.sizeBytes));
487
500
  const overviewRows = [
488
501
  ["Name", pc.bold(model.label)],
489
502
  ["Status", pc.yellow("Needs one-time setup")],
490
- ["Good for", humanCapabilitySummary(caps)],
503
+ ["Details", detailParts.join(pc.dim(" · "))],
491
504
  ];
492
- if (mtpRow.length) overviewRows.push(...mtpRow);
493
- overviewRows.push(["Size", formatBytes(model.sizeBytes)]);
494
505
  console.log("\n" + renderSection("Downloaded model", renderRows(overviewRows)));
495
506
  const detailRows = [
496
507
  ["Local file", model.path],
@@ -499,8 +510,7 @@ function printGgufModelDetails(model, drafter) {
499
510
  ["Quant", model.quant ?? "unknown"],
500
511
  ];
501
512
  if (drafter) {
502
- detailRows.push(["Drafter", drafter.path]);
503
- detailRows.push(["Drafter size", formatBytes(drafter.sizeBytes)]);
513
+ detailRows.push(["Drafter", drafter.path], ["Drafter size", formatBytes(drafter.sizeBytes)]);
504
514
  }
505
515
  console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
506
516
  }
@@ -4,6 +4,9 @@ import { estimateMemory } from "./estimate.mjs";
4
4
  import { findLlamaServer } from "./config.mjs";
5
5
  import { baseUrlForFlags, LLAMA_CPP_PORT, LLAMA_CPP_MTP_PORT } from "./backends.mjs";
6
6
  import { pc, formatBytes, renderRows, renderSection } from "./ui.mjs";
7
+ import { detectCapabilities } from "./autodetect.mjs";
8
+ import { matchDrafter } from "./scan.mjs";
9
+ import { scanGgufModels } from "./scan.mjs";
7
10
 
8
11
  const execFileAsync = promisify(execFile);
9
12
 
@@ -29,7 +32,28 @@ const THINKING_DEFAULTS = {
29
32
 
30
33
  export async function configureLocalProfile(prompt, profile) {
31
34
  let configured = profile;
32
- const caps = profile.capabilities ?? {};
35
+ // Re-detect capabilities from the model file and check for drafters
36
+ // so that re-setup can pick up MTP availability, vision changes, etc.
37
+ const freshCaps = detectCapabilities(profile.modelPath, profile.mmprojPath);
38
+ let drafterPath = profile.drafterPath ?? null;
39
+ if (!drafterPath) {
40
+ const { drafters } = await scanGgufModels();
41
+ const drafter = matchDrafter(profile.modelPath, drafters);
42
+ if (drafter) drafterPath = drafter.path;
43
+ }
44
+ const hasMtp = freshCaps.mtp || Boolean(drafterPath);
45
+ const caps = { ...freshCaps, mtp: hasMtp };
46
+ // If MTP is newly available, switch backend and add drafter path
47
+ if (hasMtp && configured.backend !== "llama-cpp-mtp") {
48
+ configured = { ...configured, backend: "llama-cpp-mtp", providerId: "llama-cpp-mtp", drafterPath, capabilities: { ...configured.capabilities, mtp: true } };
49
+ }
50
+ if (drafterPath && !configured.drafterPath) {
51
+ configured = { ...configured, drafterPath };
52
+ }
53
+ // If vision was previously disabled but mmproj is back, re-enable
54
+ if (configured.disabledMmprojPath && configured.mmprojPath === null && freshCaps.vision) {
55
+ configured = { ...configured, mmprojPath: configured.disabledMmprojPath, disabledMmprojPath: undefined, capabilities: { ...configured.capabilities, vision: true, visionDisabledReason: undefined } };
56
+ }
33
57
 
34
58
  console.log("");
35
59
  console.log(renderSection("Model setup", renderRows([