offgrid-ai 0.3.25 → 0.3.27

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.3.25",
3
+ "version": "0.3.27",
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",
@@ -6,6 +6,7 @@ import { readGgufMetadata } from "./gguf.mjs";
6
6
 
7
7
  export function detectCapabilities(modelPath, mmprojPath) {
8
8
  const meta = safeReadGgufMetadata(modelPath);
9
+ const mmprojMeta = mmprojPath ? safeReadGgufMetadata(mmprojPath) : {};
9
10
  const name = basename(modelPath).toLowerCase();
10
11
  const pathHints = String(modelPath).toLowerCase();
11
12
 
@@ -25,13 +26,14 @@ export function detectCapabilities(modelPath, mmprojPath) {
25
26
 
26
27
  // Vision — mmproj present
27
28
  const vision = Boolean(mmprojPath && existsSync(mmprojPath));
29
+ const mmprojProjectorType = stringMeta(mmprojMeta, "clip.vision.projector_type") ?? stringMeta(mmprojMeta, "clip.audio.projector_type") ?? null;
28
30
 
29
31
  // MTP (multi-token prediction) — detect speculative decoding.
30
32
  // Do not treat all Qwen models as MTP; require an explicit filename or metadata hint.
31
33
  const mtp = /\bmtp\b|draft-mtp|multi-token/i.test(pathHints) || Object.keys(meta).some((key) => /mtp|draft|speculative/i.test(key));
32
34
 
33
35
  // Quantization
34
- const quant = name.match(/(Q\d_K_[A-Z]+|UD-[A-Z0-9_]+)/i)?.[1] ?? null;
36
+ const quant = name.match(/(Q\d_K_[A-Z]+|Q\d_[01]|UD-[A-Z0-9_]+)/i)?.[1] ?? null;
35
37
 
36
38
  // Context size from metadata, fallback to name hints
37
39
  const metaCtx = architecture
@@ -39,7 +41,7 @@ export function detectCapabilities(modelPath, mmprojPath) {
39
41
  : undefined;
40
42
  const ctxSize = metaCtx ?? (thinking ? 80000 : 32768);
41
43
 
42
- return { architecture, thinking, vision, mtp, qat, imatrix, quant, metaCtx, ctxSize, meta };
44
+ return { architecture, thinking, vision, mtp, qat, imatrix, quant, metaCtx, ctxSize, meta, mmprojProjectorType };
43
45
  }
44
46
 
45
47
  // ── Compute llama-server flags from capabilities ───────────────────────────
@@ -127,4 +129,9 @@ function safeReadGgufMetadata(modelPath) {
127
129
  function numberMeta(meta, key) {
128
130
  const value = key ? meta[key] : undefined;
129
131
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
132
+ }
133
+
134
+ function stringMeta(meta, key) {
135
+ const value = key ? meta[key] : undefined;
136
+ return typeof value === "string" && value ? value : undefined;
130
137
  }
package/src/cli.mjs CHANGED
@@ -456,6 +456,33 @@ function capabilitySummary(caps) {
456
456
  return parts.length > 0 ? parts.join(" · ") : "standard GGUF";
457
457
  }
458
458
 
459
+ function isUnsupportedMmprojError(err, profile) {
460
+ const message = String(err?.message ?? "");
461
+ return Boolean(profile.mmprojPath && /unknown projector type|failed to load multimodal model|failed to load CLIP model/i.test(message));
462
+ }
463
+
464
+ function textOnlyProfile(profile) {
465
+ return normalizeProfile({
466
+ ...profile,
467
+ mmprojPath: null,
468
+ disabledMmprojPath: profile.disabledMmprojPath ?? profile.mmprojPath,
469
+ capabilities: { ...(profile.capabilities ?? {}), vision: false, visionDisabledReason: "unsupported-mmproj" },
470
+ commandArgv: removeCommandOption(profile.commandArgv ?? [], "--mmproj"),
471
+ });
472
+ }
473
+
474
+ function removeCommandOption(argv, flag) {
475
+ const next = [];
476
+ for (let i = 0; i < argv.length; i++) {
477
+ if (argv[i] === flag) {
478
+ if (argv[i + 1] && !argv[i + 1].startsWith("--")) i += 1;
479
+ continue;
480
+ }
481
+ next.push(argv[i]);
482
+ }
483
+ return next;
484
+ }
485
+
459
486
  function createManagedProfile(model, backendId) {
460
487
  return normalizeProfile({
461
488
  id: model.id.replace(/[^a-z0-9._-]+/gi, "-").toLowerCase(),
@@ -510,6 +537,13 @@ async function runProfile(profile, options = {}) {
510
537
  if (state?.pid) {
511
538
  try { await stopProfile(profile); } catch { /* best effort */ }
512
539
  }
540
+ if (!options.textOnlyRetry && isUnsupportedMmprojError(err, profile)) {
541
+ console.log(pc.yellow("Vision projector is not supported by this llama.cpp build. Retrying text-only."));
542
+ console.log(pc.dim("Update llama.cpp later to re-enable vision for this model."));
543
+ const textOnly = textOnlyProfile(profile);
544
+ await saveProfile(textOnly);
545
+ return await runProfile(textOnly, { ...options, textOnlyRetry: true });
546
+ }
513
547
  throw err;
514
548
  }
515
549
  }
@@ -965,29 +999,23 @@ async function uninstallCommand(argv) {
965
999
  console.log(pc.green("All servers stopped."));
966
1000
  }
967
1001
 
968
- // Ask about data
969
1002
  const dataDir = DATA_DIR;
970
- const keepData = await prompt.yesNo("Keep your profiles and model configurations? (Recommended if you plan to reinstall)", true);
1003
+ const mode = await prompt.choice("Choose uninstall type", [
1004
+ { value: "keep-data", label: "Uninstall app only", hint: `keep profiles and settings in ${dataDir}` },
1005
+ { value: "delete-data", label: "Full uninstall", hint: "delete profiles/settings, then uninstall app" },
1006
+ { value: "cancel", label: "Cancel" },
1007
+ ], "keep-data");
971
1008
 
972
- if (!keepData) {
973
- const confirmDelete = await prompt.yesNo(`Delete ${dataDir}? This removes all profiles and settings.`, false);
974
- if (confirmDelete) {
975
- await removeDataDir();
976
- } else {
977
- console.log(pc.dim("Keeping data directory."));
978
- }
979
- } else {
980
- console.log(pc.dim(`Keeping ${dataDir} for when you reinstall.`));
981
- }
982
-
983
- // Remove the npm package
984
- const confirmUninstall = await prompt.yesNo("Uninstall offgrid-ai npm package?", true);
985
- if (confirmUninstall) {
986
- await removeShellPath();
987
- await removeSelf();
988
- } else {
1009
+ if (mode === "cancel") {
989
1010
  console.log(pc.dim("Cancelled."));
1011
+ return;
990
1012
  }
1013
+
1014
+ if (mode === "delete-data") await removeDataDir();
1015
+ else console.log(pc.dim(`Keeping ${dataDir} for when you reinstall.`));
1016
+
1017
+ await removeShellPath();
1018
+ await removeSelf();
991
1019
  } finally {
992
1020
  prompt.close();
993
1021
  }
@@ -8,6 +8,12 @@ if (process.env.npm_config_global !== "true") process.exit(0);
8
8
  const prefix = process.env.npm_config_prefix;
9
9
  if (!prefix) process.exit(0);
10
10
 
11
+ if (isHermesPrefix(prefix, process.env.HOME)) {
12
+ console.log("offgrid-ai installed with a Hermes-managed npm prefix.");
13
+ console.log("Not adding Hermes Node to PATH automatically. Use your normal Node/npm, or run the offgrid-ai install script.");
14
+ process.exit(0);
15
+ }
16
+
11
17
  const npmBin = join(prefix, "bin");
12
18
  const marker = "# Added by offgrid-ai installer";
13
19
  const pathLine = `export PATH="${npmBin}:$PATH"`;
@@ -39,3 +45,9 @@ if (!content.includes(npmBin)) {
39
45
  console.log(`offgrid-ai is installed in ${npmBin}`);
40
46
  console.log(`Open a new terminal if the command is not found yet.`);
41
47
  }
48
+
49
+ function isHermesPrefix(prefix, home) {
50
+ const normalized = prefix.replace(/\\/gu, "/");
51
+ if (normalized.includes("/.hermes/")) return true;
52
+ return Boolean(home && normalized === `${home.replace(/\\/gu, "/")}/.hermes/node`);
53
+ }
@@ -50,10 +50,22 @@ export async function configureLocalProfile(prompt, profile) {
50
50
  console.log("");
51
51
  console.log(renderSection("QAT detected", renderRows([
52
52
  ["Meaning", "quantization-aware trained"],
53
- ["Runtime flags", "none required"],
53
+ ["Runtime flags", "none QAT-specific"],
54
54
  ])));
55
55
  }
56
56
 
57
+ if (caps.vision && profile.mmprojPath) {
58
+ console.log("");
59
+ const gemma4Unified = isGemma4UnifiedProjector(caps.mmprojProjectorType);
60
+ console.log(renderSection("Vision projector detected", renderRows([
61
+ ["Projector", caps.mmprojProjectorType ?? "unknown"],
62
+ ["Flag", `--mmproj ${profile.mmprojPath}`],
63
+ ...(gemma4Unified ? [["Note", pc.yellow("Gemma 4 unified projectors need newer llama.cpp than current Homebrew stable.")]] : []),
64
+ ])));
65
+ const useVision = await prompt.yesNo("Enable vision with --mmproj?", !gemma4Unified);
66
+ configured = useVision ? applyVisionDefaults(configured) : removeVisionDefaults(configured, gemma4Unified ? "gemma4-unified-unsupported" : "user-disabled");
67
+ }
68
+
57
69
  if (caps.thinking) {
58
70
  console.log("");
59
71
  console.log(renderSection("Thinking model detected", renderRows([
@@ -106,6 +118,23 @@ function removeMtpDefaults(profile) {
106
118
  });
107
119
  }
108
120
 
121
+ function applyVisionDefaults(profile) {
122
+ if (!profile.mmprojPath) return profile;
123
+ return applyProfileFlags({
124
+ ...profile,
125
+ capabilities: { ...(profile.capabilities ?? {}), vision: true, visionDisabledReason: undefined },
126
+ }, profile.flags, { values: { "--mmproj": profile.mmprojPath } });
127
+ }
128
+
129
+ function removeVisionDefaults(profile, reason) {
130
+ return applyProfileFlags({
131
+ ...profile,
132
+ disabledMmprojPath: profile.mmprojPath,
133
+ mmprojPath: null,
134
+ capabilities: { ...(profile.capabilities ?? {}), vision: false, visionDisabledReason: reason },
135
+ }, profile.flags, { remove: ["--mmproj"] });
136
+ }
137
+
109
138
  function applyThinkingDefaults(profile) {
110
139
  const flags = { ...profile.flags, ...THINKING_DEFAULTS };
111
140
  return applyProfileFlags(profile, flags);
@@ -179,6 +208,10 @@ function renderMemoryEstimate(profile) {
179
208
  }
180
209
  }
181
210
 
211
+ function isGemma4UnifiedProjector(projectorType) {
212
+ return /gemma4u[va]/i.test(String(projectorType ?? ""));
213
+ }
214
+
182
215
  function detectionSummary(caps) {
183
216
  const parts = [];
184
217
  if (caps.architecture) parts.push(caps.architecture);
package/src/profiles.mjs CHANGED
@@ -168,6 +168,7 @@ function summarizeCapabilities(caps) {
168
168
  imatrix: caps.imatrix,
169
169
  quant: caps.quant,
170
170
  metaCtx: caps.metaCtx,
171
+ mmprojProjectorType: caps.mmprojProjectorType,
171
172
  ctxSize: caps.ctxSize,
172
173
  };
173
174
  }
package/src/scan.mjs CHANGED
@@ -74,5 +74,5 @@ function aliasFromName(name) {
74
74
  }
75
75
 
76
76
  function quantFromName(name) {
77
- return name.match(/(Q\d_K_[A-Z]+|UD-[A-Z0-9_]+)/)?.[1];
77
+ return name.match(/(Q\d_K_[A-Z]+|Q\d_[01]|UD-[A-Z0-9_]+)/)?.[1];
78
78
  }