@otto-code/brain 0.8.6 → 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.
@@ -12,6 +12,10 @@ export const ProfileSchema = z
12
12
  modelId: z.string().nullable().default(null),
13
13
  modelPath: z.string().nullable().default(null),
14
14
  mmprojPath: z.string().nullable().default(null),
15
+ /** Stable bundle component ids enabled for this load. Paths are re-derived. */
16
+ enabledComponents: z.array(z.string()).default([]),
17
+ /** Derived component paths for the launcher; never accepted from clients. */
18
+ componentPaths: z.record(z.string()).default({}),
15
19
  contextSize: z.number(),
16
20
  cacheTypeK: z.string().default("q8_0"),
17
21
  cacheTypeV: z.string().default("q8_0"),
@@ -162,6 +166,8 @@ export const DEFAULT_BRAIN_CONFIG = BrainConfigSchema.parse({});
162
166
  export const CatalogModelSchema = z
163
167
  .object({
164
168
  id: z.string(),
169
+ /** Retired Otto-curated ids this canonical catalog entry replaces. */
170
+ replaces: z.array(z.string()).optional(),
165
171
  name: z.string(),
166
172
  publisher: z.string().optional(),
167
173
  hfRepo: z.string(),
@@ -178,6 +184,24 @@ export const CatalogModelSchema = z
178
184
  tier: z.string().optional(),
179
185
  why: z.string().optional(),
180
186
  status: z.string().optional(),
187
+ /** Declared only for multi-artifact model bundles. Plain models omit it. */
188
+ components: z
189
+ .array(z
190
+ .object({
191
+ id: z.string(),
192
+ label: z.string(),
193
+ description: z.string(),
194
+ role: z.enum(["vision_projector", "speculative_drafter"]),
195
+ hfRepo: z.string().optional(),
196
+ file: z.string(),
197
+ bytes: z.number().nullable().optional(),
198
+ required: z.boolean().default(false),
199
+ defaultDownload: z.boolean().default(false),
200
+ defaultLoad: z.boolean().default(false),
201
+ minRuntimeBuild: z.number().optional(),
202
+ })
203
+ .strict())
204
+ .optional(),
181
205
  })
182
206
  .passthrough();
183
207
  export const CatalogSchema = z
