offgrid-ai 0.8.11 → 0.8.12

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.8.11",
3
+ "version": "0.8.12",
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",
@@ -1,7 +1,7 @@
1
1
  import { ensureDirs } from "../config.mjs";
2
2
  import { backendFor, BACKENDS } from "../backends.mjs";
3
3
  import { createProfileFromModel, readProfile, saveProfile, deleteProfile, profileJsonPath } from "../profiles.mjs";
4
- import { isProfileRunning, stopProfile } from "../process.mjs";
4
+ import { isProfileRunning, isProfileServerUp, stopProfile } from "../process.mjs";
5
5
  import { syncPiConfig, removeFromPiConfig } from "../harness-pi.mjs";
6
6
  import { configureLocalProfile } from "../profile-setup.mjs";
7
7
  import { pc, startInteractive, createPrompt } from "../ui.mjs";
@@ -40,18 +40,55 @@ export async function modelCommandCenter(initialCatalog) {
40
40
  }
41
41
 
42
42
  const runningProfilesNow = [];
43
+ const serverUpIds = new Set();
43
44
  for (const profile of normalized.profiles) {
44
- if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
45
+ if (await isProfileRunning(profile)) {
46
+ runningProfilesNow.push(profile);
47
+ continue;
48
+ }
49
+ if (await isProfileServerUp(profile)) serverUpIds.add(profile.id);
45
50
  }
46
- printWorkspaceHeader(normalized, runningProfilesNow);
51
+ printWorkspaceHeader(normalized, runningProfilesNow, serverUpIds);
47
52
  await printBenchmarkLine();
48
53
 
49
54
  const nameWidth = modelNameWidth(allItems);
50
55
 
56
+ const statusFor = (item) => {
57
+ if (item.type === "profile") {
58
+ if (item.fileMissing) return "missing";
59
+ if (runningProfilesNow.some((profile) => profile.id === item.profile.id)) return "running";
60
+ if (serverUpIds.has(item.profile.id)) return "serverup";
61
+ return "ready";
62
+ }
63
+ return "setup";
64
+ };
65
+
66
+ const groupOrder = [
67
+ { key: "running", label: pc.green(" Running") },
68
+ { key: "serverup", label: pc.yellow(" Server up · model not loaded") },
69
+ { key: "ready", label: pc.blue(" Ready to chat") },
70
+ { key: "setup", label: pc.yellow(" Need setup") },
71
+ { key: "missing", label: pc.red(" File missing") },
72
+ ];
73
+ const grouped = new Map(groupOrder.map((g) => [g.key, []]));
74
+ for (const item of allItems) grouped.get(statusFor(item)).push(item);
75
+
76
+ const sectionSentinel = "__section__";
77
+ const choices = [];
78
+ for (const group of groupOrder) {
79
+ const bucket = grouped.get(group.key);
80
+ if (!bucket || bucket.length === 0) continue;
81
+ choices.push({ value: `${sectionSentinel}:${group.key}`, label: `── ${group.label} (${bucket.length}) ──`, disabled: true });
82
+ for (const item of bucket) {
83
+ const opt = modelSelectOption(item, { runningProfilesNow, serverUpIds, nameWidth });
84
+ choices.push({ value: opt.value, label: opt.label });
85
+ }
86
+ }
87
+
51
88
  const prompt = createPrompt();
52
89
  try {
53
- const selected = await prompt.choice("Select a model", allItems.map((item) => modelSelectOption(item, { runningProfilesNow, nameWidth })));
54
- if (!selected) return;
90
+ const selected = await prompt.choice("Select a model", choices);
91
+ if (!selected || selected.startsWith(`${sectionSentinel}:`)) return;
55
92
  const item = allItems.find((candidate) => itemKey(candidate) === selected);
56
93
  if (!item) return;
57
94
 
@@ -37,10 +37,24 @@ export function itemKey(item) {
37
37
  return `managed:${item.backendId}:${item.model.id}`;
38
38
  }
39
39
 
40
+ function profileRecency(item) {
41
+ const updated = item.profile?.updatedAt ?? item.profile?.createdAt;
42
+ const ts = updated ? Date.parse(updated) : NaN;
43
+ return Number.isFinite(ts) ? ts : 0;
44
+ }
45
+
46
+ function compareRecency(a, b) {
47
+ const diff = profileRecency(b) - profileRecency(a);
48
+ if (diff !== 0) return diff;
49
+ return String(a.label ?? "").localeCompare(String(b.label ?? ""));
50
+ }
51
+
40
52
  export function buildCatalogItems(normalized) {
41
53
  const { profiles, newModels, managedItems, drafters } = normalized;
54
+ const profileItems = profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) }));
55
+ profileItems.sort(compareRecency);
42
56
  return [
43
- ...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) })),
57
+ ...profileItems,
44
58
  ...newModels.map((model) => ({ type: "new", model, label: model.label, drafter: matchDrafter(model.path, drafters) })),
