@otto-code/brain 0.8.7 → 0.8.8

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/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));
@@ -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));
@@ -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
  /**
@@ -16,7 +16,7 @@ import { listRuntimes as listLmStudioRuntimes, resolveOverride } from "./lmstudi
16
16
  import { defaultRuntimeSpec, installManagedRuntime, listManagedRuntimes, } from "./managed.js";
17
17
  export { BACKENDS_DIR, LMSTUDIO_ROOT, listRuntimes as listLmStudioRuntimes } from "./lmstudio.js";
18
18
  export { buildArgs, buildEnv, formatCommand } from "./args.js";
19
- export { installManagedRuntime, listManagedRuntimes, listRuntimeDevices, verifyRuntimeExecutable, defaultRuntimeSpec, extractArchive, resolveRuntimeVariant, serverExeName, supportedVariants, DEFAULT_LLAMA_BUILD, } from "./managed.js";
19
+ export { installManagedRuntime, removeManagedRuntime, listManagedRuntimes, listRuntimeDevices, verifyRuntimeExecutable, defaultRuntimeSpec, extractArchive, resolveRuntimeVariant, serverExeName, supportedVariants, DEFAULT_LLAMA_BUILD, listRuntimeReleases, latestRuntimeBuild, resolveLatestBuildOrPin, MissingAssetError, } from "./managed.js";
20
20
  /** Every runtime available on this machine, managed first then LM Studio. */
21
21
  export function listAllRuntimes(env = process.env) {
22
22
  const paths = resolveBrainPaths(env);
@@ -30,6 +30,33 @@ export interface InstallProgress {
30
30
  * names (see the module header) - the naming scheme is not stable across tags.
31
31
  */
32
32
  export declare const DEFAULT_LLAMA_BUILD = "b10265";
33
+ export interface RuntimeRelease {
34
+ build: string;
35
+ publishedAt: string | null;
36
+ }
37
+ /** Official llama.cpp builds, newest first. Only numbered build releases are actionable. */
38
+ export declare function listRuntimeReleases(limit?: number): Promise<RuntimeRelease[]>;
39
+ /** Resolve the latest official build at the last responsible moment. */
40
+ export declare function latestRuntimeBuild(): Promise<string>;
41
+ export interface ResolvedBuild {
42
+ build: string;
43
+ /** A single line explaining a fallback, or null when "latest" resolved. */
44
+ warning: string | null;
45
+ }
46
+ /**
47
+ * The build to install for a "latest" request, with the pin as the safety net.
48
+ *
49
+ * Asking upstream is a best effort, never a precondition. `listRuntimeReleases`
50
+ * hits api.github.com unauthenticated, which is rate limited to 60 requests per
51
+ * hour per IP: behind NAT, on a corporate egress or on a CI runner that is a 403
52
+ * on an address that has spent its budget on something else entirely. An install
53
+ * the pinned build can serve must not fail because a version lookup did.
54
+ *
55
+ * The warning is deliberately one line. The daemon's BrainOpsManager keeps the
56
+ * *last* stderr line as the job's message, so a wrapped warning would surface in
57
+ * the GUI as a dangling fragment.
58
+ */
59
+ export declare function resolveLatestBuildOrPin(): Promise<ResolvedBuild>;
33
60
  /** The binary name llama.cpp ships for a platform. */
34
61
  export declare function serverExeName(platform?: NodeJS.Platform): string;
35
62
  /**
@@ -49,6 +76,17 @@ export declare function supportedVariants(platform?: NodeJS.Platform, arch?: str
49
76
  * best accelerator first and newest build first within an accelerator.
50
77
  */
51
78
  export declare function listManagedRuntimes(runtimesDir: string, platform?: NodeJS.Platform): Runtime[];
79
+ /**
80
+ * An asset upstream does not serve at all, which is what a renamed asset or a
81
+ * tag without the expected build looks like from here. Distinguished from every
82
+ * other download failure because it is the one a caller can answer by retrying
83
+ * a build whose asset names are pinned and tested, rather than by retrying the
84
+ * same URL later.
85
+ */
86
+ export declare class MissingAssetError extends Error {
87
+ readonly url: string;
88
+ constructor(url: string);
89
+ }
52
90
  /**
53
91
  * The actionable message for a dynamic-loader failure, or null when the output
54
92
  * is not one. Pure, so the classification is testable without spawning.
@@ -87,4 +125,6 @@ export declare function listRuntimeDevices(runtime: Runtime, platform?: NodeJS.P
87
125
  export declare function extractArchive(archivePath: string, destDir: string, platform?: NodeJS.Platform): Promise<void>;
88
126
  /** Download + extract a runtime spec into runtimesDir and return the Runtime. */
89
127
  export declare function installManagedRuntime(spec: RuntimeSpec, runtimesDir: string, onProgress?: (progress: InstallProgress) => void): Promise<Runtime>;
128
+ /** Remove one Otto-managed runtime. LM Studio files are outside this root. */
129
+ export declare function removeManagedRuntime(runtimesDir: string, name: string): void;
90
130
  //# sourceMappingURL=managed.d.ts.map