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 +1 -1
- package/src/backends.mjs +1 -0
- package/src/commands/main.mjs +4 -2
- package/src/commands/models.mjs +1 -1
- package/src/mlx-discovery.mjs +7 -3
- package/src/model-catalog.mjs +66 -5
- package/src/model-presenters.mjs +10 -38
- package/src/scan.mjs +4 -0
package/package.json
CHANGED
package/src/backends.mjs
CHANGED
package/src/commands/main.mjs
CHANGED
|
@@ -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) {
|
package/src/commands/models.mjs
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/mlx-discovery.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/src/model-catalog.mjs
CHANGED
|
@@ -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
|
-
|
|
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) => ({
|
|
65
|
-
|
|
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
|
|
package/src/model-presenters.mjs
CHANGED
|
@@ -101,32 +101,15 @@ function discoverySourceForItem(item) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
function optionCtxLabel(item) {
|
|
104
|
-
if (item.
|
|
105
|
-
return optionPad(`${(item.
|
|
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
|
|
111
|
-
if (item.type === "profile")
|
|
112
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|