offgrid-ai 0.8.15 → 0.9.0

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/cli.mjs CHANGED
@@ -10,7 +10,7 @@ import { uninstallCommand } from "./commands/uninstall.mjs";
10
10
 
11
11
  async function offerUpdate(argv) {
12
12
  const invocation = detectInvocation();
13
- const update = await checkForUpdate({ force: false });
13
+ const update = await checkForUpdate();
14
14
  if (!update) return false;
15
15
 
16
16
  const plan = updateCommand(invocation, argv);
@@ -56,7 +56,7 @@ async function printVersion() {
56
56
  const version = currentPackageVersion();
57
57
  console.log(`offgrid-ai v${version}`);
58
58
  const invocation = detectInvocation();
59
- const update = await checkForUpdate({ force: false });
59
+ const update = await checkForUpdate();
60
60
  if (update) {
61
61
  const plan = updateCommand(invocation, ["version"]);
62
62
  console.log(pc.yellow(`Update available: v${update.latest}. Run: ${plan.display}`));
@@ -27,8 +27,8 @@ export async function mainFlow() {
27
27
  const { models: ggufModels, drafters } = await scanGgufModels();
28
28
  const managedModels = await scanManagedModels();
29
29
  const profiles = await loadProfiles();
30
- const hasAnyBackend = llamaBinary || managedModels.some((item) => item.models.length > 0);
31
- const hasAnyModels = ggufModels.length > 0 || managedModels.some((item) => item.models.length > 0);
30
+ 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);
32
32
 
33
33
  const piInstalled = await hasPi();
34
34
  const needsLlama = ggufModels.length > 0 || profiles.some((profile) => backendFor(profile.backend).type === "local-server");
@@ -90,8 +90,12 @@ function printFoundModels(ggufModels, managedModels, llamaBinary) {
90
90
  console.log(pc.green(`✓ Found ${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"}`));
91
91
  if (!llamaBinary) console.log(pc.yellow("Install the managed llama.cpp runtime to run these GGUF models."));
92
92
  }
93
- for (const { backendId, models } of managedModels) {
94
- if (models.length > 0) console.log(pc.green(`✓ ${BACKENDS[backendId].label}: ${models.length} model${models.length === 1 ? "" : "s"}`));
93
+ for (const { backendId, models, status, reason } of managedModels) {
94
+ if (status === "unavailable") {
95
+ console.log(pc.yellow(`${BACKENDS[backendId].label}: unavailable${reason ? ` — ${reason}` : ""}`));
96
+ } else if (models.length > 0) {
97
+ console.log(pc.green(`✓ ${BACKENDS[backendId].label}: ${models.length} model${models.length === 1 ? "" : "s"}`));
98
+ }
95
99
  }
96
100
  }
97
101
 
package/src/config.mjs CHANGED
@@ -46,8 +46,13 @@ export async function loadConfig() {
46
46
  try {
47
47
  const raw = await readFile(CONFIG_PATH, "utf8");
48
48
  return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
49
- } catch {
50
- return { ...DEFAULT_CONFIG };
49
+ } catch (error) {
50
+ if (error?.code === "ENOENT") return { ...DEFAULT_CONFIG };
51
+ throw new Error(
52
+ `Failed to read config at ${CONFIG_PATH}: ${error.message}. ` +
53
+ `Fix or remove the file, then try again.`,
54
+ { cause: error }
55
+ );
51
56
  }
52
57
  }
53
58
 
@@ -99,6 +104,7 @@ export async function findLlamaServer() {
99
104
  if (existsSync(candidate)) return candidate;
100
105
  } catch { /* Homebrew not installed or llama.cpp not brewed */ }
101
106
 
107
+ // No llama-server found — caller must present actionable error or onboarding.
102
108
  return null;
103
109
  }
104
110
 
@@ -70,7 +70,7 @@ export async function hasPi() {
70
70
  // ── Internals ──────────────────────────────────────────────────────────────
71
71
 
72
72
  async function activeProviderProfiles(currentProfile) {
73
- const allProfiles = await loadProfiles().catch(() => []);
73
+ const allProfiles = await loadProfiles();
74
74
  const byAlias = new Map();
75
75
  for (const item of [...allProfiles, currentProfile]) {
76
76
  if (item.providerId !== currentProfile.providerId) continue;
package/src/managed.mjs CHANGED
@@ -10,9 +10,9 @@ export async function scanManagedModels() {
10
10
  const backend = BACKENDS[backendId];
11
11
  try {
12
12
  const models = await backend.scanModels();
13
- results.push({ backendId, models });
14
- } catch {
15
- // Managed backends are optional and may not be running.
13
+ results.push({ backendId, models, status: "ok" });
14
+ } catch (error) {
15
+ results.push({ backendId, models: [], status: "unavailable", reason: error?.message ?? String(error) });
16
16
  }
17
17
  }
18
18
  return results;
@@ -18,7 +18,8 @@ export function normalizeCatalog(catalog) {
18
18
  const profiledPaths = new Set(profiles.map((profile) => profile.modelPath).filter(Boolean));
19
19
  const newModels = ggufModels.filter((model) => !profiledPaths.has(model.path));
20
20
  const managedItems = [];
21
- for (const { backendId, models } of managedModels) {
21
+ for (const { backendId, models, status } of managedModels) {
22
+ if (status === "unavailable") continue;
22
23
  const profiledAliases = new Set(
23
24
  profiles
24
25
  .filter((profile) => profile.backend === backendId)
package/src/process.mjs CHANGED
@@ -195,47 +195,55 @@ export async function waitForReady(profile, pid, rawLogPath) {
195
195
  // ── Internals ──────────────────────────────────────────────────────────────
196
196
 
197
197
  async function serverModelIds(baseUrl) {
198
- const body = await fetchJson(`${baseUrl.replace(/\/$/, "")}/models`);
199
- return (Array.isArray(body?.data) ? body.data : [])
198
+ const result = await fetchJson(`${baseUrl.replace(/\/+$/u, "")}/models`);
199
+ if (!result.ok) return [];
200
+ return (Array.isArray(result.data?.data) ? result.data.data : [])
200
201
  .map((model) => String(model?.id ?? "").trim())
201
202
  .filter(Boolean);
202
203
  }
203
204
 
204
205
  async function ollamaLoadedModelIds(profile) {
205
- const body = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/ps`);
206
- return (Array.isArray(body?.models) ? body.models : [])
206
+ const result = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/ps`);
207
+ if (!result.ok) return [];
208
+ return (Array.isArray(result.data?.models) ? result.data.models : [])
207
209
  .flatMap((model) => [model?.name, model?.model])
208
210
  .map((id) => String(id ?? "").trim())
209
211
  .filter(Boolean);
210
212
  }
211
213
 
212
214
  async function omlxLoadedModelIds(profile) {
213
- const status = await fetchJson(`${profile.baseUrl.replace(/\/$/, "")}/models/status`);
214
- const fromStatus = (Array.isArray(status?.models) ? status.models : [])
215
- .filter((model) => model?.loaded === true)
216
- .flatMap((model) => [model?.id, model?.name, model?.model, model?.alias])
217
- .map((id) => String(id ?? "").trim())
218
- .filter(Boolean);
219
- if (Number(status?.loaded_count) === 0) return fromStatus;
220
-
221
- const summary = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/status`);
222
- const fromSummary = (Array.isArray(summary?.loaded_models) ? summary.loaded_models : [])
223
- .map((id) => String(id ?? "").trim())
224
- .filter(Boolean);
215
+ const statusResult = await fetchJson(`${profile.baseUrl.replace(/\/+$/u, "")}/models/status`);
216
+ const fromStatus = statusResult.ok
217
+ ? (Array.isArray(statusResult.data?.models) ? statusResult.data.models : [])
218
+ .filter((model) => model?.loaded === true)
219
+ .flatMap((model) => [model?.id, model?.name, model?.model, model?.alias])
220
+ .map((id) => String(id ?? "").trim())
221
+ .filter(Boolean)
222
+ : [];
223
+ if (!statusResult.ok || Number(statusResult.data?.loaded_count) === 0) return fromStatus;
224
+
225
+ const summaryResult = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/status`);
226
+ const fromSummary = summaryResult.ok
227
+ ? (Array.isArray(summaryResult.data?.loaded_models) ? summaryResult.data.loaded_models : [])
228
+ .map((id) => String(id ?? "").trim())
229
+ .filter(Boolean)
230
+ : [];
225
231
  return [...fromStatus, ...fromSummary];
226
232
  }
227
233
 
228
234
  async function fetchJson(url) {
229
235
  try {
230
236
  const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
231
- if (!response.ok) return null;
232
- return await response.json();
233
- } catch {
234
- return null;
237
+ if (!response.ok) return { ok: false, reason: "http", status: response.status, data: null };
238
+ const data = await response.json();
239
+ return { ok: true, data };
240
+ } catch (error) {
241
+ if (error?.name === "AbortError" || error?.name === "TimeoutError") return { ok: false, reason: "timeout", data: null };
242
+ return { ok: false, reason: "network", data: null };
235
243
  }
236
244
  }
237
245
 
238
- function apiRootUrl(baseUrl) {
246
+ export function apiRootUrl(baseUrl) {
239
247
  try {
240
248
  const url = new URL(baseUrl);
241
249
  url.pathname = url.pathname.replace(/\/v1\/?$/u, "") || "/";
package/src/runtime.mjs CHANGED
@@ -33,6 +33,12 @@ export async function offerManagedLlamaRuntimeUpdate(prompt, { fetchImpl = globa
33
33
  return true;
34
34
  }
35
35
 
36
+ /**
37
+ * Check for the latest llama.cpp release on GitHub.
38
+ * Returns null if the check fails (network error, timeout, etc.).
39
+ * Callers must treat null as "check failed, skip prompt" — do not
40
+ * treat null as "no update available."
41
+ */
36
42
  export async function latestLlamaRelease(fetchImpl = globalThis.fetch) {
37
43
  try {
38
44
  const response = await fetchImpl(RELEASE_API, { signal: AbortSignal.timeout(5000) });
@@ -47,6 +53,11 @@ export async function latestLlamaRelease(fetchImpl = globalThis.fetch) {
47
53
  }
48
54
  }
49
55
 
56
+ /**
57
+ * Read the installed runtime version from disk.
58
+ * Returns null if not installed or if the version file is missing/corrupt.
59
+ * Callers must treat null as "runtime not installed" — not as a hidden default.
60
+ */
50
61
  export async function installedRuntime() {
51
62
  try {
52
63
  return JSON.parse(await readFile(VERSION_PATH, "utf8"));