@otto-code/brain 0.7.5

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.
Files changed (150) hide show
  1. package/bin/otto-brain +2 -0
  2. package/dist/bench/context-corpus.d.ts +58 -0
  3. package/dist/bench/context-corpus.js +210 -0
  4. package/dist/bench/corpus.d.ts +31 -0
  5. package/dist/bench/corpus.js +379 -0
  6. package/dist/bench/curated-repos.d.ts +35 -0
  7. package/dist/bench/curated-repos.js +41 -0
  8. package/dist/bench/health.d.ts +50 -0
  9. package/dist/bench/health.js +112 -0
  10. package/dist/bench/index.d.ts +103 -0
  11. package/dist/bench/index.js +209 -0
  12. package/dist/bench/mine.d.ts +32 -0
  13. package/dist/bench/mine.js +101 -0
  14. package/dist/bench/repo-task.d.ts +93 -0
  15. package/dist/bench/repo-task.js +380 -0
  16. package/dist/bench/repo.d.ts +125 -0
  17. package/dist/bench/repo.js +195 -0
  18. package/dist/bench/rescore.d.ts +41 -0
  19. package/dist/bench/rescore.js +127 -0
  20. package/dist/bench/tasks.d.ts +124 -0
  21. package/dist/bench/tasks.js +1249 -0
  22. package/dist/bench/verify.d.ts +142 -0
  23. package/dist/bench/verify.js +350 -0
  24. package/dist/cli.d.ts +13 -0
  25. package/dist/cli.js +72 -0
  26. package/dist/commands/bench.d.ts +20 -0
  27. package/dist/commands/bench.js +222 -0
  28. package/dist/commands/calibrate.d.ts +19 -0
  29. package/dist/commands/calibrate.js +75 -0
  30. package/dist/commands/catalog.d.ts +32 -0
  31. package/dist/commands/catalog.js +48 -0
  32. package/dist/commands/config.d.ts +13 -0
  33. package/dist/commands/config.js +90 -0
  34. package/dist/commands/lifecycle.d.ts +29 -0
  35. package/dist/commands/lifecycle.js +194 -0
  36. package/dist/commands/pull.d.ts +22 -0
  37. package/dist/commands/pull.js +135 -0
  38. package/dist/commands/report.d.ts +10 -0
  39. package/dist/commands/report.js +34 -0
  40. package/dist/commands/rescore.d.ts +11 -0
  41. package/dist/commands/rescore.js +48 -0
  42. package/dist/commands/runtime.d.ts +21 -0
  43. package/dist/commands/runtime.js +62 -0
  44. package/dist/commands/scan.d.ts +24 -0
  45. package/dist/commands/scan.js +55 -0
  46. package/dist/commands/search.d.ts +31 -0
  47. package/dist/commands/search.js +135 -0
  48. package/dist/commands/share.d.ts +26 -0
  49. package/dist/commands/share.js +148 -0
  50. package/dist/commands/sweep.d.ts +21 -0
  51. package/dist/commands/sweep.js +69 -0
  52. package/dist/commands/ui.d.ts +9 -0
  53. package/dist/commands/ui.js +28 -0
  54. package/dist/config/env.d.ts +10 -0
  55. package/dist/config/env.js +48 -0
  56. package/dist/config/index.d.ts +9 -0
  57. package/dist/config/index.js +9 -0
  58. package/dist/config/otto-home.d.ts +2 -0
  59. package/dist/config/otto-home.js +24 -0
  60. package/dist/config/paths.d.ts +20 -0
  61. package/dist/config/paths.js +33 -0
  62. package/dist/config/private-files.d.ts +3 -0
  63. package/dist/config/private-files.js +32 -0
  64. package/dist/config/profiles.d.ts +34 -0
  65. package/dist/config/profiles.js +116 -0
  66. package/dist/config/schema.d.ts +1035 -0
  67. package/dist/config/schema.js +191 -0
  68. package/dist/config/store.d.ts +11 -0
  69. package/dist/config/store.js +73 -0
  70. package/dist/gguf.d.ts +61 -0
  71. package/dist/gguf.js +231 -0
  72. package/dist/gpu.d.ts +12 -0
  73. package/dist/gpu.js +49 -0
  74. package/dist/index.d.ts +14 -0
  75. package/dist/index.js +13 -0
  76. package/dist/main.d.ts +2 -0
  77. package/dist/main.js +5 -0
  78. package/dist/models/dirs.d.ts +9 -0
  79. package/dist/models/dirs.js +21 -0
  80. package/dist/models/download.d.ts +31 -0
  81. package/dist/models/download.js +105 -0
  82. package/dist/models/enrich.d.ts +36 -0
  83. package/dist/models/enrich.js +68 -0
  84. package/dist/models/hf.d.ts +62 -0
  85. package/dist/models/hf.js +114 -0
  86. package/dist/models/index.d.ts +15 -0
  87. package/dist/models/index.js +49 -0
  88. package/dist/models/manage.d.ts +27 -0
  89. package/dist/models/manage.js +112 -0
  90. package/dist/models/pick.d.ts +3 -0
  91. package/dist/models/pick.js +36 -0
  92. package/dist/models/scan.d.ts +13 -0
  93. package/dist/models/scan.js +185 -0
  94. package/dist/ops/archive.d.ts +34 -0
  95. package/dist/ops/archive.js +105 -0
  96. package/dist/ops/calibrate.d.ts +49 -0
  97. package/dist/ops/calibrate.js +74 -0
  98. package/dist/ops/report.d.ts +26 -0
  99. package/dist/ops/report.js +532 -0
  100. package/dist/ops/results.d.ts +207 -0
  101. package/dist/ops/results.js +250 -0
  102. package/dist/ops/sweep.d.ts +77 -0
  103. package/dist/ops/sweep.js +126 -0
  104. package/dist/output/index.d.ts +5 -0
  105. package/dist/output/index.js +5 -0
  106. package/dist/output/render.d.ts +4 -0
  107. package/dist/output/render.js +96 -0
  108. package/dist/output/types.d.ts +52 -0
  109. package/dist/output/types.js +9 -0
  110. package/dist/output/with-output.d.ts +3 -0
  111. package/dist/output/with-output.js +42 -0
  112. package/dist/run.d.ts +5 -0
  113. package/dist/run.js +19 -0
  114. package/dist/runtime/args.d.ts +21 -0
  115. package/dist/runtime/args.js +71 -0
  116. package/dist/runtime/index.d.ts +13 -0
  117. package/dist/runtime/index.js +41 -0
  118. package/dist/runtime/lmstudio.d.ts +10 -0
  119. package/dist/runtime/lmstudio.js +88 -0
  120. package/dist/runtime/managed.d.ts +26 -0
  121. package/dist/runtime/managed.js +148 -0
  122. package/dist/service/model-selector.d.ts +36 -0
  123. package/dist/service/model-selector.js +123 -0
  124. package/dist/service/pid-lock.d.ts +18 -0
  125. package/dist/service/pid-lock.js +50 -0
  126. package/dist/service/router.d.ts +144 -0
  127. package/dist/service/router.js +746 -0
  128. package/dist/service/scheduler.d.ts +74 -0
  129. package/dist/service/scheduler.js +110 -0
  130. package/dist/service/serve.d.ts +41 -0
  131. package/dist/service/serve.js +278 -0
  132. package/dist/service/supervisor.d.ts +67 -0
  133. package/dist/service/supervisor.js +222 -0
  134. package/dist/service/tailscale.d.ts +19 -0
  135. package/dist/service/tailscale.js +62 -0
  136. package/dist/service/tls.d.ts +77 -0
  137. package/dist/service/tls.js +192 -0
  138. package/dist/sysmon.d.ts +50 -0
  139. package/dist/sysmon.js +103 -0
  140. package/dist/tui/app.d.ts +208 -0
  141. package/dist/tui/app.js +1801 -0
  142. package/dist/tui/screen.d.ts +72 -0
  143. package/dist/tui/screen.js +176 -0
  144. package/dist/types.d.ts +76 -0
  145. package/dist/types.js +7 -0
  146. package/dist/version.d.ts +2 -0
  147. package/dist/version.js +8 -0
  148. package/dist/vram.d.ts +67 -0
  149. package/dist/vram.js +132 -0
  150. package/package.json +58 -0
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Resolves which directories to scan for models: the managed dir under
3
+ * `$OTTO_HOME/otto-brain/models` (where `otto brain pull` downloads to) plus the
4
+ * LM Studio library as an additional discovery source. The managed dir wins on id
5
+ * collisions so a pulled model shadows an identically-pathed LM Studio one.
6
+ */
7
+ import { resolveBrainPaths } from "../config/paths.js";
8
+ import { LMSTUDIO_MODELS_DIR } from "./scan.js";
9
+ export function resolveModelsDirs(config, env = process.env) {
10
+ const paths = resolveBrainPaths(env);
11
+ const managed = config.modelsDir ?? paths.modelsDir;
12
+ return [
13
+ { dir: managed, origin: "managed" },
14
+ { dir: LMSTUDIO_MODELS_DIR, origin: "lmstudio" },
15
+ ];
16
+ }
17
+ /** The single directory new downloads are written to. */
18
+ export function managedModelsDir(config, env = process.env) {
19
+ return config.modelsDir ?? resolveBrainPaths(env).modelsDir;
20
+ }
21
+ //# sourceMappingURL=dirs.js.map
@@ -0,0 +1,31 @@
1
+ import type { CatalogModel } from "../config/schema.js";
2
+ export interface PullProgress {
3
+ file: string;
4
+ receivedBytes: number;
5
+ totalBytes?: number;
6
+ }
7
+ export interface PullOptions {
8
+ model: CatalogModel;
9
+ destRoot: string;
10
+ file?: string;
11
+ token?: string | null;
12
+ onProgress?: (progress: PullProgress) => void;
13
+ }
14
+ /** Download the model file; returns the local path it was written to. */
15
+ export declare function pullModel({ model, destRoot, file, token, onProgress, }: PullOptions): Promise<string>;
16
+ export interface DownloadFilesOptions {
17
+ repo: string;
18
+ /** Repo-relative file paths (a quant's shards, plus any projector). */
19
+ files: string[];
20
+ destRoot: string;
21
+ token?: string | null;
22
+ onProgress?: (progress: PullProgress) => void;
23
+ }
24
+ /**
25
+ * Download a set of repo-relative files (a chosen quant's shards, plus the shared
26
+ * projector for a vision repo) under `<destRoot>/<repo>/<path>`, preserving any
27
+ * subdirectory. Progress accumulates across all files so a multi-shard quant
28
+ * reports one continuous byte count. Returns the paths actually written.
29
+ */
30
+ export declare function downloadRepoFiles({ repo, files, destRoot, token, onProgress, }: DownloadFilesOptions): Promise<string[]>;
31
+ //# sourceMappingURL=download.d.ts.map
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Downloads a catalog model from Hugging Face into the managed models directory,
3
+ * so `otto brain pull` needs no external tooling. Files land under
4
+ * `<managedModelsDir>/<publisher>/<repo>/<file>` to mirror the LM Studio layout
5
+ * the scanner already understands.
6
+ */
7
+ import { createWriteStream, existsSync, mkdirSync } from "node:fs";
8
+ import path from "node:path";
9
+ import { Readable } from "node:stream";
10
+ import { pipeline } from "node:stream/promises";
11
+ const HF_BASE = "https://huggingface.co";
12
+ /** The Hugging Face resolve URL for a repo file on the main branch. */
13
+ function resolveUrl(repo, file) {
14
+ return `${HF_BASE}/${repo}/resolve/main/${file}`;
15
+ }
16
+ /**
17
+ * The GGUF filename to download. Prefer an explicit override, then the catalog
18
+ * entry's `quantFile`, then the basename of the catalog `id` — which for the
19
+ * seeded catalog is `<hfRepo>/<file>.gguf`, so the id already names the file.
20
+ * Only if none of those yields a `.gguf` do we give up: community repos name
21
+ * files inconsistently, so a bare id with no gguf basename still needs --file.
22
+ */
23
+ function resolveFileName(model, override) {
24
+ const file = override ?? model.quantFile ?? deriveFileFromId(model.id);
25
+ if (!file) {
26
+ throw new Error(`no file name for ${model.id}: add "quantFile" to the catalog entry or pass --file <name.gguf>`);
27
+ }
28
+ return file;
29
+ }
30
+ /** The last path segment of the id, when it is a `.gguf` file name. */
31
+ function deriveFileFromId(id) {
32
+ const base = id.split("/").pop();
33
+ return base && base.toLowerCase().endsWith(".gguf") ? base : undefined;
34
+ }
35
+ function authHeaders(token) {
36
+ return token ? { authorization: `Bearer ${token}` } : {};
37
+ }
38
+ /**
39
+ * Stream one HF file to `destPath`, reporting bytes received. Skips (returns
40
+ * false) if the file already exists. Writes to a `.part` then renames so a
41
+ * killed download never leaves a truncated file that looks complete.
42
+ */
43
+ async function streamRepoFile(url, destPath, label, token, onProgress, received) {
44
+ mkdirSync(path.dirname(destPath), { recursive: true });
45
+ if (existsSync(destPath))
46
+ return false;
47
+ const response = await fetch(url, { headers: authHeaders(token) });
48
+ if (!response.ok || !response.body) {
49
+ throw new Error(`download failed (${response.status}) for ${url}`);
50
+ }
51
+ const totalBytes = Number(response.headers.get("content-length")) || undefined;
52
+ const body = Readable.fromWeb(response.body);
53
+ body.on("data", (chunk) => {
54
+ received.bytes += chunk.length;
55
+ onProgress?.({ file: label, receivedBytes: received.bytes, totalBytes });
56
+ });
57
+ const tmp = `${destPath}.part`;
58
+ await pipeline(body, createWriteStream(tmp));
59
+ const { renameSync } = await import("node:fs");
60
+ renameSync(tmp, destPath);
61
+ return true;
62
+ }
63
+ /** Download the model file; returns the local path it was written to. */
64
+ export async function pullModel({ model, destRoot, file, token, onProgress, }) {
65
+ const fileName = resolveFileName(model, file);
66
+ const repoDir = path.join(destRoot, model.hfRepo.replace(/\//g, path.sep));
67
+ const destPath = path.join(repoDir, fileName);
68
+ await streamRepoFile(resolveUrl(model.hfRepo, fileName), destPath, fileName, token, onProgress, {
69
+ bytes: 0,
70
+ });
71
+ return destPath;
72
+ }
73
+ /**
74
+ * Download a set of repo-relative files (a chosen quant's shards, plus the shared
75
+ * projector for a vision repo) under `<destRoot>/<repo>/<path>`, preserving any
76
+ * subdirectory. Progress accumulates across all files so a multi-shard quant
77
+ * reports one continuous byte count. Returns the paths actually written.
78
+ */
79
+ export async function downloadRepoFiles({ repo, files, destRoot, token, onProgress, }) {
80
+ const rootResolved = path.resolve(destRoot);
81
+ const repoDir = path.join(rootResolved, repo.replace(/\//g, path.sep));
82
+ const received = { bytes: 0 };
83
+ const written = [];
84
+ for (const repoFile of files) {
85
+ // `repo` and `repoFile` come from the untrusted Hugging Face API; a crafted
86
+ // repo tree could list names with `..` or backslash segments that escape the
87
+ // models dir. Split on BOTH separators, reject traversal segments, and verify
88
+ // the resolved path stays under destRoot before writing.
89
+ const segments = repoFile.split(/[/\\]/).filter(Boolean);
90
+ if (segments.some((segment) => segment === "." || segment === "..")) {
91
+ throw new Error(`Refusing to download file with unsafe path: ${repoFile}`);
92
+ }
93
+ const destPath = path.join(repoDir, ...segments);
94
+ const resolved = path.resolve(destPath);
95
+ if (resolved !== rootResolved && !resolved.startsWith(rootResolved + path.sep)) {
96
+ throw new Error(`Refusing to write outside the models directory: ${repoFile}`);
97
+ }
98
+ const url = `${HF_BASE}/${repo}/resolve/main/${repoFile}`;
99
+ const wrote = await streamRepoFile(url, resolved, repoFile, token, onProgress, received);
100
+ if (wrote)
101
+ written.push(resolved);
102
+ }
103
+ return written;
104
+ }
105
+ //# sourceMappingURL=download.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Reconciles scanned models back to their download-catalog entries so a model's
3
+ * coding metadata (useCases, tier, thinking, contextMax) survives a `pull`. The
4
+ * catalog carries this per entry, but once files land on disk scan.ts rebuilds a
5
+ * Model from filename + GGUF header alone, dropping it — this is where it is
6
+ * re-attached. Track B1 of the brain coding-capabilities work.
7
+ *
8
+ * The join key is the hfRepo path. download.ts writes each model to
9
+ * `<modelsDir>/<hfRepo>/<file>.gguf` (LM Studio mirrors the same
10
+ * `<publisher>/<repo>/<file>` layout), and scan.ts rebuilds `Model.id` as that
11
+ * same modelsDir-relative path with forward slashes. So a scanned model's id
12
+ * sits under its catalog entry's hfRepo directory, and that containment is the
13
+ * match.
14
+ *
15
+ * Total and best-effort by design: an empty catalog, a model with no match, or a
16
+ * repo carrying several quants all resolve without throwing. Discovery returns
17
+ * things unenriched on absence rather than raising — the caller decides whether
18
+ * absence matters.
19
+ */
20
+ import type { Catalog, CatalogModel } from "../config/schema.js";
21
+ import type { Model } from "../types.js";
22
+ /**
23
+ * Find the catalog entry a scanned model belongs to, or null. A model matches
24
+ * when its id path sits directly under the entry's hfRepo directory. When a repo
25
+ * ships several quants (several catalog entries share one hfRepo), the tie is
26
+ * broken by exact file name first, then by matching quant, then by the most
27
+ * specific (longest) hfRepo.
28
+ */
29
+ export declare function matchCatalogEntry(model: Model, catalog: Catalog): CatalogModel | null;
30
+ /**
31
+ * Return copies of the models with catalog coding metadata attached where a match
32
+ * exists; models with no match (and every model when the catalog is empty) pass
33
+ * through untouched. Never throws.
34
+ */
35
+ export declare function enrichWithCatalog(models: Model[], catalog: Catalog): Model[];
36
+ //# sourceMappingURL=enrich.d.ts.map
@@ -0,0 +1,68 @@
1
+ /** Normalize a repo/id path: forward slashes, lowercased, trailing slashes trimmed. */
2
+ function normalizePath(value) {
3
+ return value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
4
+ }
5
+ /** The final path segment (file name) of a scanned model's id. */
6
+ function basenameOf(id) {
7
+ const normalized = id.replace(/\\/g, "/");
8
+ const slash = normalized.lastIndexOf("/");
9
+ return slash === -1 ? normalized : normalized.slice(slash + 1);
10
+ }
11
+ /**
12
+ * Find the catalog entry a scanned model belongs to, or null. A model matches
13
+ * when its id path sits directly under the entry's hfRepo directory. When a repo
14
+ * ships several quants (several catalog entries share one hfRepo), the tie is
15
+ * broken by exact file name first, then by matching quant, then by the most
16
+ * specific (longest) hfRepo.
17
+ */
18
+ export function matchCatalogEntry(model, catalog) {
19
+ const id = normalizePath(model.id);
20
+ const base = basenameOf(model.id).toLowerCase();
21
+ let best = null;
22
+ let bestScore = -1;
23
+ for (const entry of catalog.models) {
24
+ const repo = normalizePath(entry.hfRepo);
25
+ // The model file must live under the repo directory. The trailing slash
26
+ // guards against a partial segment match (repo "a/b" vs id "a/b-30b/...").
27
+ if (!repo || !id.startsWith(`${repo}/`))
28
+ continue;
29
+ let score = repo.length; // most-specific repo wins otherwise-equal ties
30
+ if (entry.quantFile && entry.quantFile.toLowerCase() === base) {
31
+ score += 1000000;
32
+ }
33
+ else if (entry.quant &&
34
+ model.quant &&
35
+ entry.quant.toLowerCase() === model.quant.toLowerCase()) {
36
+ score += 1000;
37
+ }
38
+ if (score > bestScore) {
39
+ bestScore = score;
40
+ best = entry;
41
+ }
42
+ }
43
+ return best;
44
+ }
45
+ /**
46
+ * Return copies of the models with catalog coding metadata attached where a match
47
+ * exists; models with no match (and every model when the catalog is empty) pass
48
+ * through untouched. Never throws.
49
+ */
50
+ export function enrichWithCatalog(models, catalog) {
51
+ if (catalog.models.length === 0)
52
+ return models;
53
+ return models.map((model) => {
54
+ const entry = matchCatalogEntry(model, catalog);
55
+ if (!entry)
56
+ return model;
57
+ return {
58
+ ...model,
59
+ catalogId: entry.id,
60
+ catalogHfRepo: entry.hfRepo,
61
+ useCases: entry.useCases,
62
+ tier: entry.tier,
63
+ thinking: entry.thinking,
64
+ contextMax: entry.contextMax,
65
+ };
66
+ });
67
+ }
68
+ //# sourceMappingURL=enrich.js.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Hugging Face repo introspection: list the GGUF quantizations a repo offers so a
3
+ * user can pick which one to download (tweak Q4 <-> Q5 <-> Q6). Uses the public
4
+ * tree API - no SDK, no auth required for public repos - and pairs multipart
5
+ * shards and the shared vision projector the way the local scanner does.
6
+ */
7
+ import type { BrainConfig } from "../config/schema.js";
8
+ import type { Model } from "../types.js";
9
+ /**
10
+ * The Hugging Face repo a scanned model came from: its catalog match, else the
11
+ * first two segments of its id (`<publisher>/<repo>/<file>`). Null when the layout
12
+ * does not name a repo. Shared so the TUI, the CLI, and the daemon all agree on
13
+ * which local models belong to a given repo.
14
+ */
15
+ export declare function repoOfModel(model: Model): string | null;
16
+ /** Resolve an HF token: env first (runtime override), then persisted config. */
17
+ export declare function resolveHfToken(config?: BrainConfig | null, env?: NodeJS.ProcessEnv): string | null;
18
+ /** A downloadable quantization: its label, the repo-relative file(s), total size. */
19
+ export interface QuantOption {
20
+ quant: string;
21
+ files: string[];
22
+ sizeBytes: number;
23
+ }
24
+ /** The quants a repo offers plus its shared vision projector, if any. */
25
+ export interface RepoQuants {
26
+ repo: string;
27
+ quants: QuantOption[];
28
+ mmproj: {
29
+ files: string[];
30
+ sizeBytes: number;
31
+ } | null;
32
+ }
33
+ /** One GGUF repo from a search, in a surface-agnostic shape (TUI and app share). */
34
+ export interface ModelSearchResult {
35
+ repo: string;
36
+ author: string;
37
+ downloads: number;
38
+ likes: number;
39
+ updatedAt: string | null;
40
+ gated: boolean;
41
+ }
42
+ /**
43
+ * Search Hugging Face for GGUF model repos, most-downloaded first. Returns a
44
+ * normalized shape both the TUI and the Otto app can render; drill into a result
45
+ * with {@link listRepoQuants} to see and download its quantizations.
46
+ */
47
+ export declare function searchModels(query: string, { limit, token }?: {
48
+ limit?: number;
49
+ token?: string | null;
50
+ }): Promise<ModelSearchResult[]>;
51
+ /**
52
+ * Quality order for display: higher bits-per-weight is more faithful and larger.
53
+ * Q2 < Q3 < ... < Q8 < F16 < F32, with K_S < K_M < K_L and _0 < _1 as tie-breaks.
54
+ */
55
+ export declare function quantRank(quant: string): number;
56
+ /**
57
+ * List the GGUF quantizations available in a Hugging Face repo. Model files are
58
+ * grouped by detected quant (shards summed); the vision projector is returned
59
+ * separately because one projector serves every quant in the repo.
60
+ */
61
+ export declare function listRepoQuants(repo: string, token?: string | null): Promise<RepoQuants>;
62
+ //# sourceMappingURL=hf.d.ts.map
@@ -0,0 +1,114 @@
1
+ import { detectQuant, isProjectorFile } from "./scan.js";
2
+ const HF_BASE = "https://huggingface.co";
3
+ /**
4
+ * The Hugging Face repo a scanned model came from: its catalog match, else the
5
+ * first two segments of its id (`<publisher>/<repo>/<file>`). Null when the layout
6
+ * does not name a repo. Shared so the TUI, the CLI, and the daemon all agree on
7
+ * which local models belong to a given repo.
8
+ */
9
+ export function repoOfModel(model) {
10
+ if (model.catalogHfRepo)
11
+ return model.catalogHfRepo;
12
+ const segments = model.id.split("/");
13
+ return segments.length >= 3 ? segments.slice(0, 2).join("/") : null;
14
+ }
15
+ /** Resolve an HF token: env first (runtime override), then persisted config. */
16
+ export function resolveHfToken(config, env = process.env) {
17
+ return env.HF_TOKEN || env.HUGGING_FACE_HUB_TOKEN || config?.hfToken || null;
18
+ }
19
+ function authHeaders(token) {
20
+ return token ? { authorization: `Bearer ${token}` } : {};
21
+ }
22
+ function entryBytes(entry) {
23
+ return entry.size ?? entry.lfs?.size ?? 0;
24
+ }
25
+ /**
26
+ * Search Hugging Face for GGUF model repos, most-downloaded first. Returns a
27
+ * normalized shape both the TUI and the Otto app can render; drill into a result
28
+ * with {@link listRepoQuants} to see and download its quantizations.
29
+ */
30
+ export async function searchModels(query, { limit = 25, token = null } = {}) {
31
+ const params = new URLSearchParams({
32
+ search: query,
33
+ filter: "gguf",
34
+ sort: "downloads",
35
+ direction: "-1",
36
+ limit: String(limit),
37
+ });
38
+ const res = await fetch(`${HF_BASE}/api/models?${params.toString()}`, {
39
+ headers: authHeaders(token),
40
+ });
41
+ if (!res.ok) {
42
+ throw new Error(`Hugging Face search failed (${res.status}) for "${query}"`);
43
+ }
44
+ const entries = (await res.json());
45
+ return entries.map((entry) => ({
46
+ repo: entry.id,
47
+ author: entry.author ?? entry.id.split("/")[0] ?? "",
48
+ downloads: entry.downloads ?? 0,
49
+ likes: entry.likes ?? 0,
50
+ updatedAt: entry.lastModified ?? null,
51
+ gated: Boolean(entry.gated),
52
+ }));
53
+ }
54
+ /**
55
+ * Quality order for display: higher bits-per-weight is more faithful and larger.
56
+ * Q2 < Q3 < ... < Q8 < F16 < F32, with K_S < K_M < K_L and _0 < _1 as tie-breaks.
57
+ */
58
+ export function quantRank(quant) {
59
+ const q = quant.toUpperCase();
60
+ const digits = /Q(\d+)/.exec(q);
61
+ const base = digits ? Number(digits[1]) : /BF16|F16/.test(q) ? 16 : /F32/.test(q) ? 32 : 99;
62
+ const sub = /_K_S/.test(q)
63
+ ? 0.1
64
+ : /_K_M/.test(q)
65
+ ? 0.2
66
+ : /_K_L/.test(q)
67
+ ? 0.3
68
+ : /_1$/.test(q)
69
+ ? 0.05
70
+ : 0;
71
+ return base + sub;
72
+ }
73
+ /**
74
+ * List the GGUF quantizations available in a Hugging Face repo. Model files are
75
+ * grouped by detected quant (shards summed); the vision projector is returned
76
+ * separately because one projector serves every quant in the repo.
77
+ */
78
+ export async function listRepoQuants(repo, token = null) {
79
+ const url = `${HF_BASE}/api/models/${repo}/tree/main?recursive=true`;
80
+ const res = await fetch(url, { headers: authHeaders(token) });
81
+ if (!res.ok) {
82
+ throw new Error(`Hugging Face listing failed (${res.status}) for ${repo}`);
83
+ }
84
+ const entries = (await res.json());
85
+ const ggufs = entries.filter((e) => e.type === "file" && e.path.toLowerCase().endsWith(".gguf"));
86
+ const basename = (p) => p.split("/").pop() ?? p;
87
+ const projectors = ggufs.filter((e) => isProjectorFile(basename(e.path)));
88
+ const modelFiles = ggufs.filter((e) => !isProjectorFile(basename(e.path)));
89
+ const byQuant = new Map();
90
+ for (const entry of modelFiles) {
91
+ // Skip files whose quant we cannot classify: we could not present or manage
92
+ // an unlabelled blob sensibly, and it is usually an F16/BF16 conversion the
93
+ // picker does not want to offer as a "quant".
94
+ const quant = detectQuant(basename(entry.path));
95
+ if (!quant)
96
+ continue;
97
+ const option = byQuant.get(quant) ?? { quant, files: [], sizeBytes: 0 };
98
+ option.files.push(entry.path);
99
+ option.sizeBytes += entryBytes(entry);
100
+ byQuant.set(quant, option);
101
+ }
102
+ // Keep shard order stable so the first file is the 00001 shard.
103
+ for (const option of byQuant.values())
104
+ option.files.sort();
105
+ const quants = [...byQuant.values()].sort((a, b) => quantRank(a.quant) - quantRank(b.quant));
106
+ // One projector serves every quant; download only the largest, matching what
107
+ // the scanner picks, rather than every precision the repo ships.
108
+ const largestProjector = projectors.reduce((best, entry) => (best === null || entryBytes(entry) > entryBytes(best) ? entry : best), null);
109
+ const mmproj = largestProjector
110
+ ? { files: [largestProjector.path], sizeBytes: entryBytes(largestProjector) }
111
+ : null;
112
+ return { repo, quants, mmproj };
113
+ }
114
+ //# sourceMappingURL=hf.js.map
@@ -0,0 +1,15 @@
1
+ import type { BrainConfig } from "../config/schema.js";
2
+ import type { Model } from "../types.js";
3
+ export * from "./scan.js";
4
+ export { pickModel } from "./pick.js";
5
+ export { resolveModelsDirs, managedModelsDir, type ModelsDir } from "./dirs.js";
6
+ export { pullModel, downloadRepoFiles, type PullOptions, type PullProgress, type DownloadFilesOptions, } from "./download.js";
7
+ export { matchCatalogEntry, enrichWithCatalog } from "./enrich.js";
8
+ export { resolveHfToken, listRepoQuants, searchModels, repoOfModel, quantRank, type QuantOption, type RepoQuants, type ModelSearchResult, } from "./hf.js";
9
+ export { diskUsage, totalModelBytes, planDelete, deleteModelFiles, type DiskUsage, type DeletePlan, } from "./manage.js";
10
+ export interface ScanModelsOptions {
11
+ withMetadata?: boolean;
12
+ }
13
+ /** Scan every configured models directory (managed ∪ LM Studio), de-duplicated. */
14
+ export declare function scanModels(config: BrainConfig, env?: NodeJS.ProcessEnv, options?: ScanModelsOptions): Model[];
15
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,49 @@
1
+ /** Barrel for the models subsystem plus the config-aware union scan. */
2
+ import { resolveBrainPaths } from "../config/paths.js";
3
+ import { CatalogSchema } from "../config/schema.js";
4
+ import { loadCatalog } from "../config/store.js";
5
+ import { resolveModelsDirs } from "./dirs.js";
6
+ import { enrichWithCatalog } from "./enrich.js";
7
+ import { scan } from "./scan.js";
8
+ export * from "./scan.js";
9
+ export { pickModel } from "./pick.js";
10
+ export { resolveModelsDirs, managedModelsDir } from "./dirs.js";
11
+ export { pullModel, downloadRepoFiles, } from "./download.js";
12
+ export { matchCatalogEntry, enrichWithCatalog } from "./enrich.js";
13
+ export { resolveHfToken, listRepoQuants, searchModels, repoOfModel, quantRank, } from "./hf.js";
14
+ export { diskUsage, totalModelBytes, planDelete, deleteModelFiles, } from "./manage.js";
15
+ /** Scan every configured models directory (managed ∪ LM Studio), de-duplicated. */
16
+ export function scanModels(config, env = process.env, options = {}) {
17
+ const dirs = resolveModelsDirs(config, env);
18
+ const seen = new Set();
19
+ const all = [];
20
+ for (const { dir, origin } of dirs) {
21
+ for (const model of scan({
22
+ modelsDir: dir,
23
+ withMetadata: options.withMetadata ?? true,
24
+ origin,
25
+ })) {
26
+ if (seen.has(model.modelPath))
27
+ continue;
28
+ seen.add(model.modelPath);
29
+ all.push(model);
30
+ }
31
+ }
32
+ const enriched = enrichWithCatalog(all, loadCatalogSafe(env));
33
+ enriched.sort((a, b) => a.displayName.localeCompare(b.displayName));
34
+ return enriched;
35
+ }
36
+ /**
37
+ * The download catalog for enrichment, or an empty catalog if it cannot be read.
38
+ * Discovery must never fail because the catalog file is missing or corrupt, so a
39
+ * load error degrades to no enrichment rather than throwing.
40
+ */
41
+ function loadCatalogSafe(env) {
42
+ try {
43
+ return loadCatalog(resolveBrainPaths(env));
44
+ }
45
+ catch {
46
+ return CatalogSchema.parse({ models: [] });
47
+ }
48
+ }
49
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,27 @@
1
+ import type { Model } from "../types.js";
2
+ export interface DiskUsage {
3
+ freeBytes: number;
4
+ totalBytes: number;
5
+ }
6
+ /** Free and total bytes of the filesystem holding `dir`, or null if unavailable. */
7
+ export declare function diskUsage(dir: string): Promise<DiskUsage | null>;
8
+ /**
9
+ * Total bytes the given models occupy on disk, counting each projector once (a
10
+ * vision repo shares one projector across all its quants, so summing every
11
+ * model's mmprojBytes would over-count).
12
+ */
13
+ export declare function totalModelBytes(models: Model[]): number;
14
+ export interface DeletePlan {
15
+ files: string[];
16
+ bytes: number;
17
+ includesProjector: boolean;
18
+ }
19
+ /**
20
+ * Work out exactly which files deleting a model removes: its GGUF (all shards),
21
+ * and the paired projector ONLY when no other quant in the same repo directory
22
+ * still needs it. Never deletes a projector that a sibling quant shares.
23
+ */
24
+ export declare function planDelete(model: Model): DeletePlan;
25
+ /** Delete the files a {@link planDelete} chose, plus a now-empty repo directory. */
26
+ export declare function deleteModelFiles(model: Model): DeletePlan;
27
+ //# sourceMappingURL=manage.d.ts.map
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Local model management: disk-space accounting and safe deletion. Kept separate
3
+ * from discovery (scan) and download so the destructive path is easy to audit.
4
+ */
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { statfs } from "node:fs/promises";
8
+ // Same shard shape the scanner uses: `-00001-of-00003.gguf`.
9
+ const MULTIPART = /-(\d{5})-of-(\d{5})\.gguf$/i;
10
+ /** Free and total bytes of the filesystem holding `dir`, or null if unavailable. */
11
+ export async function diskUsage(dir) {
12
+ try {
13
+ const s = await statfs(dir);
14
+ return { freeBytes: s.bavail * s.bsize, totalBytes: s.blocks * s.bsize };
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ /**
21
+ * Total bytes the given models occupy on disk, counting each projector once (a
22
+ * vision repo shares one projector across all its quants, so summing every
23
+ * model's mmprojBytes would over-count).
24
+ */
25
+ export function totalModelBytes(models) {
26
+ const seenProjectors = new Set();
27
+ let bytes = 0;
28
+ for (const model of models) {
29
+ bytes += model.sizeBytes;
30
+ if (model.mmprojPath && !seenProjectors.has(model.mmprojPath)) {
31
+ seenProjectors.add(model.mmprojPath);
32
+ bytes += model.mmprojBytes;
33
+ }
34
+ }
35
+ return bytes;
36
+ }
37
+ /**
38
+ * Work out exactly which files deleting a model removes: its GGUF (all shards),
39
+ * and the paired projector ONLY when no other quant in the same repo directory
40
+ * still needs it. Never deletes a projector that a sibling quant shares.
41
+ */
42
+ export function planDelete(model) {
43
+ const files = [];
44
+ let bytes = 0;
45
+ const add = (file) => {
46
+ try {
47
+ bytes += fs.statSync(file).size;
48
+ files.push(file);
49
+ }
50
+ catch {
51
+ /* already gone */
52
+ }
53
+ };
54
+ // The GGUF and its shards.
55
+ const shard = model.modelPath.match(MULTIPART);
56
+ if (shard) {
57
+ const base = model.modelPath.replace(MULTIPART, "");
58
+ const total = Number(shard[2]);
59
+ for (let i = 1; i <= total; i += 1) {
60
+ add(`${base}-${String(i).padStart(5, "0")}-of-${shard[2]}.gguf`);
61
+ }
62
+ }
63
+ else {
64
+ add(model.modelPath);
65
+ }
66
+ // The projector is shared across the repo's quants; only remove it if this was
67
+ // the last model GGUF in the directory.
68
+ let includesProjector = false;
69
+ if (model.mmprojPath) {
70
+ const dir = path.dirname(model.modelPath);
71
+ const doomed = new Set(files.map((f) => path.resolve(f)));
72
+ let othersRemain = false;
73
+ try {
74
+ for (const name of fs.readdirSync(dir)) {
75
+ if (!name.toLowerCase().endsWith(".gguf"))
76
+ continue;
77
+ if (/^mmproj/i.test(name))
78
+ continue;
79
+ if (doomed.has(path.resolve(dir, name)))
80
+ continue;
81
+ othersRemain = true;
82
+ break;
83
+ }
84
+ }
85
+ catch {
86
+ othersRemain = true; // be conservative: keep the projector if unsure
87
+ }
88
+ if (!othersRemain) {
89
+ add(model.mmprojPath);
90
+ includesProjector = true;
91
+ }
92
+ }
93
+ return { files, bytes, includesProjector };
94
+ }
95
+ /** Delete the files a {@link planDelete} chose, plus a now-empty repo directory. */
96
+ export function deleteModelFiles(model) {
97
+ const plan = planDelete(model);
98
+ for (const file of plan.files) {
99
+ fs.rmSync(file, { force: true });
100
+ }
101
+ // Remove the containing directory if we emptied it.
102
+ const dir = path.dirname(model.modelPath);
103
+ try {
104
+ if (fs.readdirSync(dir).length === 0)
105
+ fs.rmdirSync(dir);
106
+ }
107
+ catch {
108
+ /* not empty or gone */
109
+ }
110
+ return plan;
111
+ }
112
+ //# sourceMappingURL=manage.js.map
@@ -0,0 +1,3 @@
1
+ import type { Model } from "../types.js";
2
+ export declare function pickModel(catalog: Model[], needle: string | undefined): Model;
3
+ //# sourceMappingURL=pick.d.ts.map