offgrid-ai 0.9.6 → 0.10.1

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/src/benchmark.mjs CHANGED
@@ -6,5 +6,7 @@ export { findBenchmarkRepo, linkBenchmarkRepo } from "./benchmark/repo.mjs";
6
6
  export { prepareBenchmarkRun } from "./benchmark/prepare.mjs";
7
7
  export { runBenchmarkInPi } from "./benchmark/pi-runner.mjs";
8
8
  export { queryServerMetrics } from "./benchmark/metrics.mjs";
9
- export { unloadModelFromServer, finalizeBenchmarkRun, renderBenchmarkSummary } from "./benchmark/finalize.mjs";
9
+ // unloadModelFromServer now lives in src/process.mjs (managed-server counterpart to stopProfile).
10
+ export { unloadModelFromServer } from "./process.mjs";
11
+ export { finalizeBenchmarkRun, renderBenchmarkSummary } from "./benchmark/finalize.mjs";
10
12
  export { benchmarkForProfile, benchmarkFlow } from "./benchmark/flow.mjs";
@@ -1,10 +1,11 @@
1
1
  import { findLlamaServer, ensureDirs } from "../config.mjs";
2
2
  import { backendFor } from "../backends.mjs";
3
3
  import { scanGgufModels } from "../scan.mjs";
4
+ import { scanMlxModels } from "../mlx-discovery.mjs";
4
5
  import { loadProfiles } from "../profiles.mjs";
5
6
  import { hasPi } from "../harness-pi.mjs";
6
7
  import { offerManagedLlamaRuntimeUpdate } from "../runtime.mjs";
7
- import { hasLmStudioInstalled, hasOllamaInstalled, hasOmlxInstalled, scanManagedModels } from "../managed.mjs";
8
+ import { hasLmStudioInstalled, hasOmlxInstalled, scanManagedModels } from "../managed.mjs";
8
9
  import { recommendedModel } from "../recommendations.mjs";
9
10
  import { pc, startInteractive, createPrompt } from "../ui.mjs";
10
11
  import { onboardFlow } from "./onboard.mjs";
