offgrid-ai 0.3.26 → 0.3.28

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.26",
3
+ "version": "0.3.28",
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
@@ -15,6 +15,7 @@ import { removeInstallerPathEntries } from "./shell-path.mjs";
15
15
  import { configureLocalProfile } from "./profile-setup.mjs";
16
16
  import { buildPrettyCommand } from "./command.mjs";
17
17
  import { detectCapabilities } from "./autodetect.mjs";
18
+ import { offerManagedLlamaRuntimeUpdate } from "./runtime.mjs";
18
19
 
19
20
  // ── Entry point ────────────────────────────────────────────────────────────
20
21
 
@@ -66,6 +67,15 @@ export async function run(argv) {
66
67
  export async function mainFlow() {
67
68
  await ensureDirs();
68
69
 
70
+ if (process.stdin.isTTY) {
71
+ const runtimePrompt = createPrompt();
72
+ try {
73
+ await offerManagedLlamaRuntimeUpdate(runtimePrompt);
74
+ } finally {
75
+ runtimePrompt.close();
76
+ }
77
+ }
78
+
69
79
  // 1. Check what backends are available
70
80
  const llamaBinary = await findLlamaServer();
71
81
  const ggufModels = await scanGgufModels();
@@ -456,6 +466,33 @@ function capabilitySummary(caps) {
456
466
  return parts.length > 0 ? parts.join(" · ") : "standard GGUF";
457
467
  }
458
468
 
469
+ function isUnsupportedMmprojError(err, profile) {
470
+ const message = String(err?.message ?? "");
471
+ return Boolean(profile.mmprojPath && /unknown projector type|failed to load multimodal model|failed to load CLIP model/i.test(message));
472
+ }
473
+
474
+ function textOnlyProfile(profile) {
475
+ return normalizeProfile({
476
+ ...profile,
477
+ mmprojPath: null,
478
+ disabledMmprojPath: profile.disabledMmprojPath ?? profile.mmprojPath,
479
+ capabilities: { ...(profile.capabilities ?? {}), vision: false, visionDisabledReason: "unsupported-mmproj" },
480
+ commandArgv: removeCommandOption(profile.commandArgv ?? [], "--mmproj"),
481
+ });
482
+ }
483
+
484
+ function removeCommandOption(argv, flag) {
485
+ const next = [];
486
+ for (let i = 0; i < argv.length; i++) {
487
+ if (argv[i] === flag) {
488
+ if (argv[i + 1] && !argv[i + 1].startsWith("--")) i += 1;
489
+ continue;
490
+ }
491
+ next.push(argv[i]);
492
+ }
493
+ return next;
494
+ }
495
+
459
496
  function createManagedProfile(model, backendId) {
460
497
  return normalizeProfile({
461
498
  id: model.id.replace(/[^a-z0-9._-]+/gi, "-").toLowerCase(),
@@ -510,6 +547,13 @@ async function runProfile(profile, options = {}) {
510
547
  if (state?.pid) {
511
548
  try { await stopProfile(profile); } catch { /* best effort */ }
512
549
  }
550
+ if (!options.textOnlyRetry && isUnsupportedMmprojError(err, profile)) {
551
+ console.log(pc.yellow("Vision projector is not supported by this llama.cpp build. Retrying text-only."));
552
+ console.log(pc.dim("Update llama.cpp later to re-enable vision for this model."));
553
+ const textOnly = textOnlyProfile(profile);
554
+ await saveProfile(textOnly);
555
+ return await runProfile(textOnly, { ...options, textOnlyRetry: true });
556
+ }
513
557
  throw err;
514
558
  }
515
559
  }
package/src/config.mjs CHANGED
@@ -10,6 +10,8 @@ export const DATA_DIR = process.env.OFFGRID_DIR || join(homedir(), ".offgrid-ai"
10
10
  export const PROFILE_DIR = join(DATA_DIR, "profiles");
11
11
  export const LOG_DIR = join(DATA_DIR, "logs");
12
12
  export const RUN_DIR = join(DATA_DIR, "run");
13
+ export const RUNTIME_DIR = join(DATA_DIR, "runtime");
14
+ export const MANAGED_LLAMA_SERVER = join(RUNTIME_DIR, "bin", "llama-server");
13
15
 
14
16
  // ── Default scan directories ──────────────────────────────────────────────
15
17
 
@@ -74,14 +76,17 @@ export async function findLlamaServer() {
74
76
  return process.env.LLAMA_SERVER_BINARY;
75
77
  }
76
78
 
77
- // 2. which/where
79
+ // 2. offgrid-ai managed runtime
80
+ if (existsSync(MANAGED_LLAMA_SERVER)) return MANAGED_LLAMA_SERVER;
81
+
82
+ // 3. which/where
78
83
  try {
79
84
  const { stdout } = await execFileAsync("which", ["llama-server"]);
80
85
  const path = stdout.trim();
81
86
  if (path && existsSync(path)) return path;
82
87
  } catch { /* not on PATH */ }
83
88
 
84
- // 3. Homebrew
89
+ // 4. Homebrew
85
90
  try {
86
91
  const { stdout } = await execFileAsync("brew", ["--prefix", "llama.cpp"]);
87
92
  const prefix = stdout.trim();
@@ -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
  }
@@ -0,0 +1,130 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { chmod, mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { basename, join } from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { MANAGED_LLAMA_SERVER, RUNTIME_DIR } from "./config.mjs";
9
+ import { compareVersions } from "./updates.mjs";
10
+ import { pc, renderCard, renderRows } from "./ui.mjs";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+ const RELEASE_API = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest";
14
+ const VERSION_PATH = join(RUNTIME_DIR, "llama.cpp", "VERSION.json");
15
+
16
+ export async function offerManagedLlamaRuntimeUpdate(prompt, { fetchImpl = globalThis.fetch } = {}) {
17
+ const latest = await latestLlamaRelease(fetchImpl);
18
+ if (!latest) return false;
19
+
20
+ const installed = await installedRuntime();
21
+ if (installed?.tag && compareBuildTags(installed.tag, latest.tag) >= 0 && existsSync(MANAGED_LLAMA_SERVER)) return false;
22
+
23
+ console.log("\n" + renderCard("llama.cpp runtime", renderRows([
24
+ ["Installed", installed?.tag ?? "none"],
25
+ ["Latest", pc.green(latest.tag)],
26
+ ["Source", "official GitHub release binary"],
27
+ ]), { formatBorder: pc.cyan }));
28
+
29
+ const shouldInstall = await prompt.yesNo(installed ? "Update llama.cpp runtime?" : "Install llama.cpp runtime?", true);
30
+ if (!shouldInstall) return false;
31
+
32
+ await installLlamaRelease(latest);
33
+ return true;
34
+ }
35
+
36
+ export async function latestLlamaRelease(fetchImpl = globalThis.fetch) {
37
+ try {
38
+ const response = await fetchImpl(RELEASE_API, { signal: AbortSignal.timeout(5000) });
39
+ if (!response.ok) return null;
40
+ const body = await response.json();
41
+ const tag = typeof body?.tag_name === "string" ? body.tag_name : null;
42
+ const asset = selectAsset(body?.assets ?? [], process.platform, process.arch);
43
+ if (!tag || !asset) return null;
44
+ return { tag, asset };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ export async function installedRuntime() {
51
+ try {
52
+ return JSON.parse(await readFile(VERSION_PATH, "utf8"));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export async function installLlamaRelease(release) {
59
+ const tmp = await mkdtemp(join(tmpdir(), "offgrid-llama-"));
60
+ const archive = join(tmp, release.asset.name);
61
+ const releaseDir = join(RUNTIME_DIR, "llama.cpp", "releases", release.tag);
62
+ const binDir = join(RUNTIME_DIR, "bin");
63
+
64
+ try {
65
+ console.log(pc.dim(`Downloading ${release.asset.name}...`));
66
+ const response = await fetch(release.asset.url);
67
+ if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
68
+ const bytes = Buffer.from(await response.arrayBuffer());
69
+ verifyDigest(bytes, release.asset.digest);
70
+ await writeFile(archive, bytes);
71
+
72
+ await rm(releaseDir, { recursive: true, force: true });
73
+ await mkdir(releaseDir, { recursive: true });
74
+ await execFileAsync("tar", ["-xzf", archive, "--strip-components", "1", "-C", releaseDir]);
75
+
76
+ const llamaServer = join(releaseDir, "llama-server");
77
+ if (!existsSync(llamaServer)) throw new Error("Release archive did not contain llama-server");
78
+ await chmod(llamaServer, 0o755);
79
+
80
+ await mkdir(binDir, { recursive: true });
81
+ await unlink(MANAGED_LLAMA_SERVER).catch(() => {});
82
+ await symlink(llamaServer, MANAGED_LLAMA_SERVER);
83
+
84
+ await mkdir(join(RUNTIME_DIR, "llama.cpp"), { recursive: true });
85
+ await writeFile(VERSION_PATH, JSON.stringify({
86
+ runtime: "llama.cpp",
87
+ tag: release.tag,
88
+ asset: release.asset.name,
89
+ url: release.asset.url,
90
+ installedAt: new Date().toISOString(),
91
+ llamaServer,
92
+ }, null, 2) + "\n", "utf8");
93
+
94
+ console.log(pc.green(`✓ Installed llama.cpp ${release.tag}`));
95
+ } finally {
96
+ await rm(tmp, { recursive: true, force: true }).catch(() => {});
97
+ }
98
+ }
99
+
100
+ function selectAsset(assets, platform, arch) {
101
+ const suffix = assetSuffix(platform, arch);
102
+ if (!suffix) return null;
103
+ const asset = assets.find((item) => item?.name?.endsWith(suffix));
104
+ if (!asset?.browser_download_url) return null;
105
+ return {
106
+ name: asset.name,
107
+ url: asset.browser_download_url,
108
+ digest: asset.digest,
109
+ size: asset.size,
110
+ };
111
+ }
112
+
113
+ function assetSuffix(platform, arch) {
114
+ const cpu = arch === "arm64" ? "arm64" : arch === "x64" ? "x64" : null;
115
+ if (!cpu) return null;
116
+ if (platform === "darwin") return `bin-macos-${cpu}.tar.gz`;
117
+ if (platform === "linux") return `bin-ubuntu-${cpu}.tar.gz`;
118
+ return null;
119
+ }
120
+
121
+ function compareBuildTags(a, b) {
122
+ return compareVersions(String(a).replace(/^b/u, ""), String(b).replace(/^b/u, ""));
123
+ }
124
+
125
+ function verifyDigest(bytes, digest) {
126
+ if (!digest?.startsWith("sha256:")) return;
127
+ const expected = digest.slice("sha256:".length);
128
+ const actual = createHash("sha256").update(bytes).digest("hex");
129
+ if (actual !== expected) throw new Error(`${basename("llama.cpp")}: checksum mismatch`);
130
+ }
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
  }