offgrid-ai 0.10.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "Privacy-first CLI for running local LLMs — discover, configure, run, benchmark",
5
5
  "author": "Eeshan Srivastava (https://eeshans.com)",
6
6
  "type": "module",
package/src/backends.mjs CHANGED
@@ -102,6 +102,7 @@ async function scanOmlxModels() {
102
102
  label: parseModelName(model.id, "omlx").display,
103
103
  aliasSuggestion: model.id,
104
104
  sizeBytes: model.size ?? 0,
105
+ contextLength: model.max_model_len ?? null,
105
106
  quant: null,
106
107
  family: null,
107
108
  backend: "omlx",
@@ -1,6 +1,7 @@
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";
@@ -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,7 +58,7 @@ 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) {
@@ -74,7 +74,7 @@ export async function modelCommandCenter(initialCatalog) {
74
74
  const bucket = grouped.get(group);
75
75
  if (!bucket || bucket.length === 0) continue;
76
76
  for (const item of bucket) {
77
- const opt = modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth, managedModels: catalog.managedModels });
77
+ const opt = modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth });
78
78
  choices.push({ value: opt.value, label: opt.label, hint: opt.hint });
79
79
  }
80
80
  }
@@ -80,7 +80,8 @@ async function scanDirRecursiveForMlx(rootDir, sourceLabel, maxDepth = 3) {
80
80
  const sizeBytes = await getMlxDirSizeBytes(dir);
81
81
  if (sizeBytes < MIN_MODEL_SIZE_BYTES) return;
82
82
  if (await isEmbeddingMlxModel(join(dir, "config.json"))) return;
83
- models.push(makeMlxModel(dir, basename(dir), sizeBytes, sourceLabel, rootDir));
83
+ const caps = await detectMlxCapabilities(dir);
84
+ models.push(makeMlxModel(dir, basename(dir), sizeBytes, sourceLabel, rootDir, caps.contextLength));
84
85
  return;
85
86
  }
86
87
 
@@ -92,7 +93,8 @@ async function scanDirRecursiveForMlx(rootDir, sourceLabel, maxDepth = 3) {
92
93
  const sizeBytes = await getMlxDirSizeBytes(fullPath);
93
94
  if (sizeBytes < MIN_MODEL_SIZE_BYTES) continue;
94
95
  if (await isEmbeddingMlxModel(join(fullPath, "config.json"))) continue;
95
- models.push(makeMlxModel(fullPath, entry.name, sizeBytes, sourceLabel, rootDir));
96
+ const caps = await detectMlxCapabilities(fullPath);
97
+ models.push(makeMlxModel(fullPath, entry.name, sizeBytes, sourceLabel, rootDir, caps.contextLength));
96
98
  } else {
97
99
  await walk(fullPath, depth + 1);
98
100
  }
