offgrid-ai 0.6.6 → 0.6.9

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,234 @@
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 { buildPrettyCommand } from "./command.mjs";
6
+ import { pc, formatBytes, renderRows, renderSection } from "./ui.mjs";
7
+ import { capabilitySummary, ggufDetailParts, isProfileFileMissing, profileDetailParts } from "./model-summary.mjs";
8
+ import { itemKey } from "./model-catalog.mjs";
9
+ import { DATA_DIR } from "./config.mjs";
10
+ import { findBenchmarkRepo } from "./benchmark.mjs";
11
+
12
+ const OPTION_SEPARATOR = pc.dim(" │ ");
13
+ const OPTION_STATUS_WIDTH = 10;
14
+ const OPTION_SOURCE_WIDTH = 14;
15
+ const OPTION_MODEL_WIDTH = 26;
16
+ const OPTION_CTX_WIDTH = 5;
17
+
18
+ const { stripVTControlCharacters } = await import("node:util");
19
+
20
+ function optionPad(text, color, width) {
21
+ const visible = stripVTControlCharacters(String(text)).length;
22
+ const padded = String(text).padEnd(width);
23
+ return color ? color(padded.slice(0, padded.length - Math.max(0, visible - width))) : padded;
24
+ }
25
+
26
+ function optionStatusTag(kind) {
27
+ const statuses = {
28
+ running: ["RUNNING", pc.green],
29
+ ready: ["READY", pc.green],
30
+ missing: ["MISSING", pc.red],
31
+ setup: ["SETUP", pc.yellow],
32
+ };
33
+ const [text, color] = statuses[kind] ?? [kind, pc.dim];
34
+ return optionPad(text, color, OPTION_STATUS_WIDTH);
35
+ }
36
+
37
+ function optionSourceTag(sourceId, label) {
38
+ const colors = {
39
+ "llama-cpp": pc.cyan,
40
+ "llama-cpp-mtp": pc.blue,
41
+ ollama: pc.green,
42
+ omlx: pc.magenta,
43
+ gguf: pc.cyan,
44
+ };
45
+ return optionPad(label, colors[sourceId] ?? pc.dim, OPTION_SOURCE_WIDTH);
46
+ }
47
+
48
+ function optionCtxLabel(item) {
49
+ if (item.type === "profile" && item.profile.flags?.ctxSize) {
50
+ return `${(item.profile.flags.ctxSize / 1000).toFixed(0)}k`;
51
+ }
52
+ return "—";
53
+ }
54
+
55
+ function optionSizeLabel(item) {
56
+ if (item.type === "profile") {
57
+ if (item.fileMissing) return "—";
58
+ if (item.profile.modelPath && existsSync(item.profile.modelPath)) {
59
+ return formatBytes(statSync(item.profile.modelPath).size);
60
+ }
61
+ return "—";
62
+ }
63
+ if (item.type === "new") {
64
+ return formatBytes(item.model.sizeBytes);
65
+ }
66
+ // managed
67
+ if (item.model.quant) return item.model.quant;
68
+ if (item.model.sizeBytes) return formatBytes(item.model.sizeBytes);
69
+ return "—";
70
+ }
71
+
72
+ function optionLabel({ status, source, name, ctx, size }) {
73
+ return [status, source, pc.bold(optionPad(name, null, OPTION_MODEL_WIDTH)), ctx, pc.dim(size)].filter(Boolean).join(OPTION_SEPARATOR);
74
+ }
75
+
76
+ export function modelSelectOption(item, { runningProfilesNow }) {
77
+ if (item.type === "profile") {
78
+ const backend = backendFor(item.profile.backend);
79
+ const running = runningProfilesNow.some((profile) => profile.id === item.profile.id);
80
+ return {
81
+ value: itemKey(item),
82
+ label: optionLabel({
83
+ status: optionStatusTag(item.fileMissing ? "missing" : running ? "running" : "ready"),
84
+ source: optionSourceTag(item.profile.backend, backend.label),
85
+ name: item.profile.label,
86
+ ctx: optionCtxLabel(item),
87
+ size: optionSizeLabel(item),
88
+ }),
89
+ };
90
+ }
91
+ if (item.type === "new") {
92
+ return {
93
+ value: itemKey(item),
94
+ label: optionLabel({
95
+ status: optionStatusTag("setup"),
96
+ source: optionSourceTag("gguf", "GGUF file"),
97
+ name: item.model.label,
98
+ ctx: optionCtxLabel(item),
99
+ size: optionSizeLabel(item),
100
+ }),
101
+ };
102
+ }
103
+ const backend = BACKENDS[item.backendId];
104
+ return {
105
+ value: itemKey(item),
106
+ label: optionLabel({
107
+ status: optionStatusTag("setup"),
108
+ source: optionSourceTag(item.backendId, backend.label),
109
+ name: item.model.label,
110
+ ctx: optionCtxLabel(item),
111
+ size: optionSizeLabel(item),
112
+ }),
113
+ };
114
+ }
115
+
116
+ export function printWorkspaceHeader(normalized, runningProfilesNow) {
117
+ const profiles = normalized.profiles;
118
+ const readyCount = profiles.filter((p) => !isProfileFileMissing(p) && !runningProfilesNow.some((r) => r.id === p.id)).length;
119
+ const runningCount = runningProfilesNow.length;
120
+ const missingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
121
+ const setupCount = normalized.newModels.length + normalized.managedItems.length;
122
+
123
+ const countParts = [];
124
+ if (readyCount > 0) countParts.push(`${readyCount} ready`);
125
+ if (runningCount > 0) countParts.push(`${runningCount} running`);
126
+ if (missingCount > 0) countParts.push(`${missingCount} missing`);
127
+ if (setupCount > 0) countParts.push(`${setupCount} need setup`);
128
+
129
+ console.log(pc.bold(" offgrid-ai"));
130
+ console.log(pc.dim(` ${countParts.join(" · ")} · ${DATA_DIR}`));
131
+ }
132
+
133
+ export async function printBenchmarkLine() {
134
+ const repoPath = await findBenchmarkRepo();
135
+ if (repoPath) {
136
+ console.log(pc.green(" ✓") + " local-llm-visual-benchmark linked");
137
+ } else {
138
+ console.log(pc.yellow(" ○") + " to run benchmarks, pair with " + pc.cyan("local-llm-visual-benchmark"));
139
+ }
140
+ }
141
+
142
+ function tableWidth() {
143
+ const header = [
144
+ optionPad("Status", null, OPTION_STATUS_WIDTH),
145
+ optionPad("Backend", null, OPTION_SOURCE_WIDTH),
146
+ optionPad("Model", null, OPTION_MODEL_WIDTH),
147
+ optionPad("Ctx", null, OPTION_CTX_WIDTH),
148
+ "Size",
149
+ ].join(" \u2502 ");
150
+ return stripVTControlCharacters(header).length;
151
+ }
152
+
153
+ export function printTableHeader() {
154
+ console.log("");
155
+ const header = [
156
+ optionPad("Status", pc.dim, OPTION_STATUS_WIDTH),
157
+ optionPad("Backend", pc.dim, OPTION_SOURCE_WIDTH),
158
+ optionPad("Model", pc.dim, OPTION_MODEL_WIDTH),
159
+ optionPad("Ctx", pc.dim, OPTION_CTX_WIDTH),
160
+ pc.dim("Size"),
161
+ ].join(OPTION_SEPARATOR);
162
+ console.log(header);
163
+ console.log(pc.dim("─".repeat(tableWidth())));
164
+ }
165
+
166
+ export function printTableFooter() {
167
+ console.log(pc.dim("─".repeat(tableWidth())));
168
+ }
169
+
170
+ export async function printProfileDetails(profile) {
171
+ const backend = backendFor(profile.backend);
172
+ const isManaged = backend.type === "managed-server";
173
+ const running = await isProfileRunning(profile);
174
+ const fileMissing = !isManaged && isProfileFileMissing(profile);
175
+ console.log("\n" + renderSection("Model overview", renderRows([
176
+ ["Name", pc.bold(profile.label)],
177
+ ["Status", fileMissing ? pc.red("File missing") : running ? pc.green("Running now") : "Ready"],
178
+ ["Details", profileDetailParts(profile, { fileMissing }).join(pc.dim(" · "))],
179
+ ["Server", fileMissing ? pc.red(profile.baseUrl) : profile.baseUrl],
180
+ ])));
181
+
182
+ const detailRows = [
183
+ ["Setup ID", profile.id],
184
+ ["Runs with", backend.label],
185
+ ["Model alias", profile.modelAlias],
186
+ ...(profile.capabilities ? [["Detected", capabilitySummary(profile.capabilities)]] : []),
187
+ ];
188
+ if (!isManaged) {
189
+ detailRows.push(
190
+ ["Local file", fileMissing ? pc.red(`${profile.modelPath} (not found)`) : profile.modelPath ?? "unknown"],
191
+ ["Vision file", profile.mmprojPath ? (existsSync(profile.mmprojPath) ? profile.mmprojPath : pc.red(`${profile.mmprojPath} (not found)`)) : "none"],
192
+ ["Model size", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
193
+ );
194
+ if (profile.drafterPath) {
195
+ detailRows.push(["Drafter", existsSync(profile.drafterPath) ? profile.drafterPath : pc.red(`${profile.drafterPath} (not found)`)]);
196
+ }
197
+ }
198
+ console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
199
+
200
+ if (fileMissing) console.log("\n" + pc.red("⚠ This model's file is no longer on disk. Remove this setup or move the file back."));
201
+
202
+ if (!isManaged && profile.commandArgv) {
203
+ const commandArgv = await readCommandArgv(profile);
204
+ console.log("\n" + renderSection("llama-server command", pc.dim(buildPrettyCommand({ ...profile, commandArgv })), { columns: 120 }));
205
+ }
206
+ }
207
+
208
+ export function printGgufModelDetails(model, drafter) {
209
+ const { caps, parts } = ggufDetailParts(model, drafter);
210
+ parts.push(formatBytes(model.sizeBytes));
211
+ console.log("\n" + renderSection("Downloaded model", renderRows([
212
+ ["Name", pc.bold(model.label)],
213
+ ["Status", pc.yellow("Needs one-time setup")],
214
+ ["Details", parts.join(pc.dim(" · "))],
215
+ ])));
216
+ const detailRows = [
217
+ ["Local file", model.path],
218
+ ["Vision file", model.mmprojPath ?? "none"],
219
+ ["Detected", capabilitySummary(caps)],
220
+ ["Quant", model.quant ?? "unknown"],
221
+ ];
222
+ if (drafter) detailRows.push(["Drafter", drafter.path], ["Drafter size", formatBytes(drafter.sizeBytes)]);
223
+ console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
224
+ }
225
+
226
+ export function printManagedModelDetails(model, backend) {
227
+ console.log("\n" + renderSection(`${backend.label} model`, renderRows([
228
+ ["Name", pc.bold(model.label)],
229
+ ["Status", pc.green(`Local model via ${backend.label}`)],
230
+ ["Model ID", pc.cyan(model.id)],
231
+ ["Quant", model.quant ?? "unknown"],
232
+ ["Family", model.family ?? "unknown"],
233
+ ])));
234
+ }
@@ -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
+ }
@@ -64,7 +64,7 @@ export async function configureLocalProfile(prompt, profile) {
64
64
  ["Sampling", samplingSummary(profile.flags)],
65
65
  ])));
