offgrid-ai 0.6.7 → 0.6.9

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.9",
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, printGgufModelDetails, printManagedModelDetails, printWorkspaceHeader, printBenchmarkLine, printTableHeader, printTableFooter, printProfileDetails } from "../model-presenters.mjs";
11
10
  import { runProfile } from "./run.mjs";
12
11
 
13
12
  export async function modelsCommand(argv) {
@@ -42,15 +41,17 @@ 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
+ printTableHeader();
47
47
 
48
48
  const prompt = createPrompt();
49
49
  try {
50
- const selected = await prompt.choice("Select a model", allItems.map((item) => modelSelectOption(item, { runningProfilesNow, drafters: normalized.drafters })));
50
+ const selected = await prompt.choice("Select a model", allItems.map((item) => modelSelectOption(item, { runningProfilesNow })));
51
51
  if (!selected) return;
52
52
  const item = allItems.find((candidate) => itemKey(candidate) === selected);
53
53
  if (!item) return;
54
+ printTableFooter();
54
55
 
55
56
  const actions = actionsForItem(item);
56
57
  const action = await prompt.choice(item.label, actions, actions[0].value);
@@ -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,36 @@ 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;
15
+ const OPTION_MODEL_WIDTH = 26;
16
+ const OPTION_CTX_WIDTH = 5;
14
17
 
15
- function optionTag(text, color, width) {
18
+ const { stripVTControlCharacters } = await import("node:util");
19
+
20
+ function optionPad(text, color, width) {
21
+ const visible = stripVTControlCharacters(String(text)).length;
16
22
  const padded = String(text).padEnd(width);
17
- return color ? color(padded) : padded;
23
+ return color ? color(padded.slice(0, padded.length - Math.max(0, visible - width))) : padded;
18
24
  }
19
25
 
20
26
  function optionStatusTag(kind) {
21
27
  const statuses = {
22
28
  running: ["RUNNING", pc.green],
23
29
  ready: ["READY", pc.green],
24
- missing: ["FILE MISSING", pc.red],
25
- setup: ["NEEDS SETUP", pc.yellow],
30
+ missing: ["MISSING", pc.red],
31
+ setup: ["SETUP", pc.yellow],
26
32
  };
27
33
  const [text, color] = statuses[kind] ?? [kind, pc.dim];
28
- return optionTag(text, color, OPTION_STATUS_WIDTH);
34
+ return optionPad(text, color, OPTION_STATUS_WIDTH);
29
35
  }
30
36
 
31
37
  function optionSourceTag(sourceId, label) {
@@ -36,11 +42,35 @@ function optionSourceTag(sourceId, label) {
36
42
  omlx: pc.magenta,
37
43
  gguf: pc.cyan,
38
44
  };
39
- return optionTag(label, colors[sourceId] ?? pc.dim, OPTION_SOURCE_WIDTH);
45
+ return optionPad(label, colors[sourceId] ?? pc.dim, OPTION_SOURCE_WIDTH);
46
+ }
47
+
48
+ function optionCtxLabel(item) {
49
+ if (item.type === "profile" && item.profile.flags?.ctxSize) {
50
+ return `${(item.profile.flags.ctxSize / 1000).toFixed(0)}k`;
51
+ }
52
+ return "—";
53
+ }
54
+
55
+ function optionSizeLabel(item) {
56
+ if (item.type === "profile") {
57
+ if (item.fileMissing) return "—";
58
+ if (item.profile.modelPath && existsSync(item.profile.modelPath)) {
59
+ return formatBytes(statSync(item.profile.modelPath).size);
60
+ }
61
+ return "—";
62
+ }
63
+ if (item.type === "new") {
64
+ return formatBytes(item.model.sizeBytes);
65
+ }
66
+ // managed
67
+ if (item.model.quant) return item.model.quant;
68
+ if (item.model.sizeBytes) return formatBytes(item.model.sizeBytes);
69
+ return "—";
40
70
  }
41
71
 
42
- function optionLabel({ status, source, name, details = [] }) {
43
- return [status, source, pc.bold(name), ...details].filter(Boolean).join(OPTION_SEPARATOR);
72
+ function optionLabel({ status, source, name, ctx, size }) {
73
+ return [status, source, pc.bold(optionPad(name, null, OPTION_MODEL_WIDTH)), ctx, pc.dim(size)].filter(Boolean).join(OPTION_SEPARATOR);
44
74
  }
45
75
 
46
76
  export function modelSelectOption(item, { runningProfilesNow }) {
@@ -53,74 +83,88 @@ export function modelSelectOption(item, { runningProfilesNow }) {
53
83
  status: optionStatusTag(item.fileMissing ? "missing" : running ? "running" : "ready"),
54
84
  source: optionSourceTag(item.profile.backend, backend.label),
55
85
  name: item.profile.label,
86
+ ctx: optionCtxLabel(item),
87
+ size: optionSizeLabel(item),
56
88
  }),
57
89
  };
58
90
  }
59
91
  if (item.type === "new") {
60
92
  return {
61
93
  value: itemKey(item),
62
- label: optionLabel({ status: optionStatusTag("setup"), source: optionSourceTag("gguf", "GGUF file"), name: item.model.label }),
94
+ label: optionLabel({
95
+ status: optionStatusTag("setup"),
96
+ source: optionSourceTag("gguf", "GGUF file"),
97
+ name: item.model.label,
98
+ ctx: optionCtxLabel(item),
99
+ size: optionSizeLabel(item),
100
+ }),
63
101
  };
64
102
  }
65
103
  const backend = BACKENDS[item.backendId];
66
104
  return {
67
105
  value: itemKey(item),
68
- label: optionLabel({ status: optionStatusTag("setup"), source: optionSourceTag(item.backendId, backend.label), name: item.model.label }),
106
+ label: optionLabel({
107
+ status: optionStatusTag("setup"),
108
+ source: optionSourceTag(item.backendId, backend.label),
109
+ name: item.model.label,
110
+ ctx: optionCtxLabel(item),
111
+ size: optionSizeLabel(item),
112
+ }),
69
113
  };
70
114
  }
71
115
 
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
- }
116
+ export function printWorkspaceHeader(normalized, runningProfilesNow) {
117
+ const profiles = normalized.profiles;
118
+ const readyCount = profiles.filter((p) => !isProfileFileMissing(p) && !runningProfilesNow.some((r) => r.id === p.id)).length;
119
+ const runningCount = runningProfilesNow.length;
120
+ const missingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
121
+ const setupCount = normalized.newModels.length + normalized.managedItems.length;
122
+
123
+ const countParts = [];
124
+ if (readyCount > 0) countParts.push(`${readyCount} ready`);
125
+ if (runningCount > 0) countParts.push(`${runningCount} running`);
126
+ if (missingCount > 0) countParts.push(`${missingCount} missing`);
127
+ if (setupCount > 0) countParts.push(`${setupCount} need setup`);
128
+
129
+ console.log(pc.bold(" offgrid-ai"));
130
+ console.log(pc.dim(` ${countParts.join(" · ")} · ${DATA_DIR}`));
131
+ }
97
132
 
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
- }
133
+ export async function printBenchmarkLine() {
134
+ const repoPath = await findBenchmarkRepo();
135
+ if (repoPath) {
136
+ console.log(pc.green(" ✓") + " local-llm-visual-benchmark linked");
137
+ } else {
138
+ console.log(pc.yellow(" ○") + " to run benchmarks, pair with " + pc.cyan("local-llm-visual-benchmark"));
111
139
  }
140
+ }
112
141
 
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
- }
123
- }
142
+ function tableWidth() {
143
+ const header = [
144
+ optionPad("Status", null, OPTION_STATUS_WIDTH),
145
+ optionPad("Backend", null, OPTION_SOURCE_WIDTH),
146
+ optionPad("Model", null, OPTION_MODEL_WIDTH),
147
+ optionPad("Ctx", null, OPTION_CTX_WIDTH),
148
+ "Size",
149
+ ].join(" \u2502 ");
150
+ return stripVTControlCharacters(header).length;
151
+ }
152
+
153
+ export function printTableHeader() {
154
+ console.log("");
155
+ const header = [
156
+ optionPad("Status", pc.dim, OPTION_STATUS_WIDTH),
157
+ optionPad("Backend", pc.dim, OPTION_SOURCE_WIDTH),
158
+ optionPad("Model", pc.dim, OPTION_MODEL_WIDTH),
159
+ optionPad("Ctx", pc.dim, OPTION_CTX_WIDTH),
160
+ pc.dim("Size"),
161
+ ].join(OPTION_SEPARATOR);
162
+ console.log(header);
163
+ console.log(pc.dim("─".repeat(tableWidth())));
164
+ }
165
+
166
+ export function printTableFooter() {
167
+ console.log(pc.dim("─".repeat(tableWidth())));
124
168
  }
125
169
 
126
170
  export async function printProfileDetails(profile) {
@@ -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}` : "";