45
59
  ...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label })),
46
60
  ];
@@ -25,6 +25,7 @@ function optionPad(text, color, width) {
25
25
  function optionStatusTag(kind) {
26
26
  const statuses = {
27
27
  running: ["RUNNING", pc.green],
28
+ serverup: ["SERVER UP", pc.yellow],
28
29
  ready: ["READY", pc.blue],
29
30
  missing: ["MISSING", pc.red],
30
31
  setup: ["SETUP", pc.yellow],
@@ -79,14 +80,16 @@ function optionLabel({ status, source, name, ctx, size, nameWidth }) {
79
80
  return [status, source, pc.bold(optionPad(name, null, nameWidth)), ctx, pc.dim(size)].join(OPTION_SEPARATOR);
80
81
  }
81
82
 
82
- export function modelSelectOption(item, { runningProfilesNow, nameWidth }) {
83
+ export function modelSelectOption(item, { runningProfilesNow, serverUpIds, nameWidth }) {
83
84
  if (item.type === "profile") {
84
85
  const backend = backendFor(item.profile.backend);
85
86
  const running = runningProfilesNow.some((profile) => profile.id === item.profile.id);
87
+ const serverUp = !running && !item.fileMissing && serverUpIds?.has(item.profile.id);
88
+ const status = item.fileMissing ? "missing" : running ? "running" : serverUp ? "serverup" : "ready";
86
89
  return {
87
90
  value: itemKey(item),
88
91
  label: optionLabel({
89
- status: optionStatusTag(item.fileMissing ? "missing" : running ? "running" : "ready"),
92
+ status: optionStatusTag(status),
90
93
  source: optionSourceTag(item.profile.backend, backend.label),
91
94
  name: item.profile.label,
92
95
  nameWidth,
@@ -122,15 +125,19 @@ export function modelSelectOption(item, { runningProfilesNow, nameWidth }) {
122
125
  };
123
126
  }
124
127
 
125
- export function printWorkspaceHeader(normalized, runningProfilesNow) {
128
+ export function printWorkspaceHeader(normalized, runningProfilesNow, serverUpIds = new Set()) {
126
129
  const profiles = normalized.profiles;
127
- const readyCount = profiles.filter((p) => !isProfileFileMissing(p) && !runningProfilesNow.some((r) => r.id === p.id)).length;
130
+ const isRunning = (p) => runningProfilesNow.some((r) => r.id === p.id);
131
+ const isMissing = (p) => isProfileFileMissing(p);
132
+ const readyCount = profiles.filter((p) => !isMissing(p) && !isRunning(p) && !serverUpIds.has(p.id)).length;
128
133
  const runningCount = runningProfilesNow.length;
129
- const missingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
134
+ const serverUpCount = profiles.filter((p) => !isMissing(p) && serverUpIds.has(p.id) && !isRunning(p)).length;
135
+ const missingCount = profiles.filter(isMissing).length;
130
136
  const setupCount = normalized.newModels.length + normalized.managedItems.length;
131
137
 
132
138
  const countParts = [];
133
139
  if (runningCount > 0) countParts.push(pc.green(`${runningCount} running`));
140
+ if (serverUpCount > 0) countParts.push(pc.yellow(`${serverUpCount} server up, model not loaded`));
134
141
  if (readyCount > 0) countParts.push(pc.blue(`${readyCount} model${readyCount === 1 ? "" : "s"} ready`));
135
142
  if (missingCount > 0) countParts.push(pc.red(`${missingCount} model${missingCount === 1 ? "" : "s"} missing`));
136
143
  if (setupCount > 0) countParts.push(pc.yellow(`${setupCount} model${setupCount === 1 ? "" : "s"} need${setupCount === 1 ? "s" : ""} setup`));
package/src/process.mjs CHANGED
@@ -113,11 +113,22 @@ export async function stopProfile(profile) {
113
113
 
114
114
  export async function isProfileRunning(profile) {
115
115
  const backend = backendFor(profile.backend);
116
- if (backend.type === "managed-server") return await serverReady(profile.baseUrl);
116
+ if (backend.type === "managed-server") {
117
+ return await serverReady(profile.baseUrl) && (await modelLoadedOnServer(profile));
118
+ }
117
119
  const state = await readState(profile.id);
118
120
  return Boolean(state?.pid && pidAlive(state.pid));
119
121
  }
120
122
 
123
+ export async function isProfileServerUp(profile) {
124
+ return await serverReady(profile.baseUrl);
125
+ }
126
+
127
+ export async function modelLoadedOnServer(profile) {
128
+ const { matches } = await serverMatchesProfile(profile);
129
+ return matches;
130
+ }
131
+
121
132
  export async function profileRuntimeStatus(profile) {
122
133
  const backend = backendFor(profile.backend);
123
134
  if (backend.type === "managed-server") {