offgrid-ai 0.6.7 → 0.6.10

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.6.7",
3
+ "version": "0.6.10",
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,13 +1,12 @@
1
1
  import { ensureDirs } from "../config.mjs";
2
2
  import { backendFor, BACKENDS } from "../backends.mjs";
3
- import { createProfileFromModel, readProfile, saveProfile, deleteProfile } from "../profiles.mjs";
3
+ import { createProfileFromModel, readProfile, saveProfile, deleteProfile, profileJsonPath } from "../profiles.mjs";
4
4
  import { isProfileRunning, stopProfile } from "../process.mjs";
5
5
  import { syncPiConfig, removeFromPiConfig } from "../harness-pi.mjs";
6
6
  import { configureLocalProfile } from "../profile-setup.mjs";
7
- import { pc, renderRows, renderCard, startInteractive, createPrompt } from "../ui.mjs";
7
+ import { pc, startInteractive, createPrompt } from "../ui.mjs";
8
8
  import { buildCatalogItems, createManagedProfile, itemKey, loadModelCatalog, normalizeCatalog } from "../model-catalog.mjs";
9
- import { isProfileFileMissing } from "../model-summary.mjs";
10
- import { modelSelectOption, printGgufModelDetails, printManagedModelDetails, printModelCards, printProfileDetails } from "../model-presenters.mjs";
9
+ import { modelSelectOption, modelNameWidth, printGgufModelDetails, printManagedModelDetails, printWorkspaceHeader, printBenchmarkLine, printProfileDetails } from "../model-presenters.mjs";
11
10
  import { runProfile } from "./run.mjs";
12
11
 
