offgrid-ai 0.3.27 → 0.3.29
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 +10 -0
- package/src/config.mjs +7 -2
- package/src/profile-setup.mjs +22 -3
- package/src/runtime.mjs +130 -0
package/package.json
CHANGED
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();
|
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.
|
|
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
|
-
//
|
|
89
|
+
// 4. Homebrew
|
|
85
90
|
try {
|
|
86
91
|
const { stdout } = await execFileAsync("brew", ["--prefix", "llama.cpp"]);
|
|
87
92
|
const prefix = stdout.trim();
|
package/src/profile-setup.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
1
3
|
import { estimateMemory } from "./estimate.mjs";
|
|
4
|
+
import { findLlamaServer } from "./config.mjs";
|
|
2
5
|
import { pc, formatBytes, renderRows, renderSection } from "./ui.mjs";
|
|
3
6
|
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
4
9
|
const CACHE_CHOICES = [
|
|
5
10
|
{ value: "bf16", label: "bf16", hint: "default: stable, good quality" },
|
|
6
11
|
{ value: "f16", label: "f16", hint: "stable fallback, similar memory to bf16" },
|
|
@@ -57,13 +62,14 @@ export async function configureLocalProfile(prompt, profile) {
|
|
|
57
62
|
if (caps.vision && profile.mmprojPath) {
|
|
58
63
|
console.log("");
|
|
59
64
|
const gemma4Unified = isGemma4UnifiedProjector(caps.mmprojProjectorType);
|
|
65
|
+
const supported = !gemma4Unified || await runtimeSupportsGemma4Unified();
|
|
60
66
|
console.log(renderSection("Vision projector detected", renderRows([
|
|
61
67
|
["Projector", caps.mmprojProjectorType ?? "unknown"],
|
|
62
68
|
["Flag", `--mmproj ${profile.mmprojPath}`],
|
|
63
|
-
...(gemma4Unified ? [["Note", pc.yellow("Gemma 4 unified projectors need
|
|
69
|
+
...(gemma4Unified && !supported ? [["Note", pc.yellow("Gemma 4 unified projectors need llama.cpp b9549+.")]] : []),
|
|
64
70
|
])));
|
|
65
|
-
const useVision = await prompt.yesNo("Enable vision with --mmproj?",
|
|
66
|
-
configured = useVision ? applyVisionDefaults(configured) : removeVisionDefaults(configured, gemma4Unified ? "gemma4-unified-unsupported" : "user-disabled");
|
|
71
|
+
const useVision = await prompt.yesNo("Enable vision with --mmproj?", supported);
|
|
72
|
+
configured = useVision ? applyVisionDefaults(configured) : removeVisionDefaults(configured, gemma4Unified && !supported ? "gemma4-unified-unsupported" : "user-disabled");
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
if (caps.thinking) {
|
|
@@ -212,6 +218,19 @@ function isGemma4UnifiedProjector(projectorType) {
|
|
|
212
218
|
return /gemma4u[va]/i.test(String(projectorType ?? ""));
|
|
213
219
|
}
|
|
214
220
|
|
|
221
|
+
async function runtimeSupportsGemma4Unified() {
|
|
222
|
+
try {
|
|
223
|
+
const binary = await findLlamaServer();
|
|
224
|
+
if (!binary) return false;
|
|
225
|
+
const { stdout, stderr } = await execFileAsync(binary, ["--version"]);
|
|
226
|
+
const output = `${stdout}\n${stderr}`;
|
|
227
|
+
const version = Number(output.match(/version:\s*(\d+)/i)?.[1]);
|
|
228
|
+
return Number.isFinite(version) && version >= 9549;
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
215
234
|
function detectionSummary(caps) {
|
|
216
235
|
const parts = [];
|
|
217
236
|
if (caps.architecture) parts.push(caps.architecture);
|
package/src/runtime.mjs
ADDED
|
@@ -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
|
+
}
|