offgrid-ai 0.3.30 → 0.4.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/README.md +8 -7
- package/install.sh +1 -1
- package/package.json +1 -1
- package/src/autodetect.mjs +2 -2
- package/src/backends.mjs +35 -15
- package/src/cli.mjs +48 -63
- package/src/config.mjs +8 -3
- package/src/gguf.mjs +12 -3
- package/src/process.mjs +60 -8
- package/src/profile-setup.mjs +5 -4
- package/src/profiles.mjs +11 -11
- package/src/runtime.mjs +3 -3
- package/src/ui.mjs +0 -2
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ curl -fsSL https://raw.githubusercontent.com/eeshansrivastava89/offgrid-ai/main/
|
|
|
19
19
|
|
|
20
20
|
You run `offgrid-ai`. It finds your local models, auto-configures everything, starts the server, and launches Pi. Zero configuration. No parameter tuning. No presets.
|
|
21
21
|
|
|
22
|
-
**First run** walks you through installing anything missing
|
|
22
|
+
**First run** walks you through installing anything missing. For GGUF models, offgrid-ai installs a managed `llama.cpp` runtime under `~/.offgrid-ai/runtime`; Homebrew is only used if you choose Homebrew-installed apps like LM Studio, Ollama, or oMLX.
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
offgrid-ai # pick a model and run it
|
|
@@ -49,7 +49,7 @@ curl -fsSL https://raw.githubusercontent.com/eeshansrivastava89/offgrid-ai/main/
|
|
|
49
49
|
|
|
50
50
|
## How it works
|
|
51
51
|
|
|
52
|
-
1. **Auto-detect everything.** Scans for GGUF models in LM Studio
|
|
52
|
+
1. **Auto-detect everything.** Scans for GGUF models in LM Studio and Hugging Face cache directories, and checks managed backends like Ollama/oMLX through their local APIs. Reads model metadata (quantization, context size, vision, thinking mode) directly from GGUF files. No presets, no manual configuration.
|
|
53
53
|
|
|
54
54
|
2. **One command to run.** `offgrid-ai` → pick a model → confirm context/KV memory settings on first setup → it starts llama-server, syncs Pi config, and launches Pi.
|
|
55
55
|
|
|
@@ -60,8 +60,8 @@ curl -fsSL https://raw.githubusercontent.com/eeshansrivastava89/offgrid-ai/main/
|
|
|
60
60
|
| Backend | Type | Auto-detected |
|
|
61
61
|
|---|---|---|
|
|
62
62
|
| **LM Studio** | Visual model browser + CLI (`lms`) | ✓ models in `~/.lmstudio/models/` |
|
|
63
|
-
| **llama.cpp** |
|
|
64
|
-
| **llama.cpp MTP** |
|
|
63
|
+
| **llama.cpp** | Managed local runtime | ✓ GGUF models in `~/.lmstudio/models/` and Hugging Face cache |
|
|
64
|
+
| **llama.cpp MTP** | Managed local runtime (speculative decoding) | ✓ MTP detected from model metadata |
|
|
65
65
|
| **Ollama** | Managed server | ✓ via `localhost:11434` |
|
|
66
66
|
| **oMLX** | Managed server | ✓ via `127.0.0.1:8000` |
|
|
67
67
|
|
|
@@ -69,15 +69,15 @@ curl -fsSL https://raw.githubusercontent.com/eeshansrivastava89/offgrid-ai/main/
|
|
|
69
69
|
|
|
70
70
|
When you run `offgrid-ai` for the first time on a fresh machine:
|
|
71
71
|
|
|
72
|
-
1. **
|
|
73
|
-
2. **
|
|
72
|
+
1. **llama.cpp runtime** — Required for GGUF models. Offered as an offgrid-ai managed runtime from official `llama.cpp` release binaries.
|
|
73
|
+
2. **Pi** — Required to chat from the Pi coding agent. Offered to install via npm if missing.
|
|
74
74
|
3. **Model backend** — At least one is needed (LM Studio recommended):
|
|
75
75
|
- **LM Studio** — visual model browser + `lms` CLI, download models with `lms get qwen/qwen3.5-9b`
|
|
76
76
|
- **Ollama** — models download on demand with `ollama pull`
|
|
77
77
|
- **oMLX** — Apple Silicon optimized
|
|
78
78
|
4. **Models** — If no models found, tells you where to get them.
|
|
79
79
|
|
|
80
|
-
Subsequent runs skip everything that's already installed. When a GGUF model is set up for the first time, offgrid-ai asks only for the memory-impacting choices: context window and KV cache precision. Sampling defaults are shown but not forced into a tuning wizard.
|
|
80
|
+
Homebrew is optional and only prompted when you choose a Homebrew-based backend install. Subsequent runs skip everything that's already installed. When a GGUF model is set up for the first time, offgrid-ai asks only for the memory-impacting choices: context window and KV cache precision. Sampling defaults are shown but not forced into a tuning wizard.
|
|
81
81
|
|
|
82
82
|
## Data directory
|
|
83
83
|
|
|
@@ -91,6 +91,7 @@ Subsequent runs skip everything that's already installed. When a GGUF model is s
|
|
|
91
91
|
notes.md # scratch notes
|
|
92
92
|
logs/
|
|
93
93
|
run/ # PID state files
|
|
94
|
+
runtime/ # managed llama.cpp binaries
|
|
94
95
|
```
|
|
95
96
|
|
|
96
97
|
## Benchmark (coming soon)
|
package/install.sh
CHANGED
|
@@ -205,7 +205,7 @@ printf "${BOLD}${GREEN} offgrid-ai ${INSTALLED_VERSION:+v${INSTALLED_VERSION} }
|
|
|
205
205
|
printf "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n"
|
|
206
206
|
echo ""
|
|
207
207
|
echo " First run will walk you through setting up everything you need"
|
|
208
|
-
echo " (llama
|
|
208
|
+
echo " (managed llama.cpp runtime for GGUF models, model backends, Pi)."
|
|
209
209
|
echo ""
|
|
210
210
|
if command -v offgrid-ai &>/dev/null; then
|
|
211
211
|
echo " Run: offgrid-ai"
|
package/package.json
CHANGED
package/src/autodetect.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { readGgufMetadata } from "./gguf.mjs";
|
|
4
|
+
import { defaultFlagsForBackend } from "./backends.mjs";
|
|
4
5
|
|
|
5
6
|
// ── Detect model capabilities from GGUF metadata ──────────────────────────
|
|
6
7
|
|
|
@@ -51,8 +52,7 @@ export function computeFlags(capabilities, modelPath, mmprojPath, draftModelPath
|
|
|
51
52
|
const isLowMem = quant && /[Qq]4[_0]/i.test(quant);
|
|
52
53
|
|
|
53
54
|
const flags = {
|
|
54
|
-
|
|
55
|
-
port: mtp ? 8081 : 8080,
|
|
55
|
+
...defaultFlagsForBackend(mtp ? "llama-cpp-mtp" : "llama-cpp"),
|
|
56
56
|
ctxSize: capabilities.ctxSize,
|
|
57
57
|
flashAttention: "on",
|
|
58
58
|
cacheTypeK: isLowMem ? "f16" : "bf16",
|
package/src/backends.mjs
CHANGED
|
@@ -3,16 +3,30 @@ import { scanGgufModels } from "./scan.mjs";
|
|
|
3
3
|
|
|
4
4
|
// ── Backend definitions ────────────────────────────────────────────────────
|
|
5
5
|
|
|
6
|
+
export const LOCAL_HOST = "127.0.0.1";
|
|
7
|
+
export const LLAMA_CPP_PORT = 8080;
|
|
8
|
+
export const LLAMA_CPP_MTP_PORT = 8081;
|
|
9
|
+
export const OLLAMA_PORT = 11434;
|
|
10
|
+
export const OMLX_PORT = 8000;
|
|
11
|
+
|
|
12
|
+
export function baseUrlFor({ host = LOCAL_HOST, port, path = "/v1" }) {
|
|
13
|
+
return `http://${host}:${port}${path}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function baseUrlForFlags(flags) {
|
|
17
|
+
return baseUrlFor({ host: flags.host, port: flags.port });
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
export const BACKENDS = {
|
|
7
21
|
"llama-cpp": {
|
|
8
22
|
id: "llama-cpp",
|
|
9
23
|
label: "llama.cpp",
|
|
10
24
|
type: "local-server",
|
|
11
25
|
providerId: "llama-cpp",
|
|
12
|
-
|
|
13
|
-
|
|
26
|
+
defaultHost: LOCAL_HOST,
|
|
27
|
+
defaultPort: LLAMA_CPP_PORT,
|
|
28
|
+
defaultBaseUrl: baseUrlFor({ port: LLAMA_CPP_PORT }),
|
|
14
29
|
needsCommandFile: true,
|
|
15
|
-
needsModelFile: true,
|
|
16
30
|
scanModels: () => scanGgufModels(),
|
|
17
31
|
},
|
|
18
32
|
"llama-cpp-mtp": {
|
|
@@ -20,11 +34,10 @@ export const BACKENDS = {
|
|
|
20
34
|
label: "llama.cpp MTP",
|
|
21
35
|
type: "local-server",
|
|
22
36
|
providerId: "llama-cpp-mtp",
|
|
23
|
-
|
|
24
|
-
|
|
37
|
+
defaultHost: LOCAL_HOST,
|
|
38
|
+
defaultPort: LLAMA_CPP_MTP_PORT,
|
|
39
|
+
defaultBaseUrl: baseUrlFor({ port: LLAMA_CPP_MTP_PORT }),
|
|
25
40
|
needsCommandFile: true,
|
|
26
|
-
needsModelFile: true,
|
|
27
|
-
extraArgs: ["--spec-type", "draft-mtp", "--spec-draft-n-max", "2"],
|
|
28
41
|
scanModels: () => scanGgufModels(),
|
|
29
42
|
},
|
|
30
43
|
"ollama": {
|
|
@@ -32,10 +45,11 @@ export const BACKENDS = {
|
|
|
32
45
|
label: "Ollama",
|
|
33
46
|
type: "managed-server",
|
|
34
47
|
providerId: "ollama",
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
defaultHost: "localhost",
|
|
49
|
+
defaultPort: OLLAMA_PORT,
|
|
50
|
+
defaultBaseUrl: baseUrlFor({ host: "localhost", port: OLLAMA_PORT }),
|
|
51
|
+
apiBaseUrl: baseUrlFor({ host: "localhost", port: OLLAMA_PORT, path: "" }),
|
|
37
52
|
needsCommandFile: false,
|
|
38
|
-
needsModelFile: false,
|
|
39
53
|
scanModels: () => scanOllamaModels(),
|
|
40
54
|
},
|
|
41
55
|
"omlx": {
|
|
@@ -43,10 +57,11 @@ export const BACKENDS = {
|
|
|
43
57
|
label: "oMLX",
|
|
44
58
|
type: "managed-server",
|
|
45
59
|
providerId: "omlx",
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
defaultHost: LOCAL_HOST,
|
|
61
|
+
defaultPort: OMLX_PORT,
|
|
62
|
+
defaultBaseUrl: baseUrlFor({ port: OMLX_PORT }),
|
|
63
|
+
apiBaseUrl: baseUrlFor({ port: OMLX_PORT, path: "" }),
|
|
48
64
|
needsCommandFile: false,
|
|
49
|
-
needsModelFile: false,
|
|
50
65
|
scanModels: () => scanOmlxModels(),
|
|
51
66
|
},
|
|
52
67
|
};
|
|
@@ -64,11 +79,16 @@ export async function backendBinaryFor(backendId) {
|
|
|
64
79
|
return discovered; // null means "not found — trigger onboarding"
|
|
65
80
|
}
|
|
66
81
|
|
|
82
|
+
export function defaultFlagsForBackend(backendId) {
|
|
83
|
+
const backend = backendFor(backendId);
|
|
84
|
+
return { host: backend.defaultHost ?? LOCAL_HOST, port: backend.defaultPort };
|
|
85
|
+
}
|
|
86
|
+
|
|
67
87
|
// ── Ollama model discovery ──────────────────────────────────────────────
|
|
68
88
|
|
|
69
89
|
async function scanOllamaModels() {
|
|
70
90
|
try {
|
|
71
|
-
const response = await fetch(
|
|
91
|
+
const response = await fetch(`${BACKENDS.ollama.apiBaseUrl}/api/tags`, { signal: AbortSignal.timeout(3000) });
|
|
72
92
|
if (!response.ok) return [];
|
|
73
93
|
const body = await response.json();
|
|
74
94
|
if (!Array.isArray(body?.models)) return [];
|
|
@@ -93,7 +113,7 @@ async function scanOllamaModels() {
|
|
|
93
113
|
|
|
94
114
|
async function scanOmlxModels() {
|
|
95
115
|
try {
|
|
96
|
-
const response = await fetch(
|
|
116
|
+
const response = await fetch(`${BACKENDS.omlx.defaultBaseUrl}/models`, { signal: AbortSignal.timeout(3000) });
|
|
97
117
|
if (!response.ok) return [];
|
|
98
118
|
const body = await response.json();
|
|
99
119
|
if (!Array.isArray(body?.data)) return [];
|
package/src/cli.mjs
CHANGED
|
@@ -2,10 +2,10 @@ import { totalmem } from "node:os";
|
|
|
2
2
|
import { existsSync, statSync, rmSync } from "node:fs";
|
|
3
3
|
import { ensureDirs, findLlamaServer, hasHomebrew, DATA_DIR } from "./config.mjs";
|
|
4
4
|
import { scanGgufModels } from "./scan.mjs";
|
|
5
|
-
import { createProfileFromModel, normalizeProfile } from "./profiles.mjs";
|
|
6
|
-
import { readProfile, saveProfile, deleteProfile, loadProfiles } from "./profiles.mjs";
|
|
5
|
+
import { createProfileFromModel, normalizeProfile, sanitizeProfileId } from "./profiles.mjs";
|
|
6
|
+
import { readProfile, saveProfile, deleteProfile, loadProfiles, readCommandArgv } from "./profiles.mjs";
|
|
7
7
|
import { backendFor, BACKENDS } from "./backends.mjs";
|
|
8
|
-
import { startServer, stopProfile, waitForReady, serverReady, isProfileRunning, profileRuntimeStatus } from "./process.mjs";
|
|
8
|
+
import { startServer, stopProfile, waitForReady, serverReady, serverMatchesProfile, isProfileRunning, profileRuntimeStatus } from "./process.mjs";
|
|
9
9
|
import { syncPiConfig, removeFromPiConfig, hasPiModel, launchPi, hasPi } from "./harness-pi.mjs";
|
|
10
10
|
import { tailFriendly } from "./logs.mjs";
|
|
11
11
|
import { estimateMemory } from "./estimate.mjs";
|
|
@@ -83,14 +83,12 @@ export async function mainFlow() {
|
|
|
83
83
|
const profiles = await loadProfiles();
|
|
84
84
|
const hasAnyBackend = llamaBinary || managedModels.some((m) => m.models.length > 0);
|
|
85
85
|
const hasAnyModels = ggufModels.length > 0 || managedModels.some((m) => m.models.length > 0);
|
|
86
|
-
const totalManaged = managedModels.reduce((sum, m) => sum + m.models.length, 0);
|
|
87
86
|
|
|
88
87
|
// 2. Check mandatory deps — if anything essential is missing, re-offer onboarding
|
|
89
88
|
const piInstalled = await hasPi();
|
|
90
|
-
const
|
|
89
|
+
const needsLlama = ggufModels.length > 0 || profiles.some((profile) => backendFor(profile.backend).type === "local-server");
|
|
91
90
|
const missingDeps = [];
|
|
92
|
-
if (!
|
|
93
|
-
if (!llamaBinary) missingDeps.push("llama-server");
|
|
91
|
+
if (needsLlama && !llamaBinary) missingDeps.push("llama-server");
|
|
94
92
|
if (!piInstalled) missingDeps.push("Pi");
|
|
95
93
|
if (missingDeps.length > 0) {
|
|
96
94
|
if (!process.stdin.isTTY) {
|
|
@@ -109,18 +107,6 @@ export async function mainFlow() {
|
|
|
109
107
|
return await onboardFlow();
|
|
110
108
|
}
|
|
111
109
|
|
|
112
|
-
// 3. Has models but no llama-server (managed backends only)
|
|
113
|
-
if (!llamaBinary && ggufModels.length > 0) {
|
|
114
|
-
// They have GGUF files but can't run them — tell them about llama-server
|
|
115
|
-
console.log(pc.yellow(`${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"} found, but llama-server is not installed.`));
|
|
116
|
-
console.log(pc.dim("Install it with: brew install llama.cpp"));
|
|
117
|
-
console.log(pc.dim("Or use Ollama/oMLX for managed model backends."));
|
|
118
|
-
if (totalManaged === 0 && profiles.length === 0) {
|
|
119
|
-
return; // Nothing to do without llama-server
|
|
120
|
-
}
|
|
121
|
-
// Fall through — they can still use managed backends
|
|
122
|
-
}
|
|
123
|
-
|
|
124
110
|
// 4. No models found at all (but backends may exist)
|
|
125
111
|
if (!hasAnyModels && profiles.length === 0) {
|
|
126
112
|
if (!process.stdin.isTTY) {
|
|
@@ -213,10 +199,10 @@ async function modelCommandCenter(catalog) {
|
|
|
213
199
|
|
|
214
200
|
async function runCommand(argv) {
|
|
215
201
|
await ensureDirs();
|
|
216
|
-
const { positional } = parseOptions(argv);
|
|
202
|
+
const { positional, options } = parseOptions(argv);
|
|
217
203
|
if (!positional[0]) return await mainFlow();
|
|
218
204
|
const profile = await readProfile(positional[0]);
|
|
219
|
-
return await runProfile(profile);
|
|
205
|
+
return await runProfile(profile, options);
|
|
220
206
|
}
|
|
221
207
|
|
|
222
208
|
async function loadModelCatalog() {
|
|
@@ -424,7 +410,8 @@ async function printProfileDetails(profile) {
|
|
|
424
410
|
]), { columns: 110 }));
|
|
425
411
|
|
|
426
412
|
if (!isManaged && profile.commandArgv) {
|
|
427
|
-
|
|
413
|
+
const commandArgv = await readCommandArgv(profile);
|
|
414
|
+
console.log("\n" + renderSection("llama-server command", pc.dim(buildPrettyCommand({ ...profile, commandArgv })), { columns: 120 }));
|
|
428
415
|
}
|
|
429
416
|
}
|
|
430
417
|
|
|
@@ -495,7 +482,7 @@ function removeCommandOption(argv, flag) {
|
|
|
495
482
|
|
|
496
483
|
function createManagedProfile(model, backendId) {
|
|
497
484
|
return normalizeProfile({
|
|
498
|
-
id: model.id
|
|
485
|
+
id: `${backendId}-${sanitizeProfileId(model.id)}`,
|
|
499
486
|
label: model.label,
|
|
500
487
|
backend: backendId,
|
|
501
488
|
modelAlias: model.aliasSuggestion,
|
|
@@ -529,6 +516,10 @@ async function runProfile(profile, options = {}) {
|
|
|
529
516
|
} else {
|
|
530
517
|
const ready = await serverReady(profile.baseUrl);
|
|
531
518
|
if (ready) {
|
|
519
|
+
const match = await serverMatchesProfile(profile);
|
|
520
|
+
if (!match.matches) {
|
|
521
|
+
throw new Error(`A different server is already responding at ${profile.baseUrl}. ${match.reason}. Stop it with offgrid-ai stop --all, or choose a different port.`);
|
|
522
|
+
}
|
|
532
523
|
console.log(pc.green(`[ready] Reusing server at ${profile.baseUrl}`));
|
|
533
524
|
} else {
|
|
534
525
|
console.log(pc.dim(`Starting ${backend.label} for ${profile.label}...`));
|
|
@@ -551,7 +542,7 @@ async function runProfile(profile, options = {}) {
|
|
|
551
542
|
console.log(pc.yellow("Vision projector is not supported by this llama.cpp build. Retrying text-only."));
|
|
552
543
|
console.log(pc.dim("Update llama.cpp later to re-enable vision for this model."));
|
|
553
544
|
const textOnly = textOnlyProfile(profile);
|
|
554
|
-
await saveProfile(textOnly);
|
|
545
|
+
await saveProfile(textOnly, { writeCommand: true });
|
|
555
546
|
return await runProfile(textOnly, { ...options, textOnlyRetry: true });
|
|
556
547
|
}
|
|
557
548
|
throw err;
|
|
@@ -782,19 +773,32 @@ async function onboardFlow() {
|
|
|
782
773
|
console.log(pc.bold("Welcome to offgrid-ai!"));
|
|
783
774
|
console.log(pc.dim("Let's make sure you have everything you need to run local models.\n"));
|
|
784
775
|
|
|
785
|
-
// 1.
|
|
786
|
-
|
|
787
|
-
if (!
|
|
788
|
-
|
|
776
|
+
// 1. llama.cpp runtime for local GGUF models
|
|
777
|
+
let llamaBinary = await findLlamaServer();
|
|
778
|
+
if (!llamaBinary) {
|
|
779
|
+
console.log(renderSection("llama.cpp runtime", renderRows([
|
|
780
|
+
["Status", pc.yellow("not installed")],
|
|
781
|
+
["Used for", "local GGUF models"],
|
|
782
|
+
["Install", "managed by offgrid-ai under ~/.offgrid-ai/runtime"],
|
|
783
|
+
]), { formatBorder: pc.cyan }));
|
|
784
|
+
await offerManagedLlamaRuntimeUpdate(prompt);
|
|
785
|
+
llamaBinary = await findLlamaServer();
|
|
786
|
+
if (!llamaBinary) {
|
|
787
|
+
console.log(pc.yellow("Skipping llama.cpp for now. You can still use Ollama/oMLX, or run offgrid-ai again to install the managed runtime."));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (llamaBinary) console.log(pc.green(`✓ llama-server: ${llamaBinary}`));
|
|
791
|
+
|
|
792
|
+
const ensureHomebrewFor = async (label) => {
|
|
793
|
+
if (await hasHomebrew()) return true;
|
|
794
|
+
const install = await prompt.yesNo(`Homebrew is needed to install ${label}. Install Homebrew now?`, true);
|
|
789
795
|
if (!install) {
|
|
790
|
-
console.log(pc.
|
|
791
|
-
|
|
792
|
-
return;
|
|
796
|
+
console.log(pc.dim(`Install ${label} manually, or install Homebrew from https://brew.sh and run offgrid-ai again.`));
|
|
797
|
+
return false;
|
|
793
798
|
}
|
|
794
799
|
console.log(pc.cyan("Installing Homebrew..."));
|
|
795
800
|
try {
|
|
796
801
|
await run("/bin/bash", ["-c", "NONINTERACTIVE=1 /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""], "Homebrew");
|
|
797
|
-
// Add brew to PATH for this session
|
|
798
802
|
const brewPaths = ["/opt/homebrew/bin", "/usr/local/bin"];
|
|
799
803
|
for (const p of brewPaths) {
|
|
800
804
|
if (existsSync(p)) {
|
|
@@ -803,43 +807,19 @@ async function onboardFlow() {
|
|
|
803
807
|
}
|
|
804
808
|
}
|
|
805
809
|
} catch {
|
|
806
|
-
console.log(pc.red(
|
|
810
|
+
console.log(pc.red("✗ Homebrew installation failed."));
|
|
807
811
|
console.log(pc.dim("Install it manually from https://brew.sh, then run offgrid-ai again."));
|
|
808
|
-
return;
|
|
812
|
+
return false;
|
|
809
813
|
}
|
|
810
814
|
if (!(await hasHomebrew())) {
|
|
811
815
|
console.log(pc.red("Homebrew was installed but not found on PATH. Restart your terminal and run offgrid-ai again."));
|
|
812
|
-
return;
|
|
816
|
+
return false;
|
|
813
817
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
// 2. llama-server
|
|
818
|
-
let llamaBinary = await findLlamaServer();
|
|
819
|
-
if (!llamaBinary) {
|
|
820
|
-
const install = await prompt.yesNo("llama-server is required to run local models. Install via Homebrew?", true);
|
|
821
|
-
if (!install) {
|
|
822
|
-
console.log(pc.red("offgrid-ai needs llama-server to run local models."));
|
|
823
|
-
console.log(pc.dim("Install it manually: brew install llama.cpp"));
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
console.log(pc.cyan("Installing llama.cpp..."));
|
|
827
|
-
try {
|
|
828
|
-
await run("brew", ["install", "llama.cpp"], "llama.cpp");
|
|
829
|
-
llamaBinary = await findLlamaServer();
|
|
830
|
-
} catch {
|
|
831
|
-
console.log(pc.red("✗ Failed to install llama.cpp."));
|
|
832
|
-
console.log(pc.dim("Install it manually: brew install llama.cpp"));
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
835
|
-
if (!llamaBinary) {
|
|
836
|
-
console.log(pc.yellow("llama.cpp installed but llama-server not found. You may need to restart your terminal."));
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
console.log(pc.green(`✓ llama-server: ${llamaBinary}`));
|
|
818
|
+
console.log(pc.green("✓ Homebrew found"));
|
|
819
|
+
return true;
|
|
820
|
+
};
|
|
841
821
|
|
|
842
|
-
//
|
|
822
|
+
// 2. Pi coding agent
|
|
843
823
|
const piInstalled = await hasPi();
|
|
844
824
|
if (!piInstalled) {
|
|
845
825
|
const install = await prompt.yesNo("Pi coding agent is required to chat with models. Install via npm?", true);
|
|
@@ -873,6 +853,7 @@ async function onboardFlow() {
|
|
|
873
853
|
// They already have models — show what was found
|
|
874
854
|
if (ggufModels.length > 0) {
|
|
875
855
|
console.log(pc.green(`✓ Found ${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"}`));
|
|
856
|
+
if (!llamaBinary) console.log(pc.yellow("Install the managed llama.cpp runtime to run these GGUF models."));
|
|
876
857
|
}
|
|
877
858
|
for (const { backendId, models } of managedModels) {
|
|
878
859
|
if (models.length > 0) {
|
|
@@ -895,6 +876,7 @@ async function onboardFlow() {
|
|
|
895
876
|
const model = recommendedModel();
|
|
896
877
|
|
|
897
878
|
if (backendChoice === "lmstudio") {
|
|
879
|
+
if (!(await ensureHomebrewFor("LM Studio"))) return;
|
|
898
880
|
console.log(pc.cyan("Installing LM Studio via Homebrew..."));
|
|
899
881
|
try {
|
|
900
882
|
await run("brew", ["install", "--cask", "lm-studio"], "LM Studio");
|
|
@@ -907,6 +889,7 @@ async function onboardFlow() {
|
|
|
907
889
|
console.log(pc.dim("Download it manually from https://lmstudio.ai"));
|
|
908
890
|
}
|
|
909
891
|
} else if (backendChoice === "ollama") {
|
|
892
|
+
if (!(await ensureHomebrewFor("Ollama"))) return;
|
|
910
893
|
console.log(pc.cyan("Installing Ollama via Homebrew..."));
|
|
911
894
|
try {
|
|
912
895
|
await run("brew", ["install", "ollama"], "Ollama");
|
|
@@ -920,6 +903,7 @@ async function onboardFlow() {
|
|
|
920
903
|
console.log(pc.dim("Install it manually from https://ollama.com"));
|
|
921
904
|
}
|
|
922
905
|
} else if (backendChoice === "omlx") {
|
|
906
|
+
if (!(await ensureHomebrewFor("oMLX"))) return;
|
|
923
907
|
console.log(pc.cyan("Installing oMLX via Homebrew..."));
|
|
924
908
|
try {
|
|
925
909
|
await run("brew", ["tap", "jundot/omlx", "https://github.com/jundot/omlx"], "oMLX tap");
|
|
@@ -934,6 +918,7 @@ async function onboardFlow() {
|
|
|
934
918
|
console.log(pc.dim("Install manually: brew tap jundot/omlx && brew install omlx"));
|
|
935
919
|
}
|
|
936
920
|
} else if (backendChoice === "all") {
|
|
921
|
+
if (!(await ensureHomebrewFor("model backends"))) return;
|
|
937
922
|
let installed = [];
|
|
938
923
|
// LM Studio
|
|
939
924
|
console.log(pc.cyan("Installing LM Studio via Homebrew..."));
|
package/src/config.mjs
CHANGED
|
@@ -76,17 +76,22 @@ export async function findLlamaServer() {
|
|
|
76
76
|
return process.env.LLAMA_SERVER_BINARY;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
// 2.
|
|
79
|
+
// 2. User config override
|
|
80
|
+
const config = await loadConfig();
|
|
81
|
+
const configured = config.binaryOverrides?.llamaServer ?? config.binaryOverrides?.["llama-server"];
|
|
82
|
+
if (configured && existsSync(configured)) return configured;
|
|
83
|
+
|
|
84
|
+
// 3. offgrid-ai managed runtime
|
|
80
85
|
if (existsSync(MANAGED_LLAMA_SERVER)) return MANAGED_LLAMA_SERVER;
|
|
81
86
|
|
|
82
|
-
//
|
|
87
|
+
// 4. PATH
|
|
83
88
|
try {
|
|
84
89
|
const { stdout } = await execFileAsync("which", ["llama-server"]);
|
|
85
90
|
const path = stdout.trim();
|
|
86
91
|
if (path && existsSync(path)) return path;
|
|
87
92
|
} catch { /* not on PATH */ }
|
|
88
93
|
|
|
89
|
-
//
|
|
94
|
+
// 5. Homebrew fallback
|
|
90
95
|
try {
|
|
91
96
|
const { stdout } = await execFileAsync("brew", ["--prefix", "llama.cpp"]);
|
|
92
97
|
const prefix = stdout.trim();
|
package/src/gguf.mjs
CHANGED
|
@@ -3,21 +3,30 @@ import { openSync, readSync, closeSync, statSync } from "node:fs";
|
|
|
3
3
|
export function readGgufMetadata(path) {
|
|
4
4
|
const buffer = readFilePrefix(path, 64 * 1024 * 1024);
|
|
5
5
|
let offset = 0;
|
|
6
|
-
|
|
6
|
+
const meta = {};
|
|
7
|
+
if (buffer.length < 24 || buffer.toString("utf8", 0, 4) !== "GGUF") return meta;
|
|
7
8
|
offset += 4;
|
|
8
9
|
offset += 4; // version
|
|
9
10
|
offset += 8; // tensor count
|
|
11
|
+
if (offset + 8 > buffer.length) return meta;
|
|
10
12
|
const kvCount = Number(buffer.readBigUInt64LE(offset));
|
|
11
13
|
offset += 8;
|
|
12
|
-
const meta = {};
|
|
13
14
|
for (let i = 0; i < kvCount; i++) {
|
|
15
|
+
if (offset + 8 > buffer.length) return meta;
|
|
14
16
|
const keyLen = Number(buffer.readBigUInt64LE(offset));
|
|
15
17
|
offset += 8;
|
|
18
|
+
if (offset + keyLen + 4 > buffer.length) return meta;
|
|
16
19
|
const key = buffer.toString("utf8", offset, offset + keyLen);
|
|
17
20
|
offset += keyLen;
|
|
18
21
|
const type = buffer.readUInt32LE(offset);
|
|
19
22
|
offset += 4;
|
|
20
|
-
|
|
23
|
+
let read;
|
|
24
|
+
try {
|
|
25
|
+
read = readValue(buffer, offset, type);
|
|
26
|
+
} catch {
|
|
27
|
+
return meta;
|
|
28
|
+
}
|
|
29
|
+
if (read.offset > buffer.length) return meta;
|
|
21
30
|
offset = read.offset;
|
|
22
31
|
meta[key] = read.value;
|
|
23
32
|
}
|
package/src/process.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import { openSync } from "node:fs";
|
|
3
|
+
import { closeSync, openSync } from "node:fs";
|
|
4
4
|
import { readFile, writeFile } from "node:fs/promises";
|
|
5
|
-
import { join } from "node:path";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
6
|
import { LOG_DIR } from "./config.mjs";
|
|
7
|
-
import { writeState, readState } from "./profiles.mjs";
|
|
7
|
+
import { writeState, readState, readCommandArgv } from "./profiles.mjs";
|
|
8
8
|
import { backendFor, backendBinaryFor } from "./backends.mjs";
|
|
9
9
|
|
|
10
10
|
const execFileAsync = promisify(execFile);
|
|
@@ -22,22 +22,27 @@ export async function startServer(profile) {
|
|
|
22
22
|
async function startLocalServer(profile) {
|
|
23
23
|
const binary = await backendBinaryFor(profile.backend);
|
|
24
24
|
if (!binary) {
|
|
25
|
-
throw new Error("llama-server not found. Install
|
|
25
|
+
throw new Error("llama-server not found. Install the managed llama.cpp runtime by running offgrid-ai interactively.");
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
const timestamp = timestampForFile();
|
|
29
29
|
const rawLogPath = join(LOG_DIR, `${profile.id}-${timestamp}.raw.log`);
|
|
30
30
|
const friendlyLogPath = join(LOG_DIR, `${profile.id}-${timestamp}.friendly.log`);
|
|
31
|
-
const
|
|
31
|
+
const commandArgv = await readCommandArgv(profile);
|
|
32
32
|
|
|
33
|
-
await writeFile(rawLogPath, `[offgrid-ai] ${new Date().toISOString()}\n[binary] ${binary}\n[argv]\n${
|
|
33
|
+
await writeFile(rawLogPath, `[offgrid-ai] ${new Date().toISOString()}\n[binary] ${binary}\n[argv]\n${commandArgv.join(" ")}\n`, "utf8");
|
|
34
34
|
await writeFile(friendlyLogPath, `[launch] starting llama-server for ${profile.label}\n`, "utf8");
|
|
35
35
|
|
|
36
36
|
// Build argv: binary + command.json args
|
|
37
|
-
const argv = [...
|
|
37
|
+
const argv = [...commandArgv];
|
|
38
38
|
|
|
39
39
|
const rawFd = openSync(rawLogPath, "a");
|
|
40
|
-
|
|
40
|
+
let child;
|
|
41
|
+
try {
|
|
42
|
+
child = spawn(binary, argv, { detached: true, stdio: ["ignore", rawFd, rawFd] });
|
|
43
|
+
} finally {
|
|
44
|
+
closeSync(rawFd);
|
|
45
|
+
}
|
|
41
46
|
child.unref();
|
|
42
47
|
|
|
43
48
|
const state = {
|
|
@@ -137,6 +142,27 @@ export async function serverReady(baseUrl) {
|
|
|
137
142
|
}
|
|
138
143
|
}
|
|
139
144
|
|
|
145
|
+
export async function serverMatchesProfile(profile) {
|
|
146
|
+
const state = await readState(profile.id);
|
|
147
|
+
if (state?.pid && pidAlive(state.pid) && state.baseUrl === profile.baseUrl) {
|
|
148
|
+
return { matches: true, reason: "tracked offgrid-ai server" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const ids = await serverModelIds(profile.baseUrl);
|
|
152
|
+
const expected = expectedModelIds(profile);
|
|
153
|
+
const normalizedIds = new Set(ids.map((id) => id.toLowerCase()));
|
|
154
|
+
if (ids.length > 0 && expected.some((id) => normalizedIds.has(id.toLowerCase()))) {
|
|
155
|
+
return { matches: true, reason: `server reports ${ids.join(", ")}` };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
matches: false,
|
|
160
|
+
reason: ids.length > 0
|
|
161
|
+
? `server reports ${ids.join(", ")}; expected ${expected.join(" or ")}`
|
|
162
|
+
: "server is untracked and did not report a recognizable model id",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
140
166
|
export async function waitForReady(profile, pid, rawLogPath) {
|
|
141
167
|
const backend = backendFor(profile.backend);
|
|
142
168
|
if (backend.type === "managed-server") return;
|
|
@@ -153,6 +179,32 @@ export async function waitForReady(profile, pid, rawLogPath) {
|
|
|
153
179
|
|
|
154
180
|
// ── Internals ──────────────────────────────────────────────────────────────
|
|
155
181
|
|
|
182
|
+
async function serverModelIds(baseUrl) {
|
|
183
|
+
try {
|
|
184
|
+
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, { signal: AbortSignal.timeout(1000) });
|
|
185
|
+
if (!response.ok) return [];
|
|
186
|
+
const body = await response.json();
|
|
187
|
+
return (Array.isArray(body?.data) ? body.data : [])
|
|
188
|
+
.map((model) => String(model?.id ?? "").trim())
|
|
189
|
+
.filter(Boolean);
|
|
190
|
+
} catch {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function expectedModelIds(profile) {
|
|
196
|
+
const fileName = profile.modelPath ? basename(profile.modelPath) : null;
|
|
197
|
+
return [
|
|
198
|
+
profile.modelAlias,
|
|
199
|
+
profile.label,
|
|
200
|
+
profile.ollamaModel,
|
|
201
|
+
profile.omlxModel,
|
|
202
|
+
profile.modelPath,
|
|
203
|
+
fileName,
|
|
204
|
+
fileName ? fileName.replace(/\.gguf$/iu, "") : null,
|
|
205
|
+
].filter(Boolean).map(String);
|
|
206
|
+
}
|
|
207
|
+
|
|
156
208
|
function pidAlive(pid) {
|
|
157
209
|
try { process.kill(pid, 0); return true; }
|
|
158
210
|
catch { return false; }
|
package/src/profile-setup.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { estimateMemory } from "./estimate.mjs";
|
|
4
4
|
import { findLlamaServer } from "./config.mjs";
|
|
5
|
+
import { baseUrlForFlags, LLAMA_CPP_PORT, LLAMA_CPP_MTP_PORT } from "./backends.mjs";
|
|
5
6
|
import { pc, formatBytes, renderRows, renderSection } from "./ui.mjs";
|
|
6
7
|
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
@@ -44,7 +45,7 @@ export async function configureLocalProfile(prompt, profile) {
|
|
|
44
45
|
if (caps.mtp) {
|
|
45
46
|
console.log(renderSection("MTP detected", renderRows([
|
|
46
47
|
["Backend", "llama.cpp MTP"],
|
|
47
|
-
["Port",
|
|
48
|
+
["Port", String(LLAMA_CPP_MTP_PORT)],
|
|
48
49
|
["Flags", "--spec-type draft-mtp --spec-draft-n-max 2"],
|
|
49
50
|
])));
|
|
50
51
|
const useMtp = await prompt.yesNo("Use MTP speculative decoding flags?", true);
|
|
@@ -111,14 +112,14 @@ export function applyRuntimeFlagOverrides(profile, overrides) {
|
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
function applyMtpDefaults(profile) {
|
|
114
|
-
const flags = { ...profile.flags, port:
|
|
115
|
+
const flags = { ...profile.flags, port: LLAMA_CPP_MTP_PORT };
|
|
115
116
|
return applyProfileFlags({ ...profile, backend: "llama-cpp-mtp", providerId: "llama-cpp-mtp" }, flags, {
|
|
116
117
|
values: { "--spec-type": "draft-mtp", "--spec-draft-n-max": 2 },
|
|
117
118
|
});
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
function removeMtpDefaults(profile) {
|
|
121
|
-
const flags = { ...profile.flags, port:
|
|
122
|
+
const flags = { ...profile.flags, port: LLAMA_CPP_PORT };
|
|
122
123
|
return applyProfileFlags({ ...profile, backend: "llama-cpp", providerId: "llama-cpp" }, flags, {
|
|
123
124
|
remove: ["--spec-type", "--spec-draft-n-max"],
|
|
124
125
|
});
|
|
@@ -156,7 +157,7 @@ function applyProfileFlags(profile, flags, edits = {}) {
|
|
|
156
157
|
const next = {
|
|
157
158
|
...profile,
|
|
158
159
|
flags,
|
|
159
|
-
baseUrl:
|
|
160
|
+
baseUrl: baseUrlForFlags(flags),
|
|
160
161
|
harnesses: {
|
|
161
162
|
...(profile.harnesses ?? {}),
|
|
162
163
|
pi: { ...(profile.harnesses?.pi ?? {}), enabled: true, model: `${profile.providerId ?? profile.backend}/${profile.modelAlias ?? profile.id}` },
|
package/src/profiles.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { mkdir, readdir, rm, unlink, writeFile, readFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { PROFILE_DIR, RUN_DIR, LOG_DIR } from "./config.mjs";
|
|
5
|
-
import { backendFor } from "./backends.mjs";
|
|
5
|
+
import { backendFor, baseUrlForFlags, defaultFlagsForBackend } from "./backends.mjs";
|
|
6
6
|
import { computeFlags } from "./autodetect.mjs";
|
|
7
7
|
import { readJson, writeJson } from "./json.mjs";
|
|
8
8
|
|
|
@@ -28,10 +28,6 @@ export function statePath(id) {
|
|
|
28
28
|
return join(RUN_DIR, `${sanitizeProfileId(id)}.state.json`);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function profileExists(id) {
|
|
32
|
-
return existsSync(profileJsonPath(id));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
31
|
export function sanitizeProfileId(value) {
|
|
36
32
|
return String(value).trim().toLowerCase().replace(/[^a-z0-9._-]+/gu, "-").replace(/^-|-$/gu, "") || "profile";
|
|
37
33
|
}
|
|
@@ -57,7 +53,7 @@ export async function readProfile(id) {
|
|
|
57
53
|
return JSON.parse(await readFile(path, "utf8"));
|
|
58
54
|
}
|
|
59
55
|
|
|
60
|
-
export async function saveProfile(profile) {
|
|
56
|
+
export async function saveProfile(profile, options = {}) {
|
|
61
57
|
const id = sanitizeProfileId(profile.id);
|
|
62
58
|
const dir = profileDir(id);
|
|
63
59
|
await mkdir(dir, { recursive: true });
|
|
@@ -75,8 +71,8 @@ export async function saveProfile(profile) {
|
|
|
75
71
|
const backend = backendFor(saved.backend);
|
|
76
72
|
if (backend.needsCommandFile) {
|
|
77
73
|
const cmdPath = commandJsonPath(id);
|
|
78
|
-
if (!existsSync(cmdPath)) {
|
|
79
|
-
await writeJson(cmdPath, { argv: saved.commandArgv ??
|
|
74
|
+
if (options.writeCommand || !existsSync(cmdPath)) {
|
|
75
|
+
await writeJson(cmdPath, { argv: saved.commandArgv ?? [] });
|
|
80
76
|
}
|
|
81
77
|
}
|
|
82
78
|
|
|
@@ -116,12 +112,11 @@ export async function deleteProfile(id, options = {}) {
|
|
|
116
112
|
export function normalizeProfile(profile) {
|
|
117
113
|
const backend = backendFor(profile.backend);
|
|
118
114
|
const flags = {
|
|
119
|
-
|
|
120
|
-
port: backend.defaultPort,
|
|
115
|
+
...defaultFlagsForBackend(backend.id),
|
|
121
116
|
...profile.flags,
|
|
122
117
|
};
|
|
123
118
|
if (!profile.baseUrl) {
|
|
124
|
-
profile.baseUrl =
|
|
119
|
+
profile.baseUrl = baseUrlForFlags(flags);
|
|
125
120
|
}
|
|
126
121
|
|
|
127
122
|
return {
|
|
@@ -175,6 +170,11 @@ function summarizeCapabilities(caps) {
|
|
|
175
170
|
|
|
176
171
|
// ── State files (for running servers) ──────────────────────────────────────
|
|
177
172
|
|
|
173
|
+
export async function readCommandArgv(profile) {
|
|
174
|
+
const command = await readJson(commandJsonPath(profile.id), null);
|
|
175
|
+
return Array.isArray(command?.argv) ? command.argv.map(String) : (profile.commandArgv ?? []).map(String);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
178
|
export async function readState(id) {
|
|
179
179
|
return readJson(statePath(id), null);
|
|
180
180
|
}
|
package/src/runtime.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export async function offerManagedLlamaRuntimeUpdate(prompt, { fetchImpl = globa
|
|
|
29
29
|
const shouldInstall = await prompt.yesNo(installed ? "Update llama.cpp runtime?" : "Install llama.cpp runtime?", true);
|
|
30
30
|
if (!shouldInstall) return false;
|
|
31
31
|
|
|
32
|
-
await installLlamaRelease(latest);
|
|
32
|
+
await installLlamaRelease(latest, { fetchImpl });
|
|
33
33
|
return true;
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -55,7 +55,7 @@ export async function installedRuntime() {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export async function installLlamaRelease(release) {
|
|
58
|
+
export async function installLlamaRelease(release, { fetchImpl = globalThis.fetch } = {}) {
|
|
59
59
|
const tmp = await mkdtemp(join(tmpdir(), "offgrid-llama-"));
|
|
60
60
|
const archive = join(tmp, release.asset.name);
|
|
61
61
|
const releaseDir = join(RUNTIME_DIR, "llama.cpp", "releases", release.tag);
|
|
@@ -63,7 +63,7 @@ export async function installLlamaRelease(release) {
|
|
|
63
63
|
|
|
64
64
|
try {
|
|
65
65
|
console.log(pc.dim(`Downloading ${release.asset.name}...`));
|
|
66
|
-
const response = await
|
|
66
|
+
const response = await fetchImpl(release.asset.url);
|
|
67
67
|
if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
|
|
68
68
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
69
69
|
verifyDigest(bytes, release.asset.digest);
|
package/src/ui.mjs
CHANGED
|
@@ -3,8 +3,6 @@ import pc from "picocolors";
|
|
|
3
3
|
import { stripVTControlCharacters } from "node:util";
|
|
4
4
|
|
|
5
5
|
export { pc };
|
|
6
|
-
export { pc as colors };
|
|
7
|
-
|
|
8
6
|
export function formatBytes(bytes) {
|
|
9
7
|
if (!Number.isFinite(bytes)) return "unknown";
|
|
10
8
|
const units = ["B", "KB", "MB", "GB", "TB"];
|