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.
@@ -0,0 +1,165 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { mkdir, readdir, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { PROFILE_DIR, RUN_DIR, LOG_DIR } from "./config.mjs";
5
+ import { backendFor } from "./backends.mjs";
6
+ import { readJson, writeJson } from "./json.mjs";
7
+
8
+ // ── Path helpers ───────────────────────────────────────────────────────────
9
+
10
+ export function profileDir(id) {
11
+ return join(PROFILE_DIR, sanitizeProfileId(id));
12
+ }
13
+
14
+ export function profileJsonPath(id) {
15
+ return join(profileDir(id), "profile.json");
16
+ }
17
+
18
+ export function commandJsonPath(id) {
19
+ return join(profileDir(id), "command.json");
20
+ }
21
+
22
+ export function notesPath(id) {
23
+ return join(profileDir(id), "notes.md");
24
+ }
25
+
26
+ export function statePath(id) {
27
+ return join(RUN_DIR, `${sanitizeProfileId(id)}.state.json`);
28
+ }
29
+
30
+ export function profileExists(id) {
31
+ return existsSync(profileJsonPath(id));
32
+ }
33
+
34
+ export function sanitizeProfileId(value) {
35
+ return String(value).trim().toLowerCase().replace(/[^a-z0-9._-]+/gu, "-").replace(/^-|-$/gu, "") || "profile";
36
+ }
37
+
38
+ export function slugFromLabel(value) {
39
+ return sanitizeProfileId(value);
40
+ }
41
+
42
+ // ── CRUD ───────────────────────────────────────────────────────────────────
43
+
44
+ export async function loadProfiles() {
45
+ const entries = await readdir(PROFILE_DIR, { withFileTypes: true }).catch(() => []);
46
+ const ids = entries
47
+ .filter((e) => e.isDirectory() && existsSync(profileJsonPath(e.name)))
48
+ .map((e) => e.name)
49
+ .sort();
50
+ return Promise.all(ids.map((id) => readProfile(id)));
51
+ }
52
+
53
+ export async function readProfile(id) {
54
+ const path = profileJsonPath(id);
55
+ if (!existsSync(path)) throw new Error(`Profile "${id}" not found.`);
56
+ return JSON.parse(await readFile(path, "utf8"));
57
+ }
58
+
59
+ export async function saveProfile(profile) {
60
+ const id = sanitizeProfileId(profile.id);
61
+ const dir = profileDir(id);
62
+ await mkdir(dir, { recursive: true });
63
+ const now = new Date().toISOString();
64
+ const existing = await readJsonIfExists(profileJsonPath(id), null);
65
+ const saved = {
66
+ ...profile,
67
+ id,
68
+ createdAt: existing?.createdAt ?? now,
69
+ updatedAt: now,
70
+ };
71
+ await writeJson(profileJsonPath(id), saved);
72
+
73
+ // Write JSON command file for llama-server backends
74
+ const backend = backendFor(saved.backend);
75
+ if (backend.needsCommandFile) {
76
+ const cmdPath = commandJsonPath(id);
77
+ if (!existsSync(cmdPath)) {
78
+ await writeJson(cmdPath, { argv: saved.commandArgv ?? saved.flags ?? [] });
79
+ }
80
+ }
81
+
82
+ if (!existsSync(notesPath(id))) {
83
+ await writeFile(notesPath(id), `# ${saved.label}\n\nNotes for this model profile.\n`, "utf8");
84
+ }
85
+ return saved;
86
+ }
87
+
88
+ export async function deleteProfile(id, options = {}) {
89
+ const dir = profileDir(id);
90
+ const results = { profileDir: false, state: false, logs: [] };
91
+ if (existsSync(dir)) {
92
+ await rm(dir, { recursive: true, force: true });
93
+ results.profileDir = true;
94
+ }
95
+ const stateFile = statePath(id);
96
+ if (existsSync(stateFile)) {
97
+ await unlink(stateFile);
98
+ results.state = true;
99
+ }
100
+ if (!options.keepLogs) {
101
+ const entries = await readdir(LOG_DIR, { withFileTypes: true }).catch(() => []);
102
+ const prefix = `${sanitizeProfileId(id)}-`;
103
+ for (const entry of entries) {
104
+ if (entry.isFile() && entry.name.startsWith(prefix)) {
105
+ await unlink(join(LOG_DIR, entry.name));
106
+ results.logs.push(entry.name);
107
+ }
108
+ }
109
+ }
110
+ return results;
111
+ }
112
+
113
+ // ── Normalize / auto-detect ────────────────────────────────────────────────
114
+
115
+ export function normalizeProfile(profile, modelPath, mmprojPath) {
116
+ const backend = backendFor(profile.backend);
117
+ const flags = {
118
+ host: "127.0.0.1",
119
+ port: backend.defaultPort,
120
+ ...profile.flags,
121
+ };
122
+ if (!profile.baseUrl) {
123
+ profile.baseUrl = `http://${flags.host}:${flags.port}/v1`;
124
+ }
125
+
126
+ return {
127
+ ...profile,
128
+ flags,
129
+ providerId: profile.providerId ?? backend.providerId,
130
+ harnesses: profile.harnesses ?? {
131
+ pi: { enabled: true, model: `${profile.providerId ?? backend.providerId}/${profile.modelAlias ?? profile.id}` },
132
+ },
133
+ };
134
+ }
135
+
136
+ // ── Auto-create profile from a discovered model ────────────────────────────
137
+
138
+ export async function createProfileFromModel(model, backendId = "llama-cpp") {
139
+ const { detectCapabilities } = await import("./autodetect.mjs");
140
+ const caps = detectCapabilities(model.path, model.mmprojPath);
141
+ const id = slugFromLabel(model.label);
142
+ const { flags, argv } = computeFlags(caps, model.path, model.mmprojPath, null);
143
+
144
+ return normalizeProfile({
145
+ id,
146
+ label: model.label,
147
+ backend: backendId,
148
+ modelAlias: model.aliasSuggestion,
149
+ modelPath: model.path,
150
+ mmprojPath: model.mmprojPath,
151
+ preset: null, // no presets — auto-detected
152
+ flags,
153
+ commandArgv: argv,
154
+ }, model.path, model.mmprojPath);
155
+ }
156
+
157
+ // ── State files (for running servers) ──────────────────────────────────────
158
+
159
+ export async function readState(id) {
160
+ return readJson(statePath(id), null);
161
+ }
162
+
163
+ export async function writeState(id, state) {
164
+ await writeJson(statePath(id), state);
165
+ }
package/src/scan.mjs ADDED
@@ -0,0 +1,78 @@
1
+ import { statSync } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { getModelScanDirs } from "./config.mjs";
5
+
6
+ export async function scanGgufModels(dirs) {
7
+ const scanDirs = dirs ?? await getModelScanDirs();
8
+ const allModels = [];
9
+
10
+ for (const root of scanDirs) {
11
+ const models = await scanOneDir(root);
12
+ allModels.push(...models);
13
+ }
14
+
15
+ // Deduplicate by path
16
+ const seen = new Set();
17
+ return allModels.filter((m) => {
18
+ if (seen.has(m.path)) return false;
19
+ seen.add(m.path);
20
+ return true;
21
+ }).sort((a, b) => a.label.localeCompare(b.label));
22
+ }
23
+
24
+ async function scanOneDir(root) {
25
+ const files = await findFiles(root, (path) => path.toLowerCase().endsWith(".gguf"));
26
+ const mmprojs = files.filter((path) => basename(path).toLowerCase().includes("mmproj"));
27
+ const models = files.filter((path) => !basename(path).toLowerCase().includes("mmproj"));
28
+
29
+ return models.map((path) => {
30
+ const dir = dirname(path);
31
+ const mmprojPath = mmprojs.find((candidate) => dirname(candidate) === dir) ?? null;
32
+ const name = basename(path).replace(/\.gguf$/i, "");
33
+ return {
34
+ path,
35
+ mmprojPath,
36
+ label: labelFromName(name),
37
+ aliasSuggestion: aliasFromName(name),
38
+ quant: quantFromName(name),
39
+ sizeBytes: statSync(path).size,
40
+ backend: "llama-cpp",
41
+ source: "local-gguf",
42
+ };
43
+ });
44
+ }
45
+
46
+ async function findFiles(root, predicate) {
47
+ const result = [];
48
+ async function walk(dir) {
49
+ let entries;
50
+ try {
51
+ entries = await readdir(dir, { withFileTypes: true });
52
+ } catch {
53
+ return;
54
+ }
55
+ for (const entry of entries) {
56
+ const path = join(dir, entry.name);
57
+ if (entry.isDirectory()) await walk(path);
58
+ else if (entry.isFile() && predicate(path)) result.push(path);
59
+ }
60
+ }
61
+ await walk(root);
62
+ return result;
63
+ }
64
+
65
+ function labelFromName(name) {
66
+ return name
67
+ .replace(/-/g, " ")
68
+ .replace(/\bqwen/i, "Qwen")
69
+ .replace(/q4_k_m/i, "Q4_K_M");
70
+ }
71
+
72
+ function aliasFromName(name) {
73
+ return name.replace(/-Q4_K_M$/i, "-GGUF");
74
+ }
75
+
76
+ function quantFromName(name) {
77
+ return name.match(/(Q\d_K_[A-Z]+|UD-[A-Z0-9_]+)/)?.[1];
78
+ }
package/src/ui.mjs ADDED
@@ -0,0 +1,102 @@
1
+ import { cancel, confirm, intro, isCancel, select, text } from "@clack/prompts";
2
+ import pc from "picocolors";
3
+
4
+ export { pc };
5
+ export { pc as colors };
6
+
7
+ export function printHelp() {
8
+ console.log(`${pc.bold("offgrid-ai")} — privacy-first local LLM runner\n`);
9
+ console.log("Usage:");
10
+ console.log(" offgrid-ai Pick a model and run");
11
+ console.log(" offgrid-ai models List profiles and models");
12
+ console.log(" offgrid-ai run [id] Run a profile (start server + launch Pi)");
13
+ console.log(" offgrid-ai stop [id] Stop a running server");
14
+ console.log(" offgrid-ai benchmark Run a benchmark prompt");
15
+ console.log("");
16
+ console.log(pc.bold("Run modes (--with):"));
17
+ console.log(" pi Launch Pi with the selected model (default)");
18
+ console.log(" server Start server only, no harness");
19
+ }
20
+
21
+ export function formatBytes(bytes) {
22
+ if (!Number.isFinite(bytes)) return "unknown";
23
+ const units = ["B", "KB", "MB", "GB", "TB"];
24
+ let size = bytes;
25
+ let unit = 0;
26
+ while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; }
27
+ return `${size.toFixed(unit === 0 ? 0 : 2)} ${units[unit]}`;
28
+ }
29
+
30
+ export function startInteractive(title = "offgrid-ai") {
31
+ if (process.stdin.isTTY) console.clear();
32
+ intro(title);
33
+ }
34
+
35
+ export function createPrompt() {
36
+ return {
37
+ async text(label, defaultValue) {
38
+ const value = await text({ message: label, initialValue: defaultValue === undefined ? undefined : String(defaultValue) });
39
+ return handleCancel(value)?.trim() || String(defaultValue ?? "");
40
+ },
41
+ async number(label, defaultValue, min, max) {
42
+ const value = await text({
43
+ message: label, initialValue: String(defaultValue),
44
+ validate(input) { const n = Number(input); if (!Number.isFinite(n) || n < min || n > max) return `Enter a number from ${min} to ${max}.`; },
45
+ });
46
+ return Number(handleCancel(value));
47
+ },
48
+ async yesNo(label, defaultValue) {
49
+ return handleCancel(await confirm({ message: label, initialValue: defaultValue }));
50
+ },
51
+ async choice(label, choices, defaultValue) {
52
+ return handleCancel(await select({
53
+ message: label, initialValue: defaultValue,
54
+ options: choices.map((c) => ({ value: c.value, label: c.label ?? c.value, hint: c.hint })),
55
+ }));
56
+ },
57
+ close() {},
58
+ };
59
+ }
60
+
61
+ function handleCancel(value) {
62
+ if (isCancel(value)) { cancel("Cancelled."); process.exit(0); }
63
+ return value;
64
+ }
65
+
66
+ export function relativeTime(date) {
67
+ const ms = Date.now() - date.getTime();
68
+ const abs = Math.abs(ms);
69
+ for (const [label, size] of [["day", 86400000], ["hour", 3600000], ["minute", 60000], ["second", 1000]]) {
70
+ if (abs >= size) { const v = Math.round(abs / size); return `${v} ${label}${v === 1 ? "" : "s"} ago`; }
71
+ }
72
+ return "just now";
73
+ }
74
+
75
+ export function renderRows(rows) {
76
+ const width = Math.max(...rows.map(([key]) => pc.strip(String(key)).length));
77
+ return rows.map(([key, value]) => {
78
+ const visible = pc.strip(String(key)).length;
79
+ return `${key}${" ".repeat(Math.max(1, width - visible + 2))}${value}`;
80
+ }).join("\n");
81
+ }
82
+
83
+ export function renderSection(title, body) {
84
+ return `${pc.magenta("◆")} ${pc.bold(title)}\n${body}`;
85
+ }
86
+
87
+ export function parseOptions(argv) {
88
+ const positional = [];
89
+ const options = {};
90
+ for (let i = 0; i < argv.length; i++) {
91
+ const item = argv[i];
92
+ if (item.startsWith("--")) {
93
+ const key = item.slice(2);
94
+ const next = argv[i + 1];
95
+ if (next && !next.startsWith("--")) { options[key] = next; i += 1; }
96
+ else options[key] = true;
97
+ } else {
98
+ positional.push(item);
99
+ }
100
+ }
101
+ return { positional, options };
102
+ }