offgrid-ai 0.17.0 → 0.18.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.
- package/package.json +1 -1
- package/resources/recommendations.json +8 -8
- package/src/commands/main.mjs +1 -4
- package/src/commands/models.mjs +199 -15
- package/src/commands/onboard.mjs +6 -106
- package/src/commands/run.mjs +1 -0
- package/src/commands/status.mjs +1 -0
- package/src/commands/stop.mjs +1 -0
- package/src/discovery-shared.mjs +2 -3
- package/src/download.mjs +221 -0
- package/src/harness-pi.mjs +2 -3
- package/src/huggingface.mjs +72 -72
- package/src/managed.mjs +1 -6
- package/src/model-presenters.mjs +1 -23
- package/src/model-summary.mjs +2 -2
- package/src/omlx-runtime.mjs +29 -4
- package/src/process.mjs +3 -5
- package/src/profiles.mjs +1 -1
- package/src/runtime.mjs +2 -2
- package/src/ui.mjs +2 -0
- package/resources/hf-download.py +0 -79
- package/src/backend-installers.mjs +0 -42
package/src/download.mjs
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Model download flow — HuggingFace downloads with quant picker and RAM fit.
|
|
2
|
+
// Used by onboarding (no models found) and the model picker (↓ Download a model).
|
|
3
|
+
|
|
4
|
+
import { hasHfCli, parseHfRef, resolveHfDownload, downloadModel, listGgufFiles, getHfModelInfo, isMlxRepo } from "./huggingface.mjs";
|
|
5
|
+
import { detectHardware, installedRamGB, getFreeDiskBytes } from "./hardware.mjs";
|
|
6
|
+
import { allFittingModels } from "./recommendations.mjs";
|
|
7
|
+
import { parseModelName } from "./model-name.mjs";
|
|
8
|
+
import { HF_HUB_DIR } from "./config.mjs";
|
|
9
|
+
import { offerOmlxRestart } from "./omlx-runtime.mjs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { pc, formatBytes, renderCard, renderRows } from "./ui.mjs";
|
|
13
|
+
|
|
14
|
+
const GB = 1024 ** 3;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Interactive model download flow.
|
|
18
|
+
* @param {object} prompt - createPrompt() instance
|
|
19
|
+
* @returns {Promise<boolean>} true if a model was downloaded
|
|
20
|
+
*/
|
|
21
|
+
export async function downloadFlow(prompt) {
|
|
22
|
+
console.log("");
|
|
23
|
+
const method = await prompt.choice("Download a model", [
|
|
24
|
+
{ value: "manual", label: "Enter a HuggingFace repo ID" },
|
|
25
|
+
{ value: "recommended", label: "Recommended for your machine" },
|
|
26
|
+
], "manual");
|
|
27
|
+
|
|
28
|
+
if (!method) return false;
|
|
29
|
+
|
|
30
|
+
let repo, filename;
|
|
31
|
+
|
|
32
|
+
if (method === "recommended") {
|
|
33
|
+
const hardware = detectHardware();
|
|
34
|
+
const models = allFittingModels(hardware);
|
|
35
|
+
if (models.length === 0) {
|
|
36
|
+
console.log(pc.yellow("No recommended models fit your hardware."));
|
|
37
|
+
console.log(pc.dim("You can still enter a repo ID manually."));
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
const choices = models.map((m) => ({
|
|
41
|
+
value: m,
|
|
42
|
+
label: `${pc.bold(m.label)} ${pc.dim(`(${m.minRamGb} GB RAM min)`)}`,
|
|
43
|
+
}));
|
|
44
|
+
const selected = await prompt.choice("Select a model", choices, choices[0].value);
|
|
45
|
+
if (!selected) return false;
|
|
46
|
+
|
|
47
|
+
// Determine available formats (ignore empty strings)
|
|
48
|
+
const hasGguf = Boolean(selected.gguf);
|
|
49
|
+
const hasMlx = Boolean(selected.mlx && selected.mlx.trim());
|
|
50
|
+
|
|
51
|
+
let format;
|
|
52
|
+
if (hasGguf && hasMlx) {
|
|
53
|
+
// Both available — let the user choose
|
|
54
|
+
const formatChoices = [
|
|
55
|
+
{ value: "gguf", label: `GGUF (llama.cpp) — ${selected.gguf.split("/").pop()}` },
|
|
56
|
+
{ value: "mlx", label: `MLX (oMLX) — ${selected.mlx}` },
|
|
57
|
+
];
|
|
58
|
+
// Default to MLX on Apple Silicon, GGUF elsewhere
|
|
59
|
+
const defaultFormat = (hardware.platform === "darwin" && hardware.arch === "arm64") ? "mlx" : "gguf";
|
|
60
|
+
format = await prompt.choice("Download format", formatChoices, defaultFormat);
|
|
61
|
+
if (!format) return false;
|
|
62
|
+
} else if (hasGguf) {
|
|
63
|
+
format = "gguf";
|
|
64
|
+
} else if (hasMlx) {
|
|
65
|
+
format = "mlx";
|
|
66
|
+
} else {
|
|
67
|
+
console.log(pc.yellow("No download path available for this model."));
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (format === "gguf") {
|
|
72
|
+
const ref = parseHfRef(selected.gguf);
|
|
73
|
+
repo = ref.repo;
|
|
74
|
+
filename = ref.filename;
|
|
75
|
+
} else {
|
|
76
|
+
repo = selected.mlx;
|
|
77
|
+
filename = undefined;
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
console.log(pc.dim(" Browse models at huggingface.co/models"));
|
|
81
|
+
const input = await prompt.text("HuggingFace repo ID (e.g. unsloth/gemma-4-E2B-it-GGUF)", "");
|
|
82
|
+
if (!input || !input.trim()) return false;
|
|
83
|
+
const ref = parseHfRef(input.trim());
|
|
84
|
+
repo = ref.repo;
|
|
85
|
+
filename = ref.filename;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// For GGUF repos without a specific file, show quant picker
|
|
89
|
+
if (!filename) {
|
|
90
|
+
let ggufFiles;
|
|
91
|
+
try {
|
|
92
|
+
ggufFiles = await listGgufFiles(repo);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.log(pc.red(`Could not fetch repo info: ${err.message}`));
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
if (ggufFiles.length > 0) {
|
|
98
|
+
filename = await pickGgufQuant(prompt, repo, ggufFiles);
|
|
99
|
+
if (!filename) return false;
|
|
100
|
+
} else {
|
|
101
|
+
// No GGUF files — check if it's an MLX repo via HF metadata
|
|
102
|
+
let modelInfo;
|
|
103
|
+
try {
|
|
104
|
+
modelInfo = await getHfModelInfo(repo);
|
|
105
|
+
} catch {
|
|
106
|
+
console.log(pc.red(`Could not fetch repo info for ${repo}. Check the repo ID and try again.`));
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (!isMlxRepo(modelInfo)) {
|
|
110
|
+
console.log(pc.yellow(`This repo is not a GGUF or MLX model (library: ${modelInfo.library_name ?? "unknown"}).`));
|
|
111
|
+
console.log(pc.dim("For llama.cpp: look for a repo ending in -GGUF (e.g. org/model-name-GGUF)"));
|
|
112
|
+
console.log(pc.dim("For oMLX: look for a repo in mlx-community/ (e.g. mlx-community/model-name-4bit)"));
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
// It's MLX — download everything
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check for huggingface_hub
|
|
120
|
+
if (!(await hasHfCli())) {
|
|
121
|
+
console.log(pc.yellow("HuggingFace CLI is required to download models."));
|
|
122
|
+
console.log(pc.dim("Install it: pip3 install huggingface_hub"));
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Resolve download plan
|
|
127
|
+
const ref = filename ? `${repo}/${filename}` : repo;
|
|
128
|
+
let plan;
|
|
129
|
+
try {
|
|
130
|
+
plan = await resolveHfDownload(ref);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.log(pc.red(`Could not resolve download: ${err.message}`));
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Check disk space
|
|
137
|
+
const freeBytes = getFreeDiskBytes(HF_HUB_DIR);
|
|
138
|
+
if (plan.totalSizeBytes > 0 && freeBytes < plan.totalSizeBytes * 1.1) {
|
|
139
|
+
console.log(pc.red(`Not enough disk space: need ~${formatBytes(plan.totalSizeBytes)}, only ${formatBytes(freeBytes)} free.`));
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
console.log(pc.dim(`\nDownloading ${repo}${filename ? `/${filename}` : ""} (${formatBytes(plan.totalSizeBytes)})`));
|
|
144
|
+
if (plan.format === "mlx") {
|
|
145
|
+
const modelParts = repo.split("/").filter(Boolean);
|
|
146
|
+
const localDir = join(homedir(), ".omlx", "models", ...modelParts);
|
|
147
|
+
console.log(pc.dim(`Location: ${localDir}\n`));
|
|
148
|
+
} else {
|
|
149
|
+
console.log(pc.dim(`Location: HF cache (${HF_HUB_DIR})\n`));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
if (plan.format === "mlx") {
|
|
154
|
+
// Download directly to ~/.omlx/models/<org>/<model> — oMLX scans this dir
|
|
155
|
+
const modelParts = repo.split("/").filter(Boolean);
|
|
156
|
+
const localDir = join(homedir(), ".omlx", "models", ...modelParts);
|
|
157
|
+
await downloadModel(plan, { localDir });
|
|
158
|
+
console.log(pc.green("\n✓ Download complete."));
|
|
159
|
+
await offerOmlxRestart(prompt, "to load the new model");
|
|
160
|
+
} else {
|
|
161
|
+
await downloadModel(plan);
|
|
162
|
+
console.log(pc.green("\n✓ Download complete. Run offgrid-ai again to see the model in the picker."));
|
|
163
|
+
}
|
|
164
|
+
return true;
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.log(pc.red("\nDownload failed: " + err.message));
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Quant picker with RAM fit indicators ───────────────────────────────────
|
|
172
|
+
|
|
173
|
+
async function pickGgufQuant(prompt, repo, ggufFiles) {
|
|
174
|
+
const hardware = detectHardware();
|
|
175
|
+
const totalRam = hardware.totalRamBytes;
|
|
176
|
+
const availableRam = totalRam - 4 * GB; // leave 4GB for OS
|
|
177
|
+
|
|
178
|
+
// Sort by size descending (highest quality first)
|
|
179
|
+
const sorted = [...ggufFiles].sort((a, b) => b.sizeBytes - a.sizeBytes);
|
|
180
|
+
|
|
181
|
+
// Find recommended: largest file that fits comfortably
|
|
182
|
+
const recommended = sorted.find((f) => f.sizeBytes + 2 * GB <= availableRam);
|
|
183
|
+
|
|
184
|
+
console.log("");
|
|
185
|
+
console.log(renderCard("Select quantization", renderRows([
|
|
186
|
+
["Your RAM", `${installedRamGB()} GB`],
|
|
187
|
+
["Available", `~${formatBytes(availableRam)} (after OS)`],
|
|
188
|
+
["Rule", "Lower quant = smaller/faster · Higher = better quality"],
|
|
189
|
+
]), { formatBorder: pc.cyan }));
|
|
190
|
+
console.log("");
|
|
191
|
+
|
|
192
|
+
const choices = sorted.map((file) => {
|
|
193
|
+
const sizeBytes = file.sizeBytes;
|
|
194
|
+
const parsed = parseModelName(file.path, "huggingface");
|
|
195
|
+
const quant = parsed.quant ?? file.path.replace(/\.gguf$/i, "");
|
|
196
|
+
|
|
197
|
+
let indicator, fitLabel;
|
|
198
|
+
if (sizeBytes > availableRam) {
|
|
199
|
+
indicator = pc.red("✗");
|
|
200
|
+
fitLabel = pc.red("won't fit");
|
|
201
|
+
} else if (sizeBytes + 2 * GB > availableRam) {
|
|
202
|
+
indicator = pc.yellow("⚠");
|
|
203
|
+
fitLabel = pc.yellow("tight");
|
|
204
|
+
} else {
|
|
205
|
+
indicator = pc.green("✓");
|
|
206
|
+
fitLabel = pc.green("fits");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const isRecommended = recommended && file.path === recommended.path;
|
|
210
|
+
const hint = isRecommended ? "recommended" : undefined;
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
value: file.path,
|
|
214
|
+
label: `${indicator} ${quant.padEnd(12)} ${formatBytes(sizeBytes).padEnd(10)} ${fitLabel}`,
|
|
215
|
+
...(hint ? { hint } : {}),
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const defaultValue = recommended?.path;
|
|
220
|
+
return await prompt.choice("Quantization", choices, defaultValue);
|
|
221
|
+
}
|
package/src/harness-pi.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
2
|
+
import { execFile, spawn } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
3
4
|
import { PI_CONFIG } from "./config.mjs";
|
|
4
5
|
import { loadProfiles } from "./profiles.mjs";
|
|
5
6
|
import { readJson, writeJson } from "./json.mjs";
|
|
@@ -69,8 +70,6 @@ export async function launchPi(profile) {
|
|
|
69
70
|
|
|
70
71
|
export async function hasPi() {
|
|
71
72
|
try {
|
|
72
|
-
const { execFile } = await import("node:child_process");
|
|
73
|
-
const { promisify } = await import("node:util");
|
|
74
73
|
await promisify(execFile)("which", ["pi"]);
|
|
75
74
|
return true;
|
|
76
75
|
} catch {
|
package/src/huggingface.mjs
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
// HuggingFace model download helpers.
|
|
2
|
-
// Uses the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Uses the `hf` CLI (from huggingface_hub) for actual downloads.
|
|
3
|
+
// The interactive model/quant selection happens in download.mjs; here we
|
|
4
|
+
// just hand off to the CLI and let it handle progress bars, resumption, etc.
|
|
5
5
|
|
|
6
|
-
import { execFile } from "node:child_process";
|
|
6
|
+
import { spawn, execFile } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
|
-
import { join, dirname } from "node:path";
|
|
9
8
|
import { mkdir } from "node:fs/promises";
|
|
10
|
-
import { fileURLToPath } from "node:url";
|
|
11
9
|
import { HF_HUB_DIR } from "./config.mjs";
|
|
12
10
|
|
|
13
11
|
const execFileAsync = promisify(execFile);
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/** Check whether python3 + huggingface_hub is available. */
|
|
18
|
-
export async function hasHuggingfaceHub() {
|
|
13
|
+
/** Check whether the `hf` CLI is available. */
|
|
14
|
+
export async function hasHfCli() {
|
|
19
15
|
try {
|
|
20
|
-
const { stdout } = await execFileAsync("
|
|
16
|
+
const { stdout } = await execFileAsync("hf", ["--version"]);
|
|
21
17
|
return Boolean(stdout.trim());
|
|
22
18
|
} catch {
|
|
23
19
|
return false;
|
|
@@ -96,6 +92,33 @@ async function getHfTree(repo, { branch = "main", fetchImpl = globalThis.fetch }
|
|
|
96
92
|
return await response.json();
|
|
97
93
|
}
|
|
98
94
|
|
|
95
|
+
/** List all GGUF files in a HuggingFace repo with their sizes. */
|
|
96
|
+
export async function listGgufFiles(repo, { fetchImpl = globalThis.fetch } = {}) {
|
|
97
|
+
const tree = await getHfTree(repo, { fetchImpl });
|
|
98
|
+
return tree
|
|
99
|
+
.filter((f) => f.type === "file" && f.path.endsWith(".gguf"))
|
|
100
|
+
.map((f) => ({
|
|
101
|
+
path: f.path,
|
|
102
|
+
sizeBytes: f.lfs?.size ?? f.size ?? 0,
|
|
103
|
+
}))
|
|
104
|
+
.sort((a, b) => a.sizeBytes - b.sizeBytes);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Fetch model metadata from the HF API. */
|
|
108
|
+
export async function getHfModelInfo(repo, { fetchImpl = globalThis.fetch } = {}) {
|
|
109
|
+
const url = `https://huggingface.co/api/models/${repo}`;
|
|
110
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(10000) });
|
|
111
|
+
if (!response.ok) throw new Error(`HuggingFace API error: HTTP ${response.status} for ${repo}`);
|
|
112
|
+
return await response.json();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Check if a repo is MLX-formatted based on its HF metadata. */
|
|
116
|
+
export function isMlxRepo(modelInfo) {
|
|
117
|
+
if (modelInfo.library_name === "mlx") return true;
|
|
118
|
+
if (Array.isArray(modelInfo.tags) && modelInfo.tags.includes("mlx")) return true;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
99
122
|
/** Resolve a user-provided HF reference into a download plan. */
|
|
100
123
|
export async function resolveHfDownload(input, { fetchImpl = globalThis.fetch } = {}) {
|
|
101
124
|
const { repo, filename } = parseHfRef(input);
|
|
@@ -136,74 +159,51 @@ export async function resolveHfDownload(input, { fetchImpl = globalThis.fetch }
|
|
|
136
159
|
}
|
|
137
160
|
|
|
138
161
|
/**
|
|
139
|
-
* Download a resolved model
|
|
162
|
+
* Download a resolved model using the `hf` CLI.
|
|
163
|
+
* GGUF: downloads single file to HF cache (offgrid-ai scanner finds it there).
|
|
164
|
+
* MLX: downloads full repo to a local directory (oMLX scans ~/.omlx/models).
|
|
165
|
+
* Progress bars are handled natively by the CLI (stdio inherited).
|
|
140
166
|
* @param {object} model - from resolveHfDownload
|
|
141
|
-
* @param {object} options
|
|
142
|
-
* @param {
|
|
167
|
+
* @param {object} [options]
|
|
168
|
+
* @param {string} [options.localDir] - for MLX: target directory
|
|
143
169
|
* @returns {Promise<{ localDir: string, format: string }>}
|
|
144
170
|
*/
|
|
145
|
-
export async function
|
|
146
|
-
|
|
171
|
+
export async function downloadModel(model, options = {}) {
|
|
172
|
+
const args = ["download", model.repo];
|
|
173
|
+
let localDir;
|
|
147
174
|
|
|
148
|
-
const script = HF_DOWNLOAD_SCRIPT;
|
|
149
|
-
const args = ["--repo", model.repo, "--cache-dir", HF_HUB_DIR];
|
|
150
175
|
if (model.format === "gguf") {
|
|
151
|
-
|
|
176
|
+
// Single file to HF cache — scanner finds it there
|
|
177
|
+
await mkdir(HF_HUB_DIR, { recursive: true });
|
|
178
|
+
args.push(model.files[0].filename, "--cache-dir", HF_HUB_DIR);
|
|
179
|
+
localDir = HF_HUB_DIR;
|
|
180
|
+
} else if (options.localDir) {
|
|
181
|
+
// Full repo to a flat local directory (oMLX)
|
|
182
|
+
await mkdir(options.localDir, { recursive: true });
|
|
183
|
+
args.push(
|
|
184
|
+
"--local-dir", options.localDir,
|
|
185
|
+
"--exclude", "*.md",
|
|
186
|
+
"--exclude", ".gitattributes",
|
|
187
|
+
"--exclude", "LICENSE",
|
|
188
|
+
"--exclude", ".gitignore",
|
|
189
|
+
);
|
|
190
|
+
localDir = options.localDir;
|
|
191
|
+
} else {
|
|
192
|
+
// Fallback: full repo to HF cache
|
|
193
|
+
await mkdir(HF_HUB_DIR, { recursive: true });
|
|
194
|
+
args.push("--cache-dir", HF_HUB_DIR);
|
|
195
|
+
localDir = HF_HUB_DIR;
|
|
152
196
|
}
|
|
153
197
|
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
return new Promise((resolve, reject) => {
|
|
157
|
-
const child = execFile("python3", [script, ...args], { env: process.env });
|
|
158
|
-
|
|
159
|
-
let stdoutBuf = "";
|
|
160
|
-
let downloadedBytes = 0;
|
|
161
|
-
let currentFile = null;
|
|
162
|
-
|
|
163
|
-
// huggingface_hub streams NDJSON progress events to stdout, one per line.
|
|
164
|
-
// Buffer and split on complete newlines so an event split across chunk
|
|
165
|
-
// boundaries isn't silently dropped.
|
|
166
|
-
const handleLine = (line) => {
|
|
167
|
-
if (!line) return;
|
|
168
|
-
try {
|
|
169
|
-
const event = JSON.parse(line);
|
|
170
|
-
if (event.type === "progress") {
|
|
171
|
-
downloadedBytes = event.downloadedBytes ?? downloadedBytes;
|
|
172
|
-
currentFile = event.file ?? currentFile;
|
|
173
|
-
onProgress({
|
|
174
|
-
downloadedBytes,
|
|
175
|
-
totalBytes: model.totalSizeBytes,
|
|
176
|
-
percentage: Math.min(100, Math.round((downloadedBytes / model.totalSizeBytes) * 100)),
|
|
177
|
-
file: currentFile,
|
|
178
|
-
});
|
|
179
|
-
} else if (event.type === "complete") {
|
|
180
|
-
resolve({ localDir: event.localDir, format: model.format });
|
|
181
|
-
} else if (event.type === "error") {
|
|
182
|
-
reject(new Error(event.message));
|
|
183
|
-
}
|
|
184
|
-
} catch {
|
|
185
|
-
// Ignore non-JSON output (progress bars, etc.)
|
|
186
|
-
}
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
child.stdout?.on("data", (chunk) => {
|
|
190
|
-
stdoutBuf += String(chunk);
|
|
191
|
-
let nl;
|
|
192
|
-
while ((nl = stdoutBuf.indexOf("\n")) !== -1) {
|
|
193
|
-
handleLine(stdoutBuf.slice(0, nl));
|
|
194
|
-
stdoutBuf = stdoutBuf.slice(nl + 1);
|
|
195
|
-
}
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
child.stderr?.on("data", () => {
|
|
199
|
-
// huggingface_hub prints progress bars to stderr; ignore.
|
|
200
|
-
});
|
|
201
|
-
|
|
198
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
199
|
+
const child = spawn("hf", args, { stdio: "inherit", env: process.env });
|
|
202
200
|
child.on("error", reject);
|
|
203
|
-
child.on("exit",
|
|
204
|
-
// Flush any final line that lacked a trailing newline.
|
|
205
|
-
if (stdoutBuf.trim()) handleLine(stdoutBuf.trim());
|
|
206
|
-
if (code !== 0) reject(new Error(`Download failed with exit code ${code}`));
|
|
207
|
-
});
|
|
201
|
+
child.on("exit", resolve);
|
|
208
202
|
});
|
|
203
|
+
|
|
204
|
+
if (exitCode !== 0) {
|
|
205
|
+
throw new Error(`hf download exited with code ${exitCode}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { localDir, format: model.format };
|
|
209
209
|
}
|
package/src/managed.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { BACKENDS } from "./backends.mjs";
|
|
3
|
-
import { hasOmlx } from "./omlx-runtime.mjs";
|
|
4
3
|
|
|
5
|
-
|
|
4
|
+
const MANAGED_BACKEND_IDS = ["omlx"];
|
|
6
5
|
|
|
7
6
|
export async function scanManagedModels() {
|
|
8
7
|
const results = [];
|
|
@@ -21,7 +20,3 @@ export async function scanManagedModels() {
|
|
|
21
20
|
export function hasLmStudioInstalled() {
|
|
22
21
|
return existsSync("/Applications/LM Studio.app");
|
|
23
22
|
}
|
|
24
|
-
|
|
25
|
-
export async function hasOmlxInstalled() {
|
|
26
|
-
return await hasOmlx();
|
|
27
|
-
}
|
package/src/model-presenters.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { existsSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
4
|
import { backendFor } from "./backends.mjs";
|
|
4
5
|
import { computeServerCommand, buildStartScript, isProfileRunning } from "./process.mjs";
|
|
5
6
|
import { profileDir } from "./profiles.mjs";
|
|
6
7
|
import { pc, formatBytes, renderSectionRows } from "./ui.mjs";
|
|
7
8
|
import { capabilitySummary, ggufDetailParts, isProfileFileMissing, profileDetailParts } from "./model-summary.mjs";
|
|
8
9
|
import { itemKey } from "./model-catalog.mjs";
|
|
9
|
-
import { DATA_DIR } from "./config.mjs";
|
|
10
10
|
|
|
11
11
|
const OPTION_SEPARATOR = " ";
|
|
12
12
|
const OPTION_STATUS_WIDTH = 12;
|
|
@@ -15,8 +15,6 @@ const OPTION_SOURCE_WIDTH = 14;
|
|
|
15
15
|
const OPTION_QUANT_WIDTH = 10;
|
|
16
16
|
const OPTION_CTX_WIDTH = 5;
|
|
17
17
|
|
|
18
|
-
const { stripVTControlCharacters } = await import("node:util");
|
|
19
|
-
|
|
20
18
|
function optionPad(text, color, width) {
|
|
21
19
|
const visible = stripVTControlCharacters(String(text)).length;
|
|
22
20
|
const padding = Math.max(1, width - visible);
|
|
@@ -197,26 +195,6 @@ export function inferBackendId(item) {
|
|
|
197
195
|
return "llama-cpp";
|
|
198
196
|
}
|
|
199
197
|
|
|
200
|
-
export function printWorkspaceHeader(normalized, runningProfilesNow, modelMissingIds = new Set()) {
|
|
201
|
-
const profiles = normalized.profiles;
|
|
202
|
-
const isRunning = (p) => runningProfilesNow.some((r) => r.id === p.id);
|
|
203
|
-
const isMissing = (p) => isProfileFileMissing(p) || modelMissingIds.has(p.id);
|
|
204
|
-
const readyCount = profiles.filter((p) => !isMissing(p) && !isRunning(p)).length;
|
|
205
|
-
const runningCount = runningProfilesNow.length;
|
|
206
|
-
const missingCount = profiles.filter(isMissing).length;
|
|
207
|
-
const setupCount = normalized.newModels.length + normalized.managedItems.length;
|
|
208
|
-
|
|
209
|
-
const countParts = [];
|
|
210
|
-
if (runningCount > 0) countParts.push(pc.green(`${runningCount} running`));
|
|
211
|
-
if (readyCount > 0) countParts.push(pc.blue(`${readyCount} model${readyCount === 1 ? "" : "s"} ready`));
|
|
212
|
-
if (missingCount > 0) countParts.push(pc.red(`${missingCount} model${missingCount === 1 ? "" : "s"} missing`));
|
|
213
|
-
if (setupCount > 0) countParts.push(pc.yellow(`${setupCount} model${setupCount === 1 ? "" : "s"} need${setupCount === 1 ? "s" : ""} setup`));
|
|
214
|
-
|
|
215
|
-
console.log(` ${countParts.join(pc.dim(" · "))}`);
|
|
216
|
-
console.log(pc.dim(` Profiles: ${DATA_DIR}`));
|
|
217
|
-
console.log(pc.dim(" ─────────────────────────────────────────────────────────"));
|
|
218
|
-
}
|
|
219
|
-
|
|
220
198
|
export async function printProfileDetails(profile) {
|
|
221
199
|
const backend = backendFor(profile.backend);
|
|
222
200
|
const isManaged = backend.type === "managed-server";
|
package/src/model-summary.mjs
CHANGED
|
@@ -23,7 +23,7 @@ export function capabilitySummary(caps) {
|
|
|
23
23
|
return parts.length > 0 ? parts.join(" · ") : "standard GGUF";
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
function profileMtpLabel(profile, drafters, { detailed = false } = {}) {
|
|
27
27
|
if (profile.drafterPath) {
|
|
28
28
|
return detailed ? pc.green(`MTP enabled (drafter: ${basename(profile.drafterPath)})`) : pc.green("MTP enabled");
|
|
29
29
|
}
|
|
@@ -34,7 +34,7 @@ export function profileMtpLabel(profile, drafters, { detailed = false } = {}) {
|
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
function ggufMtpLabel(model, drafter) {
|
|
38
38
|
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
39
39
|
if (caps.mtp || Boolean(drafter)) return pc.green("MTP ✓");
|
|
40
40
|
if (caps.architecture === "gemma4") return pc.yellow("MTP: needs drafter");
|
package/src/omlx-runtime.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { join } from "node:path";
|
|
|
14
14
|
import { promisify } from "node:util";
|
|
15
15
|
import { compareVersions } from "./updates.mjs";
|
|
16
16
|
import { hasHomebrew, ensureHomebrewFor } from "./config.mjs";
|
|
17
|
-
import { commandExists } from "./exec.mjs";
|
|
17
|
+
import { commandExists, runCommand } from "./exec.mjs";
|
|
18
18
|
import { pc, renderCard, renderRows } from "./ui.mjs";
|
|
19
19
|
|
|
20
20
|
const execFileAsync = promisify(execFile);
|
|
@@ -138,7 +138,6 @@ export async function offerManagedOmlxUpdate(prompt, { fetchImpl = globalThis.fe
|
|
|
138
138
|
if (!shouldUpdate) return false;
|
|
139
139
|
|
|
140
140
|
try {
|
|
141
|
-
const { runCommand } = await import("./exec.mjs");
|
|
142
141
|
console.log(pc.dim("Updating oMLX via Homebrew..."));
|
|
143
142
|
await runCommand("brew", ["update"], { label: "brew update" });
|
|
144
143
|
await runCommand("brew", ["upgrade", "omlx"], { label: "brew upgrade omlx" });
|
|
@@ -153,6 +152,34 @@ export async function offerManagedOmlxUpdate(prompt, { fetchImpl = globalThis.fe
|
|
|
153
152
|
|
|
154
153
|
// ── Installation ───────────────────────────────────────────────────────────
|
|
155
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Offer to restart oMLX so it picks up new or deleted models.
|
|
157
|
+
* @param {object} prompt - UI prompt interface (yesNo)
|
|
158
|
+
* @param {string} [reason] - why we're restarting (e.g. "to load the new model")
|
|
159
|
+
* @returns {Promise<boolean>} true if oMLX was restarted
|
|
160
|
+
*/
|
|
161
|
+
export async function offerOmlxRestart(prompt, reason = "to update its model list") {
|
|
162
|
+
const bin = await findOmlx();
|
|
163
|
+
if (!bin) {
|
|
164
|
+
console.log(pc.dim("Restart oMLX manually: omlx restart"));
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const shouldRestart = await prompt.yesNo(`Restart oMLX ${reason}?`, true);
|
|
168
|
+
if (!shouldRestart) {
|
|
169
|
+
console.log(pc.dim("Restart manually later: omlx restart"));
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
await execFileAsync(bin, ["restart"], { timeout: 15000 });
|
|
174
|
+
console.log(pc.green("✓ oMLX restarted"));
|
|
175
|
+
return true;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.log(pc.red(`✗ Restart failed: ${err.message}`));
|
|
178
|
+
console.log(pc.dim("Restart manually: omlx restart"));
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
156
183
|
/**
|
|
157
184
|
* Install oMLX. Uses Homebrew if available (automating tap + install).
|
|
158
185
|
* If Homebrew is not available, prompts to download the DMG from GitHub
|
|
@@ -167,7 +194,6 @@ export async function installOmlx(prompt, run) {
|
|
|
167
194
|
|
|
168
195
|
if (!hasBrew) {
|
|
169
196
|
if (!(await ensureHomebrewFor(prompt, run || (async (cmd, args, label) => {
|
|
170
|
-
const { runCommand } = await import("./exec.mjs");
|
|
171
197
|
return runCommand(cmd, args, { label });
|
|
172
198
|
}), "oMLX"))) {
|
|
173
199
|
console.log(pc.dim("Install oMLX manually:"));
|
|
@@ -179,7 +205,6 @@ export async function installOmlx(prompt, run) {
|
|
|
179
205
|
|
|
180
206
|
// Install oMLX via Homebrew
|
|
181
207
|
const runner = run || (async (cmd, args, label) => {
|
|
182
|
-
const { runCommand } = await import("./exec.mjs");
|
|
183
208
|
return runCommand(cmd, args, { label });
|
|
184
209
|
});
|
|
185
210
|
|
package/src/process.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { basename, join } from "node:path";
|
|
|
6
6
|
import { LOG_DIR } from "./config.mjs";
|
|
7
7
|
import { writeState, readState, profileDir } from "./profiles.mjs";
|
|
8
8
|
import { backendFor, backendBinaryFor } from "./backends.mjs";
|
|
9
|
+
import { computeFlags } from "./autodetect.mjs";
|
|
10
|
+
import { findOmlx } from "./omlx-runtime.mjs";
|
|
9
11
|
import { pc } from "./ui.mjs";
|
|
10
12
|
|
|
11
13
|
const execFileAsync = promisify(execFile);
|
|
@@ -23,7 +25,6 @@ export async function computeServerCommand(profile) {
|
|
|
23
25
|
if (!binary) throw new Error("Server binary not found. Run offgrid-ai interactively to install.");
|
|
24
26
|
|
|
25
27
|
// llama-cpp
|
|
26
|
-
const { computeFlags } = await import("./autodetect.mjs");
|
|
27
28
|
const result = computeFlags(
|
|
28
29
|
profile.capabilities ?? {},
|
|
29
30
|
profile.modelPath,
|
|
@@ -129,14 +130,11 @@ async function startManagedServer(profile, backend) {
|
|
|
129
130
|
// Try to start the managed server via CLI
|
|
130
131
|
if (backend.id === "omlx") {
|
|
131
132
|
try {
|
|
132
|
-
const { execFile } = await import("node:child_process");
|
|
133
|
-
const { promisify } = await import("node:util");
|
|
134
|
-
const { findOmlx } = await import("./omlx-runtime.mjs");
|
|
135
133
|
const omlxBin = await findOmlx();
|
|
136
134
|
if (!omlxBin) {
|
|
137
135
|
throw new Error(`${backend.label} is not installed. Run offgrid-ai to install it, or install manually: brew tap jundot/omlx && brew install omlx`);
|
|
138
136
|
}
|
|
139
|
-
await
|
|
137
|
+
await execFileAsync(omlxBin, ["start"], { timeout: 10000 });
|
|
140
138
|
} catch (err) {
|
|
141
139
|
if (err.message.includes("not installed")) throw err;
|
|
142
140
|
throw new Error(`${backend.label} could not be auto-started: ${err.message}. Run \`omlx start\` manually.`, { cause: err });
|
package/src/profiles.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdir, readdir, rm, unlink, writeFile, readFile } from "node:fs/promise
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { PROFILE_DIR, RUN_DIR, LOG_DIR } from "./config.mjs";
|
|
5
5
|
import { backendFor, baseUrlForFlags, defaultFlagsForBackend, BACKENDS } from "./backends.mjs";
|
|
6
|
+
import { detectCapabilities } from "./autodetect.mjs";
|
|
6
7
|
import { computeFlags } from "./autodetect.mjs";
|
|
7
8
|
import { readJson, writeJson } from "./json.mjs";
|
|
8
9
|
|
|
@@ -130,7 +131,6 @@ export function normalizeProfile(profile) {
|
|
|
130
131
|
// ── Auto-create profile from a discovered model ────────────────────────────
|
|
131
132
|
|
|
132
133
|
export async function createProfileFromModel(model, backendId, drafterPath) {
|
|
133
|
-
const { detectCapabilities } = await import("./autodetect.mjs");
|
|
134
134
|
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
135
135
|
// If a drafter is provided, this model supports MTP regardless of filename
|
|
136
136
|
const hasMtp = caps.mtp || Boolean(drafterPath);
|
package/src/runtime.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from "node:fs/promises";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import {
|
|
6
|
+
import { join } from "node:path";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { MANAGED_LLAMA_SERVER, RUNTIME_DIR } from "./config.mjs";
|
|
9
9
|
import { compareVersions } from "./updates.mjs";
|
|
@@ -137,5 +137,5 @@ function verifyDigest(bytes, digest) {
|
|
|
137
137
|
if (!digest?.startsWith("sha256:")) return;
|
|
138
138
|
const expected = digest.slice("sha256:".length);
|
|
139
139
|
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
140
|
-
if (actual !== expected) throw new Error(
|
|
140
|
+
if (actual !== expected) throw new Error("llama.cpp: checksum mismatch");
|
|
141
141
|
}
|
package/src/ui.mjs
CHANGED
|
@@ -237,6 +237,8 @@ export function createPrompt() {
|
|
|
237
237
|
|
|
238
238
|
export async function modelSelect(label, groups, { defaultKey, pageSize = 20 } = {}) {
|
|
239
239
|
const choices = [];
|
|
240
|
+
// Blank line after the prompt message for visual separation
|
|
241
|
+
choices.push(new Separator(" "));
|
|
240
242
|
for (let i = 0; i < groups.length; i++) {
|
|
241
243
|
const group = groups[i];
|
|
242
244
|
// Add blank line before each group (except the first)
|