@otto-code/brain 0.8.7 → 0.8.9

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 (61) hide show
  1. package/dist/cli.js +2 -1
  2. package/dist/commands/bench.js +19 -5
  3. package/dist/commands/catalog.d.ts +3 -0
  4. package/dist/commands/catalog.js +2 -0
  5. package/dist/commands/pull.d.ts +1 -0
  6. package/dist/commands/pull.js +47 -14
  7. package/dist/commands/repo-download.d.ts +14 -0
  8. package/dist/commands/repo-download.js +29 -0
  9. package/dist/commands/runtime.d.ts +3 -0
  10. package/dist/commands/runtime.js +65 -18
  11. package/dist/commands/search.d.ts +3 -0
  12. package/dist/commands/search.js +38 -17
  13. package/dist/config/builtin-hosting-profiles.d.ts +8 -0
  14. package/dist/config/builtin-hosting-profiles.js +32 -0
  15. package/dist/config/hosting-profiles.d.ts +33 -0
  16. package/dist/config/hosting-profiles.js +71 -0
  17. package/dist/config/index.d.ts +1 -0
  18. package/dist/config/index.js +1 -0
  19. package/dist/config/paths.d.ts +2 -0
  20. package/dist/config/paths.js +1 -0
  21. package/dist/config/profile-edit.d.ts +5 -3
  22. package/dist/config/profile-edit.js +134 -14
  23. package/dist/config/profiles.js +66 -3
  24. package/dist/config/schema.d.ts +998 -0
  25. package/dist/config/schema.js +79 -0
  26. package/dist/config/store.js +28 -16
  27. package/dist/gguf.d.ts +1 -0
  28. package/dist/gguf.js +1 -0
  29. package/dist/models/download.d.ts +7 -0
  30. package/dist/models/download.js +165 -17
  31. package/dist/models/enrich.d.ts +6 -19
  32. package/dist/models/enrich.js +138 -4
  33. package/dist/models/hf.d.ts +14 -1
  34. package/dist/models/hf.js +239 -6
  35. package/dist/models/index.d.ts +2 -2
  36. package/dist/models/index.js +8 -5
  37. package/dist/models/manage.d.ts +4 -0
  38. package/dist/models/manage.js +34 -4
  39. package/dist/models/scan.js +8 -42
  40. package/dist/ops/calibrate.d.ts +4 -1
  41. package/dist/ops/calibrate.js +10 -7
  42. package/dist/ops/sweep.d.ts +3 -1
  43. package/dist/ops/sweep.js +3 -3
  44. package/dist/runtime/args.d.ts +2 -2
  45. package/dist/runtime/args.js +18 -1
  46. package/dist/runtime/index.d.ts +8 -1
  47. package/dist/runtime/index.js +11 -1
  48. package/dist/runtime/managed.d.ts +40 -0
  49. package/dist/runtime/managed.js +146 -7
  50. package/dist/service/host-api.d.ts +20 -3
  51. package/dist/service/host-api.js +313 -20
  52. package/dist/service/router.d.ts +18 -0
  53. package/dist/service/router.js +89 -4
  54. package/dist/service/serve.js +221 -22
  55. package/dist/service/supervisor.d.ts +32 -4
  56. package/dist/service/supervisor.js +30 -6
  57. package/dist/tui/app.js +1 -1
  58. package/dist/types.d.ts +24 -0
  59. package/dist/vram.d.ts +3 -0
  60. package/dist/vram.js +24 -6
  61. package/package.json +1 -1
