offgrid-ai 0.3.10 → 0.3.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/README.md +2 -2
- package/install.sh +3 -3
- package/package.json +2 -2
- package/src/autodetect.mjs +11 -2
- package/src/cli.mjs +66 -113
- package/src/profile-setup.mjs +82 -0
- package/src/shell-path.mjs +58 -0
- package/src/ui.mjs +11 -3
- package/src/updates.mjs +125 -0
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ curl -fsSL https://raw.githubusercontent.com/eeshansrivastava89/offgrid-ai/main/
|
|
|
51
51
|
|
|
52
52
|
1. **Auto-detect everything.** Scans for GGUF models in LM Studio, HuggingFace, and Ollama directories. Reads model metadata (quantization, context size, vision, thinking mode) directly from the GGUF file. No presets, no manual configuration.
|
|
53
53
|
|
|
54
|
-
2. **One command to run.** `offgrid-ai` → pick a model →
|
|
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
|
|
|
56
56
|
3. **One model at a time.** Laptops have limited RAM. One server, one model, no confusion.
|
|
57
57
|
|
|
@@ -77,7 +77,7 @@ When you run `offgrid-ai` for the first time on a fresh machine:
|
|
|
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.
|
|
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.
|
|
81
81
|
|
|
82
82
|
## Data directory
|
|
83
83
|
|
package/install.sh
CHANGED
|
@@ -144,8 +144,8 @@ fi
|
|
|
144
144
|
INSTALLED_VERSION=""
|
|
145
145
|
|
|
146
146
|
if [[ -n "$NPM_BIN" && -x "$NPM_BIN/offgrid-ai" ]]; then
|
|
147
|
-
# Get version
|
|
148
|
-
INSTALLED_VERSION="$(OFFGRID_NO_UPDATE_CHECK=1 "$NPM_BIN/offgrid-ai" version 2>/dev/null || echo "")"
|
|
147
|
+
# Get plain version from `offgrid-ai version` output (`offgrid-ai vX.Y.Z`).
|
|
148
|
+
INSTALLED_VERSION="$(OFFGRID_NO_UPDATE_CHECK=1 "$NPM_BIN/offgrid-ai" version 2>/dev/null | sed -E 's/^offgrid-ai v//' || echo "")"
|
|
149
149
|
|
|
150
150
|
if command -v offgrid-ai &>/dev/null; then
|
|
151
151
|
# Already on PATH — nothing to do
|
|
@@ -179,7 +179,7 @@ if [[ -n "$NPM_BIN" && -x "$NPM_BIN/offgrid-ai" ]]; then
|
|
|
179
179
|
else
|
|
180
180
|
# Fallback: try to find it anywhere on PATH after install
|
|
181
181
|
if command -v offgrid-ai &>/dev/null; then
|
|
182
|
-
INSTALLED_VERSION="$(offgrid-ai version 2>/dev/null || echo "")"
|
|
182
|
+
INSTALLED_VERSION="$(OFFGRID_NO_UPDATE_CHECK=1 offgrid-ai version 2>/dev/null | sed -E 's/^offgrid-ai v//' || echo "")"
|
|
183
183
|
ok "offgrid-ai ${INSTALLED_VERSION:+v${INSTALLED_VERSION} }installed at $(command -v offgrid-ai)"
|
|
184
184
|
else
|
|
185
185
|
echo ""
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "offgrid-ai",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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",
|
|
7
7
|
"bin": {
|
|
8
|
-
"offgrid-ai": "
|
|
8
|
+
"offgrid-ai": "bin/offgrid-ai.mjs"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"bin/*.mjs",
|
package/src/autodetect.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { readGgufMetadata } from "./gguf.mjs";
|
|
|
5
5
|
// ── Detect model capabilities from GGUF metadata ──────────────────────────
|
|
6
6
|
|
|
7
7
|
export function detectCapabilities(modelPath, mmprojPath) {
|
|
8
|
-
const meta =
|
|
8
|
+
const meta = safeReadGgufMetadata(modelPath);
|
|
9
9
|
const name = basename(modelPath).toLowerCase();
|
|
10
10
|
|
|
11
11
|
// Architecture
|
|
@@ -17,7 +17,7 @@ export function detectCapabilities(modelPath, mmprojPath) {
|
|
|
17
17
|
const thinking = hasThinkingKwargs || nameHintsThinking;
|
|
18
18
|
|
|
19
19
|
// Vision — mmproj present
|
|
20
|
-
const vision = mmprojPath && existsSync(mmprojPath);
|
|
20
|
+
const vision = Boolean(mmprojPath && existsSync(mmprojPath));
|
|
21
21
|
|
|
22
22
|
// MTP (multi-token prediction) — detect speculative decoding
|
|
23
23
|
const mtp = /mtp/i.test(name) || architecture === "qwen3";
|
|
@@ -107,6 +107,15 @@ export function computeFlags(capabilities, modelPath, mmprojPath, draftModelPath
|
|
|
107
107
|
|
|
108
108
|
// ── Internal helper ─────────────────────────────────────────────────────
|
|
109
109
|
|
|
110
|
+
function safeReadGgufMetadata(modelPath) {
|
|
111
|
+
if (!existsSync(modelPath)) return {};
|
|
112
|
+
try {
|
|
113
|
+
return readGgufMetadata(modelPath);
|
|
114
|
+
} catch {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
110
119
|
function numberMeta(meta, key) {
|
|
111
120
|
const value = key ? meta[key] : undefined;
|
|
112
121
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
package/src/cli.mjs
CHANGED
|
@@ -10,64 +10,41 @@ import { syncPiConfig, removeFromPiConfig, hasPiModel, launchPi, hasPi } from ".
|
|
|
10
10
|
import { tailFriendly } from "./logs.mjs";
|
|
11
11
|
import { estimateMemory } from "./estimate.mjs";
|
|
12
12
|
import { pc, formatBytes, renderRows, renderSection, startInteractive, createPrompt, parseOptions } from "./ui.mjs";
|
|
13
|
+
import { checkForUpdate, currentPackageVersion, detectInvocation, updateCommand, runUpdateCommand } from "./updates.mjs";
|
|
14
|
+
import { removeInstallerPathEntries } from "./shell-path.mjs";
|
|
15
|
+
import { configureLocalProfile } from "./profile-setup.mjs";
|
|
13
16
|
|
|
14
|
-
// ──
|
|
15
|
-
|
|
16
|
-
const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
|
|
17
|
+
// ── Entry point ────────────────────────────────────────────────────────────
|
|
17
18
|
|
|
18
|
-
async function
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const cacheFile = join(DATA_DIR, "update-cache.json");
|
|
19
|
+
async function offerUpdate(argv) {
|
|
20
|
+
const invocation = detectInvocation();
|
|
21
|
+
const update = await checkForUpdate({ force: invocation === "npx" });
|
|
22
|
+
if (!update) return false;
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
} catch { cached = null; }
|
|
24
|
+
const plan = updateCommand(invocation, argv);
|
|
25
|
+
console.log(pc.yellow(`\nUpdate available: v${update.latest}. You have v${update.current}.`));
|
|
26
|
+
console.log(pc.dim(`Run: ${plan.display}`));
|
|
27
|
+
console.log();
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
if (cached?.lastChecked && Date.now() - cached.lastChecked < UPDATE_CHECK_INTERVAL) {
|
|
32
|
-
return cached.latestVersion && cached.latestVersion !== cached.currentVersion ? { current: cached.currentVersion, latest: cached.latestVersion } : null;
|
|
33
|
-
}
|
|
29
|
+
if (!process.stdin.isTTY) return false;
|
|
34
30
|
|
|
35
|
-
|
|
31
|
+
const prompt = createPrompt();
|
|
36
32
|
try {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const { dirname } = await import("node:path");
|
|
47
|
-
const { fileURLToPath } = await import("node:url");
|
|
48
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
49
|
-
const currentVersion = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version;
|
|
50
|
-
|
|
51
|
-
// Cache the result
|
|
52
|
-
await writeFile(cacheFile, JSON.stringify({ lastChecked: Date.now(), currentVersion, latestVersion }), "utf8");
|
|
53
|
-
|
|
54
|
-
return latestVersion !== currentVersion ? { current: currentVersion, latest: latestVersion } : null;
|
|
55
|
-
} catch {
|
|
56
|
-
// Network error or timeout — fail silently
|
|
57
|
-
return null;
|
|
33
|
+
const shouldUpdate = await prompt.yesNo("Update now?", false);
|
|
34
|
+
if (!shouldUpdate) return false;
|
|
35
|
+
await runUpdateCommand(plan);
|
|
36
|
+
if (plan.mode === "install-global") {
|
|
37
|
+
console.log(pc.green("Updated. Run offgrid-ai again to use the new version."));
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
} finally {
|
|
41
|
+
prompt.close();
|
|
58
42
|
}
|
|
59
43
|
}
|
|
60
44
|
|
|
61
|
-
// ── Entry point ────────────────────────────────────────────────────────────
|
|
62
|
-
|
|
63
45
|
export async function run(argv) {
|
|
64
46
|
if (argv.length === 0) {
|
|
65
|
-
|
|
66
|
-
if (update) {
|
|
67
|
-
console.log(pc.yellow(`\nUpdate available: ${update.latest}. You have v${update.current}.`));
|
|
68
|
-
console.log(pc.dim("Run: npm install -g offgrid-ai@latest"));
|
|
69
|
-
console.log();
|
|
70
|
-
}
|
|
47
|
+
if (await offerUpdate(argv)) return;
|
|
71
48
|
return mainFlow();
|
|
72
49
|
}
|
|
73
50
|
const [command] = argv;
|
|
@@ -294,14 +271,19 @@ async function pickAndRun(prompt, profiles, newModels, managedItems) {
|
|
|
294
271
|
const model = newModels.find((m) => m.path === modelPath);
|
|
295
272
|
if (!model) throw new Error("Model not found.");
|
|
296
273
|
const profile = await createProfileFromModel(model);
|
|
297
|
-
await
|
|
298
|
-
|
|
299
|
-
await
|
|
300
|
-
|
|
274
|
+
const configured = await configureLocalProfile(prompt, profile);
|
|
275
|
+
if (!configured) return;
|
|
276
|
+
await saveProfile(configured);
|
|
277
|
+
console.log(pc.green(`Saved profile: ${configured.label}`));
|
|
278
|
+
await syncPiConfig(configured);
|
|
279
|
+
return await runProfile(configured);
|
|
301
280
|
}
|
|
302
281
|
|
|
303
282
|
if (selected.startsWith("managed:")) {
|
|
304
|
-
const
|
|
283
|
+
const managedSelection = selected.slice("managed:".length);
|
|
284
|
+
const separator = managedSelection.indexOf(":");
|
|
285
|
+
const backendId = separator === -1 ? managedSelection : managedSelection.slice(0, separator);
|
|
286
|
+
const modelId = separator === -1 ? "" : managedSelection.slice(separator + 1);
|
|
305
287
|
const model = managedItems.find((m) => m.model.id === modelId && m.backendId === backendId)?.model;
|
|
306
288
|
if (!model) throw new Error("Model not found.");
|
|
307
289
|
const profile = normalizeProfile({
|
|
@@ -342,10 +324,9 @@ async function runProfile(profile, options = {}) {
|
|
|
342
324
|
console.log(pc.green(`[ready] ${backend.label} at ${profile.baseUrl}`));
|
|
343
325
|
} else {
|
|
344
326
|
const ready = await serverReady(profile.baseUrl);
|
|
345
|
-
if (ready
|
|
346
|
-
console.log(pc.green(`[ready]
|
|
347
|
-
|
|
348
|
-
} else if (!ready) {
|
|
327
|
+
if (ready) {
|
|
328
|
+
console.log(pc.green(`[ready] Reusing server at ${profile.baseUrl}`));
|
|
329
|
+
} else {
|
|
349
330
|
console.log(pc.dim(`Starting ${backend.label} for ${profile.label}...`));
|
|
350
331
|
let state;
|
|
351
332
|
try {
|
|
@@ -840,11 +821,15 @@ async function uninstallCommand(argv) {
|
|
|
840
821
|
const { options } = parseOptions(argv);
|
|
841
822
|
const force = options.force || options.f;
|
|
842
823
|
|
|
843
|
-
if (!process.stdin.isTTY
|
|
844
|
-
|
|
824
|
+
if (!process.stdin.isTTY && !force) {
|
|
825
|
+
throw new Error("Non-interactive uninstall requires --force to avoid accidental data loss.");
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (force) {
|
|
829
|
+
await stopTrackedServers();
|
|
845
830
|
await removeDataDir();
|
|
846
|
-
await removeSelf();
|
|
847
831
|
await removeShellPath();
|
|
832
|
+
await removeSelf();
|
|
848
833
|
return;
|
|
849
834
|
}
|
|
850
835
|
|
|
@@ -881,8 +866,8 @@ async function uninstallCommand(argv) {
|
|
|
881
866
|
// Remove the npm package
|
|
882
867
|
const confirmUninstall = await prompt.yesNo("Uninstall offgrid-ai npm package?", true);
|
|
883
868
|
if (confirmUninstall) {
|
|
884
|
-
await removeSelf();
|
|
885
869
|
await removeShellPath();
|
|
870
|
+
await removeSelf();
|
|
886
871
|
} else {
|
|
887
872
|
console.log(pc.dim("Cancelled."));
|
|
888
873
|
}
|
|
@@ -891,6 +876,14 @@ async function uninstallCommand(argv) {
|
|
|
891
876
|
}
|
|
892
877
|
}
|
|
893
878
|
|
|
879
|
+
async function stopTrackedServers() {
|
|
880
|
+
const running = await runningProfiles();
|
|
881
|
+
for (const { profile } of running) {
|
|
882
|
+
const result = await stopProfile(profile);
|
|
883
|
+
console.log(result.stopped ? pc.green(`✓ ${result.message}`) : pc.dim(result.message));
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
894
887
|
async function removeDataDir() {
|
|
895
888
|
const dataDir = DATA_DIR;
|
|
896
889
|
if (existsSync(dataDir)) {
|
|
@@ -907,47 +900,13 @@ async function removeDataDir() {
|
|
|
907
900
|
}
|
|
908
901
|
|
|
909
902
|
async function removeShellPath() {
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
const { promisify } = await import("node:util");
|
|
913
|
-
const { execFile } = await import("node:child_process");
|
|
914
|
-
let npmBin;
|
|
915
|
-
try {
|
|
916
|
-
const { stdout } = await promisify(execFile)("npm", ["bin", "-g"]);
|
|
917
|
-
npmBin = stdout.trim();
|
|
918
|
-
} catch { return; }
|
|
919
|
-
if (!npmBin) return;
|
|
920
|
-
|
|
921
|
-
const marker = "# Added by offgrid-ai installer";
|
|
922
|
-
const pathLine = `export PATH="${npmBin}:$PATH"`;
|
|
923
|
-
const rcFiles = [`${homedir()}/.zshrc`, `${homedir()}/.bashrc`, `${homedir()}/.bash_profile`];
|
|
924
|
-
let cleaned = false;
|
|
925
|
-
|
|
926
|
-
for (const rcFile of rcFiles) {
|
|
927
|
-
if (!existsSync(rcFile)) continue;
|
|
928
|
-
let content;
|
|
929
|
-
try { content = await readFile(rcFile, "utf8"); } catch { continue; }
|
|
930
|
-
if (!content.includes(npmBin) && !content.includes(marker)) continue;
|
|
931
|
-
|
|
932
|
-
const lines = content.split("\n");
|
|
933
|
-
const filtered = lines.filter((line, i) => {
|
|
934
|
-
// Remove the marker comment line
|
|
935
|
-
if (line.trim() === marker) return false;
|
|
936
|
-
// Remove the PATH export line added by the installer
|
|
937
|
-
if (line.trim() === pathLine) return false;
|
|
938
|
-
// Also remove a blank line that immediately precedes the marker (formatter)
|
|
939
|
-
if (i + 1 < lines.length && lines[i + 1].trim() === marker && line.trim() === "") return false;
|
|
940
|
-
return true;
|
|
941
|
-
});
|
|
942
|
-
|
|
943
|
-
if (filtered.length !== lines.length) {
|
|
944
|
-
await writeFile(rcFile, filtered.join("\n").replace(/\n{3,}/g, "\n\n") + "\n", "utf8");
|
|
945
|
-
console.log(pc.green(`✓ Cleaned PATH from ${rcFile}`));
|
|
946
|
-
cleaned = true;
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
if (!cleaned) {
|
|
903
|
+
const cleaned = await removeInstallerPathEntries();
|
|
904
|
+
if (cleaned.length === 0) {
|
|
950
905
|
console.log(pc.dim("No offgrid-ai PATH entries found in shell configs."));
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
for (const rcFile of cleaned) {
|
|
909
|
+
console.log(pc.green(`✓ Cleaned PATH from ${rcFile}`));
|
|
951
910
|
}
|
|
952
911
|
}
|
|
953
912
|
|
|
@@ -1015,19 +974,13 @@ async function scanManagedModels() {
|
|
|
1015
974
|
}
|
|
1016
975
|
|
|
1017
976
|
async function printVersion() {
|
|
1018
|
-
const
|
|
1019
|
-
|
|
1020
|
-
const
|
|
1021
|
-
const
|
|
1022
|
-
|
|
1023
|
-
const
|
|
1024
|
-
console.log(`
|
|
1025
|
-
const update = await checkForUpdate();
|
|
1026
|
-
if (update) {
|
|
1027
|
-
console.log(pc.yellow(`Update available: ${update.latest}. Run: npm install -g offgrid-ai@latest`));
|
|
1028
|
-
}
|
|
1029
|
-
} catch {
|
|
1030
|
-
console.log("offgrid-ai v0.1.0");
|
|
977
|
+
const version = currentPackageVersion();
|
|
978
|
+
console.log(`offgrid-ai v${version}`);
|
|
979
|
+
const invocation = detectInvocation();
|
|
980
|
+
const update = await checkForUpdate({ force: invocation === "npx" });
|
|
981
|
+
if (update) {
|
|
982
|
+
const plan = updateCommand(invocation, ["version"]);
|
|
983
|
+
console.log(pc.yellow(`Update available: v${update.latest}. Run: ${plan.display}`));
|
|
1031
984
|
}
|
|
1032
985
|
}
|
|
1033
986
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { estimateMemory } from "./estimate.mjs";
|
|
2
|
+
import { pc, formatBytes, renderRows, renderSection } from "./ui.mjs";
|
|
3
|
+
|
|
4
|
+
const CACHE_CHOICES = [
|
|
5
|
+
{ value: "bf16", label: "bf16", hint: "default: stable, good quality" },
|
|
6
|
+
{ value: "f16", label: "f16", hint: "stable fallback, similar memory to bf16" },
|
|
7
|
+
{ value: "q8_0", label: "q8_0", hint: "lower memory, usually safe" },
|
|
8
|
+
{ value: "q4_0", label: "q4_0", hint: "lowest memory, quality/speed tradeoff" },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export async function configureLocalProfile(prompt, profile) {
|
|
12
|
+
console.log("");
|
|
13
|
+
console.log(renderSection("Model setup", renderRows([
|
|
14
|
+
["Model", pc.bold(profile.label)],
|
|
15
|
+
["Context", `${profile.flags.ctxSize.toLocaleString()} tokens`],
|
|
16
|
+
["KV cache", `${profile.flags.cacheTypeK}/${profile.flags.cacheTypeV}`],
|
|
17
|
+
["Sampling", samplingSummary(profile.flags)],
|
|
18
|
+
])));
|
|
19
|
+
console.log(pc.dim("Larger context windows use more memory. KV cache precision controls memory used by attention history."));
|
|
20
|
+
console.log(pc.dim("Sampling defaults are shown for transparency; you can edit command.json later if needed.\n"));
|
|
21
|
+
|
|
22
|
+
const ctxSize = await prompt.number("Context window tokens", profile.flags.ctxSize, 1024, 1048576);
|
|
23
|
+
const cacheTypeK = await prompt.choice("K cache precision", CACHE_CHOICES, profile.flags.cacheTypeK);
|
|
24
|
+
const cacheTypeV = await prompt.choice("V cache precision", CACHE_CHOICES, profile.flags.cacheTypeV);
|
|
25
|
+
const configured = applyRuntimeFlagOverrides(profile, { ctxSize, cacheTypeK, cacheTypeV });
|
|
26
|
+
|
|
27
|
+
console.log("");
|
|
28
|
+
console.log(renderSection("Defaults", renderRows([
|
|
29
|
+
["Temperature", configured.flags.temperature],
|
|
30
|
+
["Top-p", configured.flags.topP],
|
|
31
|
+
["Top-k", configured.flags.topK],
|
|
32
|
+
["Min-p", configured.flags.minP],
|
|
33
|
+
["Presence penalty", configured.flags.presencePenalty],
|
|
34
|
+
["Repeat penalty", configured.flags.repeatPenalty],
|
|
35
|
+
])));
|
|
36
|
+
|
|
37
|
+
console.log("\n" + renderMemoryEstimate(configured));
|
|
38
|
+
if (!(await prompt.yesNo("Save profile with these settings?", true))) return null;
|
|
39
|
+
return configured;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function applyRuntimeFlagOverrides(profile, overrides) {
|
|
43
|
+
const flags = { ...profile.flags, ...overrides };
|
|
44
|
+
return {
|
|
45
|
+
...profile,
|
|
46
|
+
flags,
|
|
47
|
+
baseUrl: `http://${flags.host}:${flags.port}/v1`,
|
|
48
|
+
commandArgv: updateArgv(profile.commandArgv ?? [], {
|
|
49
|
+
"--ctx-size": flags.ctxSize,
|
|
50
|
+
"--cache-type-k": flags.cacheTypeK,
|
|
51
|
+
"--cache-type-v": flags.cacheTypeV,
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function updateArgv(argv, values) {
|
|
57
|
+
const next = [...argv];
|
|
58
|
+
for (const [flag, value] of Object.entries(values)) {
|
|
59
|
+
const index = next.indexOf(flag);
|
|
60
|
+
if (index === -1) next.push(flag, String(value));
|
|
61
|
+
else next[index + 1] = String(value);
|
|
62
|
+
}
|
|
63
|
+
return next;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderMemoryEstimate(profile) {
|
|
67
|
+
try {
|
|
68
|
+
const est = estimateMemory(profile.modelPath, profile.mmprojPath, null, profile.flags);
|
|
69
|
+
return renderSection("Memory", renderRows([
|
|
70
|
+
["Estimated total", pc.bold(`~${formatBytes(est.totalBytes)}`)],
|
|
71
|
+
["Model", formatBytes(est.modelBytes)],
|
|
72
|
+
["KV cache", est.kvBytes ? `~${formatBytes(est.kvBytes)} (${profile.flags.ctxSize.toLocaleString()} ctx, ${profile.flags.cacheTypeK}/${profile.flags.cacheTypeV})` : "unknown"],
|
|
73
|
+
...(est.note ? [["Note", pc.yellow(est.note)]] : []),
|
|
74
|
+
]));
|
|
75
|
+
} catch {
|
|
76
|
+
return renderSection("Memory", pc.dim("Estimate unavailable for this model."));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function samplingSummary(flags) {
|
|
81
|
+
return `temp ${flags.temperature}, top-p ${flags.topP}, top-k ${flags.topK}`;
|
|
82
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
export const INSTALLER_PATH_MARKER = "# Added by offgrid-ai installer";
|
|
6
|
+
|
|
7
|
+
export function defaultShellConfigFiles(home = homedir()) {
|
|
8
|
+
return [`${home}/.zshrc`, `${home}/.bashrc`, `${home}/.bash_profile`];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function removeInstallerPathBlock(content) {
|
|
12
|
+
const lines = content.split("\n");
|
|
13
|
+
const kept = [];
|
|
14
|
+
let changed = false;
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < lines.length; i++) {
|
|
17
|
+
const line = lines[i];
|
|
18
|
+
const next = lines[i + 1];
|
|
19
|
+
|
|
20
|
+
if (line.trim() === "" && next?.trim() === INSTALLER_PATH_MARKER) {
|
|
21
|
+
changed = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (line.trim() === INSTALLER_PATH_MARKER) {
|
|
26
|
+
changed = true;
|
|
27
|
+
const following = lines[i + 1]?.trim() ?? "";
|
|
28
|
+
if (/^export\s+PATH=".+:\$PATH"$/u.test(following)) i += 1;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
kept.push(line);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
changed,
|
|
37
|
+
content: kept.join("\n").replace(/\n{3,}/gu, "\n\n").replace(/\s+$/u, "") + "\n",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function removeInstallerPathEntries(rcFiles = defaultShellConfigFiles()) {
|
|
42
|
+
const cleaned = [];
|
|
43
|
+
for (const rcFile of rcFiles) {
|
|
44
|
+
if (!existsSync(rcFile)) continue;
|
|
45
|
+
let content;
|
|
46
|
+
try {
|
|
47
|
+
content = await readFile(rcFile, "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const result = removeInstallerPathBlock(content);
|
|
53
|
+
if (!result.changed) continue;
|
|
54
|
+
await writeFile(rcFile, result.content, "utf8");
|
|
55
|
+
cleaned.push(rcFile);
|
|
56
|
+
}
|
|
57
|
+
return cleaned;
|
|
58
|
+
}
|
package/src/ui.mjs
CHANGED
|
@@ -51,6 +51,7 @@ function handleCancel(value) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export function renderRows(rows) {
|
|
54
|
+
if (rows.length === 0) return "";
|
|
54
55
|
const width = Math.max(...rows.map(([key]) => stripVTControlCharacters(String(key)).length));
|
|
55
56
|
return rows.map(([key, value]) => {
|
|
56
57
|
const visible = stripVTControlCharacters(String(key)).length;
|
|
@@ -67,14 +68,21 @@ export function parseOptions(argv) {
|
|
|
67
68
|
const options = {};
|
|
68
69
|
for (let i = 0; i < argv.length; i++) {
|
|
69
70
|
const item = argv[i];
|
|
71
|
+
if (item === "--") {
|
|
72
|
+
positional.push(...argv.slice(i + 1));
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
70
75
|
if (item.startsWith("--")) {
|
|
71
|
-
const key = item.slice(2);
|
|
76
|
+
const [key, inlineValue] = item.slice(2).split(/=(.*)/u, 2);
|
|
72
77
|
const next = argv[i + 1];
|
|
73
|
-
if (
|
|
78
|
+
if (inlineValue !== undefined) options[key] = inlineValue;
|
|
79
|
+
else if (next && !next.startsWith("-")) { options[key] = next; i += 1; }
|
|
74
80
|
else options[key] = true;
|
|
81
|
+
} else if (/^-[A-Za-z]+$/u.test(item)) {
|
|
82
|
+
for (const key of item.slice(1)) options[key] = true;
|
|
75
83
|
} else {
|
|
76
84
|
positional.push(item);
|
|
77
85
|
}
|
|
78
86
|
}
|
|
79
87
|
return { positional, options };
|
|
80
|
-
}
|
|
88
|
+
}
|
package/src/updates.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { DATA_DIR } from "./config.mjs";
|
|
7
|
+
|
|
8
|
+
const PACKAGE_NAME = "offgrid-ai";
|
|
9
|
+
const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export async function checkForUpdate({ now = Date.now(), fetchImpl = globalThis.fetch, force = false } = {}) {
|
|
12
|
+
if (process.env.OFFGRID_NO_UPDATE_CHECK) return null;
|
|
13
|
+
|
|
14
|
+
const currentVersion = currentPackageVersion();
|
|
15
|
+
const cacheFile = join(DATA_DIR, "update-cache.json");
|
|
16
|
+
const cached = await readUpdateCache(cacheFile);
|
|
17
|
+
|
|
18
|
+
if (!force && cached?.currentVersion === currentVersion && cached?.lastChecked && now - cached.lastChecked < UPDATE_CHECK_INTERVAL) {
|
|
19
|
+
return updateResult(currentVersion, cached.latestVersion);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetchImpl(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
|
|
24
|
+
signal: AbortSignal.timeout(3000),
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) return null;
|
|
27
|
+
const body = await response.json();
|
|
28
|
+
const latestVersion = typeof body?.version === "string" ? body.version : null;
|
|
29
|
+
if (!latestVersion) return null;
|
|
30
|
+
|
|
31
|
+
await mkdir(DATA_DIR, { recursive: true });
|
|
32
|
+
await writeFile(cacheFile, JSON.stringify({ lastChecked: now, currentVersion, latestVersion }, null, 2) + "\n", "utf8");
|
|
33
|
+
|
|
34
|
+
return updateResult(currentVersion, latestVersion);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function currentPackageVersion() {
|
|
41
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
43
|
+
return pkg.version;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isNewerVersion(candidate, current) {
|
|
47
|
+
return compareVersions(candidate, current) > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function compareVersions(a, b) {
|
|
51
|
+
const left = versionParts(a);
|
|
52
|
+
const right = versionParts(b);
|
|
53
|
+
const len = Math.max(left.length, right.length);
|
|
54
|
+
for (let i = 0; i < len; i++) {
|
|
55
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
56
|
+
if (diff !== 0) return diff;
|
|
57
|
+
}
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function detectInvocation(env = process.env) {
|
|
62
|
+
const execPath = env.npm_execpath ?? "";
|
|
63
|
+
if (/(^|[\\/])npx-cli\.js$/u.test(execPath)) return "npx";
|
|
64
|
+
if (env.npm_command === "exec") return "npx";
|
|
65
|
+
return "global";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function updateCommand(invocation = detectInvocation(), argv = []) {
|
|
69
|
+
if (invocation === "npx") {
|
|
70
|
+
const args = ["exec", "--yes", "--", `${PACKAGE_NAME}@latest`, ...argv];
|
|
71
|
+
return {
|
|
72
|
+
cmd: "npm",
|
|
73
|
+
args,
|
|
74
|
+
display: shellCommand("npm", args),
|
|
75
|
+
mode: "run-latest",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const args = ["install", "-g", `${PACKAGE_NAME}@latest`];
|
|
80
|
+
return {
|
|
81
|
+
cmd: "npm",
|
|
82
|
+
args,
|
|
83
|
+
display: shellCommand("npm", args),
|
|
84
|
+
mode: "install-global",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function runUpdateCommand(plan) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const child = spawn(plan.cmd, plan.args, { stdio: "inherit" });
|
|
91
|
+
child.on("error", reject);
|
|
92
|
+
child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${plan.cmd} exited with code ${code}`)));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function readUpdateCache(cacheFile) {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(await readFile(cacheFile, "utf8"));
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function updateResult(currentVersion, latestVersion) {
|
|
105
|
+
return isNewerVersion(latestVersion, currentVersion)
|
|
106
|
+
? { current: currentVersion, latest: latestVersion }
|
|
107
|
+
: null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function versionParts(value) {
|
|
111
|
+
return String(value)
|
|
112
|
+
.replace(/^v/u, "")
|
|
113
|
+
.split(/[.-]/u)
|
|
114
|
+
.map((part) => Number.parseInt(part, 10))
|
|
115
|
+
.filter((part) => Number.isFinite(part));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function shellCommand(cmd, args) {
|
|
119
|
+
return [cmd, ...args.map(shellQuote)].join(" ");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function shellQuote(value) {
|
|
123
|
+
const text = String(value);
|
|
124
|
+
return /^[A-Za-z0-9_./:@=-]+$/u.test(text) ? text : JSON.stringify(text);
|
|
125
|
+
}
|