66
66
  console.log(pc.dim("Larger context windows use more memory. KV cache precision controls memory used by attention history."));
67
- console.log(pc.dim("Sampling defaults are shown for transparency; you can edit command.json later if needed.\n"));
67
+ console.log(pc.dim("Sampling defaults are shown for transparency; you can edit the profile later if needed.\n"));
68
68
 
69
69
  if (caps.mtp) {
70
70
  const drafterInfo = configured.drafterPath ? `\n Drafter: ${configured.drafterPath}` : "";
@@ -137,20 +137,29 @@ export function applyRuntimeFlagOverrides(profile, overrides) {
137
137
  return applyProfileFlags(profile, flags);
138
138
  }
139
139
 
140
- function applyMtpDefaults(profile) {
140
+ export function applyMtpDefaults(profile) {
141
141
  const flags = { ...profile.flags, port: LLAMA_CPP_MTP_PORT };
142
142
  const edits = {
143
143
  values: { "--spec-type": "draft-mtp", "--spec-draft-n-max": 4 },
144
144
  };
145
- if (profile.drafterPath) {
146
- edits.values["--spec-draft-model"] = profile.drafterPath;
147
- }
148
- 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);
149
152
  }
150
153
 
151
- function removeMtpDefaults(profile) {
154
+ export function removeMtpDefaults(profile) {
152
155
  const flags = { ...profile.flags, port: LLAMA_CPP_PORT };
153
- 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, {
154
163
  remove: ["--spec-type", "--spec-draft-n-max", "--spec-draft-model"],
155
164
  });
156
165
  }
@@ -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
+ }