offgrid-ai 0.6.5 → 0.6.7

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.
@@ -0,0 +1,64 @@
1
+ import { ensureDirs } from "../config.mjs";
2
+ import { readProfile, loadProfiles } from "../profiles.mjs";
3
+ import { stopProfile, profileRuntimeStatus } from "../process.mjs";
4
+ import { pc, startInteractive, createPrompt, parseOptions } from "../ui.mjs";
5
+
6
+ export async function stopCommand(argv) {
7
+ await ensureDirs();
8
+ const { positional, options } = parseOptions(argv);
9
+
10
+ if (options.all) return stopAll();
11
+ if (positional[0]) return stopOne(positional[0]);
12
+
13
+ const running = await runningProfiles();
14
+ if (running.length === 0) {
15
+ console.log(pc.dim("No offgrid-ai servers are running."));
16
+ return;
17
+ }
18
+
19
+ if (!process.stdin.isTTY) {
20
+ for (const { profile, status } of running) console.log(` ${pc.green("●")} ${pc.bold(profile.label)} · pid ${status.pid}`);
21
+ console.log(pc.dim("Stop with: offgrid-ai stop <id>"));
22
+ return;
23
+ }
24
+
25
+ startInteractive("offgrid-ai stop");
26
+ const prompt = createPrompt();
27
+ try {
28
+ const choices = running.map(({ profile, status }) => ({ value: profile.id, label: profile.label, hint: `pid ${status.pid} · ${profile.baseUrl}` }));
29
+ if (running.length > 1) choices.unshift({ value: "__all", label: "Stop all", hint: `${running.length} servers` });
30
+ choices.push({ value: "__cancel", label: "Cancel" });
31
+
32
+ const selected = await prompt.choice("Stop", choices, choices[0].value);
33
+ if (selected === "__cancel") return;
34
+
35
+ const targets = selected === "__all" ? running : running.filter((item) => item.profile.id === selected);
36
+ for (const { profile } of targets) await printStopResult(profile);
37
+ } finally {
38
+ prompt.close();
39
+ }
40
+ }
41
+
42
+ export async function stopOne(id) {
43
+ await printStopResult(await readProfile(id));
44
+ }
45
+
46
+ export async function stopAll() {
47
+ const running = await runningProfiles();
48
+ if (running.length === 0) {
49
+ console.log(pc.dim("No offgrid-ai servers are running."));
50
+ return;
51
+ }
52
+ for (const { profile } of running) await printStopResult(profile);
53
+ }
54
+
55
+ export async function runningProfiles() {
56
+ const profiles = await loadProfiles();
57
+ const statuses = await Promise.all(profiles.map(async (profile) => ({ profile, status: await profileRuntimeStatus(profile) })));
58
+ return statuses.filter((item) => item.status.running);
59
+ }
60
+
61
+ async function printStopResult(profile) {
62
+ const result = await stopProfile(profile);
63
+ console.log(result.stopped ? pc.green(result.message) : pc.yellow(result.message));
64
+ }
@@ -0,0 +1,96 @@
1
+ import { existsSync, rmSync } from "node:fs";
2
+ import { DATA_DIR } from "../config.mjs";
3
+ import { removeInstallerPathEntries } from "../shell-path.mjs";
4
+ import { stopProfile } from "../process.mjs";
5
+ import { runCommand } from "../exec.mjs";
6
+ import { pc, startInteractive, createPrompt, parseOptions } from "../ui.mjs";
7
+ import { runningProfiles } from "./stop.mjs";
8
+
9
+ export async function uninstallCommand(argv) {
10
+ const { options } = parseOptions(argv);
11
+ const force = options.force || options.f;
12
+
13
+ if (!process.stdin.isTTY && !force) throw new Error("Non-interactive uninstall requires --force to avoid accidental data loss.");
14
+
15
+ if (force) {
16
+ await stopTrackedServers();
17
+ await removeDataDir();
18
+ await removeShellPath();
19
+ await removeSelf();
20
+ return;
21
+ }
22
+
23
+ startInteractive("offgrid-ai uninstall");
24
+ const prompt = createPrompt();
25
+ try {
26
+ console.log(pc.bold("offgrid-ai uninstall\n"));
27
+ const running = await runningProfiles();
28
+ if (running.length > 0) {
29
+ console.log(pc.yellow(`${running.length} server(s) still running. Stopping...`));
30
+ for (const { profile } of running) await stopProfile(profile);
31
+ console.log(pc.green("All servers stopped."));
32
+ }
33
+
34
+ const mode = await prompt.choice("Choose uninstall type", [
35
+ { value: "keep-data", label: "Uninstall app only", hint: `keep profiles and settings in ${DATA_DIR}` },
36
+ { value: "delete-data", label: "Full uninstall", hint: "delete profiles/settings, then uninstall app" },
37
+ { value: "cancel", label: "Cancel" },
38
+ ], "keep-data");
39
+
40
+ if (mode === "cancel") {
41
+ console.log(pc.dim("Cancelled."));
42
+ return;
43
+ }
44
+
45
+ if (mode === "delete-data") await removeDataDir();
46
+ else console.log(pc.dim(`Keeping ${DATA_DIR} for when you reinstall.`));
47
+
48
+ await removeShellPath();
49
+ await removeSelf();
50
+ } finally {
51
+ prompt.close();
52
+ }
53
+ }
54
+
55
+ async function stopTrackedServers() {
56
+ const running = await runningProfiles();
57
+ for (const { profile } of running) {
58
+ const result = await stopProfile(profile);
59
+ console.log(result.stopped ? pc.green(`✓ ${result.message}`) : pc.dim(result.message));
60
+ }
61
+ }
62
+
63
+ async function removeDataDir() {
64
+ if (existsSync(DATA_DIR)) {
65
+ try {
66
+ rmSync(DATA_DIR, { recursive: true, force: true });
67
+ console.log(pc.green(`✓ Removed ${DATA_DIR}`));
68
+ } catch (err) {
69
+ console.log(pc.red(`Failed to remove ${DATA_DIR}: ${err.message}`));
70
+ console.log(pc.dim(`Remove it manually: rm -rf ${DATA_DIR}`));
71
+ }
72
+ } else {
73
+ console.log(pc.dim(`${DATA_DIR} doesn't exist — already clean.`));
74
+ }
75
+ }
76
+
77
+ async function removeShellPath() {
78
+ const cleaned = await removeInstallerPathEntries();
79
+ if (cleaned.length === 0) {
80
+ console.log(pc.dim("No offgrid-ai PATH entries found in shell configs."));
81
+ return;
82
+ }
83
+ for (const rcFile of cleaned) console.log(pc.green(`✓ Cleaned PATH from ${rcFile}`));
84
+ }
85
+
86
+ async function removeSelf() {
87
+ console.log(pc.cyan("\nUninstalling offgrid-ai..."));
88
+ try {
89
+ await runCommand("npm", ["uninstall", "-g", "offgrid-ai"], { label: "npm uninstall", verbose: process.argv.includes("--verbose") });
90
+ console.log(pc.green("\n✓ offgrid-ai has been uninstalled."));
91
+ console.log(pc.dim("Reinstall anytime with: npm install -g offgrid-ai@latest --prefer-online"));
92
+ } catch {
93
+ console.log(pc.red("\n✗ Could not auto-uninstall. Run this manually:"));
94
+ console.log(pc.bold(" npm uninstall -g offgrid-ai"));
95
+ }
96
+ }
package/src/exec.mjs ADDED
@@ -0,0 +1,31 @@
1
+ import { spawn } from "node:child_process";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+
5
+ export const execFileAsync = promisify(execFile);
6
+
7
+ export function runCommand(cmd, args, { label = cmd, verbose = false } = {}) {
8
+ return new Promise((resolve, reject) => {
9
+ const child = spawn(cmd, args, { stdio: verbose ? "inherit" : ["ignore", "pipe", "pipe"] });
10
+ let stderr = "";
11
+ if (!verbose) child.stderr?.on("data", (data) => { stderr += data; });
12
+ child.on("close", (code) => {
13
+ if (code === 0) resolve();
14
+ else reject(new Error(lastStderrLines(stderr) || `${label} exited with code ${code}`));
15
+ });
16
+ child.on("error", reject);
17
+ });
18
+ }
19
+
20
+ export async function commandExists(name) {
21
+ try {
22
+ await execFileAsync("which", [name]);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function lastStderrLines(stderr) {
30
+ return String(stderr).split("\n").filter((line) => line.trim()).slice(-3).join("\n");
31
+ }
@@ -0,0 +1,31 @@
1
+ import { existsSync } from "node:fs";
2
+ import { BACKENDS } from "./backends.mjs";
3
+ import { commandExists } from "./exec.mjs";
4
+
5
+ export const MANAGED_BACKEND_IDS = ["ollama", "omlx"];
6
+
7
+ export async function scanManagedModels() {
8
+ const results = [];
9
+ for (const backendId of MANAGED_BACKEND_IDS) {
10
+ const backend = BACKENDS[backendId];
11
+ try {
12
+ const models = await backend.scanModels();
13
+ results.push({ backendId, models });
14
+ } catch {
15
+ // Managed backends are optional and may not be running.
16
+ }
17
+ }
18
+ return results;
19
+ }
20
+
21
+ export function hasLmStudioInstalled() {
22
+ return existsSync("/Applications/LM Studio.app");
23
+ }
24
+
25
+ export function hasOllamaInstalled() {
26
+ return commandExists("ollama");
27
+ }
28
+
29
+ export function hasOmlxInstalled() {
30
+ return commandExists("omlx");
31
+ }
@@ -0,0 +1,58 @@
1
+ import { scanGgufModels, matchDrafter } from "./scan.mjs";
2
+ import { loadProfiles, normalizeProfile, sanitizeProfileId } from "./profiles.mjs";
3
+ import { scanManagedModels } from "./managed.mjs";
4
+ import { isProfileFileMissing } from "./model-summary.mjs";
5
+
6
+ export async function loadModelCatalog() {
7
+ const [profiles, { models: ggufModels, drafters }, managedModels] = await Promise.all([
8
+ loadProfiles(),
9
+ scanGgufModels(),
10
+ scanManagedModels(),
11
+ ]);
12
+ return normalizeCatalog({ profiles, ggufModels, drafters, managedModels });
13
+ }
14
+
15
+ export function normalizeCatalog(catalog) {
16
+ if (catalog.newModels && catalog.managedItems) return catalog;
17
+ const { profiles, ggufModels, drafters, managedModels } = catalog;
18
+ const profiledPaths = new Set(profiles.map((profile) => profile.modelPath).filter(Boolean));
19
+ const newModels = ggufModels.filter((model) => !profiledPaths.has(model.path));
20
+ const managedItems = [];
21
+ for (const { backendId, models } of managedModels) {
22
+ const profiledAliases = new Set(
23
+ profiles
24
+ .filter((profile) => profile.backend === backendId)
25
+ .map((profile) => backendId === "ollama" ? `ollama:${profile.ollamaModel ?? profile.modelAlias}` : `omlx:${profile.omlxModel ?? profile.modelAlias}`),
26
+ );
27
+ for (const model of models) {
28
+ if (!profiledAliases.has(`${backendId}:${model.id}`)) managedItems.push({ model, backendId });
29
+ }
30
+ }
31
+ return { profiles, ggufModels, drafters, managedModels, newModels, managedItems };
32
+ }
33
+
34
+ export function itemKey(item) {
35
+ if (item.type === "profile") return `profile:${item.profile.id}`;
36
+ if (item.type === "new") return `new:${item.model.path}`;
37
+ return `managed:${item.backendId}:${item.model.id}`;
38
+ }
39
+
40
+ export function buildCatalogItems(normalized) {
41
+ const { profiles, newModels, managedItems, drafters } = normalized;
42
+ return [
43
+ ...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) })),
44
+ ...newModels.map((model) => ({ type: "new", model, label: model.label, drafter: matchDrafter(model.path, drafters) })),
45
+ ...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label })),
46
+ ];
47
+ }
48
+
49
+ export function createManagedProfile(model, backendId) {
50
+ return normalizeProfile({
51
+ id: `${backendId}-${sanitizeProfileId(model.id)}`,
52
+ label: model.label,
53
+ backend: backendId,
54
+ modelAlias: model.aliasSuggestion,
55
+ ...(backendId === "ollama" ? { ollamaModel: model.id } : {}),
56
+ ...(backendId === "omlx" ? { omlxModel: model.id } : {}),
57
+ });
58
+ }
@@ -0,0 +1,190 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { BACKENDS, backendFor } from "./backends.mjs";
3
+ import { readCommandArgv } from "./profiles.mjs";
4
+ import { isProfileRunning } from "./process.mjs";
5
+ import { detectCapabilities } from "./autodetect.mjs";
6
+ import { buildPrettyCommand } from "./command.mjs";
7
+ import { pc, formatBytes, renderRows, renderSection, renderCard } from "./ui.mjs";
8
+ import { capabilitySummary, ggufDetailParts, ggufMtpLabel, isProfileFileMissing, profileDetailParts, profileMtpLabel } from "./model-summary.mjs";
9
+ import { itemKey } from "./model-catalog.mjs";
10
+
11
+ const OPTION_SEPARATOR = pc.dim(" │ ");
12
+ const OPTION_STATUS_WIDTH = 12;
13
+ const OPTION_SOURCE_WIDTH = 14;
14
+
15
+ function optionTag(text, color, width) {
16
+ const padded = String(text).padEnd(width);
17
+ return color ? color(padded) : padded;
18
+ }
19
+
20
+ function optionStatusTag(kind) {
21
+ const statuses = {
22
+ running: ["RUNNING", pc.green],
23
+ ready: ["READY", pc.green],
24
+ missing: ["FILE MISSING", pc.red],
25
+ setup: ["NEEDS SETUP", pc.yellow],
26
+ };
27
+ const [text, color] = statuses[kind] ?? [kind, pc.dim];
28
+ return optionTag(text, color, OPTION_STATUS_WIDTH);
29
+ }
30
+
31
+ function optionSourceTag(sourceId, label) {
32
+ const colors = {
33
+ "llama-cpp": pc.cyan,
34
+ "llama-cpp-mtp": pc.blue,
35
+ ollama: pc.green,
36
+ omlx: pc.magenta,
37
+ gguf: pc.cyan,
38
+ };
39
+ return optionTag(label, colors[sourceId] ?? pc.dim, OPTION_SOURCE_WIDTH);
40
+ }
41
+
42
+ function optionLabel({ status, source, name, details = [] }) {
43
+ return [status, source, pc.bold(name), ...details].filter(Boolean).join(OPTION_SEPARATOR);
44
+ }
45
+
46
+ export function modelSelectOption(item, { runningProfilesNow }) {
47
+ if (item.type === "profile") {
48
+ const backend = backendFor(item.profile.backend);
49
+ const running = runningProfilesNow.some((profile) => profile.id === item.profile.id);
50
+ return {
51
+ value: itemKey(item),
52
+ label: optionLabel({
53
+ status: optionStatusTag(item.fileMissing ? "missing" : running ? "running" : "ready"),
54
+ source: optionSourceTag(item.profile.backend, backend.label),
55
+ name: item.profile.label,
56
+ }),
57
+ };
58
+ }
59
+ if (item.type === "new") {
60
+ return {
61
+ value: itemKey(item),
62
+ label: optionLabel({ status: optionStatusTag("setup"), source: optionSourceTag("gguf", "GGUF file"), name: item.model.label }),
63
+ };
64
+ }
65
+ const backend = BACKENDS[item.backendId];
66
+ return {
67
+ value: itemKey(item),
68
+ label: optionLabel({ status: optionStatusTag("setup"), source: optionSourceTag(item.backendId, backend.label), name: item.model.label }),
69
+ };
70
+ }
71
+
72
+ export function printModelCards(items, { runningProfilesNow, drafters }) {
73
+ const profiles = items.filter((item) => item.type === "profile");
74
+ const newModels = items.filter((item) => item.type === "new");
75
+ const managed = items.filter((item) => item.type === "managed");
76
+
77
+ if (profiles.length > 0) {
78
+ console.log("\n" + pc.bold("Ready to chat"));
79
+ for (const item of profiles) {
80
+ const { profile } = item;
81
+ const backend = backendFor(profile.backend);
82
+ const running = runningProfilesNow.some((runningProfile) => runningProfile.id === profile.id);
83
+ const status = item.fileMissing ? pc.red("File missing") : running ? pc.green("Running now") : "Ready";
84
+ const border = item.fileMissing ? pc.red : running ? pc.green : pc.dim;
85
+ const detailParts = [item.fileMissing ? pc.red("File not found") : profileDetailParts(profile, { drafters }).filter(Boolean)[0]];
86
+ const mtpLabel = profileMtpLabel(profile, drafters);
87
+ if (mtpLabel) detailParts.push(mtpLabel);
88
+ const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
89
+ if (ctxLabel) detailParts.push(ctxLabel);
90
+ console.log(renderCard(profile.label, renderRows([
91
+ ["Status", status],
92
+ ["Details", detailParts.join(pc.dim(" · "))],
93
+ ["Runs with", backend.label],
94
+ ]), { formatBorder: border }));
95
+ }
96
+ }
97
+
98
+ if (newModels.length > 0) {
99
+ console.log("\n" + pc.bold("Needs setup"));
100
+ for (const item of newModels) {
101
+ const caps = detectCapabilities(item.model.path, item.model.mmprojPath);
102
+ const detailParts = [capabilitySummary(caps)];
103
+ const mtpLabel = ggufMtpLabel(item.model, item.drafter);
104
+ if (mtpLabel) detailParts.push(mtpLabel);
105
+ detailParts.push(formatBytes(item.model.sizeBytes));
106
+ console.log(renderCard(item.model.label, renderRows([
107
+ ["Status", pc.yellow("Needs setup")],
108
+ ["Details", detailParts.join(pc.dim(" · "))],
109
+ ]), { formatBorder: pc.yellow }));
110
+ }
111
+ }
112
+
113
+ for (const backendId of ["ollama", "omlx"]) {
114
+ const managedForBackend = managed.filter((item) => item.backendId === backendId);
115
+ if (managedForBackend.length === 0) continue;
116
+ const backend = BACKENDS[backendId];
117
+ console.log("\n" + pc.bold(`Via ${backend.label}`));
118
+ for (const item of managedForBackend) {
119
+ console.log(renderCard(item.model.label, renderRows([
120
+ ["Details", [item.model.id, item.model.quant].filter(Boolean).join(pc.dim(" · "))],
121
+ ]), { formatBorder: pc.dim }));
122
+ }
123
+ }
124
+ }
125
+
126
+ export async function printProfileDetails(profile) {
127
+ const backend = backendFor(profile.backend);
128
+ const isManaged = backend.type === "managed-server";
129
+ const running = await isProfileRunning(profile);
130
+ const fileMissing = !isManaged && isProfileFileMissing(profile);
131
+ console.log("\n" + renderSection("Model overview", renderRows([
132
+ ["Name", pc.bold(profile.label)],
133
+ ["Status", fileMissing ? pc.red("File missing") : running ? pc.green("Running now") : "Ready"],
134
+ ["Details", profileDetailParts(profile, { fileMissing }).join(pc.dim(" · "))],
135
+ ["Server", fileMissing ? pc.red(profile.baseUrl) : profile.baseUrl],
136
+ ])));
137
+
138
+ const detailRows = [
139
+ ["Setup ID", profile.id],
140
+ ["Runs with", backend.label],
141
+ ["Model alias", profile.modelAlias],
142
+ ...(profile.capabilities ? [["Detected", capabilitySummary(profile.capabilities)]] : []),
143
+ ];
144
+ if (!isManaged) {
145
+ detailRows.push(
146
+ ["Local file", fileMissing ? pc.red(`${profile.modelPath} (not found)`) : profile.modelPath ?? "unknown"],
147
+ ["Vision file", profile.mmprojPath ? (existsSync(profile.mmprojPath) ? profile.mmprojPath : pc.red(`${profile.mmprojPath} (not found)`)) : "none"],
148
+ ["Model size", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
149
+ );
150
+ if (profile.drafterPath) {
151
+ detailRows.push(["Drafter", existsSync(profile.drafterPath) ? profile.drafterPath : pc.red(`${profile.drafterPath} (not found)`)]);
152
+ }
153
+ }
154
+ console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
155
+
156
+ if (fileMissing) console.log("\n" + pc.red("⚠ This model's file is no longer on disk. Remove this setup or move the file back."));
157
+
158
+ if (!isManaged && profile.commandArgv) {
159
+ const commandArgv = await readCommandArgv(profile);
160
+ console.log("\n" + renderSection("llama-server command", pc.dim(buildPrettyCommand({ ...profile, commandArgv })), { columns: 120 }));
161
+ }
162
+ }
163
+
164
+ export function printGgufModelDetails(model, drafter) {
165
+ const { caps, parts } = ggufDetailParts(model, drafter);
166
+ parts.push(formatBytes(model.sizeBytes));
167
+ console.log("\n" + renderSection("Downloaded model", renderRows([
168
+ ["Name", pc.bold(model.label)],
169
+ ["Status", pc.yellow("Needs one-time setup")],
170
+ ["Details", parts.join(pc.dim(" · "))],
171
+ ])));
172
+ const detailRows = [
173
+ ["Local file", model.path],
174
+ ["Vision file", model.mmprojPath ?? "none"],
175
+ ["Detected", capabilitySummary(caps)],
176
+ ["Quant", model.quant ?? "unknown"],
177
+ ];
178
+ if (drafter) detailRows.push(["Drafter", drafter.path], ["Drafter size", formatBytes(drafter.sizeBytes)]);
179
+ console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
180
+ }
181
+
182
+ export function printManagedModelDetails(model, backend) {
183
+ console.log("\n" + renderSection(`${backend.label} model`, renderRows([
184
+ ["Name", pc.bold(model.label)],
185
+ ["Status", pc.green(`Local model via ${backend.label}`)],
186
+ ["Model ID", pc.cyan(model.id)],
187
+ ["Quant", model.quant ?? "unknown"],
188
+ ["Family", model.family ?? "unknown"],
189
+ ])));
190
+ }
@@ -0,0 +1,61 @@
1
+ import { existsSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+ import { backendFor } from "./backends.mjs";
4
+ import { matchDrafter } from "./scan.mjs";
5
+ import { detectCapabilities } from "./autodetect.mjs";
6
+ import { pc, humanCapabilitySummary } from "./ui.mjs";
7
+
8
+ export function isProfileFileMissing(profile) {
9
+ const backend = backendFor(profile.backend);
10
+ if (backend.type === "managed-server") return false;
11
+ if (!profile.modelPath) return true;
12
+ return !existsSync(profile.modelPath);
13
+ }
14
+
15
+ export function capabilitySummary(caps) {
16
+ const parts = [];
17
+ if (caps.architecture) parts.push(caps.architecture);
18
+ if (caps.quant) parts.push(caps.quant);
19
+ if (caps.mtp) parts.push("MTP");
20
+ if (caps.qat) parts.push("QAT");
21
+ if (caps.thinking) parts.push("thinking");
22
+ if (caps.vision) parts.push("vision");
23
+ return parts.length > 0 ? parts.join(" · ") : "standard GGUF";
24
+ }
25
+
26
+ export function profileMtpLabel(profile, drafters, { detailed = false } = {}) {
27
+ if (profile.drafterPath) {
28
+ return detailed ? pc.green(`MTP enabled (drafter: ${basename(profile.drafterPath)})`) : pc.green("MTP enabled");
29
+ }
30
+ if (drafters && profile.modelPath && matchDrafter(profile.modelPath, drafters)) return pc.yellow("MTP available");
31
+ if (profile.capabilities?.architecture === "gemma4") {
32
+ return detailed ? pc.yellow("MTP available — download a drafter model to enable 2× speedup") : pc.yellow("MTP: needs drafter");
33
+ }
34
+ return null;
35
+ }
36
+
37
+ export function ggufMtpLabel(model, drafter) {
38
+ const caps = detectCapabilities(model.path, model.mmprojPath);
39
+ if (caps.mtp || Boolean(drafter)) return pc.green("MTP ✓");
40
+ if (caps.architecture === "gemma4") return pc.yellow("MTP: needs drafter");
41
+ return null;
42
+ }
43
+
44
+ export function profileDetailParts(profile, { fileMissing = false, drafters = null } = {}) {
45
+ const parts = [fileMissing ? pc.red("File not found") : humanCapabilitySummary(profile.capabilities ?? {})];
46
+ const mtpLabel = profileMtpLabel(profile, drafters, { detailed: true });
47
+ if (mtpLabel) parts.push(mtpLabel);
48
+ const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
49
+ if (ctxLabel) parts.push(ctxLabel);
50
+ return parts;
51
+ }
52
+
53
+ export function ggufDetailParts(model, drafter) {
54
+ const caps = detectCapabilities(model.path, model.mmprojPath);
55
+ const parts = [humanCapabilitySummary(caps)];
56
+ const mtpLabel = ggufMtpLabel(model, drafter);
57
+ if (mtpLabel) parts.push(mtpLabel);
58
+ const ctxLabel = caps.ctxSize ? `${(caps.ctxSize / 1000).toFixed(0)}k ctx` : null;
59
+ if (ctxLabel) parts.push(ctxLabel);
60
+ return { caps, parts };
61
+ }
@@ -34,16 +34,14 @@ export async function configureLocalProfile(prompt, profile) {
34
34
  let configured = profile;
35
35
  // Re-detect capabilities from the model file and check for drafters
36
36
  // so that re-setup can pick up MTP availability, vision changes, etc.
37
- const hfSource = profile.hfRepo ? { hfRepo: profile.hfRepo, hfVariant: profile.hfVariant } : {};
38
- const freshCaps = { ...detectCapabilities(profile.modelPath, profile.mmprojPath), ...hfSource };
37
+ const freshCaps = detectCapabilities(profile.modelPath, profile.mmprojPath);
39
38
  let drafterPath = profile.drafterPath ?? null;
40
- if (!drafterPath && !profile.hfRepo) {
39
+ if (!drafterPath) {
41
40
  const { drafters } = await scanGgufModels();
42
41
  const drafter = matchDrafter(profile.modelPath, drafters);
43
42
  if (drafter) drafterPath = drafter.path;
44
43
  }
45
- const hfGemma4Mtp = Boolean(profile.hfRepo && /gemma-?4/i.test(`${profile.hfRepo} ${profile.label}`));
46
- const hasMtp = freshCaps.mtp || Boolean(drafterPath) || hfGemma4Mtp;
44
+ const hasMtp = freshCaps.mtp || Boolean(drafterPath);
47
45
  const caps = { ...freshCaps, mtp: hasMtp };
48
46
  // If MTP is newly available, switch backend and add drafter path
49
47
  if (hasMtp && configured.backend !== "llama-cpp-mtp") {
@@ -73,7 +71,7 @@ export async function configureLocalProfile(prompt, profile) {
73
71
  console.log(renderSection("MTP detected", renderRows([
74
72
  ["Backend", "llama.cpp MTP"],
75
73
  ["Port", String(LLAMA_CPP_MTP_PORT)],
76
- ["Flags", `--spec-type draft-mtp --spec-draft-n-max 4${configured.drafterPath && !configured.hfRepo ? " --spec-draft-model <drafter>" : ""}`],
74
+ ["Flags", `--spec-type draft-mtp --spec-draft-n-max 4${configured.drafterPath ? " --spec-draft-model <drafter>" : ""}`],
77
75
  ])));
78
76
  if (drafterInfo) console.log(pc.dim(drafterInfo));
79
77
  const useMtp = await prompt.yesNo("Use MTP speculative decoding?", true);
@@ -139,20 +137,29 @@ export function applyRuntimeFlagOverrides(profile, overrides) {
139
137
  return applyProfileFlags(profile, flags);
140
138
  }
141
139
 
142
- function applyMtpDefaults(profile) {
140
+ export function applyMtpDefaults(profile) {
143
141
  const flags = { ...profile.flags, port: LLAMA_CPP_MTP_PORT };
144
142
  const edits = {
145
143
  values: { "--spec-type": "draft-mtp", "--spec-draft-n-max": 4 },
146
144
  };
147
- if (profile.drafterPath && !profile.hfRepo) {
148
- edits.values["--spec-draft-model"] = profile.drafterPath;
149
- }
150
- return applyProfileFlags({ ...profile, backend: "llama-cpp-mtp", providerId: "llama-cpp-mtp" }, flags, edits);
145
+ if (profile.drafterPath) edits.values["--spec-draft-model"] = profile.drafterPath;
146
+ return applyProfileFlags({
147
+ ...profile,
148
+ backend: "llama-cpp-mtp",
149
+ providerId: "llama-cpp-mtp",
150
+ capabilities: { ...(profile.capabilities ?? {}), mtp: true },
151
+ }, flags, edits);
151
152
  }
152
153
 
153
- function removeMtpDefaults(profile) {
154
+ export function removeMtpDefaults(profile) {
154
155
  const flags = { ...profile.flags, port: LLAMA_CPP_PORT };
155
- return applyProfileFlags({ ...profile, backend: "llama-cpp", providerId: "llama-cpp" }, flags, {
156
+ return applyProfileFlags({
157
+ ...profile,
158
+ backend: "llama-cpp",
159
+ providerId: "llama-cpp",
160
+ drafterPath: null,
161
+ capabilities: { ...(profile.capabilities ?? {}), mtp: false },
162
+ }, flags, {
156
163
  remove: ["--spec-type", "--spec-draft-n-max", "--spec-draft-model"],
157
164
  });
158
165
  }
package/src/profiles.mjs CHANGED
@@ -134,15 +134,11 @@ export function normalizeProfile(profile) {
134
134
  export async function createProfileFromModel(model, backendId, drafterPath) {
135
135
  const { detectCapabilities } = await import("./autodetect.mjs");
136
136
  const caps = detectCapabilities(model.path, model.mmprojPath);
137
- // If a drafter is provided, this model supports MTP regardless of filename.
138
- // For Hugging Face Gemma 4 GGUF repos, prefer llama.cpp's -hf companion-file
139
- // discovery so root mtp-*.gguf drafters do not need offgrid-ai file matching.
140
- const hfGemma4Mtp = Boolean(model.hfRepo && /gemma-?4/i.test(`${model.hfRepo} ${model.label}`));
141
- const hasMtp = caps.mtp || Boolean(drafterPath) || hfGemma4Mtp;
137
+ // If a drafter is provided, this model supports MTP regardless of filename
138
+ const hasMtp = caps.mtp || Boolean(drafterPath);
142
139
  const backend = backendId ?? (hasMtp ? "llama-cpp-mtp" : "llama-cpp");
143
- const hfSource = model.hfRepo ? { hfRepo: model.hfRepo, hfVariant: model.hfVariant } : {};
144
140
  const { flags, argv } = computeFlags(
145
- { ...caps, mtp: hasMtp, ...hfSource },
141
+ { ...caps, mtp: hasMtp },
146
142
  model.path,
147
143
  model.mmprojPath,
148
144
  drafterPath ?? null,
@@ -154,12 +150,11 @@ export async function createProfileFromModel(model, backendId, drafterPath) {
154
150
  backend,
155
151
  providerId: backend,
156
152
  modelAlias: model.aliasSuggestion,
157
- source: model.hfRepo ? "huggingface" : model.source,
153
+ source: model.source,
158
154
  modelPath: model.path,
159
- mmprojPath: model.hfRepo ? null : model.mmprojPath,
160
- ...hfSource,
155
+ mmprojPath: model.mmprojPath,
161
156
  drafterPath: drafterPath ?? null,
162
- capabilities: summarizeCapabilities({ ...caps, mtp: hasMtp, ...hfSource }),
157
+ capabilities: summarizeCapabilities({ ...caps, mtp: hasMtp }),
163
158
  preset: null, // no presets — auto-detected
164
159
  flags,
165
160
  commandArgv: argv,
@@ -177,8 +172,6 @@ function summarizeCapabilities(caps) {
177
172
  quant: caps.quant,
178
173
  metaCtx: caps.metaCtx,
179
174
  mmprojProjectorType: caps.mmprojProjectorType,
180
- hfRepo: caps.hfRepo,
181
- hfVariant: caps.hfVariant,
182
175
  ctxSize: caps.ctxSize,
183
176
  };
184
177
  }
@@ -0,0 +1,17 @@
1
+ import { totalmem } from "node:os";
2
+
3
+ const MODEL_TIERS = [
4
+ { maxGB: 8, lms: "google/gemma-4-e2b", ollama: "gemma4:e2b", label: "Gemma 4 E2B (2B effective)" },
5
+ { maxGB: 16, lms: "google/gemma-4-e4b", ollama: "gemma4:e4b", label: "Gemma 4 E4B (4B effective)" },
6
+ { maxGB: 32, lms: "qwen/qwen3.5-9b", ollama: "qwen3.5:9b-q4_K_M", label: "Qwen 3.5 9B" },
7
+ { maxGB: Infinity, lms: "qwen/qwen3.6-35b-a3b", ollama: "qwen3.6:35b-a3b", label: "Qwen 3.6 35B-A3B" },
8
+ ];
9
+
10
+ export function recommendedModel() {
11
+ const gb = totalmem() / (1024 ** 3);
12
+ return MODEL_TIERS.find((tier) => gb <= tier.maxGB) ?? MODEL_TIERS[MODEL_TIERS.length - 1];
13
+ }
14
+
15
+ export function installedRamGB() {
16
+ return (totalmem() / (1024 ** 3)).toFixed(0);
17
+ }