offgrid-ai 0.1.2

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/src/config.mjs ADDED
@@ -0,0 +1,102 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { readFile, writeFile } from "node:fs/promises";
6
+
7
+ // ── Base directories ──────────────────────────────────────────────────────
8
+
9
+ export const DATA_DIR = process.env.OFFGRID_DIR || join(homedir(), ".offgrid-ai");
10
+ export const PROFILE_DIR = join(DATA_DIR, "profiles");
11
+ export const LOG_DIR = join(DATA_DIR, "logs");
12
+ export const RUN_DIR = join(DATA_DIR, "run");
13
+
14
+ // ── Default scan directories ──────────────────────────────────────────────
15
+
16
+ export const DEFAULT_MODEL_DIRS = [
17
+ join(homedir(), ".lmstudio", "models"),
18
+ join(homedir(), ".cache", "huggingface", "hub"),
19
+ ];
20
+
21
+ // ── External config paths ─────────────────────────────────────────────────
22
+
23
+ export const PI_CONFIG = join(homedir(), ".pi", "agent", "models.json");
24
+
25
+ // ── Ensure data directories exist ─────────────────────────────────────────
26
+
27
+ export async function ensureDirs() {
28
+ await mkdir(PROFILE_DIR, { recursive: true });
29
+ await mkdir(LOG_DIR, { recursive: true });
30
+ await mkdir(RUN_DIR, { recursive: true });
31
+ }
32
+
33
+ // ── User config ───────────────────────────────────────────────────────────
34
+
35
+ const CONFIG_PATH = join(DATA_DIR, "config.json");
36
+
37
+ const DEFAULT_CONFIG = {
38
+ modelScanDirs: [],
39
+ benchmarkRepoPath: null,
40
+ binaryOverrides: {},
41
+ };
42
+
43
+ export async function loadConfig() {
44
+ try {
45
+ const raw = await readFile(CONFIG_PATH, "utf8");
46
+ return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
47
+ } catch {
48
+ return { ...DEFAULT_CONFIG };
49
+ }
50
+ }
51
+
52
+ export async function saveConfig(config) {
53
+ await mkdir(dirname(CONFIG_PATH), { recursive: true });
54
+ await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf8");
55
+ }
56
+
57
+ // ── Model scan directories ────────────────────────────────────────────────
58
+
59
+ export async function getModelScanDirs() {
60
+ const config = await loadConfig();
61
+ return [...DEFAULT_MODEL_DIRS, ...config.modelScanDirs];
62
+ }
63
+
64
+ // ── Binary discovery ──────────────────────────────────────────────────────
65
+
66
+ import { execFile } from "node:child_process";
67
+ import { promisify } from "node:util";
68
+
69
+ const execFileAsync = promisify(execFile);
70
+
71
+ export async function findLlamaServer() {
72
+ // 1. Env override
73
+ if (process.env.LLAMA_SERVER_BINARY && existsSync(process.env.LLAMA_SERVER_BINARY)) {
74
+ return process.env.LLAMA_SERVER_BINARY;
75
+ }
76
+
77
+ // 2. which/where
78
+ try {
79
+ const { stdout } = await execFileAsync("which", ["llama-server"]);
80
+ const path = stdout.trim();
81
+ if (path && existsSync(path)) return path;
82
+ } catch { /* not on PATH */ }
83
+
84
+ // 3. Homebrew
85
+ try {
86
+ const { stdout } = await execFileAsync("brew", ["--prefix", "llama.cpp"]);
87
+ const prefix = stdout.trim();
88
+ const candidate = join(prefix, "bin", "llama-server");
89
+ if (existsSync(candidate)) return candidate;
90
+ } catch { /* Homebrew not installed or llama.cpp not brewed */ }
91
+
92
+ return null;
93
+ }
94
+
95
+ export async function hasHomebrew() {
96
+ try {
97
+ await execFileAsync("which", ["brew"]);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
@@ -0,0 +1,113 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { readGgufMetadata } from "./gguf.mjs";
3
+ import pc from "picocolors";
4
+
5
+ export function estimateMemory(modelPath, mmprojPath, draftModelPath, flags) {
6
+ const modelBytes = statSync(modelPath).size;
7
+ const mmprojBytes = mmprojPath && existsSync(mmprojPath) ? statSync(mmprojPath).size : 0;
8
+ const draftBytes = draftModelPath && existsSync(draftModelPath) ? statSync(draftModelPath).size : 0;
9
+ const metadata = readGgufMetadata(modelPath);
10
+ const architecture = metadata["general.architecture"];
11
+ const prefix = typeof architecture === "string" ? architecture : null;
12
+ const layers = numberMeta(metadata, prefix && `${prefix}.block_count`);
13
+ const headKv = numberOrArrayMeta(metadata, prefix && `${prefix}.attention.head_count_kv`);
14
+ const keyLength = numberOrArrayMeta(metadata, prefix && `${prefix}.attention.key_length`);
15
+ const valueLength = numberOrArrayMeta(metadata, prefix && `${prefix}.attention.value_length`);
16
+ const slidingWindow = numberMeta(metadata, prefix && `${prefix}.attention.sliding_window`);
17
+ const slidingWindowPattern = booleanArrayMeta(metadata, prefix && `${prefix}.attention.sliding_window_pattern`);
18
+ const keyLengthSwa = numberMeta(metadata, prefix && `${prefix}.attention.key_length_swa`);
19
+ const valueLengthSwa = numberMeta(metadata, prefix && `${prefix}.attention.value_length_swa`);
20
+ const bytesK = bytesForCacheType(flags.cacheTypeK);
21
+ const bytesV = bytesForCacheType(flags.cacheTypeV);
22
+ const kv = estimateKvBytes({
23
+ ctxSize: flags.ctxSize,
24
+ parallel: flags.parallel ?? 1,
25
+ layers,
26
+ headKv,
27
+ keyLength,
28
+ valueLength,
29
+ slidingWindow,
30
+ slidingWindowPattern,
31
+ keyLengthSwa,
32
+ valueLengthSwa,
33
+ bytesK,
34
+ bytesV,
35
+ });
36
+ const overheadBytes = 1024 ** 3;
37
+ return {
38
+ modelBytes,
39
+ mmprojBytes,
40
+ draftBytes,
41
+ kvBytes: kv.bytes,
42
+ overheadBytes,
43
+ totalBytes: modelBytes + mmprojBytes + draftBytes + kv.bytes + overheadBytes,
44
+ note: kv.note,
45
+ };
46
+ }
47
+
48
+ function estimateKvBytes(input) {
49
+ const { ctxSize, parallel, layers, bytesK, bytesV } = input;
50
+ if (!layers || !ctxSize || !bytesK || !bytesV) {
51
+ return { bytes: 0, note: "KV estimate unavailable: missing GGUF architecture metadata.", mode: "unknown" };
52
+ }
53
+
54
+ const canLayer = input.headKv && input.keyLength && input.valueLength;
55
+ if (!canLayer) return { bytes: 0, note: "KV estimate unavailable: missing GGUF architecture metadata.", mode: "unknown" };
56
+
57
+ if (Array.isArray(input.headKv) || Array.isArray(input.keyLength) || Array.isArray(input.valueLength) || input.slidingWindowPattern?.length) {
58
+ let total = 0;
59
+ for (let i = 0; i < layers; i++) {
60
+ const headKv = valueForLayer(input.headKv, i);
61
+ let keyLength = valueForLayer(input.keyLength, i);
62
+ let valueLength = valueForLayer(input.valueLength, i);
63
+ let layerCtx = ctxSize;
64
+ if (input.slidingWindowPattern?.[i] && input.slidingWindow) {
65
+ layerCtx = Math.min(ctxSize, input.slidingWindow);
66
+ keyLength = input.keyLengthSwa ?? keyLength;
67
+ valueLength = input.valueLengthSwa ?? valueLength;
68
+ }
69
+ if (!headKv || !keyLength || !valueLength) {
70
+ return { bytes: 0, note: "KV estimate unavailable: incomplete layer-specific GGUF metadata.", mode: "unknown" };
71
+ }
72
+ total += layerCtx * parallel * headKv * ((keyLength * bytesK) + (valueLength * bytesV));
73
+ }
74
+ return { bytes: total, note: "", mode: input.slidingWindowPattern?.length ? "layered-swa" : "layered" };
75
+ }
76
+
77
+ return {
78
+ bytes: ctxSize * parallel * layers * input.headKv * ((input.keyLength * bytesK) + (input.valueLength * bytesV)),
79
+ note: "",
80
+ mode: "simple",
81
+ };
82
+ }
83
+
84
+ function valueForLayer(value, index) {
85
+ return Array.isArray(value) ? value[index] : value;
86
+ }
87
+
88
+ function numberMeta(meta, key) {
89
+ const value = key ? meta[key] : undefined;
90
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
91
+ }
92
+
93
+ function numberOrArrayMeta(meta, key) {
94
+ const value = key ? meta[key] : undefined;
95
+ if (typeof value === "number" && Number.isFinite(value)) return value;
96
+ if (Array.isArray(value) && value.every((item) => typeof item === "number" && Number.isFinite(item))) return value;
97
+ return undefined;
98
+ }
99
+
100
+ function booleanArrayMeta(meta, key) {
101
+ const value = key ? meta[key] : undefined;
102
+ return Array.isArray(value) && value.every((item) => typeof item === "boolean") ? value : undefined;
103
+ }
104
+
105
+ function bytesForCacheType(type) {
106
+ const normalized = String(type ?? "").toLowerCase();
107
+ if (normalized === "f32") return 4;
108
+ if (normalized === "f16" || normalized === "bf16") return 2;
109
+ if (normalized === "q8_0") return 1;
110
+ if (["q4_0", "q4_1", "iq4_nl"].includes(normalized)) return 0.5;
111
+ if (["q5_0", "q5_1"].includes(normalized)) return 0.625;
112
+ return undefined;
113
+ }
package/src/gguf.mjs ADDED
@@ -0,0 +1,70 @@
1
+ import { openSync, readSync, closeSync, statSync } from "node:fs";
2
+
3
+ export function readGgufMetadata(path) {
4
+ const buffer = readFilePrefix(path, 64 * 1024 * 1024);
5
+ let offset = 0;
6
+ if (buffer.toString("utf8", 0, 4) !== "GGUF") return {};
7
+ offset += 4;
8
+ offset += 4; // version
9
+ offset += 8; // tensor count
10
+ const kvCount = Number(buffer.readBigUInt64LE(offset));
11
+ offset += 8;
12
+ const meta = {};
13
+ for (let i = 0; i < kvCount; i++) {
14
+ const keyLen = Number(buffer.readBigUInt64LE(offset));
15
+ offset += 8;
16
+ const key = buffer.toString("utf8", offset, offset + keyLen);
17
+ offset += keyLen;
18
+ const type = buffer.readUInt32LE(offset);
19
+ offset += 4;
20
+ const read = readValue(buffer, offset, type);
21
+ offset = read.offset;
22
+ meta[key] = read.value;
23
+ }
24
+ return meta;
25
+ }
26
+
27
+ function readFilePrefix(path, maxBytes) {
28
+ const fd = openSync(path, "r");
29
+ try {
30
+ const size = Math.min(statSync(path).size, maxBytes);
31
+ const buffer = Buffer.alloc(size);
32
+ readSync(fd, buffer, 0, size, 0);
33
+ return buffer;
34
+ } finally {
35
+ closeSync(fd);
36
+ }
37
+ }
38
+
39
+ function readValue(buffer, offset, type) {
40
+ if (type === 0) return { value: buffer.readUInt8(offset), offset: offset + 1 };
41
+ if (type === 1) return { value: buffer.readInt8(offset), offset: offset + 1 };
42
+ if (type === 2) return { value: buffer.readUInt16LE(offset), offset: offset + 2 };
43
+ if (type === 3) return { value: buffer.readInt16LE(offset), offset: offset + 2 };
44
+ if (type === 4) return { value: buffer.readUInt32LE(offset), offset: offset + 4 };
45
+ if (type === 5) return { value: buffer.readInt32LE(offset), offset: offset + 4 };
46
+ if (type === 6) return { value: buffer.readFloatLE(offset), offset: offset + 4 };
47
+ if (type === 7) return { value: Boolean(buffer.readUInt8(offset)), offset: offset + 1 };
48
+ if (type === 8) {
49
+ const len = Number(buffer.readBigUInt64LE(offset));
50
+ offset += 8;
51
+ return { value: buffer.toString("utf8", offset, offset + len), offset: offset + len };
52
+ }
53
+ if (type === 9) {
54
+ const itemType = buffer.readUInt32LE(offset);
55
+ offset += 4;
56
+ const len = Number(buffer.readBigUInt64LE(offset));
57
+ offset += 8;
58
+ const values = [];
59
+ for (let i = 0; i < len; i++) {
60
+ const item = readValue(buffer, offset, itemType);
61
+ offset = item.offset;
62
+ if (i < 32) values.push(item.value);
63
+ }
64
+ return { value: values, offset };
65
+ }
66
+ if (type === 10) return { value: Number(buffer.readBigUInt64LE(offset)), offset: offset + 8 };
67
+ if (type === 11) return { value: Number(buffer.readBigInt64LE(offset)), offset: offset + 8 };
68
+ if (type === 12) return { value: buffer.readDoubleLE(offset), offset: offset + 8 };
69
+ throw new Error(`Unsupported GGUF metadata value type ${type}`);
70
+ }
@@ -0,0 +1,140 @@
1
+ import { existsSync } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import { PI_CONFIG } from "./config.mjs";
4
+ import { loadProfiles } from "./profiles.mjs";
5
+ import { readJson, writeJson } from "./json.mjs";
6
+ import pc from "picocolors";
7
+
8
+ // ── Sync Pi config ─────────────────────────────────────────────────────────
9
+
10
+ export async function syncPiConfig(profile) {
11
+ const profiles = await activeProviderProfiles(profile);
12
+ const config = await readJson(PI_CONFIG, { providers: {} });
13
+ config.providers ??= {};
14
+ config.providers[profile.providerId] = {
15
+ baseUrl: profile.baseUrl,
16
+ api: "openai-completions",
17
+ apiKey: piApiKey(profile.providerId),
18
+ compat: providerCompat(profile.providerId),
19
+ models: profiles.map(piModelConfig),
20
+ };
21
+ await writeJson(PI_CONFIG, config);
22
+ console.log(pc.green(`Synced Pi config: ${PI_CONFIG} (${profiles.length} model${profiles.length === 1 ? "" : "s"})`));
23
+ }
24
+
25
+ // ── Remove from Pi config ──────────────────────────────────────────────────
26
+
27
+ export async function removeFromPiConfig(profile) {
28
+ const config = await readJson(PI_CONFIG, { providers: {} });
29
+ config.providers ??= {};
30
+ const provider = config.providers[profile.providerId];
31
+ if (!provider?.models) return { cleaned: false, reason: `no ${profile.providerId} provider in Pi config` };
32
+ const before = provider.models.length;
33
+ provider.models = provider.models.filter((m) => m.id !== profile.modelAlias);
34
+ if (provider.models.length === 0) delete config.providers[profile.providerId];
35
+ if (before > provider.models.length) {
36
+ await writeJson(PI_CONFIG, config);
37
+ return { cleaned: true, removed: before - provider.models.length };
38
+ }
39
+ return { cleaned: false, reason: `${profile.modelAlias} not in Pi config` };
40
+ }
41
+
42
+ // ── Check if Pi has the model ──────────────────────────────────────────────
43
+
44
+ export async function hasPiModel(profile) {
45
+ const config = await readJson(PI_CONFIG, null);
46
+ return Boolean(config?.providers?.[profile.providerId]?.models?.some?.((m) => m.id === profile.modelAlias));
47
+ }
48
+
49
+ // ── Launch Pi ──────────────────────────────────────────────────────────────
50
+
51
+ export async function launchPi(profile) {
52
+ const model = profile.harnesses?.pi?.model ?? `${profile.providerId}/${profile.modelAlias}`;
53
+ console.log(pc.bold(`[pi] pi --model ${model}`));
54
+ await runForeground("pi", ["--model", model]);
55
+ }
56
+
57
+ // ── Check if Pi is installed ───────────────────────────────────────────────
58
+
59
+ export async function hasPi() {
60
+ try {
61
+ const { execFile } = await import("node:child_process");
62
+ const { promisify } = await import("node:util");
63
+ await promisify(execFile)("which", ["pi"]);
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ // ── Internals ──────────────────────────────────────────────────────────────
71
+
72
+ async function activeProviderProfiles(currentProfile) {
73
+ const allProfiles = await loadProfiles().catch(() => []);
74
+ const byAlias = new Map();
75
+ for (const item of [...allProfiles, currentProfile]) {
76
+ if (item.providerId !== currentProfile.providerId) continue;
77
+ if (item.backend !== "llama-cpp" && item.backend !== "llama-cpp-mtp") {
78
+ byAlias.set(item.modelAlias, item);
79
+ continue;
80
+ }
81
+ if (!item.modelPath || !existsSync(item.modelPath)) continue;
82
+ byAlias.set(item.modelAlias, item);
83
+ }
84
+ return Array.from(byAlias.values()).sort((a, b) => a.modelAlias.localeCompare(b.modelAlias));
85
+ }
86
+
87
+ function piModelConfig(profile) {
88
+ const compat = modelCompat(profile);
89
+ const reasoning = modelReasoning(profile);
90
+ return {
91
+ id: profile.modelAlias,
92
+ name: profile.label,
93
+ input: modelInput(profile),
94
+ ...(reasoning === undefined ? {} : { reasoning }),
95
+ ...(compat ? { compat } : {}),
96
+ ...(profile.flags?.ctxSize ? { contextWindow: profile.flags.ctxSize } : {}),
97
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
98
+ };
99
+ }
100
+
101
+ function modelInput(profile) {
102
+ return profile.mmprojPath && existsSync(profile.mmprojPath) ? ["text", "image"] : ["text"];
103
+ }
104
+
105
+ function modelCompat(profile) {
106
+ if (profile.compat) return profile.compat;
107
+ const family = modelFamily(profile);
108
+ if (family.includes("qwen") || family.includes("gemma-4") || family.includes("gemma 4")) {
109
+ return { thinkingFormat: "qwen-chat-template" };
110
+ }
111
+ return null;
112
+ }
113
+
114
+ function modelReasoning(profile) {
115
+ if (profile.reasoning !== undefined) return Boolean(profile.reasoning);
116
+ const family = modelFamily(profile);
117
+ if (family.includes("qwen") || family.includes("gemma-4") || family.includes("gemma 4")) return true;
118
+ return undefined;
119
+ }
120
+
121
+ function modelFamily(profile) {
122
+ return [profile.id, profile.label, profile.modelAlias, profile.modelPath, profile.ollamaModel, profile.omlxModel].filter(Boolean).join(" ").toLowerCase();
123
+ }
124
+
125
+ function piApiKey(providerId) {
126
+ return providerId === "ollama" ? "ollama" : "none";
127
+ }
128
+
129
+ function providerCompat(providerId) {
130
+ if (providerId === "ollama") return { supportsDeveloperRole: true, supportsReasoningEffort: false };
131
+ return { supportsDeveloperRole: false, supportsReasoningEffort: false };
132
+ }
133
+
134
+ function runForeground(cmd, argv) {
135
+ return new Promise((resolve, reject) => {
136
+ const child = spawn(cmd, argv, { stdio: "inherit" });
137
+ child.on("error", reject);
138
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${code}`)));
139
+ });
140
+ }
package/src/json.mjs ADDED
@@ -0,0 +1,16 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+
4
+ export async function readJson(path, fallback) {
5
+ try {
6
+ return JSON.parse(await readFile(path, "utf8"));
7
+ } catch (error) {
8
+ if (error?.code === "ENOENT") return fallback;
9
+ throw error;
10
+ }
11
+ }
12
+
13
+ export async function writeJson(path, value) {
14
+ await mkdir(dirname(path), { recursive: true });
15
+ await writeFile(path, JSON.stringify(value, null, 2) + "\n", "utf8");
16
+ }
package/src/logs.mjs ADDED
@@ -0,0 +1,42 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { appendFile } from "node:fs/promises";
3
+ import { createReadStream } from "node:fs";
4
+ import pc from "picocolors";
5
+
6
+ export function tailFriendly(rawLogPath, friendlyLogPath) {
7
+ let offset = existsSync(rawLogPath) ? statSync(rawLogPath).size : 0;
8
+ let stopped = false;
9
+ const seen = new Set();
10
+ const timer = setInterval(async () => {
11
+ try {
12
+ if (stopped || !existsSync(rawLogPath)) return;
13
+ const size = statSync(rawLogPath).size;
14
+ if (size <= offset) return;
15
+ const stream = createReadStream(rawLogPath, { start: offset, end: size - 1, encoding: "utf8" });
16
+ offset = size;
17
+ let text = "";
18
+ for await (const chunk of stream) text += chunk;
19
+ for (const line of text.split(/\r?\n/).filter(Boolean)) {
20
+ const friendly = friendlyLine(line);
21
+ if (!friendly || seen.has(friendly)) continue;
22
+ seen.add(friendly);
23
+ console.log(friendly);
24
+ await appendFile(friendlyLogPath, pc.strip(friendly) + "\n", "utf8");
25
+ }
26
+ } catch { /* friendly logging must never crash */ }
27
+ }, 300);
28
+ return { stop() { stopped = true; clearInterval(timer); } };
29
+ }
30
+
31
+ function friendlyLine(line) {
32
+ const lower = line.toLowerCase();
33
+ const trimmed = line.trim();
34
+ if (lower.includes("error") || lower.includes("failed")) return pc.red(`[error] ${trimmed}`);
35
+ if (lower.includes("listening") || lower.includes("http server")) return pc.green(`[server] ${trimmed}`);
36
+ if (lower.includes("llm_load") || lower.includes("load_model") || lower.includes("loading model")) return pc.cyan(`[load] ${trimmed}`);
37
+ if (lower.includes("mmproj")) return pc.cyan(`[vision] ${trimmed}`);
38
+ if (lower.includes("prompt eval") || lower.includes("prompt eval time")) return pc.green(`[timing] ${trimmed}`);
39
+ if (lower.includes("eval time") || lower.includes("generation")) return pc.green(`[timing] ${trimmed}`);
40
+ if (lower.includes("slot") && lower.includes("launch_slot")) return pc.cyan(`[request] ${trimmed}`);
41
+ return null;
42
+ }
@@ -0,0 +1,175 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { existsSync, openSync } from "node:fs";
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { LOG_DIR } from "./config.mjs";
7
+ import { writeState, readState } from "./profiles.mjs";
8
+ import { backendFor, backendBinaryFor } from "./backends.mjs";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ // ── Start server ───────────────────────────────────────────────────────────
13
+
14
+ export async function startServer(profile) {
15
+ const backend = backendFor(profile.backend);
16
+ if (backend.type === "managed-server") {
17
+ return startManagedServer(profile, backend);
18
+ }
19
+ return startLocalServer(profile);
20
+ }
21
+
22
+ async function startLocalServer(profile) {
23
+ const binary = await backendBinaryFor(profile.backend);
24
+ if (!binary) {
25
+ throw new Error("llama-server not found. Install it via Homebrew: brew install llama.cpp");
26
+ }
27
+
28
+ const timestamp = timestampForFile();
29
+ const rawLogPath = join(LOG_DIR, `${profile.id}-${timestamp}.raw.log`);
30
+ const friendlyLogPath = join(LOG_DIR, `${profile.id}-${timestamp}.friendly.log`);
31
+ const commandJson = profile.commandArgv ?? [];
32
+
33
+ await writeFile(rawLogPath, `[offgrid-ai] ${new Date().toISOString()}\n[binary] ${binary}\n[argv]\n${commandJson.join(" ")}\n`, "utf8");
34
+ await writeFile(friendlyLogPath, `[launch] starting llama-server for ${profile.label}\n`, "utf8");
35
+
36
+ // Build argv: binary + command.json args
37
+ const argv = [...commandJson];
38
+
39
+ const rawFd = openSync(rawLogPath, "a");
40
+ const child = spawn(binary, argv, { detached: true, stdio: ["ignore", rawFd, rawFd] });
41
+ child.unref();
42
+
43
+ const state = {
44
+ pid: child.pid,
45
+ profileId: profile.id,
46
+ baseUrl: profile.baseUrl,
47
+ binary,
48
+ rawLogPath,
49
+ friendlyLogPath,
50
+ startedAt: new Date().toISOString(),
51
+ };
52
+ await writeState(profile.id, state);
53
+ return state;
54
+ }
55
+
56
+ async function startManagedServer(profile, backend) {
57
+ const ready = await serverReady(profile.baseUrl);
58
+ if (ready) {
59
+ // Already running
60
+ } else {
61
+ for (let i = 0; i < 60; i++) {
62
+ await sleep(2000);
63
+ if (await serverReady(profile.baseUrl)) break;
64
+ process.stdout.write(".");
65
+ }
66
+ if (!(await serverReady(profile.baseUrl))) {
67
+ throw new Error(`${backend.label} is not responding at ${profile.baseUrl}. Start it and try again.`);
68
+ }
69
+ }
70
+ const state = {
71
+ pid: null,
72
+ profileId: profile.id,
73
+ baseUrl: profile.baseUrl,
74
+ managedBy: backend.id,
75
+ startedAt: new Date().toISOString(),
76
+ };
77
+ await writeState(profile.id, state);
78
+ return state;
79
+ }
80
+
81
+ // ── Stop server ────────────────────────────────────────────────────────────
82
+
83
+ export async function stopProfile(profile) {
84
+ const backend = backendFor(profile.backend);
85
+ if (backend.type === "managed-server") {
86
+ return { stopped: false, message: `${backend.label} is a managed service — offgrid-ai does not stop it.` };
87
+ }
88
+ const state = await readState(profile.id);
89
+ if (!state?.pid) return { stopped: false, message: `No tracked pid for ${profile.id}.` };
90
+ if (!pidAlive(state.pid)) {
91
+ await writeState(profile.id, { ...state, pid: null, stoppedAt: new Date().toISOString(), stopReason: "pid-not-running" });
92
+ return { stopped: false, message: `${profile.id} pid ${state.pid} is no longer running.` };
93
+ }
94
+ try {
95
+ try {
96
+ process.kill(-state.pid, "SIGTERM");
97
+ } catch {
98
+ process.kill(state.pid, "SIGTERM");
99
+ }
100
+ await writeState(profile.id, { ...state, pid: null, stoppedAt: new Date().toISOString(), stopSignal: "SIGTERM" });
101
+ return { stopped: true, message: `Stopped ${profile.id} pid ${state.pid}` };
102
+ } catch (error) {
103
+ return { stopped: false, message: `Could not stop pid ${state.pid}: ${error.message}` };
104
+ }
105
+ }
106
+
107
+ // ── Status checks ──────────────────────────────────────────────────────────
108
+
109
+ export async function isProfileRunning(profile) {
110
+ const backend = backendFor(profile.backend);
111
+ if (backend.type === "managed-server") return await serverReady(profile.baseUrl);
112
+ const state = await readState(profile.id);
113
+ return Boolean(state?.pid && pidAlive(state.pid));
114
+ }
115
+
116
+ export async function profileRuntimeStatus(profile) {
117
+ const backend = backendFor(profile.backend);
118
+ if (backend.type === "managed-server") {
119
+ const ready = await serverReady(profile.baseUrl);
120
+ return { state: null, pid: null, running: ready, ready, rssBytes: null, startedAt: null };
121
+ }
122
+ const state = await readState(profile.id);
123
+ const running = Boolean(state?.pid && pidAlive(state.pid));
124
+ const [ready, rssBytes] = await Promise.all([
125
+ serverReady(profile.baseUrl),
126
+ running ? pidRssBytes(state.pid) : Promise.resolve(null),
127
+ ]);
128
+ return { state, pid: state?.pid ?? null, running, ready, rssBytes, startedAt: state?.startedAt ? new Date(state.startedAt) : null };
129
+ }
130
+
131
+ export async function serverReady(baseUrl) {
132
+ try {
133
+ const response = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, { signal: AbortSignal.timeout(1000) });
134
+ return response.ok;
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ export async function waitForReady(profile, pid, rawLogPath) {
141
+ const backend = backendFor(profile.backend);
142
+ if (backend.type === "managed-server") return;
143
+ for (let i = 0; i < 180; i++) {
144
+ if (await serverReady(profile.baseUrl)) return;
145
+ if (pid && !pidAlive(pid)) {
146
+ const tail = await readFile(rawLogPath, "utf8").catch(() => "");
147
+ throw new Error(`llama-server exited early. Last log lines:\n${tail.split(/\r?\n/).slice(-20).join("\n")}`);
148
+ }
149
+ await sleep(1000);
150
+ }
151
+ throw new Error(`Timed out waiting for ${profile.baseUrl}/models`);
152
+ }
153
+
154
+ // ── Internals ──────────────────────────────────────────────────────────────
155
+
156
+ function pidAlive(pid) {
157
+ try { process.kill(pid, 0); return true; }
158
+ catch { return false; }
159
+ }
160
+
161
+ async function pidRssBytes(pid) {
162
+ try {
163
+ const { stdout } = await execFileAsync("ps", ["-o", "rss=", "-p", String(pid)]);
164
+ const rssKb = Number(stdout.trim().split(/\s+/)[0]);
165
+ return Number.isFinite(rssKb) ? rssKb * 1024 : null;
166
+ } catch { return null; }
167
+ }
168
+
169
+ function timestampForFile() {
170
+ return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
171
+ }
172
+
173
+ function sleep(ms) {
174
+ return new Promise((resolve) => setTimeout(resolve, ms));
175
+ }