@@ -26,9 +27,10 @@ export async function mainFlow() {
26
27
  const llamaBinary = await findLlamaServer();
27
28
  const { models: ggufModels, drafters } = await scanGgufModels();
28
29
  const managedModels = await scanManagedModels();
30
+ const mlxModels = await scanMlxModels();
29
31
  const profiles = await loadProfiles();
30
32
  const hasAnyBackend = llamaBinary || managedModels.some((item) => item.status === "ok" && item.models.length > 0);
31
- const hasAnyModels = ggufModels.length > 0 || managedModels.some((item) => item.status === "ok" && item.models.length > 0);
33
+ const hasAnyModels = ggufModels.length > 0 || mlxModels.length > 0 || managedModels.some((item) => item.status === "ok" && item.models.length > 0);
32
34
 
33
35
  const piInstalled = await hasPi();
34
36
  const needsLlama = ggufModels.length > 0 || profiles.some((profile) => backendFor(profile.backend).type === "local-server");
@@ -56,16 +58,16 @@ export async function mainFlow() {
56
58
  if (!process.stdin.isTTY) return await statusCommand();
57
59
 
58
60
  startInteractive("offgrid-ai");
59
- return await modelCommandCenter({ profiles, ggufModels, managedModels, drafters });
61
+ return await modelCommandCenter({ profiles, ggufModels, managedModels, mlxModels, drafters });
60
62
  }
61
63
 
62
64
  async function printNoModelsHelp(llamaBinary) {
63
65
  console.log(pc.yellow("No models found."));
64
66
  console.log(pc.dim("You need to download a model to use offgrid-ai.\n"));
65
67
 
66
- const [ollamaInstalled, omlxInstalled] = await Promise.all([hasOllamaInstalled(), hasOmlxInstalled()]);
68
+ const omlxInstalled = await hasOmlxInstalled();
67
69
  const lmStudioInstalled = hasLmStudioInstalled();
68
- const hasBackends = llamaBinary || ollamaInstalled || omlxInstalled || lmStudioInstalled;
70
+ const hasBackends = llamaBinary || omlxInstalled || lmStudioInstalled;
69
71
  if (!hasBackends) {
70
72
  console.log(pc.dim("Run offgrid-ai to install a backend and download a model."));
71
73
  return;
@@ -73,7 +75,6 @@ async function printNoModelsHelp(llamaBinary) {
73
75
 
74
76
  console.log(pc.bold("Backend status:"));
75
77
  console.log(` ${lmStudioInstalled ? pc.green("✓") : pc.red("✗")} LM Studio ${lmStudioInstalled ? "— installed" : "— not installed"}`);
76
- console.log(` ${ollamaInstalled ? pc.green("✓") : pc.red("✗")} Ollama ${ollamaInstalled ? "— installed" : "— not installed"}`);
77
78
  console.log(` ${omlxInstalled ? pc.green("✓") : pc.red("✗")} oMLX ${omlxInstalled ? "— installed" : "— not installed"}`);
78
79
  console.log(` ${llamaBinary ? pc.green("✓") : pc.red("✗")} llama-server ${llamaBinary ? "— installed" : "— not installed"}`);
79
80
  console.log();
@@ -84,6 +85,5 @@ async function printNoModelsHelp(llamaBinary) {
84
85
  console.log(" Open LM Studio → browse models → download");
85
86
  console.log(pc.dim(` Recommended: ${model.label}`));
86
87
  }
87
- if (ollamaInstalled) console.log(pc.bold(` ollama pull ${model.ollama}`));
88
88
  if (omlxInstalled) console.log(pc.bold(" omlx start"));
89
89
  }
@@ -6,7 +6,7 @@ import { syncPiConfig, removeFromPiConfig } from "../harness-pi.mjs";
6
6
  import { configureLocalProfile } from "../profile-setup.mjs";
7
7
  import { pc, startInteractive, createPrompt } from "../ui.mjs";
8
8
  import { buildCatalogItems, createManagedProfile, itemKey, loadModelCatalog, normalizeCatalog } from "../model-catalog.mjs";
9
- import { modelSelectOption, modelNameWidth, printGgufModelDetails, printManagedModelDetails, printWorkspaceHeader, printBenchmarkLine, printProfileDetails } from "../model-presenters.mjs";
9
+ import { modelSelectOption, modelNameWidth, printGgufModelDetails, printMlxModelDetails, printManagedModelDetails, printWorkspaceHeader, printBenchmarkLine, printProfileDetails } from "../model-presenters.mjs";
10
10
  import { runProfile } from "./run.mjs";
11
11
 
12
12
  const { stripVTControlCharacters } = await import("node:util");
@@ -40,7 +40,6 @@ export async function modelCommandCenter(initialCatalog) {
40
40
  }
41
41
 
42
42
  const runningProfilesNow = [];
43
- const serverUpIds = new Set();
44
43
  const modelMissingIds = new Set();
45
44
  for (const profile of normalized.profiles) {
46
45
  if (await isProfileRunning(profile)) {
@@ -48,11 +47,10 @@ export async function modelCommandCenter(initialCatalog) {
48
47
  continue;
49
48
  }
50
49
  if (backendFor(profile.backend).type === "managed-server" && await isProfileServerUp(profile)) {
51
- if (await modelAvailableOnServer(profile)) serverUpIds.add(profile.id);
52
- else modelMissingIds.add(profile.id);
50
+ if (!(await modelAvailableOnServer(profile))) modelMissingIds.add(profile.id);
53
51
  }
54
52
  }
55
- printWorkspaceHeader(normalized, runningProfilesNow, serverUpIds, modelMissingIds);
53
+ printWorkspaceHeader(normalized, runningProfilesNow, modelMissingIds);
56
54
  await printBenchmarkLine();
57
55
 
58
56
  const nameWidth = modelNameWidth(allItems);
@@ -62,13 +60,12 @@ export async function modelCommandCenter(initialCatalog) {
62
60
  if (item.fileMissing) return "missing";
63
61
  if (runningProfilesNow.some((profile) => profile.id === item.profile.id)) return "running";
64
62
  if (modelMissingIds.has(item.profile.id)) return "missing";
65
- if (serverUpIds.has(item.profile.id)) return "serverup";
66
63
  return "ready";
67
64
  }
68
65
  return "setup";
69
66
  };
70
67
 
71
- const groupOrder = ["running", "serverup", "ready", "setup", "missing"];
68
+ const groupOrder = ["running", "ready", "setup", "missing"];
72
69
  const grouped = new Map(groupOrder.map((key) => [key, []]));
73
70
  for (const item of allItems) grouped.get(statusFor(item)).push(item);
74
71
 
@@ -77,7 +74,7 @@ export async function modelCommandCenter(initialCatalog) {
77
74
  const bucket = grouped.get(group);
78
75
  if (!bucket || bucket.length === 0) continue;
79
76
  for (const item of bucket) {
80
- const opt = modelSelectOption(item, { runningProfilesNow, serverUpIds, modelMissingIds, nameWidth });
77
+ const opt = modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth });
81
78
  choices.push({ value: opt.value, label: opt.label, hint: opt.hint });
82
79
  }
83
80
  }
@@ -159,6 +156,7 @@ async function performAction(prompt, action, item) {
159
156
  if (action === "inspect") {
160
157
  if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
161
158
  if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
159
+ if (item.model?.format === "mlx") return await printMlxModelDetails(item.model);
162
160
  return printGgufModelDetails(item.model, item.drafter);
163
161
  }
164
162
  if (action === "benchmark") {
@@ -169,20 +167,13 @@ async function performAction(prompt, action, item) {
169
167
  const { benchmarkFlow } = await import("../benchmark.mjs");
170
168
  return await benchmarkFlow();
171
169
  }
172
- if (action === "run") return await runItem(prompt, item);
170
+ if (action === "run") return await runItem(item);
173
171
  if (action === "reconfigure" || action === "setup") return await setupItem(prompt, item, action);
174
172
  if (action === "remove" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
175
173
  }
176
174
 
177
- async function runItem(prompt, item) {
178
- if (item.type === "profile") return await runProfile(await readProfile(item.profile.id));
179
- const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
180
- const configured = await configureLocalProfile(prompt, profile);
181
- if (!configured) return;
182
- await saveProfile(configured);
183
- await syncPiConfig(configured);
184
- printProfileSaved(configured.id);
185
- return await runProfile(configured);
175
+ async function runItem(item) {
176
+ return await runProfile(await readProfile(item.profile.id));
186
177
  }
187
178
 
188
179
  function printProfileSaved(id) {
@@ -205,6 +196,18 @@ async function setupItem(prompt, item, action) {
205
196
  printProfileSaved(profile.id);
206
197
  return;
207
198
  }
199
+ // MLX models: build a mlx-vlm profile and run interactive config.
200
+ if (item.model.format === "mlx") {
201
+ const { createProfileFromMlxModel } = await import("../profiles.mjs");
202
+ const { configureMlxProfile } = await import("../profile-setup.mjs");
203
+ const profile = await createProfileFromMlxModel(item.model);
204
+ const configured = await configureMlxProfile(prompt, profile);
205
+ if (!configured) return;
206
+ await saveProfile(configured, { writeCommand: true });
207
+ await syncPiConfig(configured);
208
+ printProfileSaved(configured.id);
209
+ return;
210
+ }
208
211
  const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
209
212
  const configured = await configureLocalProfile(prompt, profile);
210
213
  if (!configured) return;
@@ -1,14 +1,17 @@
1
1
  import { existsSync } from "node:fs";
2
- import { ensureDirs, findLlamaServer, hasHomebrew } from "../config.mjs";
2
+ import { ensureDirs, findLlamaServer, hasHomebrew, HF_HUB_DIR } from "../config.mjs";
3
3
  import { BACKENDS } from "../backends.mjs";
4
4
  import { scanGgufModels } from "../scan.mjs";
5
+ import { scanMlxModels } from "../mlx-discovery.mjs";
5
6
  import { hasPi } from "../harness-pi.mjs";
6
7
  import { offerManagedLlamaRuntimeUpdate } from "../runtime.mjs";
7
8
  import { scanManagedModels } from "../managed.mjs";
8
9
  import { BACKEND_INSTALL_CHOICES, BACKEND_INSTALLERS } from "../backend-installers.mjs";
9
- import { installedRamGB, recommendedModel } from "../recommendations.mjs";
10
+ import { recommendedModel, selectFormat, allFittingModels } from "../recommendations.mjs";
11
+ import { hasHuggingfaceHub, resolveHfDownload, downloadToHfCache } from "../huggingface.mjs";
12
+ import { detectHardware, getFreeDiskBytes, installedRamGB } from "../hardware.mjs";
10
13
  import { runCommand } from "../exec.mjs";
11
- import { pc, renderRows, renderSection, startInteractive, createPrompt } from "../ui.mjs";
14
+ import { pc, formatBytes, renderRows, renderSection, startInteractive, createPrompt } from "../ui.mjs";
12
15
 
13
16
  export async function onboardFlow() {
14
17
  await ensureDirs();
@@ -24,14 +27,22 @@ export async function onboardFlow() {
24
27
  const llamaBinary = await ensureLlamaRuntime(prompt);
25
28
  if (!(await ensurePi(prompt, run))) return;
26
29
 
27
- const { models: ggufModels } = await scanGgufModels();
28
- const managedModels = await scanManagedModels();
30
+ const [{ models: ggufModels }, managedModels, mlxModels] = await Promise.all([
31
+ scanGgufModels(),
32
+ scanManagedModels(),
33
+ scanMlxModels(),
34
+ ]);
29
35
  const totalManaged = managedModels.reduce((sum, item) => sum + item.models.length, 0);
30
- const hasModels = ggufModels.length > 0 || totalManaged > 0;
36
+ const hasModels = ggufModels.length > 0 || totalManaged > 0 || mlxModels.length > 0;
31
37
 
32
38
  if (hasModels) {
33
- printFoundModels(ggufModels, managedModels, llamaBinary);
39
+ printFoundModels(ggufModels, managedModels, mlxModels, llamaBinary);
34
40
  } else {
41
+ const canDownload = await hasHuggingfaceHub();
42
+ if (canDownload) {
43
+ const downloaded = await offerModelDownload(prompt);
44
+ if (downloaded) return;
45
+ }
35
46
  await offerBackendInstall(prompt, run);
36
47
  return;
37
48
  }
@@ -52,7 +63,7 @@ async function ensureLlamaRuntime(prompt) {
52
63
  ]), { formatBorder: pc.cyan }));
53
64
  await offerManagedLlamaRuntimeUpdate(prompt);
54
65
  llamaBinary = await findLlamaServer();
55
- if (!llamaBinary) console.log(pc.yellow("Skipping llama.cpp for now. You can still use Ollama/oMLX, or run offgrid-ai again to install the managed runtime."));
66
+ if (!llamaBinary) console.log(pc.yellow("Skipping llama.cpp for now. You can still use oMLX, or run offgrid-ai again to install the managed runtime."));
56
67
  }
57
68
  if (llamaBinary) console.log(pc.green(`✓ llama-server: ${llamaBinary}`));
58
69
  return llamaBinary;
@@ -85,11 +96,14 @@ async function ensurePi(prompt, run) {
85
96
  return true;
86
97
  }
87
98
 
88
- function printFoundModels(ggufModels, managedModels, llamaBinary) {
99
+ function printFoundModels(ggufModels, managedModels, mlxModels, llamaBinary) {
89
100
  if (ggufModels.length > 0) {
90
101
  console.log(pc.green(`✓ Found ${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"}`));
91
102
  if (!llamaBinary) console.log(pc.yellow("Install the managed llama.cpp runtime to run these GGUF models."));
92
103
  }
104
+ if (mlxModels.length > 0) {
105
+ console.log(pc.green(`✓ Found ${mlxModels.length} MLX model${mlxModels.length === 1 ? "" : "s"}`));
106
+ }
93
107
  for (const { backendId, models, status, reason } of managedModels) {
94
108
  if (status === "unavailable") {
95
109
  console.log(pc.yellow(`${BACKENDS[backendId].label}: unavailable${reason ? ` — ${reason}` : ""}`));
@@ -99,6 +113,50 @@ function printFoundModels(ggufModels, managedModels, llamaBinary) {
99
113
  }
100
114
  }
101
115
 
116
+ async function offerModelDownload(prompt) {
117
+ const hardware = detectHardware();
118
+ const candidates = allFittingModels(hardware)
119
+ .map((entry) => ({ entry, format: selectFormat(entry, hardware) }))
120
+ .filter((item) => item.format != null);
121
+ if (candidates.length === 0) {
122
+ console.log(pc.yellow("No curated models fit your hardware."));
123
+ return false;
124
+ }
125
+
126
+ const primary = candidates[0];
127
+ console.log(renderSection("Download a recommended model", renderRows([
128
+ ["Model", pc.bold(primary.entry.label)],
129
+ ["Format", primary.format],
130
+ ["Minimum RAM", String(primary.entry.minRamGb) + " GB"],
131
+ ["Your RAM", installedRamGB() + " GB"],
132
+ ]), { formatBorder: pc.cyan }));
133
+
134
+ const shouldDownload = await prompt.yesNo("Download " + primary.entry.label + " (" + primary.format + ")?", true);
135
+ if (!shouldDownload) return false;
136
+
137
+ const hfRef = primary.format === "mlx" ? primary.entry.mlx : primary.entry.gguf;
138
+ try {
139
+ const plan = await resolveHfDownload(hfRef);
140
+ console.log(pc.dim("Total size: " + formatBytes(plan.totalSizeBytes)));
141
+ const freeBytes = getFreeDiskBytes(HF_HUB_DIR);
142
+ if (plan.totalSizeBytes > 0 && freeBytes < plan.totalSizeBytes * 1.1) {
143
+ console.log(pc.red(`Not enough disk space in ${HF_HUB_DIR}: need ~${formatBytes(plan.totalSizeBytes)}, only ${formatBytes(freeBytes)} free.`));
144
+ return false;
145
+ }
146
+ await downloadToHfCache(plan, {
147
+ onProgress({ percentage }) {
148
+ process.stdout.write(pc.cyan("\r " + percentage + "% downloaded"));
149
+ },
150
+ });
151
+ process.stdout.write("\n");
152
+ console.log(pc.green("✓ Download complete. Run offgrid-ai to use the model."));
153
+ return true;
154
+ } catch (err) {
155
+ console.log(pc.red("Download failed: " + err.message));
156
+ return false;
157
+ }
158
+ }
159
+
102
160
  async function offerBackendInstall(prompt, run) {
103
161
  console.log(pc.yellow("\nNo models found."));
104
162
  console.log(pc.dim("You need at least one model backend to use offgrid-ai.\n"));
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { ensureDirs } from "../config.mjs";
3
3
  import { backendFor } from "../backends.mjs";
4
4
  import { normalizeProfile, readProfile, saveProfile } from "../profiles.mjs";
5
- import { startServer, stopProfile, waitForReady, serverReady, serverMatchesProfile, modelAvailableOnServer } from "../process.mjs";
5
+ import { startServer, stopProfile, waitForReady, serverReady, serverMatchesProfile, modelAvailableOnServer, unloadModelFromServer } from "../process.mjs";
6
6
  import { syncPiConfig, hasPiModel, launchPi, hasPi } from "../harness-pi.mjs";
7
7
  import { tailFriendly } from "../logs.mjs";
8
8
  import { estimateMemory } from "../estimate.mjs";
@@ -35,7 +35,7 @@ export async function runProfile(profile, options = {}) {
35
35
  }
36
36
  const available = await modelAvailableOnServer(profile);
37
37
  if (!available) {
38
- const modelId = profile.omlxModel ?? profile.ollamaModel ?? profile.modelAlias ?? profile.label;
38
+ const modelId = profile.omlxModel ?? profile.modelAlias ?? profile.label;
39
39
  throw new Error(`${modelId} is not available on ${backend.label} at ${profile.baseUrl}.`);
40
40
  }
41
41
  console.log(pc.green(`[ready] ${backend.label} at ${profile.baseUrl}`));
@@ -116,9 +116,24 @@ async function launchHarness(profile, options, isManaged, withHarness, backend)
116
116
  try {
117
117
  await launchPi(profile);
118
118
  } finally {
119
- if (!isManaged && !options["keep-server"]) {
120
- const result = await stopProfile(profile);
121
- console.log(result.stopped ? pc.green(`[stop] ${result.message}`) : pc.dim(`[stop] ${result.message}`));
119
+ if (!options["keep-server"]) {
120
+ if (!isManaged) {
121
+ const result = await stopProfile(profile);
122
+ console.log(result.stopped ? pc.green(`[stop] ${result.message}`) : pc.dim(`[stop] ${result.message}`));
123
+ } else {
124
+ // Managed-server backends (oMLX): unload the model from the
125
+ // server's memory via its HTTP API. The server itself stays running
126
+ // (offgrid-ai doesn't manage it), but the model is released — same UX
127
+ // as local-server backends where stopProfile kills the process.
128
+ const result = await unloadModelFromServer(profile);
129
+ if (result.unloaded) {
130
+ console.log(pc.green(`[unload] ${backend.label}: model unloaded`));
131
+ } else if (result.reason) {
132
+ console.log(pc.dim(`[unload] ${backend.label}: ${result.reason}`));
133
+ } else if (result.error) {
134
+ console.log(pc.yellow(`[unload] ${backend.label}: ${result.error}`));
135
+ }
136
+ }
122
137
  }
123
138
  }
124
139
  }
@@ -42,7 +42,7 @@ export async function statusCommand() {
42
42
  const detailRows = [];
43
43
  for (const { profile, status } of [...managedUpMissing, ...managedUpNotLoaded]) {
44
44
  const backend = backendFor(profile.backend);
45
- const modelId = profile.omlxModel ?? profile.ollamaModel ?? profile.modelAlias ?? profile.id;
45
+ const modelId = profile.omlxModel ?? profile.modelAlias ?? profile.id;
46
46
  const state = status.modelAvailable
47
47
  ? pc.yellow("server up · model not loaded")
48
48
  : pc.red("server up · model missing");
package/src/config.mjs CHANGED
@@ -15,9 +15,17 @@ export const MANAGED_LLAMA_SERVER = join(RUNTIME_DIR, "bin", "llama-server");
15
15
 
16
16
  // ── Default scan directories ──────────────────────────────────────────────
17
17
 
18
+ // HuggingFace hub cache: $HF_HUB_CACHE, else $HF_HOME/hub, else
19
+ // ~/.cache/huggingface/hub. This is where huggingface_hub stores
20
+ // models--org--name/... and where offgrid-ai scans + downloads. Pointing at the
21
+ // hub (not the HF root) keeps the HF-hub MLX/GGUF scanners and the downloader
22
+ // on the same layout.
23
+ export const HF_HUB_DIR = process.env.HF_HUB_CACHE
24
+ || (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache", "huggingface", "hub"));
25
+
18
26
  export const DEFAULT_MODEL_DIRS = [
19
27
  join(homedir(), ".lmstudio", "models"),
20
- join(homedir(), ".cache", "huggingface", "hub"),
28
+ HF_HUB_DIR,
21
29
  ];
22
30
 
23
31
  // ── External config paths ─────────────────────────────────────────────────
@@ -65,7 +73,8 @@ export async function saveConfig(config) {
65
73
 
66
74
  export async function getModelScanDirs() {
67
75
  const config = await loadConfig();
68
- return [...DEFAULT_MODEL_DIRS, ...config.modelScanDirs];
76
+ // Dedupe (a user may list a default dir explicitly) so we never scan twice.
77
+ return [...DEFAULT_MODEL_DIRS, ...config.modelScanDirs].filter((dir, i, arr) => arr.indexOf(dir) === i);
69
78
  }
70
79
 
71
80
  // ── Binary discovery ──────────────────────────────────────────────────────
@@ -0,0 +1,44 @@
1
+ // Shared discovery helpers used by both the GGUF scanner (scan.mjs) and the
2
+ // MLX scanner (mlx-discovery.mjs). Keeping these here avoids a cross-dependency
3
+ // between the two format-specific scanners.
4
+
5
+ import { basename, dirname } from "node:path";
6
+
7
+ /** Minimum on-disk size for a model to count as real (skips tiny test/embedding files). */
8
+ export const MIN_MODEL_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
9
+
10
+ /**
11
+ * Model-type / architecture names that indicate an embedding model. Shared by
12
+ * GGUF filtering (general.architecture) and MLX filtering (config.model_type /
13
+ * architectures[0]). Format-specific heuristics (e.g. GGUF filename patterns)
14
+ * live alongside this set in each scanner.
15
+ */
16
+ export const EMBEDDING_MODEL_TYPES = new Set([
17
+ "bert",
18
+ "roberta",
19
+ "mpnet",
20
+ "nomic_bert",
21
+ "nomic-bert",
22
+ "jina",
23
+ "e5",
24
+ "gte",
25
+ "bge",
26
+ "all_minilm",
27
+ "all-minilm",
28
+ "sentence_transformers",
29
+ "sentence-transformers",
30
+ ]);
31
+
32
+ /**
33
+ * Infer a human-readable source label from a model scan path.
34
+ * Generic container folders (models, hub, cache) defer to their parent name
35
+ * (e.g. ~/.cache/huggingface/hub -> "huggingface"; ~/.lmstudio/models -> "lmstudio").
36
+ */
37
+ export function inferSourceLabel(scanPath) {
38
+ const name = basename(scanPath).replace(/^\./, "");
39
+ const parent = basename(dirname(scanPath));
40
+ if (name === "models" || name === "hub" || name === "cache") {
41
+ return parent.replace(/^\./, "");
42
+ }
43
+ return name;
44
+ }
@@ -0,0 +1,49 @@
1
+ // Hardware detection — pure functions, no side effects.
2
+ // Ported from deprecated-offgrid-desktop/src/main/hardware.ts.
3
+ //
4
+ // This is the canonical source for hardware facts the model-serving logic
5
+ // needs: recommendations, onboarding, MLX context sizing, disk-space guards,
6
+ // and memory estimation.
7
+
8
+ import { totalmem } from "node:os";
9
+ import { statfsSync, existsSync } from "node:fs";
10
+ import { dirname } from "node:path";
11
+
12
+ /**
13
+ * Detect system hardware via the Node.js `os` module.
14
+ * Pure function — no side effects, trivially testable.
15
+ */
16
+ export function detectHardware() {
17
+ return {
18
+ totalRamBytes: totalmem(),
19
+ platform: process.platform,
20
+ arch: process.arch,
21
+ };
22
+ }
23
+
24
+ /** Installed RAM in GB (integer). */
25
+ export function installedRamGB() {
26
+ return Math.round(totalmem() / (1024 ** 3));
27
+ }
28
+
29
+ /**
30
+ * Get available disk space for a directory (in bytes).
31
+ *
32
+ * If the directory doesn't exist yet, walks up to the nearest existing parent
33
+ * directory. Returns a very large number if the check fails (so we don't block
34
+ * a download unnecessarily).
35
+ */
36
+ export function getFreeDiskBytes(dir) {
37
+ try {
38
+ let checkDir = dir;
39
+ while (!existsSync(checkDir)) {
40
+ const parent = dirname(checkDir);
41
+ if (parent === checkDir) break; // reached root
42
+ checkDir = parent;
43
+ }
44
+ const stats = statfsSync(checkDir);
45
+ return stats.bavail * stats.bsize;
46
+ } catch {
47
+ return Number.MAX_SAFE_INTEGER;
48
+ }
49
+ }
@@ -5,6 +5,18 @@ import { loadProfiles } from "./profiles.mjs";
5
5
  import { readJson, writeJson } from "./json.mjs";
6
6
  import pc from "picocolors";
7
7
 
8
+ // ── Pi model id ─────────────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * The model id Pi must send in requests. mlx-vlm registers the loaded model
12
+ * with the full --model path as its API id (verified via /v1/models); sending
13
+ * the repo-id label instead makes mlx-vlm unload the local model and re-fetch
14
+ * the repo from HuggingFace. Other backends use the friendly modelAlias.
15
+ */
16
+ export function piApiModelId(profile) {
17
+ return profile.backend === "mlx-vlm" ? profile.modelPath : profile.modelAlias;
18
+ }
19
+
8
20
  // ── Sync Pi config ─────────────────────────────────────────────────────────
9
21
 
10
22
  export async function syncPiConfig(profile) {
@@ -30,26 +42,27 @@ export async function removeFromPiConfig(profile) {
30
42
  const provider = config.providers[profile.providerId];
31
43
  if (!provider?.models) return { cleaned: false, reason: `no ${profile.providerId} provider in Pi config` };
32
44
  const before = provider.models.length;
33
- provider.models = provider.models.filter((m) => m.id !== profile.modelAlias);
45
+ const apiId = piApiModelId(profile);
46
+ provider.models = provider.models.filter((m) => m.id !== apiId);
34
47
  if (provider.models.length === 0) delete config.providers[profile.providerId];
35
48
  if (before > provider.models.length) {
36
49
  await writeJson(PI_CONFIG, config);
37
50
  return { cleaned: true, removed: before - provider.models.length };
38
51
  }
39
- return { cleaned: false, reason: `${profile.modelAlias} not in Pi config` };
52
+ return { cleaned: false, reason: `${apiId} not in Pi config` };
40
53
  }
41
54
 
42
55
  // ── Check if Pi has the model ──────────────────────────────────────────────
43
56
 
44
57
  export async function hasPiModel(profile) {
45
58
  const config = await readJson(PI_CONFIG, null);
46
- return Boolean(config?.providers?.[profile.providerId]?.models?.some?.((m) => m.id === profile.modelAlias));
59
+ return Boolean(config?.providers?.[profile.providerId]?.models?.some?.((m) => m.id === piApiModelId(profile)));
47
60
  }
48
61
 
49
62
  // ── Launch Pi ──────────────────────────────────────────────────────────────
50
63
 
51
64
  export async function launchPi(profile) {
52
- const model = profile.harnesses?.pi?.model ?? `${profile.providerId}/${profile.modelAlias}`;
65
+ const model = `${profile.providerId}/${piApiModelId(profile)}`;
53
66
  console.log(pc.bold(`[pi] pi --model ${model}`));
54
67
  await runForeground("pi", ["--model", model]);
55
68
  }
@@ -88,7 +101,7 @@ function piModelConfig(profile) {
88
101
  const compat = modelCompat(profile);
89
102
  const reasoning = modelReasoning(profile);
90
103
  return {
91
- id: profile.modelAlias,
104
+ id: piApiModelId(profile),
92
105
  name: profile.label,
93
106
  input: modelInput(profile),
94
107
  ...(reasoning === undefined ? {} : { reasoning }),
@@ -99,7 +112,9 @@ function piModelConfig(profile) {
99
112
  }
100
113
 
101
114
  function modelInput(profile) {
102
- return profile.mmprojPath && existsSync(profile.mmprojPath) ? ["text", "image"] : ["text"];
115
+ if (profile.mmprojPath && existsSync(profile.mmprojPath)) return ["text", "image"];
116
+ if (profile.capabilities?.vision) return ["text", "image"];
117
+ return ["text"];
103
118
  }
104
119
 
105
120
  function modelCompat(profile) {
@@ -119,15 +134,14 @@ function modelReasoning(profile) {
119
134
  }
120
135
 
121
136
  function modelFamily(profile) {
122
- return [profile.id, profile.label, profile.modelAlias, profile.modelPath, profile.ollamaModel, profile.omlxModel].filter(Boolean).join(" ").toLowerCase();
137
+ return [profile.id, profile.label, profile.modelAlias, profile.modelPath, profile.omlxModel].filter(Boolean).join(" ").toLowerCase();
123
138
  }
124
139
 
125
- function piApiKey(providerId) {
126
- return providerId === "ollama" ? "ollama" : "none";
140
+ function piApiKey() {
141
+ return "none";
127
142
  }
128
143
 
129
- function providerCompat(providerId) {
130
- if (providerId === "ollama") return { supportsDeveloperRole: true, supportsReasoningEffort: false };
144
+ function providerCompat() {
131
145
  return { supportsDeveloperRole: false, supportsReasoningEffort: false };
132
146
  }
133
147