@@ -43,23 +43,25 @@ export function saveProfilesStore(store, paths = resolveBrainPaths()) {
43
43
  export function loadCatalog(paths = resolveBrainPaths()) {
44
44
  const current = readJson(paths.catalogFile, CatalogSchema);
45
45
  if (current) {
46
- // The catalog is intentionally persisted so local Brain homes survive
47
- // package updates, but additive metadata still needs to reach existing
48
- // homes. Backfill only fields introduced by the seed catalog; user-owned
49
- // catalog values remain authoritative.
46
+ // Curated entries are Otto-owned product copy. Replace every entry whose
47
+ // stable download id ships in the seed so catalog corrections reach every
48
+ // existing Brain home after an upgrade. Keep entries with unknown ids: they
49
+ // are the only user-owned catalog records and must survive product updates.
50
+ //
51
+ // Do not merge field-by-field. A partial merge leaves old names or stale
52
+ // descriptions behind indefinitely, which is precisely what a catalog
53
+ // migration is meant to prevent.
50
54
  const legacy = readJson(path.join(packageRoot(), "config", "downloads.json"), CatalogSchema);
51
55
  if (!legacy)
52
56
  return current;
53
- const seedById = new Map(legacy.models.map((model) => [model.id, model]));
54
- let changed = false;
55
- const models = current.models.map((model) => {
56
- const seed = seedById.get(model.id);
57
- if (model.reasoningEfforts === undefined && seed?.reasoningEfforts !== undefined) {
58
- changed = true;
59
- return { ...model, reasoningEfforts: seed.reasoningEfforts };
60
- }
61
- return model;
62
- });
57
+ const seedIds = new Set(legacy.models.map((model) => model.id));
58
+ // A source repository can change while the underlying curated model stays
59
+ // the same. Those retired ids are product-owned too: drop them rather than
60
+ // displaying an obsolete duplicate beside its canonical replacement.
61
+ const retiredSeedIds = new Set(legacy.models.flatMap((model) => model.replaces ?? []));
62
+ const userModels = current.models.filter((model) => !seedIds.has(model.id) && !retiredSeedIds.has(model.id));
63
+ const models = [...legacy.models, ...userModels];
64
+ const changed = JSON.stringify(models) !== JSON.stringify(current.models);
63
65
  if (!changed)
64
66
  return current;
65
67
  const merged = { ...current, models };
@@ -11,6 +11,13 @@ export interface PullOptions {
11
11
  token?: string | null;
12
12
  onProgress?: (progress: PullProgress) => void;
13
13
  }
14
+ /** Exact, manifest-driven files for a bundle selection. No quant discovery is
15
+ * involved, so a component pull can never select an arbitrary projector. */
16
+ export declare function bundleDownloadPlan(model: CatalogModel, componentIds?: string[], primaryFiles?: string[], primaryBytes?: number): {
17
+ repo: string;
18
+ files: string[];
19
+ totalBytes: number | null;
20
+ };
14
21
  /** Download the model file; returns the local path it was written to. */
15
22
  export declare function pullModel({ model, destRoot, file, token, onProgress, }: PullOptions): Promise<string>;
16
23
  export interface DownloadFilesOptions {
@@ -4,7 +4,7 @@
4
4
  * `<managedModelsDir>/<publisher>/<repo>/<file>` to mirror the LM Studio layout
5
5
  * the scanner already understands.
6
6
  */
7
- import { createWriteStream, existsSync, mkdirSync, rmSync } from "node:fs";
7
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { Readable } from "node:stream";
10
10
  import { pipeline } from "node:stream/promises";
@@ -35,41 +35,184 @@ function deriveFileFromId(id) {
35
35
  function authHeaders(token) {
36
36
  return token ? { authorization: `Bearer ${token}` } : {};
37
37
  }
38
+ function partialMetadataPath(tmp) {
39
+ return `${tmp}.json`;
40
+ }
41
+ function clearPartial(tmp) {
42
+ rmSync(tmp, { force: true });
43
+ rmSync(partialMetadataPath(tmp), { force: true });
44
+ }
45
+ /** Read only a partial whose source identity and total length were recorded. */
46
+ function resumablePartial(tmp) {
47
+ try {
48
+ const bytes = statSync(tmp).size;
49
+ const metadata = JSON.parse(readFileSync(partialMetadataPath(tmp), "utf8"));
50
+ if (bytes <= 0 ||
51
+ !metadata.etag ||
52
+ !Number.isSafeInteger(metadata.totalBytes) ||
53
+ metadata.totalBytes <= bytes) {
54
+ return null;
55
+ }
56
+ return { bytes, metadata };
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ function contentLength(response) {
63
+ const length = Number(response.headers.get("content-length"));
64
+ return Number.isSafeInteger(length) && length >= 0 ? length : null;
65
+ }
66
+ function isMatchingRangeResponse(response, partial) {
67
+ if (response.status !== 206)
68
+ return false;
69
+ const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get("content-range") ?? "");
70
+ if (!match)
71
+ return false;
72
+ const start = Number(match[1]);
73
+ const end = Number(match[2]);
74
+ const totalBytes = Number(match[3]);
75
+ const length = contentLength(response);
76
+ const etag = response.headers.get("etag");
77
+ return (start === partial.bytes &&
78
+ end >= start &&
79
+ totalBytes === partial.metadata.totalBytes &&
80
+ length === end - start + 1 &&
81
+ (!etag || etag === partial.metadata.etag));
82
+ }
83
+ function writePartialMetadata(tmp, metadata) {
84
+ writeFileSync(partialMetadataPath(tmp), JSON.stringify(metadata), {
85
+ encoding: "utf8",
86
+ mode: 0o600,
87
+ });
88
+ }
38
89
  /**
39
90
  * 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.
91
+ * false) if the file already exists. Interrupted downloads keep a `.part` only
92
+ * when its ETag and complete length were recorded. A later attempt resumes it
93
+ * only after Hugging Face confirms the exact byte range belongs to that same
94
+ * representation; otherwise it restarts cleanly.
42
95
  */
43
96
  async function streamRepoFile(url, destPath, label, token, onProgress, received) {
44
97
  const tmp = `${destPath}.part`;
45
- // Remove leftovers from an earlier interrupted attempt before starting a
46
- // fresh request, including when this request fails before opening a stream.
47
- rmSync(tmp, { force: true });
48
98
  mkdirSync(path.dirname(destPath), { recursive: true });
49
- if (existsSync(destPath))
99
+ if (existsSync(destPath)) {
100
+ clearPartial(tmp);
50
101
  return false;
51
- const response = await fetch(url, { headers: authHeaders(token) });
52
- if (!response.ok || !response.body) {
102
+ }
103
+ let partial = resumablePartial(tmp);
104
+ if (!partial && existsSync(tmp))
105
+ clearPartial(tmp);
106
+ let response;
107
+ if (partial) {
108
+ response = await fetch(url, {
109
+ headers: {
110
+ ...authHeaders(token),
111
+ range: `bytes=${partial.bytes}-`,
112
+ "if-range": partial.metadata.etag,
113
+ },
114
+ });
115
+ // A 200 is the defined If-Range response when the remote representation
116
+ // changed (or the server cannot range). The saved bytes are no longer safe
117
+ // to append, so start over with the complete response it supplied.
118
+ if (response.status === 200) {
119
+ clearPartial(tmp);
120
+ partial = null;
121
+ }
122
+ else if (!isMatchingRangeResponse(response, partial)) {
123
+ // Discard the partial before failing. A persistent non-200/non-206 answer
124
+ // to the ranged request (an expired signed CDN redirect returning 403, an
125
+ // upstream 5xx, a proxy that strips Range) would otherwise wedge this file
126
+ // forever: the partial stays valid, so every later attempt replays the
127
+ // same ranged request and gets the same answer, and only a 200 ever clears
128
+ // it. Dropping it here costs the resume but lets the next attempt restart
129
+ // cleanly from byte zero.
130
+ clearPartial(tmp);
131
+ throw new Error(`download resume failed (${response.status}) for ${url}`);
132
+ }
133
+ }
134
+ else {
135
+ response = await fetch(url, { headers: authHeaders(token) });
136
+ }
137
+ if ((response.status !== 200 && response.status !== 206) || !response.body) {
53
138
  throw new Error(`download failed (${response.status}) for ${url}`);
54
139
  }
55
- const totalBytes = Number(response.headers.get("content-length")) || undefined;
140
+ const append = partial !== null;
141
+ const resumedBytes = partial?.bytes ?? 0;
142
+ const totalBytes = partial?.metadata.totalBytes ?? contentLength(response);
143
+ if (!append) {
144
+ const etag = response.headers.get("etag");
145
+ if (etag && totalBytes !== null && totalBytes > 0) {
146
+ writePartialMetadata(tmp, { etag, totalBytes });
147
+ }
148
+ else {
149
+ // An unidentifiable partial can never be proved safe to resume.
150
+ clearPartial(tmp);
151
+ }
152
+ }
56
153
  const body = Readable.fromWeb(response.body);
154
+ if (append)
155
+ received.bytes += resumedBytes;
57
156
  body.on("data", (chunk) => {
58
157
  received.bytes += chunk.length;
59
- onProgress?.({ file: label, receivedBytes: received.bytes, totalBytes });
158
+ onProgress?.({
159
+ file: label,
160
+ receivedBytes: received.bytes,
161
+ totalBytes: totalBytes ?? undefined,
162
+ });
60
163
  });
61
164
  try {
62
- await pipeline(body, createWriteStream(tmp));
63
- const { renameSync } = await import("node:fs");
165
+ await pipeline(body, createWriteStream(tmp, { flags: append ? "a" : "w" }));
166
+ // Undici rejects the body stream on a premature close (measured: a short
167
+ // Content-Length body, with either a destroyed socket or a clean FIN, and a
168
+ // truncated chunked body all reject), so a cut transfer never reaches this
169
+ // line. A short but *valid* 206 does: a server may answer an open-ended
170
+ // `bytes=N-` with any narrower range, and isMatchingRangeResponse accepts
171
+ // that because it requires end >= start, not end === totalBytes - 1.
172
+ // Renaming then would publish a truncated model as a complete one. The
173
+ // partial survives the throw below, so the next attempt resumes from the
174
+ // bytes this one did add.
175
+ const writtenBytes = statSync(tmp).size;
176
+ if (totalBytes !== null && totalBytes > 0 && writtenBytes !== totalBytes) {
177
+ throw new Error(`download incomplete for ${url}: wrote ${writtenBytes} of ${totalBytes} bytes`);
178
+ }
64
179
  renameSync(tmp, destPath);
180
+ rmSync(partialMetadataPath(tmp), { force: true });
65
181
  }
66
- finally {
67
- // Cancellation kills the CLI child while the stream is still writing. Do
68
- // not leave a truncated `.part` behind for the next quant attempt.
69
- rmSync(tmp, { force: true });
182
+ catch (error) {
183
+ // Keep only a partial that has a recorded immutable identity and full
184
+ // length. Everything else is deliberately discarded on the next attempt.
185
+ if (!resumablePartial(tmp))
186
+ clearPartial(tmp);
187
+ throw error;
70
188
  }
71
189
  return true;
72
190
  }
191
+ /** Exact, manifest-driven files for a bundle selection. No quant discovery is
192
+ * involved, so a component pull can never select an arbitrary projector. */
193
+ export function bundleDownloadPlan(model, componentIds = [], primaryFiles, primaryBytes) {
194
+ const selected = new Set(componentIds);
195
+ const known = new Set((model.components ?? []).map((component) => component.id));
196
+ const unknown = componentIds.filter((id) => !known.has(id));
197
+ if (unknown.length)
198
+ throw new Error(`unknown bundle components: ${unknown.join(", ")}`);
199
+ const components = (model.components ?? []).filter((component) => component.required || selected.has(component.id));
200
+ const foreign = components.find((component) => (component.hfRepo ?? model.hfRepo) !== model.hfRepo);
201
+ if (foreign)
202
+ throw new Error(`component ${foreign.id} uses a separate repository and needs its own plan`);
203
+ return {
204
+ repo: model.hfRepo,
205
+ files: [
206
+ ...(primaryFiles ?? [resolveFileName(model)]),
207
+ ...components.map((component) => component.file),
208
+ ],
209
+ totalBytes: (primaryBytes ?? model.approxWeightsBytes) === undefined ||
210
+ components.some((component) => component.bytes == null)
211
+ ? null
212
+ : (primaryBytes ?? model.approxWeightsBytes) +
213
+ components.reduce((sum, component) => sum + (component.bytes ?? 0), 0),
214
+ };
215
+ }
73
216
  /** Download the model file; returns the local path it was written to. */
74
217
  export async function pullModel({ model, destRoot, file, token, onProgress, }) {
75
218
  const fileName = resolveFileName(model, file);
@@ -1,22 +1,3 @@
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
1
  import type { Catalog, CatalogModel } from "../config/schema.js";
21
2
  import type { Model } from "../types.js";
22
3
  /**
@@ -1,6 +1,32 @@
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 fs from "node:fs";
21
+ import path from "node:path";
1
22
  /** Normalize a repo/id path: forward slashes, lowercased, trailing slashes trimmed. */
2
23
  function normalizePath(value) {
3
- return value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
24
+ const normalized = value.replaceAll("\\", "/");
25
+ let end = normalized.length;
26
+ while (end > 0 && normalized.charCodeAt(end - 1) === 47) {
27
+ end -= 1;
28
+ }
29
+ return normalized.slice(0, end).toLowerCase();
4
30
  }
5
31
  /** The final path segment (file name) of a scanned model's id. */
6
32
  function basenameOf(id) {
@@ -48,16 +74,21 @@ export function matchCatalogEntry(model, catalog) {
48
74
  * through untouched. Never throws.
49
75
  */
50
76
  export function enrichWithCatalog(models, catalog) {
51
- if (catalog.models.length === 0)
52
- return models;
53
77
  return models.map((model) => {
54
78
  const entry = matchCatalogEntry(model, catalog);
55
79
  if (!entry)
56
- return model;
80
+ return enrichDiscoveredProjector(model);
81
+ const components = resolveComponents(model, entry);
82
+ const projector = components?.find((component) => component.role === "vision_projector");
57
83
  return {
58
84
  ...model,
59
85
  catalogId: entry.id,
60
86
  catalogHfRepo: entry.hfRepo,
87
+ components,
88
+ // A manifest is authoritative. Do not pair a random same-directory
89
+ // projector when the catalog declares the exact companion artifact.
90
+ mmprojPath: projector?.path ?? (components ? null : model.mmprojPath),
91
+ mmprojBytes: projector?.bytes ?? (components ? 0 : model.mmprojBytes),
61
92
  useCases: entry.useCases,
62
93
  tier: entry.tier,
63
94
  thinking: entry.thinking,
@@ -66,4 +97,64 @@ export function enrichWithCatalog(models, catalog) {
66
97
  };
67
98
  });
68
99
  }
100
+ /** Promote a scanner-paired projector in an arbitrary Hugging Face repository
101
+ * into the same component inventory shape used by curated bundles. */
102
+ function enrichDiscoveredProjector(model) {
103
+ if (!model.mmprojPath)
104
+ return model;
105
+ return {
106
+ ...model,
107
+ components: [
108
+ {
109
+ id: "vision-projector",
110
+ label: "Vision projector",
111
+ description: "Adds image understanding",
112
+ role: "vision_projector",
113
+ path: model.mmprojPath,
114
+ bytes: model.mmprojBytes,
115
+ required: false,
116
+ defaultDownload: false,
117
+ defaultLoad: true,
118
+ available: true,
119
+ },
120
+ ],
121
+ };
122
+ }
123
+ function resolveComponents(model, entry) {
124
+ if (!entry.components)
125
+ return undefined;
126
+ const modelDir = path.dirname(model.modelPath);
127
+ return entry.components.map((component) => {
128
+ const componentRepo = component.hfRepo ?? entry.hfRepo;
129
+ // A selected catalog primary and its declared companions share a repo in the
130
+ // managed layout. For a companion repository, derive its absolute path from
131
+ // the scanned model's models root rather than accepting a client path.
132
+ const repoTail = componentRepo.split("/").join(path.sep);
133
+ const marker = entry.hfRepo.split("/").join(path.sep);
134
+ const root = modelDir.endsWith(marker) ? modelDir.slice(0, -marker.length) : modelDir;
135
+ const candidate = path.resolve(root, repoTail, component.file);
136
+ let bytes = 0;
137
+ try {
138
+ bytes = fs.statSync(candidate).size;
139
+ }
140
+ catch {
141
+ bytes = component.bytes ?? 0;
142
+ }
143
+ const available = fs.existsSync(candidate);
144
+ return {
145
+ id: component.id,
146
+ label: component.label,
147
+ description: component.description,
148
+ role: component.role,
149
+ path: available ? candidate : null,
150
+ bytes,
151
+ required: component.required,
152
+ defaultDownload: component.defaultDownload,
153
+ defaultLoad: component.defaultLoad,
154
+ available,
155
+ ...(available ? {} : { unavailableReason: "Not downloaded" }),
156
+ ...(component.minRuntimeBuild ? { minRuntimeBuild: component.minRuntimeBuild } : {}),
157
+ };
158
+ });
159
+ }
69
160
  //# sourceMappingURL=enrich.js.map
@@ -38,15 +38,28 @@ export interface ModelSearchResult {
38
38
  likes: number;
39
39
  updatedAt: string | null;
40
40
  gated: boolean;
41
+ summary: string | null;
41
42
  }
43
+ /** Drop every cached summary. Exported for tests and for a token change, which
44
+ * can flip which repos are readable at all. */
45
+ export declare function clearCardSummaryCache(): void;
42
46
  /**
43
47
  * Search Hugging Face for GGUF model repos, most-downloaded first. Returns a
44
48
  * normalized shape both the TUI and the Otto app can render; drill into a result
45
49
  * with {@link listRepoQuants} to see and download its quantizations.
50
+ *
51
+ * Card summaries are a *bounded* enrichment, never a gate on the result set. Each
52
+ * one costs one or two extra round trips, so the whole batch shares one budget
53
+ * (`summaryBudgetMs`, 0 to skip them entirely); rows that miss it come back with
54
+ * `summary: null`, which every consumer already treats as "not available". When
55
+ * the budget expires the outstanding fetches are aborted rather than left to run,
56
+ * so the one-shot `otto brain search --json` the daemon shells out to can exit
57
+ * immediately instead of lingering on open sockets.
46
58
  */
47
- export declare function searchModels(query: string, { limit, token }?: {
59
+ export declare function searchModels(query: string, { limit, token, summaryBudgetMs, }?: {
48
60
  limit?: number;
49
61
  token?: string | null;
62
+ summaryBudgetMs?: number;
50
63
  }): Promise<ModelSearchResult[]>;
51
64
  /**
52
65
  * Quality order for display: higher bits-per-weight is more faithful and larger.