@@ -155,6 +157,7 @@ async function scanHfHubForMlx(dir, sourceLabel) {
155
157
  path: snapshotPath,
156
158
  filePath: snapshotPath,
157
159
  sizeBytes,
160
+ contextLength: (await detectMlxCapabilities(snapshotPath)).contextLength,
158
161
  backend: "mlx-vlm",
159
162
  format: "mlx",
160
163
  source: sourceLabel,
@@ -185,13 +188,14 @@ async function isEmbeddingMlxModel(configPath) {
185
188
 
186
189
  // ── MLX model entry builder ───────────────────────────────────────────────
187
190
 
188
- function makeMlxModel(dir, label, sizeBytes, sourceLabel, rootDir) {
191
+ function makeMlxModel(dir, label, sizeBytes, sourceLabel, rootDir, contextLength = null) {
189
192
  return {
190
193
  id: `${sourceLabel}:${dir.replace(rootDir + "/", "")}`,
191
194
  label,
192
195
  path: dir,
193
196
  filePath: dir,
194
197
  sizeBytes,
198
+ contextLength,
195
199
  backend: "mlx-vlm",
196
200
  format: "mlx",
197
201
  source: sourceLabel,
@@ -3,6 +3,7 @@ import { loadProfiles, normalizeProfile, sanitizeProfileId } from "./profiles.mj
3
3
  import { scanManagedModels } from "./managed.mjs";
4
4
  import { scanMlxModels } from "./mlx-discovery.mjs";
5
5
  import { isProfileFileMissing } from "./model-summary.mjs";
6
+ import { backendFor } from "./backends.mjs";
6
7
 
7
8
  export async function loadModelCatalog() {
8
9
  const [profiles, { models: ggufModels, drafters }, managedModels, mlxModels] = await Promise.all([
@@ -34,7 +35,7 @@ export function normalizeCatalog(catalog) {
34
35
  if (!profiledAliases.has(`${backendId}:${model.id}`)) managedItems.push({ model, backendId });
35
36
  }
36
37
  }
37
- return { profiles, ggufModels, drafters, managedModels, newModels, managedItems };
38
+ return { profiles, ggufModels, drafters, managedModels, mlxModels, newModels, managedItems };
38
39
  }
39
40
 
40
41
  export function itemKey(item) {
@@ -56,13 +57,73 @@ function compareRecency(a, b) {
56
57
  }
57
58
 
58
59
  export function buildCatalogItems(normalized) {
59
- const { profiles, newModels, managedItems, drafters } = normalized;
60
- const profileItems = profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) }));
60
+ const { profiles, newModels, managedItems, drafters, ggufModels = [], mlxModels = [], managedModels = [] } = normalized;
61
+
62
+ // Lookup maps for enriching profile items with scan data (size + context).
63
+ const scanByPath = new Map();
64
+ for (const m of ggufModels) scanByPath.set(m.path, m);
65
+ for (const m of mlxModels) scanByPath.set(m.filePath ?? m.path, m);
66
+
67
+ const managedByKey = new Map();
68
+ for (const { backendId, models } of managedModels) {
69
+ for (const m of models) managedByKey.set(`${backendId}:${m.id}`, m);
70
+ }
71
+
72
+ const profileItems = profiles.map((profile) => {
73
+ const item = { type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) };
74
+
75
+ // Resolve size: profile.modelSizeBytes → scan lookup → managed lookup
76
+ let sizeBytes = profile.modelSizeBytes || 0;
77
+ if (!sizeBytes && profile.modelPath) {
78
+ const scanModel = scanByPath.get(profile.modelPath);
79
+ if (scanModel?.sizeBytes) sizeBytes = scanModel.sizeBytes;
80
+ }
81
+ if (!sizeBytes) {
82
+ const backend = backendFor(profile.backend);
83
+ if (backend.type === "managed-server" && profile.omlxModel) {
84
+ const managedModel = managedByKey.get(`${profile.backend}:${profile.omlxModel}`);
85
+ if (managedModel?.sizeBytes) sizeBytes = managedModel.sizeBytes;
86
+ }
87
+ }
88
+ item.sizeBytes = sizeBytes || null;
89
+
90
+ // Resolve context: flags.ctxSize (configured) → capabilities.ctxSize (trained) → scan → managed
91
+ let contextLength = profile.flags?.ctxSize ?? null;
92
+ if (!contextLength) contextLength = profile.capabilities?.ctxSize ?? null;
93
+ if (!contextLength && profile.modelPath) {
94
+ const scanModel = scanByPath.get(profile.modelPath);
95
+ if (scanModel?.contextLength) contextLength = scanModel.contextLength;
96
+ }
97
+ if (!contextLength) {
98
+ const backend = backendFor(profile.backend);
99
+ if (backend.type === "managed-server" && profile.omlxModel) {
100
+ const managedModel = managedByKey.get(`${profile.backend}:${profile.omlxModel}`);
101
+ if (managedModel?.contextLength) contextLength = managedModel.contextLength;
102
+ }
103
+ }
104
+ item.contextLength = contextLength;
105
+
106
+ return item;
107
+ });
61
108
  profileItems.sort(compareRecency);
62
109
  return [
63
110
  ...profileItems,
64
- ...newModels.map((model) => ({ type: "new", model, label: model.label, drafter: matchDrafter(model.path, drafters) })),
65
- ...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label })),
111
+ ...newModels.map((model) => ({
112
+ type: "new",
113
+ model,
114
+ label: model.label,
115
+ drafter: matchDrafter(model.path, drafters),
116
+ sizeBytes: model.sizeBytes || null,
117
+ contextLength: model.contextLength ?? null,
118
+ })),
119
+ ...managedItems.map(({ model, backendId }) => ({
120
+ type: "managed",
121
+ model,
122
+ backendId,
123
+ label: model.label,
124
+ sizeBytes: model.sizeBytes || null,
125
+ contextLength: model.contextLength ?? null,
126
+ })),
66
127
  ];
67
128
  }
68
129
 
@@ -101,32 +101,15 @@ function discoverySourceForItem(item) {
101
101
  }
102
102
 