13
12
  export async function modelsCommand(argv) {
@@ -42,12 +41,14 @@ export async function modelCommandCenter(initialCatalog) {
42
41
  for (const profile of normalized.profiles) {
43
42
  if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
44
43
  }
45
- printWorkspaceSummary(normalized, runningProfilesNow);
46
- printModelCards(allItems, { runningProfilesNow, drafters: normalized.drafters });
44
+ printWorkspaceHeader(normalized, runningProfilesNow);
45
+ await printBenchmarkLine();
46
+
47
+ const nameWidth = modelNameWidth(allItems);
47
48
 
48
49
  const prompt = createPrompt();
49
50
  try {
50
- const selected = await prompt.choice("Select a model", allItems.map((item) => modelSelectOption(item, { runningProfilesNow, drafters: normalized.drafters })));
51
+ const selected = await prompt.choice("Select a model", allItems.map((item) => modelSelectOption(item, { runningProfilesNow, nameWidth })));
51
52
  if (!selected) return;
52
53
  const item = allItems.find((candidate) => itemKey(candidate) === selected);
53
54
  if (!item) return;
@@ -61,16 +62,7 @@ export async function modelCommandCenter(initialCatalog) {
61
62
  }
62
63
  }
63
64
 
64
- function printWorkspaceSummary(normalized, runningProfilesNow) {
65
- const fileMissingCount = normalized.profiles.filter((profile) => isProfileFileMissing(profile)).length;
66
- const summaryBorder = fileMissingCount > 0 ? pc.red : normalized.newModels.length > 0 ? pc.yellow : pc.dim;
67
- console.log("\n" + renderCard("Your local AI workspace", renderRows([
68
- ["Setups", `${normalized.profiles.length} saved`],
69
- ["Need setup", normalized.newModels.length > 0 ? pc.yellow(`${normalized.newModels.length} model${normalized.newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
70
- ["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
71
- ["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
72
- ]), { formatBorder: summaryBorder }));
73
- }
65
+
74
66
 
75
67
  function actionsForItem(item) {
76
68
  if (item.type === "profile") {
@@ -122,26 +114,36 @@ async function runItem(prompt, item) {
122
114
  if (!configured) return;
123
115
  await saveProfile(configured);
124
116
  await syncPiConfig(configured);
117
+ printProfileSaved(configured.id);
125
118
  return await runProfile(configured);
126
119
  }
127
120
 
121
+ function printProfileSaved(id) {
122
+ console.log(pc.dim(` Profile: ${profileJsonPath(id)}`));
123
+ }
124
+
128
125
  async function setupItem(prompt, item, action) {
129
126
  if (item.type === "profile") {
130
127
  const configured = await configureLocalProfile(prompt, await readProfile(item.profile.id));
131
128
  if (!configured) return;
132
129
  await saveProfile(configured, { writeCommand: true });
133
- return await syncPiConfig(configured);
130
+ await syncPiConfig(configured);
131
+ printProfileSaved(configured.id);
132
+ return;
134
133
  }
135
134
  if (item.type === "managed") {
136
135
  const profile = createManagedProfile(item.model, item.backendId);
137
136
  await saveProfile(profile);
138
- return await syncPiConfig(profile);
137
+ await syncPiConfig(profile);
138
+ printProfileSaved(profile.id);
139
+ return;
139
140
  }
140
141
  const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
141
142
  const configured = await configureLocalProfile(prompt, profile);
142
143
  if (!configured) return;
143
144
  await saveProfile(configured, { writeCommand: action === "reconfigure" });
144
- return await syncPiConfig(configured);
145
+ await syncPiConfig(configured);
146
+ printProfileSaved(configured.id);
145
147
  }
146
148
 
147
149
  async function removeProfileInteractive(id) {
@@ -2,30 +2,34 @@ import { existsSync, statSync } from "node:fs";
2
2
  import { BACKENDS, backendFor } from "./backends.mjs";
3
3
  import { readCommandArgv } from "./profiles.mjs";
4
4
  import { isProfileRunning } from "./process.mjs";
5
- import { detectCapabilities } from "./autodetect.mjs";
6
5
  import { buildPrettyCommand } from "./command.mjs";
7
- import { pc, formatBytes, renderRows, renderSection, renderCard } from "./ui.mjs";
8
- import { capabilitySummary, ggufDetailParts, ggufMtpLabel, isProfileFileMissing, profileDetailParts, profileMtpLabel } from "./model-summary.mjs";
6
+ import { pc, formatBytes, renderRows, renderSection } from "./ui.mjs";
7
+ import { capabilitySummary, ggufDetailParts, isProfileFileMissing, profileDetailParts } from "./model-summary.mjs";
9
8
  import { itemKey } from "./model-catalog.mjs";
9
+ import { DATA_DIR } from "./config.mjs";
10
+ import { findBenchmarkRepo } from "./benchmark.mjs";
10
11
 
11
12
  const OPTION_SEPARATOR = pc.dim(" │ ");
12
- const OPTION_STATUS_WIDTH = 12;
13
+ const OPTION_STATUS_WIDTH = 10;
13
14
  const OPTION_SOURCE_WIDTH = 14;
14
15
 
15
- function optionTag(text, color, width) {
16
- const padded = String(text).padEnd(width);
17
- return color ? color(padded) : padded;
16
+ const { stripVTControlCharacters } = await import("node:util");
17
+
18
+ function optionPad(text, color, width) {
19
+ const visible = stripVTControlCharacters(String(text)).length;
20
+ const padding = Math.max(1, width - visible);
21
+ return (color ? color(String(text)) : String(text)) + " ".repeat(padding);
18
22
  }
19
23
 
20
24
  function optionStatusTag(kind) {
21
25
  const statuses = {
22
26
  running: ["RUNNING", pc.green],
23
27
  ready: ["READY", pc.green],
24
- missing: ["FILE MISSING", pc.red],
25
- setup: ["NEEDS SETUP", pc.yellow],
28
+ missing: ["MISSING", pc.red],
29
+ setup: ["SETUP", pc.yellow],
26
30
  };
27
31
  const [text, color] = statuses[kind] ?? [kind, pc.dim];
28
- return optionTag(text, color, OPTION_STATUS_WIDTH);
32
+ return optionPad(text, color, OPTION_STATUS_WIDTH);
29
33
  }
30
34
 
31
35
  function optionSourceTag(sourceId, label) {
@@ -36,14 +40,45 @@ function optionSourceTag(sourceId, label) {
36
40
  omlx: pc.magenta,
37
41
  gguf: pc.cyan,
38
42
  };
39
- return optionTag(label, colors[sourceId] ?? pc.dim, OPTION_SOURCE_WIDTH);
43
+ return optionPad(label, colors[sourceId] ?? pc.dim, OPTION_SOURCE_WIDTH);
40
44
  }
41
45
 
42
- function optionLabel({ status, source, name, details = [] }) {
43
- return [status, source, pc.bold(name), ...details].filter(Boolean).join(OPTION_SEPARATOR);
46
+ function optionCtxLabel(item) {
47
+ if (item.type === "profile" && item.profile.flags?.ctxSize) {
48
+ return `${(item.profile.flags.ctxSize / 1000).toFixed(0)}k`;
49
+ }
50
+ return "—";
44
51
  }
45
52
 
46
- export function modelSelectOption(item, { runningProfilesNow }) {
53
+ function optionSizeLabel(item) {
54
+ if (item.type === "profile") {
55
+ if (item.fileMissing) return "—";
56
+ if (item.profile.modelPath && existsSync(item.profile.modelPath)) {
57
+ return formatBytes(statSync(item.profile.modelPath).size);
58
+ }
59
+ return "—";
60
+ }
61
+ if (item.type === "new") {
62
+ return formatBytes(item.model.sizeBytes);
63
+ }
64
+ // managed
65
+ if (item.model.quant) return item.model.quant;
66
+ if (item.model.sizeBytes) return formatBytes(item.model.sizeBytes);
67
+ return "—";
68
+ }
69
+
70
+ export function modelNameWidth(items) {
71
+ const maxName = Math.max(...items.map((item) =>
72
+ stripVTControlCharacters(item.label ?? item.model?.label ?? item.profile?.label ?? "").length,
73
+ ));
74
+ return Math.max(20, maxName + 2);
75
+ }
76
+
77
+ function optionLabel({ status, source, name, ctx, size, nameWidth }) {
78
+ return [status, source, pc.bold(optionPad(name, null, nameWidth)), ctx, pc.dim(size)].filter(Boolean).join(OPTION_SEPARATOR);
79
+ }
80
+
81
+ export function modelSelectOption(item, { runningProfilesNow, nameWidth }) {
47
82
  if (item.type === "profile") {
48
83
  const backend = backendFor(item.profile.backend);
49
84
  const running = runningProfilesNow.some((profile) => profile.id === item.profile.id);
@@ -53,73 +88,62 @@ export function modelSelectOption(item, { runningProfilesNow }) {
53
88
  status: optionStatusTag(item.fileMissing ? "missing" : running ? "running" : "ready"),
54
89
  source: optionSourceTag(item.profile.backend, backend.label),
55
90
  name: item.profile.label,
91
+ nameWidth,
92
+ ctx: optionCtxLabel(item),
93
+ size: optionSizeLabel(item),
56
94
  }),
57
95
  };
58
96
  }
59
97
  if (item.type === "new") {
60
98
  return {
61
99
  value: itemKey(item),
62
- label: optionLabel({ status: optionStatusTag("setup"), source: optionSourceTag("gguf", "GGUF file"), name: item.model.label }),
100
+ label: optionLabel({
101
+ status: optionStatusTag("setup"),
102
+ source: optionSourceTag("gguf", "GGUF file"),
103
+ name: item.model.label,
104
+ nameWidth,
105
+ ctx: optionCtxLabel(item),
106
+ size: optionSizeLabel(item),
107
+ }),
63
108
  };
64
109
  }
65
110
  const backend = BACKENDS[item.backendId];
66
111
  return {
67
112
  value: itemKey(item),
68
- label: optionLabel({ status: optionStatusTag("setup"), source: optionSourceTag(item.backendId, backend.label), name: item.model.label }),
113
+ label: optionLabel({
114
+ status: optionStatusTag("setup"),
115
+ source: optionSourceTag(item.backendId, backend.label),
116
+ name: item.model.label,
117
+ nameWidth,
118
+ ctx: optionCtxLabel(item),
119
+ size: optionSizeLabel(item),
120
+ }),
69
121
  };
70
122
  }
71
123
 
72
- export function printModelCards(items, { runningProfilesNow, drafters }) {
73
- const profiles = items.filter((item) => item.type === "profile");
74
- const newModels = items.filter((item) => item.type === "new");
75
- const managed = items.filter((item) => item.type === "managed");
76
-
77
- if (profiles.length > 0) {
78
- console.log("\n" + pc.bold("Ready to chat"));
79
- for (const item of profiles) {
80
- const { profile } = item;
81
- const backend = backendFor(profile.backend);
82
- const running = runningProfilesNow.some((runningProfile) => runningProfile.id === profile.id);
83
- const status = item.fileMissing ? pc.red("File missing") : running ? pc.green("Running now") : "Ready";
84
- const border = item.fileMissing ? pc.red : running ? pc.green : pc.dim;
85
- const detailParts = [item.fileMissing ? pc.red("File not found") : profileDetailParts(profile, { drafters }).filter(Boolean)[0]];
86
- const mtpLabel = profileMtpLabel(profile, drafters);
87
- if (mtpLabel) detailParts.push(mtpLabel);
88
- const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
89
- if (ctxLabel) detailParts.push(ctxLabel);
90
- console.log(renderCard(profile.label, renderRows([
91
- ["Status", status],
92
- ["Details", detailParts.join(pc.dim(" · "))],
93
- ["Runs with", backend.label],
94
- ]), { formatBorder: border }));
95
- }
96
- }
97
-
98
- if (newModels.length > 0) {
99
- console.log("\n" + pc.bold("Needs setup"));
100
- for (const item of newModels) {
101
- const caps = detectCapabilities(item.model.path, item.model.mmprojPath);
102
- const detailParts = [capabilitySummary(caps)];
103
- const mtpLabel = ggufMtpLabel(item.model, item.drafter);
104
- if (mtpLabel) detailParts.push(mtpLabel);
105
- detailParts.push(formatBytes(item.model.sizeBytes));
106
- console.log(renderCard(item.model.label, renderRows([
107
- ["Status", pc.yellow("Needs setup")],
108
- ["Details", detailParts.join(pc.dim(" · "))],
109
- ]), { formatBorder: pc.yellow }));
110
- }
111
- }
124
+ export function printWorkspaceHeader(normalized, runningProfilesNow) {
125
+ const profiles = normalized.profiles;
126
+ const readyCount = profiles.filter((p) => !isProfileFileMissing(p) && !runningProfilesNow.some((r) => r.id === p.id)).length;
127
+ const runningCount = runningProfilesNow.length;
128
+ const missingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
129
+ const setupCount = normalized.newModels.length + normalized.managedItems.length;
130
+
131
+ const countParts = [];
132
+ if (runningCount > 0) countParts.push(`${runningCount} running`);
133
+ if (readyCount > 0) countParts.push(pc.green(`${readyCount} model${readyCount === 1 ? "" : "s"} ready`));
134
+ if (missingCount > 0) countParts.push(pc.red(`${missingCount} model${missingCount === 1 ? "" : "s"} missing`));
135
+ if (setupCount > 0) countParts.push(pc.yellow(`${setupCount} model${setupCount === 1 ? "" : "s"} need${setupCount === 1 ? "s" : ""} setup`));
136
+
137
+ console.log(pc.dim(` ${countParts.join(" · ")}`));
138
+ console.log(pc.dim(` Profiles: ${DATA_DIR}`));
139
+ }
112
140
 
113
- for (const backendId of ["ollama", "omlx"]) {
114
- const managedForBackend = managed.filter((item) => item.backendId === backendId);
115
- if (managedForBackend.length === 0) continue;
116
- const backend = BACKENDS[backendId];
117
- console.log("\n" + pc.bold(`Via ${backend.label}`));
118
- for (const item of managedForBackend) {
119
- console.log(renderCard(item.model.label, renderRows([
120
- ["Details", [item.model.id, item.model.quant].filter(Boolean).join(pc.dim(" · "))],
121
- ]), { formatBorder: pc.dim }));
122
- }
141
+ export async function printBenchmarkLine() {
142
+ const repoPath = await findBenchmarkRepo();
143
+ if (repoPath) {
144
+ console.log(pc.green(" ✓") + " local-llm-visual-benchmark linked");
145
+ } else {
146
+ console.log(pc.yellow(" ○") + " to run benchmarks, pair with " + pc.cyan("local-llm-visual-benchmark"));
123
147
  }
124
148
  }
125
149
 
@@ -163,7 +187,7 @@ export async function printProfileDetails(profile) {
163
187
 
164
188
  export function printGgufModelDetails(model, drafter) {
165
189
  const { caps, parts } = ggufDetailParts(model, drafter);
166
- parts.push(formatBytes(model.sizeBytes));
190
+ parts.push(formatBytes(model.model.sizeBytes));
167
191
  console.log("\n" + renderSection("Downloaded model", renderRows([
168
192
  ["Name", pc.bold(model.label)],
169
193
  ["Status", pc.yellow("Needs one-time setup")],
@@ -187,4 +211,4 @@ export function printManagedModelDetails(model, backend) {
187
211
  ["Quant", model.quant ?? "unknown"],
188
212
  ["Family", model.family ?? "unknown"],
189
213
  ])));
190
- }
214
+ }
@@ -64,7 +64,7 @@ export async function configureLocalProfile(prompt, profile) {
64
64
  ["Sampling", samplingSummary(profile.flags)],
65
65
  ])));
66
66
  console.log(pc.dim("Larger context windows use more memory. KV cache precision controls memory used by attention history."));
67
- console.log(pc.dim("Sampling defaults are shown for transparency; you can edit command.json later if needed.\n"));
67
+ console.log(pc.dim("Sampling defaults are shown for transparency; you can edit the profile later if needed.\n"));
68
68
 
69
69
  if (caps.mtp) {
70
70
  const drafterInfo = configured.drafterPath ? `\n Drafter: ${configured.drafterPath}` : "";