package/dist/models/hf.js CHANGED
@@ -22,12 +22,229 @@ function authHeaders(token) {
22
22
  function entryBytes(entry) {
23
23
  return entry.size ?? entry.lfs?.size ?? 0;
24
24
  }
25
+ /**
26
+ * Model-card summaries, and the three traps they set.
27
+ *
28
+ * 1. **`base_model` chains can be circular.** A quantizer's card names its base,
29
+ * whose card can name the quantization straight back. Two guards, because one
30
+ * is not enough: the chain of repos being resolved is carried down the
31
+ * recursion and re-entering a repo already on it is refused, and the chain is
32
+ * depth-capped. A guard on direct self-reference alone misses A -> B -> A.
33
+ * 2. **A shared cache of *pending* promises reintroduces the deadlock the chain
34
+ * guard just removed.** With three rows resolving concurrently, A -> B -> C -> A
35
+ * can have each stack awaiting another stack's in-flight entry, a wait-for
36
+ * cycle no per-stack chain can observe. So the cache holds *settled values
37
+ * only*, and the in-flight dedupe map is consulted **only at the top level**,
38
+ * never inside the recursion. Every in-flight promise therefore resolves
39
+ * without ever awaiting another in-flight promise, which makes the wait-for
40
+ * graph a forest and a cycle unrepresentable.
41
+ * 3. **The service is long lived.** The cache is capped and LRU-evicted, and
42
+ * entries expire, with a much shorter life for misses so a transient failure
43
+ * does not stick for the process lifetime.
44
+ *
45
+ * Every fetch also carries a timeout, and the caller's abort signal, so a slow or
46
+ * hanging remote cannot pin an RPC open.
47
+ */
48
+ const SUMMARY_CACHE_MAX = 256;
49
+ const SUMMARY_TTL_MS = 30 * 60000;
50
+ /** Misses expire fast: a 404 today is often a card published tomorrow. */
51
+ const SUMMARY_MISS_TTL_MS = 5 * 60000;
52
+ const SUMMARY_FETCH_TIMEOUT_MS = 4000;
53
+ /** Deadline for the search and tree listings, which carry the actual payload. */
54
+ const REQUEST_TIMEOUT_MS = 15000;
55
+ /** How long a whole search may spend on summaries before degrading to null. */
56
+ const DEFAULT_SUMMARY_BUDGET_MS = 2500;
57
+ /** Repos one `base_model` branch may visit before giving up on finding prose. */
58
+ const MAX_BASE_MODEL_DEPTH = 3;
59
+ const cardSummaryCache = new Map();
60
+ const inFlightSummaries = new Map();
61
+ /** A live cache entry, or null when absent or expired. Refreshes LRU recency. */
62
+ function readCachedSummary(key) {
63
+ const hit = cardSummaryCache.get(key);
64
+ if (!hit)
65
+ return null;
66
+ if (hit.expiresAt <= Date.now()) {
67
+ cardSummaryCache.delete(key);
68
+ return null;
69
+ }
70
+ // Re-insert so the most recently read key is the youngest in iteration order.
71
+ cardSummaryCache.delete(key);
72
+ cardSummaryCache.set(key, hit);
73
+ return hit;
74
+ }
75
+ function writeCachedSummary(key, value) {
76
+ cardSummaryCache.delete(key);
77
+ cardSummaryCache.set(key, {
78
+ value,
79
+ expiresAt: Date.now() + (value === null ? SUMMARY_MISS_TTL_MS : SUMMARY_TTL_MS),
80
+ });
81
+ // Map iterates in insertion order, so the first key is the least recently used.
82
+ while (cardSummaryCache.size > SUMMARY_CACHE_MAX) {
83
+ const oldest = cardSummaryCache.keys().next();
84
+ if (oldest.done)
85
+ break;
86
+ cardSummaryCache.delete(oldest.value);
87
+ }
88
+ }
89
+ /** Drop every cached summary. Exported for tests and for a token change, which
90
+ * can flip which repos are readable at all. */
91
+ export function clearCardSummaryCache() {
92
+ cardSummaryCache.clear();
93
+ inFlightSummaries.clear();
94
+ }
95
+ function cleanCardSummary(markdown) {
96
+ const body = markdown.replace(/^---\s*[\s\S]*?---\s*/u, "");
97
+ const paragraphs = body
98
+ .split(/\r?\n\s*\r?\n/u)
99
+ .map((paragraph) => paragraph
100
+ .replace(/<[^>]+>/gu, " ")
101
+ .replace(/!?(\[[^\]]*\]\([^)]*\))/gu, "$1")
102
+ .replace(/[>*#`_]/gu, " ")
103
+ .replace(/\s+/gu, " ")
104
+ .trim())
105
+ .filter((paragraph) => paragraph.length >= 80);
106
+ const useful = paragraphs.find((paragraph) => !/^(community model|special thanks|disclaimers|model creator|gguf quantization)/iu.test(paragraph) &&
107
+ !/lm studio community models highlights program|lm studio is not the creator/iu.test(paragraph));
108
+ if (!useful)
109
+ return null;
110
+ const sentences = useful.match(/[^.!?]+[.!?]+(?:\s|$)/gu) ?? [useful];
111
+ const summary = sentences.slice(0, 2).join(" ").trim();
112
+ return summary.length >= 80 ? summary.slice(0, 360).trim() : null;
113
+ }
114
+ /**
115
+ * A request the caller cannot proceed without, so a failure throws rather than
116
+ * reporting absence. It is still bounded: without a deadline a remote that
117
+ * accepts the connection and then goes quiet pins the RPC that awaits it open
118
+ * for as long as the socket lives, which is the whole reason the summary
119
+ * fetches carry one too.
120
+ */
121
+ async function fetchHf(url, token, what) {
122
+ try {
123
+ return await fetch(url, {
124
+ headers: authHeaders(token),
125
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
126
+ });
127
+ }
128
+ catch (cause) {
129
+ const timedOut = cause instanceof Error && cause.name === "TimeoutError";
130
+ throw new Error(timedOut
131
+ ? `Hugging Face ${what} timed out after ${REQUEST_TIMEOUT_MS}ms`
132
+ : `Hugging Face ${what} could not reach the server`, { cause });
133
+ }
134
+ }
135
+ /**
136
+ * One card fetch, bounded by its own timeout and by the caller's abort. Returns
137
+ * null on any failure: discovery reports absence rather than throwing, and a
138
+ * missing card is the overwhelmingly common case (most GGUF repos have none).
139
+ */
140
+ async function fetchCard(url, token, signal) {
141
+ const timeout = AbortSignal.timeout(SUMMARY_FETCH_TIMEOUT_MS);
142
+ try {
143
+ return await fetch(url, {
144
+ headers: authHeaders(token),
145
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
146
+ });
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ /**
153
+ * Resolve one repo's summary, following `base_model` when the repo has no card
154
+ * prose of its own. `chain` is every repo already being resolved on this branch,
155
+ * lowercased; it is both the cycle guard and the depth counter. Never consults
156
+ * the in-flight map (see the header note): the recursion only ever reads settled
157
+ * cache entries, so it cannot wait on another row's unfinished work.
158
+ */
159
+ async function resolveCardSummary(repo, token, chain, signal) {
160
+ const key = repo.toLowerCase();
161
+ const cached = readCachedSummary(key);
162
+ if (cached)
163
+ return cached.value;
164
+ const readme = await fetchCard(`${HF_BASE}/${repo}/raw/main/README.md`, token, signal);
165
+ let summary = null;
166
+ if (readme?.ok) {
167
+ summary = cleanCardSummary(await readme.text().catch(() => ""));
168
+ }
169
+ // Only a depth cut makes the answer chain-specific: this repo might still
170
+ // have resolved from a shallower entry point, so that null must not be cached.
171
+ let truncated = false;
172
+ // Once the caller has given up, do not spend a second round trip chasing the
173
+ // base model: the answer can no longer be used.
174
+ if (summary === null && !signal?.aborted) {
175
+ if (chain.length >= MAX_BASE_MODEL_DEPTH) {
176
+ truncated = true;
177
+ }
178
+ else {
179
+ // The repo names no prose of its own, so try the model it was quantized
180
+ // from. The `${HF_BASE}/` prefix is committed before the interpolation, so
181
+ // a hostile base_model cannot redirect this off huggingface.co and leak
182
+ // the token; keep that ordering if you touch these URLs.
183
+ const detail = await fetchCard(`${HF_BASE}/api/models/${repo}`, token, signal);
184
+ if (detail?.ok) {
185
+ const card = (await detail.json().catch(() => null));
186
+ const declared = card?.cardData?.base_model;
187
+ const baseModel = Array.isArray(declared) ? declared[0] : declared;
188
+ // Refusing a repo already on this branch is what breaks A -> B -> A.
189
+ // Caching the null is still correct here: a cycle carries no prose at
190
+ // any entry point, so every member resolves to null however it is
191
+ // reached.
192
+ const baseKey = baseModel?.toLowerCase();
193
+ if (baseModel && baseKey && !chain.includes(baseKey)) {
194
+ summary = await resolveCardSummary(baseModel, token, [...chain, baseKey], signal);
195
+ }
196
+ }
197
+ }
198
+ }
199
+ // An aborted branch resolved nothing because we stopped asking, not because
200
+ // the repo has no card. Caching that would let one slow search poison the next.
201
+ if (!truncated && !signal?.aborted)
202
+ writeCachedSummary(key, summary);
203
+ return summary;
204
+ }
205
+ /**
206
+ * Top-level entry: the only place the in-flight map is read, so duplicate repos
207
+ * in one result set share a fetch without ever forming a wait-for cycle.
208
+ */
209
+ function modelCardSummary(repo, token, signal) {
210
+ const key = repo.toLowerCase();
211
+ const cached = readCachedSummary(key);
212
+ if (cached)
213
+ return Promise.resolve(cached.value);
214
+ const existing = inFlightSummaries.get(key);
215
+ if (existing)
216
+ return existing;
217
+ const pending = resolveCardSummary(repo, token, [key], signal)
218
+ .catch(() => null)
219
+ .finally(() => {
220
+ inFlightSummaries.delete(key);
221
+ });
222
+ inFlightSummaries.set(key, pending);
223
+ return pending;
224
+ }
225
+ async function mapWithConcurrency(values, limit, mapper) {
226
+ let cursor = 0;
227
+ await Promise.all(Array.from({ length: Math.min(limit, values.length) }, async () => {
228
+ while (cursor < values.length) {
229
+ const index = cursor++;
230
+ await mapper(values[index], index);
231
+ }
232
+ }));
233
+ }
25
234
  /**
26
235
  * Search Hugging Face for GGUF model repos, most-downloaded first. Returns a
27
236
  * normalized shape both the TUI and the Otto app can render; drill into a result
28
237
  * with {@link listRepoQuants} to see and download its quantizations.
238
+ *
239
+ * Card summaries are a *bounded* enrichment, never a gate on the result set. Each
240
+ * one costs one or two extra round trips, so the whole batch shares one budget
241
+ * (`summaryBudgetMs`, 0 to skip them entirely); rows that miss it come back with
242
+ * `summary: null`, which every consumer already treats as "not available". When
243
+ * the budget expires the outstanding fetches are aborted rather than left to run,
244
+ * so the one-shot `otto brain search --json` the daemon shells out to can exit
245
+ * immediately instead of lingering on open sockets.
29
246
  */
30
- export async function searchModels(query, { limit = 25, token = null } = {}) {
247
+ export async function searchModels(query, { limit = 25, token = null, summaryBudgetMs = DEFAULT_SUMMARY_BUDGET_MS, } = {}) {
31
248
  const params = new URLSearchParams({
32
249
  search: query,
33
250
  filter: "gguf",
@@ -35,14 +252,12 @@ export async function searchModels(query, { limit = 25, token = null } = {}) {
35
252
  direction: "-1",
36
253
  limit: String(limit),
37
254
  });
38
- const res = await fetch(`${HF_BASE}/api/models?${params.toString()}`, {
39
- headers: authHeaders(token),
40
- });
255
+ const res = await fetchHf(`${HF_BASE}/api/models?${params.toString()}`, token, "search");
41
256
  if (!res.ok) {
42
257
  throw new Error(`Hugging Face search failed (${res.status}) for "${query}"`);
43
258
  }
44
259
  const entries = (await res.json());
45
- return entries.map((entry) => ({
260
+ const rows = entries.map((entry) => ({
46
261
  repo: entry.id,
47
262
  author: entry.author ?? entry.id.split("/")[0] ?? "",
48
263
  downloads: entry.downloads ?? 0,
@@ -50,6 +265,24 @@ export async function searchModels(query, { limit = 25, token = null } = {}) {
50
265
  updatedAt: entry.lastModified ?? null,
51
266
  gated: Boolean(entry.gated),
52
267
  }));
268
+ const summaries = rows.map(() => null);
269
+ if (summaryBudgetMs > 0 && rows.length > 0) {
270
+ const controller = new AbortController();
271
+ const gather = mapWithConcurrency(rows, 4, async (row, index) => {
272
+ summaries[index] = await modelCardSummary(row.repo, token, controller.signal);
273
+ }).catch(() => undefined);
274
+ let expire;
275
+ const deadline = new Promise((resolve) => {
276
+ expire = setTimeout(resolve, summaryBudgetMs);
277
+ // Never let the budget timer itself be the reason a CLI run stays alive.
278
+ expire.unref?.();
279
+ });
280
+ await Promise.race([gather, deadline]);
281
+ if (expire)
282
+ clearTimeout(expire);
283
+ controller.abort();
284
+ }
285
+ return rows.map((row, index) => ({ ...row, summary: summaries[index] ?? null }));
53
286
  }
54
287
  /**
55
288
  * Quality order for display: higher bits-per-weight is more faithful and larger.
@@ -77,7 +310,7 @@ export function quantRank(quant) {
77
310
  */
78
311
  export async function listRepoQuants(repo, token = null) {
79
312
  const url = `${HF_BASE}/api/models/${repo}/tree/main?recursive=true`;
80
- const res = await fetch(url, { headers: authHeaders(token) });
313
+ const res = await fetchHf(url, token, `listing for ${repo}`);
81
314
  if (!res.ok) {
82
315
  throw new Error(`Hugging Face listing failed (${res.status}) for ${repo}`);
83
316
  }
@@ -3,9 +3,9 @@ import type { Model } from "../types.js";
3
3
  export * from "./scan.js";
4
4
  export { pickModel, pickAutoModel } from "./pick.js";
5
5
  export { resolveModelsDirs, managedModelsDir, type ModelsDir } from "./dirs.js";
6
- export { pullModel, downloadRepoFiles, type PullOptions, type PullProgress, type DownloadFilesOptions, } from "./download.js";
6
+ export { pullModel, bundleDownloadPlan, downloadRepoFiles, type PullOptions, type PullProgress, type DownloadFilesOptions, } from "./download.js";
7
7
  export { matchCatalogEntry, enrichWithCatalog } from "./enrich.js";
8
- export { resolveHfToken, listRepoQuants, searchModels, repoOfModel, quantRank, type QuantOption, type RepoQuants, type ModelSearchResult, } from "./hf.js";
8
+ export { resolveHfToken, listRepoQuants, searchModels, clearCardSummaryCache, repoOfModel, quantRank, type QuantOption, type RepoQuants, type ModelSearchResult, } from "./hf.js";
9
9
  export { diskUsage, totalModelBytes, planDelete, deleteModelFiles, type DiskUsage, type DeletePlan, } from "./manage.js";
10
10
  export interface ScanModelsOptions {
11
11
  withMetadata?: boolean;
@@ -9,14 +9,17 @@ import { scan } from "./scan.js";
9
9
  export * from "./scan.js";
10
10
  export { pickModel, pickAutoModel } from "./pick.js";
11
11
  export { resolveModelsDirs, managedModelsDir } from "./dirs.js";
12
- export { pullModel, downloadRepoFiles, } from "./download.js";
12
+ export { pullModel, bundleDownloadPlan, downloadRepoFiles, } from "./download.js";
13
13
  export { matchCatalogEntry, enrichWithCatalog } from "./enrich.js";
14
- export { resolveHfToken, listRepoQuants, searchModels, repoOfModel, quantRank, } from "./hf.js";
14
+ export { resolveHfToken, listRepoQuants, searchModels, clearCardSummaryCache, repoOfModel, quantRank, } from "./hf.js";
15
15
  export { diskUsage, totalModelBytes, planDelete, deleteModelFiles, } from "./manage.js";
16
16
  /** Scan every configured models directory (managed ∪ LM Studio), de-duplicated. */
17
17
  export function scanModels(config, env = process.env, options = {}) {
18
18
  const dirs = resolveModelsDirs(config, env);
19
- const seen = new Set();
19
+ // `Model.id` is the stable repo-relative artifact identity. The same file
20
+ // can exist in the managed store and LM Studio's store at different absolute
21
+ // paths; managed is scanned first and deliberately owns that collision.
22
+ const seenIds = new Set();
20
23
  const all = [];
21
24
  for (const { dir, origin } of dirs) {
22
25
  for (const model of scan({
@@ -24,9 +27,9 @@ export function scanModels(config, env = process.env, options = {}) {
24
27
  withMetadata: options.withMetadata ?? true,
25
28
  origin,
26
29
  })) {
27
- if (seen.has(model.modelPath))
30
+ if (seenIds.has(model.id))
28
31
  continue;
29
- seen.add(model.modelPath);
32
+ seenIds.add(model.id);
30
33
  all.push(model);
31
34
  }
32
35
  }
@@ -15,6 +15,7 @@ export interface DeletePlan {
15
15
  files: string[];
16
16
  bytes: number;
17
17
  includesProjector: boolean;
18
+ componentIds: string[];
18
19
  }
19
20
  /**
20
21
  * Work out exactly which files deleting a model removes: its GGUF (all shards),
@@ -24,4 +25,7 @@ export interface DeletePlan {
24
25
  export declare function planDelete(model: Model): DeletePlan;
25
26
  /** Delete the files a {@link planDelete} chose, plus a now-empty repo directory. */
26
27
  export declare function deleteModelFiles(model: Model): DeletePlan;
28
+ /** Remove one optional bundle artifact. The primary model is never accepted,
29
+ * and a loaded profile may not lose a component underneath llama.cpp. */
30
+ export declare function deleteComponentFile(model: Model, componentId: string): DeletePlan;
27
31
  //# sourceMappingURL=manage.d.ts.map
@@ -66,7 +66,7 @@ export function planDelete(model) {
66
66
  // The projector is shared across the repo's quants; only remove it if this was
67
67
  // the last model GGUF in the directory.
68
68
  let includesProjector = false;
69
- if (model.mmprojPath) {
69
+ if (model.mmprojPath || model.components?.length) {
70
70
  const dir = path.dirname(model.modelPath);
71
71
  const doomed = new Set(files.map((f) => path.resolve(f)));
72
72
  let othersRemain = false;
@@ -86,11 +86,24 @@ export function planDelete(model) {
86
86
  othersRemain = true; // be conservative: keep the projector if unsure
87
87
  }
88
88
  if (!othersRemain) {
89
- add(model.mmprojPath);
90
- includesProjector = true;
89
+ if (model.mmprojPath) {
90
+ add(model.mmprojPath);
91
+ includesProjector = true;
92
+ }
93
+ for (const component of model.components ?? []) {
94
+ if (component.path && !files.includes(component.path))
95
+ add(component.path);
96
+ }
91
97
  }
92
98
  }
93
- return { files, bytes, includesProjector };
99
+ return {
100
+ files,
101
+ bytes,
102
+ includesProjector,
103
+ componentIds: (model.components ?? [])
104
+ .filter((component) => component.path && files.includes(component.path))
105
+ .map((component) => component.id),
106
+ };
94
107
  }
95
108
  /** Delete the files a {@link planDelete} chose, plus a now-empty repo directory. */
96
109
  export function deleteModelFiles(model) {
@@ -109,4 +122,21 @@ export function deleteModelFiles(model) {
109
122
  }
110
123
  return plan;
111
124
  }
125
+ /** Remove one optional bundle artifact. The primary model is never accepted,
126
+ * and a loaded profile may not lose a component underneath llama.cpp. */
127
+ export function deleteComponentFile(model, componentId) {
128
+ const component = model.components?.find((candidate) => candidate.id === componentId);
129
+ if (!component || !component.path)
130
+ throw new Error(`component "${componentId}" is not installed`);
131
+ if (component.required)
132
+ throw new Error("required bundle components cannot be removed separately");
133
+ const bytes = fs.statSync(component.path).size;
134
+ fs.rmSync(component.path, { force: true });
135
+ return {
136
+ files: [component.path],
137
+ bytes,
138
+ includesProjector: component.role === "vision_projector",
139
+ componentIds: [component.id],
140
+ };
141
+ }
112
142
  //# sourceMappingURL=manage.js.map
@@ -9,42 +9,11 @@ import os from "node:os";
9
9
  import path from "node:path";
10
10
  import * as gguf from "../gguf.js";
11
11
  export const LMSTUDIO_MODELS_DIR = path.join(os.homedir(), ".lmstudio", "models");
12
- // Quantisation labels as they appear in filenames, longest first so that
13
- // Q4_K_M wins over Q4_K.
14
- const QUANT_PATTERNS = [
15
- "IQ1_S",
16
- "IQ1_M",
17
- "IQ2_XXS",
18
- "IQ2_XS",
19
- "IQ2_S",
20
- "IQ2_M",
21
- "IQ3_XXS",
22
- "IQ3_XS",
23
- "IQ3_S",
24
- "IQ3_M",
25
- "IQ4_XS",
26
- "IQ4_NL",
27
- "Q2_K_S",
28
- "Q2_K",
29
- "Q3_K_S",
30
- "Q3_K_M",
31
- "Q3_K_L",
32
- "Q4_K_S",
33
- "Q4_K_M",
34
- "Q5_K_S",
35
- "Q5_K_M",
36
- "Q6_K",
37
- "Q8_0",
38
- "Q4_0",
39
- "Q4_1",
40
- "Q5_0",
41
- "Q5_1",
42
- "NVFP4",
43
- "MXFP4",
44
- "BF16",
45
- "F16",
46
- "F32",
47
- ];
12
+ // The quant is the terminal GGUF filename suffix, optionally preceded by a
13
+ // source-defined qualifier such as Muse's `UD-`. Matching a known substring
14
+ // truncates newer labels (for example Q2_K_XL -> Q2_K) and misses them when
15
+ // their exact spelling is not in a hard-coded list.
16
+ const QUANT_SUFFIX = /(?:^|[-_])((?:UD-)?(?:IQ[1-4]|Q[2-8])(?:_[A-Z0-9]+)*|NVFP\d+|MXFP\d+|BF16|F16|F32)(?:-(?:MTP|IMATRIX|DISTILL))?(?:-\d{5}-OF-\d{5})?\.GGUF$/i;
48
17
  const MULTIPART = /-(\d{5})-of-(\d{5})\.gguf$/i;
49
18
  function walk(dir, out = []) {
50
19
  let entries;
@@ -64,12 +33,9 @@ function walk(dir, out = []) {
64
33
  return out;
65
34
  }
66
35
  export function detectQuant(filename) {
67
- const upper = filename.toUpperCase();
68
- for (const pattern of QUANT_PATTERNS) {
69
- if (upper.includes(pattern))
70
- return pattern;
71
- }
72
- return null;
36
+ const match = path.basename(filename).match(QUANT_SUFFIX);
37
+ // `UD-` is a source filename qualifier, not part of the user-facing quant.
38
+ return match?.[1]?.replace(/^UD-/i, "").toUpperCase() ?? null;
73
39
  }
74
40
  export function isProjectorFile(filename) {
75
41
  return /^mmproj/i.test(path.basename(filename));
@@ -1,3 +1,4 @@
1
+ import { Supervisor } from "../service/supervisor.js";
1
2
  import type { Model, Runtime } from "../types.js";
2
3
  import type { Profile } from "../config/schema.js";
3
4
  /**
@@ -31,6 +32,8 @@ export interface CalibrateOptions {
31
32
  profile: Profile;
32
33
  samples?: number[];
33
34
  internalPort?: number;
35
+ /** Reuse the host's resident supervisor instead of creating a sidecar server. */
36
+ supervisor?: Supervisor;
34
37
  onProgress?: (event: CalibrateProgress) => void;
35
38
  }
36
39
  /** The measured KV-cache profile calibration produces. */
@@ -45,5 +48,5 @@ export interface CalibrationMeasurement {
45
48
  vision: boolean;
46
49
  measuredAt: string;
47
50
  }
48
- export declare function calibrate({ runtime, model, profile, samples, internalPort, onProgress, }: CalibrateOptions): Promise<CalibrationMeasurement>;
51
+ export declare function calibrate({ runtime, model, profile, samples, internalPort, supervisor: optionsSupervisor, onProgress, }: CalibrateOptions): Promise<CalibrationMeasurement>;
49
52
  //# sourceMappingURL=calibrate.d.ts.map
@@ -12,21 +12,24 @@ import { DEFAULT_INTERNAL_PORT, Supervisor } from "../service/supervisor.js";
12
12
  * buffers) cancels out of the difference.
13
13
  */
14
14
  export const DEFAULT_SAMPLES = [8192, 65536];
15
- export async function calibrate({ runtime, model, profile, samples = DEFAULT_SAMPLES, internalPort = DEFAULT_INTERNAL_PORT + 1, onProgress = () => { }, }) {
16
- if (samples.length < 2)
15
+ export async function calibrate({ runtime, model, profile, samples, internalPort = DEFAULT_INTERNAL_PORT + 1, supervisor: optionsSupervisor, onProgress = () => { }, }) {
16
+ const nativeContext = model.metadata?.contextLength ?? null;
17
+ const effectiveSamples = samples ??
18
+ (nativeContext ? [8192, nativeContext * profile.contextMultiplier] : DEFAULT_SAMPLES);
19
+ if (effectiveSamples.length < 2)
17
20
  throw new Error("calibration needs at least two context sizes");
18
- const native = model.metadata?.contextLength || Math.max(...samples);
21
+ const native = (nativeContext || Math.max(...effectiveSamples)) * profile.contextMultiplier;
19
22
  const points = [];
20
- for (const contextSize of samples) {
23
+ for (const contextSize of effectiveSamples) {
21
24
  if (contextSize > native) {
22
- onProgress({ phase: "skip", contextSize, reason: `exceeds native context ${native}` });
25
+ onProgress({ phase: "skip", contextSize, reason: `exceeds configured context ${native}` });
23
26
  continue;
24
27
  }
25
- const supervisor = new Supervisor({ runtime, internalPort });
28
+ const supervisor = optionsSupervisor ?? new Supervisor({ runtime, internalPort });
26
29
  onProgress({ phase: "loading", contextSize });
27
30
  const baseline = await usedBytes();
28
31
  try {
29
- await supervisor.start(model, { ...profile, contextSize });
32
+ await supervisor.start(model, { ...profile, contextSize }, { preserveLogs: Boolean(optionsSupervisor) });
30
33
  const used = supervisor.vramAtReadyBytes ?? (await usedBytes());
31
34
  const delta = Number(used) - Number(supervisor.vramBaselineBytes ?? baseline);
32
35
  points.push({ contextSize, deltaBytes: delta, loadSeconds: supervisor.loadSeconds });
@@ -70,8 +70,10 @@ export interface SweepOptions {
70
70
  maxTokens?: number;
71
71
  temperature?: number;
72
72
  internalPort?: number;
73
+ /** Reuse the host's resident supervisor instead of creating a sidecar server. */
74
+ supervisor?: Supervisor;
73
75
  onProgress?: (event: SweepProgress) => void;
74
76
  }
75
- export declare function sweep({ runtime, model, profile, budgets, maxTokens, temperature, internalPort, onProgress, }: SweepOptions): Promise<SweepReport>;
77
+ export declare function sweep({ runtime, model, profile, budgets, maxTokens, temperature, internalPort, supervisor: optionsSupervisor, onProgress, }: SweepOptions): Promise<SweepReport>;
76
78
  export {};
77
79
  //# sourceMappingURL=sweep.d.ts.map
package/dist/ops/sweep.js CHANGED
@@ -81,13 +81,13 @@ export async function runTrial({ supervisor, maxTokens, temperature, }) {
81
81
  contentPerSecond: elapsedSeconds > 0 ? content.length / elapsedSeconds : 0,
82
82
  };
83
83
  }
84
- export async function sweep({ runtime, model, profile, budgets = DEFAULT_BUDGETS, maxTokens = 8192, temperature = 0.7, internalPort = DEFAULT_INTERNAL_PORT + 2, onProgress = () => { }, }) {
84
+ export async function sweep({ runtime, model, profile, budgets = DEFAULT_BUDGETS, maxTokens = 8192, temperature = 0.7, internalPort = DEFAULT_INTERNAL_PORT + 2, supervisor: optionsSupervisor, onProgress = () => { }, }) {
85
85
  const results = [];
86
86
  for (const budget of budgets) {
87
- const supervisor = new Supervisor({ runtime, internalPort });
87
+ const supervisor = optionsSupervisor ?? new Supervisor({ runtime, internalPort });
88
88
  onProgress({ phase: "loading", budget });
89
89
  try {
90
- await supervisor.start(model, { ...profile, reasoningBudget: budget });
90
+ await supervisor.start(model, { ...profile, reasoningBudget: budget }, { preserveLogs: Boolean(optionsSupervisor) });
91
91
  onProgress({ phase: "generating", budget });
92
92
  const trial = await runTrial({ supervisor, maxTokens, temperature });
93
93
  results.push({ budget, ...trial, error: null });
@@ -4,7 +4,7 @@
4
4
  * managed one, since both resolve to a `Runtime` (exe + optional vendorDir).
5
5
  */
6
6
  import type { Profile } from "../config/schema.js";
7
- import type { Runtime } from "../types.js";
7
+ import type { Model, Runtime } from "../types.js";
8
8
  export interface ServeTarget {
9
9
  port: number;
10
10
  host?: string;
@@ -28,7 +28,7 @@ export declare function buildEnv(runtime: Runtime, baseEnv?: NodeJS.ProcessEnv,
28
28
  * Only settings that demonstrably matter for stable local inference are emitted -
29
29
  * no experimental sampler knobs.
30
30
  */
31
- export declare function buildArgs(profile: Profile, { port, host }: ServeTarget): string[];
31
+ export declare function buildArgs(profile: Profile, { port, host }: ServeTarget, model?: Model): string[];
32
32
  /** The same command as a copy-pasteable shell line, for the TUI to display. */
33
33
  export declare function formatCommand(runtime: Runtime, args: string[]): string;
34
34
  //# sourceMappingURL=args.d.ts.map
@@ -29,7 +29,7 @@ export function buildEnv(runtime, baseEnv = process.env, platform = process.plat
29
29
  * Only settings that demonstrably matter for stable local inference are emitted -
30
30
  * no experimental sampler knobs.
31
31
  */
32
- export function buildArgs(profile, { port, host = "127.0.0.1" }) {
32
+ export function buildArgs(profile, { port, host = "127.0.0.1" }, model) {
33
33
  const args = [
34
34
  "-m",
35
35
  profile.modelPath ?? "",
@@ -52,6 +52,11 @@ export function buildArgs(profile, { port, host = "127.0.0.1" }) {
52
52
  if (profile.vision && profile.mmprojPath) {
53
53
  args.push("--mmproj", profile.mmprojPath);
54
54
  }
55
+ // Component paths were resolved by the host from the catalog manifest. Only
56
+ // the known llama.cpp role is emitted; clients never supply process paths.
57
+ const drafter = profile.componentPaths?.speculative_drafter;
58
+ if (drafter)
59
+ args.push("--model-draft", drafter);
55
60
  // The setting that was actually breaking long agentic runs.
56
61
  if (profile.reasoningBudget !== null && profile.reasoningBudget !== undefined) {
57
62
  args.push("--reasoning-budget", String(profile.reasoningBudget));
@@ -61,10 +66,22 @@ export function buildArgs(profile, { port, host = "127.0.0.1" }) {
61
66
  }
62
67
  if (profile.parallelSlots)
63
68
  args.push("--parallel", String(profile.parallelSlots));
69
+ if (profile.contextMultiplier > 1) {
70
+ const nativeContext = model?.metadata?.contextLength;
71
+ args.push("--rope-scaling", "yarn", "--rope-scale", String(profile.contextMultiplier), ...(nativeContext ? ["--yarn-orig-ctx", String(nativeContext)] : []), ...(model?.metadata?.arch
72
+ ? ["--override-kv", `${model.metadata.arch}.context_length=int:${profile.contextSize}`]
73
+ : []));
74
+ }
64
75
  if (profile.batchSize)
65
76
  args.push("-b", String(profile.batchSize));
66
77
  if (profile.ubatchSize)
67
78
  args.push("-ub", String(profile.ubatchSize));
79
+ if (profile.chatTemplateFile) {
80
+ args.push("--chat-template-file", profile.chatTemplateFile);
81
+ if (Object.keys(profile.chatTemplateKwargs ?? {}).length > 0) {
82
+ args.push("--chat-template-kwargs", JSON.stringify(profile.chatTemplateKwargs));
83
+ }
84
+ }
68
85
  if (profile.extraArgs && profile.extraArgs.length)
69
86
  args.push(...profile.extraArgs);
70
87
  return args;
@@ -3,7 +3,7 @@ import type { Runtime } from "../types.js";
3
3
  import { type InstallProgress, type RuntimeTarget } from "./managed.js";
4
4
  export { BACKENDS_DIR, LMSTUDIO_ROOT, listRuntimes as listLmStudioRuntimes } from "./lmstudio.js";
5
5
  export { buildArgs, buildEnv, formatCommand, type ServeTarget } from "./args.js";
6
- export { installManagedRuntime, listManagedRuntimes, listRuntimeDevices, verifyRuntimeExecutable, defaultRuntimeSpec, extractArchive, resolveRuntimeVariant, serverExeName, supportedVariants, DEFAULT_LLAMA_BUILD, type RuntimeSpec, type RuntimeTarget, type RuntimeVariant, type InstallProgress, } from "./managed.js";
6
+ export { installManagedRuntime, removeManagedRuntime, listManagedRuntimes, listRuntimeDevices, verifyRuntimeExecutable, defaultRuntimeSpec, extractArchive, resolveRuntimeVariant, serverExeName, supportedVariants, DEFAULT_LLAMA_BUILD, listRuntimeReleases, latestRuntimeBuild, resolveLatestBuildOrPin, MissingAssetError, type ResolvedBuild, type RuntimeRelease, type RuntimeSpec, type RuntimeTarget, type RuntimeVariant, type InstallProgress, } from "./managed.js";
7
7
  /** Every runtime available on this machine, managed first then LM Studio. */
8
8
  export declare function listAllRuntimes(env?: NodeJS.ProcessEnv): Runtime[];
9
9
  /**
@@ -14,6 +14,13 @@ export declare function listAllRuntimes(env?: NodeJS.ProcessEnv): Runtime[];
14
14
  export declare function probeNvidiaGpu(): Promise<boolean>;
15
15
  /** The runtime to use given config, or null when none is available. */
16
16
  export declare function resolveRuntime(config: BrainConfig, env?: NodeJS.ProcessEnv): Runtime | null;
17
+ /**
18
+ * The numeric llama.cpp build carried by a resolved runtime, when its source
19
+ * identifies one. LM Studio and explicit overrides need not expose their
20
+ * upstream build, so callers must treat null as incompatible with a component
21
+ * that declares a minimum build rather than guessing compatibility.
22
+ */
23
+ export declare function runtimeBuild(runtime: Runtime | null | undefined): number | null;
17
24
  /** Ensure a runtime exists, downloading the default managed build if none does. */
18
25
  export declare function ensureRuntime(config: BrainConfig, env?: NodeJS.ProcessEnv, onProgress?: (progress: InstallProgress) => void, target?: RuntimeTarget): Promise<Runtime>;
19
26
  //# sourceMappingURL=index.d.ts.map