103
103
  function optionCtxLabel(item) {
104
- if (item.type === "profile" && item.profile.flags?.ctxSize) {
105
- return optionPad(`${(item.profile.flags.ctxSize / 1000).toFixed(0)}k`, null, OPTION_CTX_WIDTH);
104
+ if (item.contextLength) {
105
+ return optionPad(`${(item.contextLength / 1000).toFixed(0)}k`, null, OPTION_CTX_WIDTH);
106
106
  }
107
107
  return optionPad("—", null, OPTION_CTX_WIDTH);
108
108
  }
109
109
 
110
- function optionSizeLabel(item, managedModels) {
111
- if (item.type === "profile") {
112
- if (item.fileMissing) return "—";
113
- if (item.profile.modelSizeBytes) return formatBytes(item.profile.modelSizeBytes);
114
- if (item.profile.modelPath && existsSync(item.profile.modelPath)) {
115
- const s = statSync(item.profile.modelPath);
116
- // Only stat regular files — a modelPath that is a directory (MLX)
117
- // reports the dir entry size, not the model size.
118
- if (s.isFile()) return formatBytes(s.size);
119
- }
120
- const managedSize = managedProfileSizeBytes(item.profile, managedModels);
121
- if (managedSize) return formatBytes(managedSize);
122
- return "—";
123
- }
124
- if (item.type === "new") {
125
- return formatBytes(item.model.sizeBytes);
126
- }
127
- // managed
128
- if (item.model.sizeBytes) return formatBytes(item.model.sizeBytes);
129
- if (item.model.quant) return item.model.quant;
110
+ function optionSizeLabel(item) {
111
+ if (item.type === "profile" && item.fileMissing) return "—";
112
+ if (item.sizeBytes) return formatBytes(item.sizeBytes);
130
113
  return "—";
131
114
  }
132
115
 
@@ -141,7 +124,7 @@ function optionLabel({ status, backend, source, name, ctx, size, nameWidth }) {
141
124
  return [status, backend, source, pc.bold(optionPad(name, null, nameWidth)), ctx, pc.dim(size)].join(OPTION_SEPARATOR);
142
125
  }
143
126
 
144
- export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth, managedModels }) {
127
+ export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth }) {
145
128
  const sourceId = discoverySourceForItem(item) ?? "unknown";
146
129
  const backendId = inferBackendId(item);
147
130
  if (item.type === "profile") {
@@ -162,7 +145,7 @@ export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, n
162
145
  name: item.profile.label,
163
146
  nameWidth,
164
147
  ctx: optionCtxLabel(item),
165
- size: optionSizeLabel(item, managedModels),
148
+ size: optionSizeLabel(item),
166
149
  }),
167
150
  ...(hint ? { hint: pc.red(hint) } : {}),
168
151
  };
@@ -177,7 +160,7 @@ export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, n
177
160
  name: item.model.label,
178
161
  nameWidth,
179
162
  ctx: optionCtxLabel(item),
180
- size: optionSizeLabel(item, managedModels),
163
+ size: optionSizeLabel(item),
181
164
  }),
182
165
  };
183
166
  }
@@ -190,22 +173,11 @@ export function modelSelectOption(item, { runningProfilesNow, modelMissingIds, n
190
173
  name: item.model.label,
191
174
  nameWidth,
192
175
  ctx: optionCtxLabel(item),
193
- size: optionSizeLabel(item, managedModels),
176
+ size: optionSizeLabel(item),
194
177
  }),
195
178
  };
196
179
  }
197
180
 
198
- function managedProfileSizeBytes(profile, managedModels) {
199
- if (!managedModels || !Array.isArray(managedModels)) return null;
200
- const backend = backendFor(profile.backend);
201
- if (backend.type !== "managed-server") return null;
202
- const backendModels = managedModels.find((m) => m.backendId === profile.backend)?.models ?? [];
203
- const modelId = profile.omlxModel ?? null;
204
- if (!modelId) return null;
205
- const model = backendModels.find((m) => m.id === modelId);
206
- return model?.sizeBytes || null;
207
- }
208
-
209
181
  function inferBackendId(item) {
210
182
  if (item.type === "profile") return item.profile.backend;
211
183
  if (item.type === "managed") return item.backendId;
@@ -266,7 +238,7 @@ export async function printProfileDetails(profile) {
266
238
  detailRows.push(
267
239
  ["Local file", fileMissing ? pc.red(`${profile.modelPath} (not found)`) : profile.modelPath ?? "unknown"],
268
240
  ["Vision file", profile.mmprojPath ? (existsSync(profile.mmprojPath) ? profile.mmprojPath : pc.red(`${profile.mmprojPath} (not found)`)) : "none"],
269
- ["Model size", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
241
+ ["Model size", profile.modelSizeBytes ? formatBytes(profile.modelSizeBytes) : (profile.modelPath && existsSync(profile.modelPath) && statSync(profile.modelPath).isFile() ? formatBytes(statSync(profile.modelPath).size) : "unknown")],
270
242
  );
271
243
  if (profile.drafterPath) {
272
244
  detailRows.push(["Drafter", existsSync(profile.drafterPath) ? profile.drafterPath : pc.red(`${profile.drafterPath} (not found)`)]);
package/src/scan.mjs CHANGED
@@ -57,6 +57,9 @@ async function scanOneDir(root, sourceLabel = "local-gguf") {
57
57
  // Read GGUF metadata to detect drafter architecture and embeddings
58
58
  const meta = safeReadGgufMetadata(path);
59
59
  const architecture = typeof meta["general.architecture"] === "string" ? meta["general.architecture"] : null;
60
+ const contextLength = architecture && typeof meta[`${architecture}.context_length`] === "number"
61
+ ? meta[`${architecture}.context_length`]
62
+ : null;
60
63
 
61
64
  if (isEmbeddingArchitecture(architecture, name)) continue;
62
65
 
@@ -81,6 +84,7 @@ async function scanOneDir(root, sourceLabel = "local-gguf") {
81
84
  aliasSuggestion: parsed.id,
82
85
  quant: parsed.quant,
83
86
  sizeBytes,
87
+ contextLength,
84
88
  backend: "llama-cpp",
85
89
  source: sourceLabel,
86
90
  });