@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
@@ -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"),
@@ -21,11 +25,54 @@ export const ProfileSchema = z
21
25
  reasoningBudget: z.number().default(1536),
22
26
  reasoningBudgetMessage: z.string().default(DEFAULT_REASONING_MESSAGE),
23
27
  parallelSlots: z.number().default(1),
28
+ /** RoPE extension factor; 1 keeps the GGUF-native context window. */
29
+ contextMultiplier: z.number().default(1),
30
+ /** Cleared only by a successful calibration of this saved model profile. */
31
+ calibrationRequired: z.boolean().default(true),
24
32
  batchSize: z.number().nullable().default(null),
25
33
  ubatchSize: z.number().nullable().default(null),
26
34
  extraArgs: z.array(z.string()).default([]),
35
+ /** The selected profile id when `hostingProfileMode` is `custom`. */
36
+ hostingProfileId: z.string().nullable().default(null),
37
+ /**
38
+ * Whether this model inherits its family's profile, disables profiles, or
39
+ * selects one itself. `inherit` is the default because it is what a profile
40
+ * stored before this field existed meant: the family default applied to
41
+ * every model in the family that had not overridden it. Defaulting to `off`
42
+ * would silently drop that default on every profile written by an older
43
+ * Brain. With no family default set, `inherit` resolves to nothing, which
44
+ * is exactly what `off` does, so the safe default costs nothing.
45
+ */
46
+ hostingProfileMode: z.enum(["inherit", "off", "custom"]).default("inherit"),
47
+ /** Derived at load time from the selected Brain-owned hosting profile. */
48
+ chatTemplateFile: z.string().nullable().default(null),
49
+ /** Derived at load time; passed directly to llama-server's Jinja engine. */
50
+ chatTemplateKwargs: z.record(z.unknown()).default({}),
51
+ /**
52
+ * Derived at load time from the selected hosting profile's system-prompt
53
+ * addendum. The router injects it per request rather than the launcher
54
+ * baking it into a CLI flag; see `hosting-profiles.ts` for why.
55
+ */
56
+ chatSystemAddendum: z.string().nullable().default(null),
27
57
  })
28
58
  .passthrough();
59
+ /**
60
+ * A named, Brain-owned inference composition. The template is intentionally
61
+ * text, rather than a user-owned path: remote Brains must be able to apply the
62
+ * same profile and no client is allowed to make llama-server read arbitrary
63
+ * files from its host.
64
+ */
65
+ export const HostingProfileSchema = z
66
+ .object({
67
+ id: z.string(),
68
+ name: z.string(),
69
+ family: z.string(),
70
+ description: z.string().default(""),
71
+ template: z.string().nullable().default(null),
72
+ systemPromptAddendum: z.string().nullable().default(null),
73
+ templateKwargs: z.record(z.unknown()).default({}),
74
+ })
75
+ .strict();
29
76
  export const CalibrationSampleSchema = z
30
77
  .object({
31
78
  contextSize: z.number(),
@@ -63,6 +110,16 @@ export const ProfilesStoreSchema = z
63
110
  profiles: z.record(ProfileSchema).default({}),
64
111
  calibrations: z.record(z.record(CalibrationSchema)).default({}),
65
112
  geometryCalibrations: z.record(GeometryCalibrationSchema).default({}),
113
+ /** Named reusable profiles, scoped to this Brain rather than any client. */
114
+ hostingProfiles: z.record(HostingProfileSchema).default({}),
115
+ /** Optional family default; individual model profiles may override it. */
116
+ familyHostingProfileIds: z.record(z.string().nullable()).default({}),
117
+ /**
118
+ * A saved edit made while that model was resident. It stays visible when
119
+ * the user leaves and revisits the model settings, and clears only after a
120
+ * successful fresh llama-server load of that model.
121
+ */
122
+ pendingReloadModelIds: z.record(z.boolean()).default({}),
66
123
  lastModelId: z.string().nullable().default(null),
67
124
  })
68
125
  .passthrough();
@@ -162,7 +219,11 @@ export const DEFAULT_BRAIN_CONFIG = BrainConfigSchema.parse({});
162
219
  export const CatalogModelSchema = z
163
220
  .object({
164
221
  id: z.string(),
222
+ /** Retired Otto-curated ids this canonical catalog entry replaces. */
223
+ replaces: z.array(z.string()).optional(),
165
224
  name: z.string(),
225
+ /** Stable UI family identity. Otto clients resolve this to a monochrome glyph. */
226
+ family: z.string().optional(),
166
227
  publisher: z.string().optional(),
167
228
  hfRepo: z.string(),
168
229
  quant: z.string(),
@@ -178,6 +239,24 @@ export const CatalogModelSchema = z
178
239
  tier: z.string().optional(),
179
240
  why: z.string().optional(),
180
241
  status: z.string().optional(),
242
+ /** Declared only for multi-artifact model bundles. Plain models omit it. */
243
+ components: z
244
+ .array(z
245
+ .object({
246
+ id: z.string(),
247
+ label: z.string(),
248
+ description: z.string(),
249
+ role: z.enum(["vision_projector", "speculative_drafter"]),
250
+ hfRepo: z.string().optional(),
251
+ file: z.string(),
252
+ bytes: z.number().nullable().optional(),
253
+ required: z.boolean().default(false),
254
+ defaultDownload: z.boolean().default(false),
255
+ defaultLoad: z.boolean().default(false),
256
+ minRuntimeBuild: z.number().optional(),
257
+ })
258
+ .strict())
259
+ .optional(),
181
260
  })
182
261
  .passthrough();
183
262
  export const CatalogSchema = z
@@ -10,6 +10,7 @@ import { writePrivateFileAtomicSync } from "./private-files.js";
10
10
  import { packageRoot, resolveBrainPaths } from "./paths.js";
11
11
  import { BrainConfigSchema, CatalogSchema, ProfilesStoreSchema, } from "./schema.js";
12
12
  import { applyEnvOverrides } from "./env.js";
13
+ import { seedBuiltinHostingProfiles } from "./builtin-hosting-profiles.js";
13
14
  function readJson(file, schema) {
14
15
  if (!existsSync(file))
15
16
  return null;
@@ -26,15 +27,24 @@ function writeJson(file, data) {
26
27
  // ------------------------------------------------------------------- profiles
27
28
  export function loadProfilesStore(paths = resolveBrainPaths()) {
28
29
  const current = readJson(paths.profilesFile, ProfilesStoreSchema);
29
- if (current)
30
+ if (current) {
31
+ if (seedBuiltinHostingProfiles(current))
32
+ writeJson(paths.profilesFile, current);
30
33
  return current;
34
+ }
31
35
  const legacy = path.join(packageRoot(), "config", "profiles.json");
32
36
  const migrated = readJson(legacy, ProfilesStoreSchema);
33
37
  if (migrated) {
38
+ seedBuiltinHostingProfiles(migrated);
34
39
  writeJson(paths.profilesFile, migrated);
35
40
  return migrated;
36
41
  }
37
- return ProfilesStoreSchema.parse({});
42
+ const seeded = ProfilesStoreSchema.parse({});
43
+ seedBuiltinHostingProfiles(seeded);
44
+ // A read-only command must not create profiles.json. In-memory seeding keeps
45
+ // built-ins available; the first real profile save persists the whole store.
46
+ // This avoids racing a running service that later writes its startup snapshot.
47
+ return seeded;
38
48
  }
39
49
  export function saveProfilesStore(store, paths = resolveBrainPaths()) {
40
50
  writeJson(paths.profilesFile, store);
@@ -43,23 +53,25 @@ export function saveProfilesStore(store, paths = resolveBrainPaths()) {
43
53
  export function loadCatalog(paths = resolveBrainPaths()) {
44
54
  const current = readJson(paths.catalogFile, CatalogSchema);
45
55
  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.
56
+ // Curated entries are Otto-owned product copy. Replace every entry whose
57
+ // stable download id ships in the seed so catalog corrections reach every
58
+ // existing Brain home after an upgrade. Keep entries with unknown ids: they
59
+ // are the only user-owned catalog records and must survive product updates.
60
+ //
61
+ // Do not merge field-by-field. A partial merge leaves old names or stale
62
+ // descriptions behind indefinitely, which is precisely what a catalog
63
+ // migration is meant to prevent.
50
64
  const legacy = readJson(path.join(packageRoot(), "config", "downloads.json"), CatalogSchema);
51
65
  if (!legacy)
52
66
  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
- });
67
+ const seedIds = new Set(legacy.models.map((model) => model.id));
68
+ // A source repository can change while the underlying curated model stays
69
+ // the same. Those retired ids are product-owned too: drop them rather than
70
+ // displaying an obsolete duplicate beside its canonical replacement.
71
+ const retiredSeedIds = new Set(legacy.models.flatMap((model) => model.replaces ?? []));
72
+ const userModels = current.models.filter((model) => !seedIds.has(model.id) && !retiredSeedIds.has(model.id));
73
+ const models = [...legacy.models, ...userModels];
74
+ const changed = JSON.stringify(models) !== JSON.stringify(current.models);
63
75
  if (!changed)
64
76
  return current;
65
77
  const merged = { ...current, models };
package/dist/gguf.d.ts CHANGED
@@ -35,6 +35,7 @@ interface GgufSummary {
35
35
  tensorCount: number;
36
36
  arch: string;
37
37
  name: string | null;
38
+ basename: string | null;
38
39
  sizeLabel: string | null;
39
40
  fileType: number | null;
40
41
  contextLength: number | null;
package/dist/gguf.js CHANGED
@@ -210,6 +210,7 @@ export function summarize(file) {
210
210
  tensorCount,
211
211
  arch,
212
212
  name: meta["general.name"] || null,
213
+ basename: meta["general.basename"] || null,
213
214
  sizeLabel: meta["general.size_label"] || null,
214
215
  fileType: (meta["general.file_type"] ?? null),
215
216
  contextLength: (get("context_length") ?? null),
@@ -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,189 @@ 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);
101
+ // A bundle plan counts every selected artifact. An artifact that is
102
+ // already present is complete work, not zero work, otherwise the progress
103
+ // denominator includes it while the numerator never can.
104
+ received.bytes += statSync(destPath).size;
105
+ onProgress?.({ file: label, receivedBytes: received.bytes });
50
106
  return false;
51
- const response = await fetch(url, { headers: authHeaders(token) });
52
- if (!response.ok || !response.body) {
107
+ }
108
+ let partial = resumablePartial(tmp);
109
+ if (!partial && existsSync(tmp))
110
+ clearPartial(tmp);
111
+ let response;
112
+ if (partial) {
113
+ response = await fetch(url, {
114
+ headers: {
115
+ ...authHeaders(token),
116
+ range: `bytes=${partial.bytes}-`,
117
+ "if-range": partial.metadata.etag,
118
+ },
119
+ });
120
+ // A 200 is the defined If-Range response when the remote representation
121
+ // changed (or the server cannot range). The saved bytes are no longer safe
122
+ // to append, so start over with the complete response it supplied.
123
+ if (response.status === 200) {
124
+ clearPartial(tmp);
125
+ partial = null;
126
+ }
127
+ else if (!isMatchingRangeResponse(response, partial)) {
128
+ // Discard the partial before failing. A persistent non-200/non-206 answer
129
+ // to the ranged request (an expired signed CDN redirect returning 403, an
130
+ // upstream 5xx, a proxy that strips Range) would otherwise wedge this file
131
+ // forever: the partial stays valid, so every later attempt replays the
132
+ // same ranged request and gets the same answer, and only a 200 ever clears
133
+ // it. Dropping it here costs the resume but lets the next attempt restart
134
+ // cleanly from byte zero.
135
+ clearPartial(tmp);
136
+ throw new Error(`download resume failed (${response.status}) for ${url}`);
137
+ }
138
+ }
139
+ else {
140
+ response = await fetch(url, { headers: authHeaders(token) });
141
+ }
142
+ if ((response.status !== 200 && response.status !== 206) || !response.body) {
53
143
  throw new Error(`download failed (${response.status}) for ${url}`);
54
144
  }
55
- const totalBytes = Number(response.headers.get("content-length")) || undefined;
145
+ const append = partial !== null;
146
+ const resumedBytes = partial?.bytes ?? 0;
147
+ const totalBytes = partial?.metadata.totalBytes ?? contentLength(response);
148
+ if (!append) {
149
+ const etag = response.headers.get("etag");
150
+ if (etag && totalBytes !== null && totalBytes > 0) {
151
+ writePartialMetadata(tmp, { etag, totalBytes });
152
+ }
153
+ else {
154
+ // An unidentifiable partial can never be proved safe to resume.
155
+ clearPartial(tmp);
156
+ }
157
+ }
56
158
  const body = Readable.fromWeb(response.body);
159
+ if (append)
160
+ received.bytes += resumedBytes;
57
161
  body.on("data", (chunk) => {
58
162
  received.bytes += chunk.length;
59
- onProgress?.({ file: label, receivedBytes: received.bytes, totalBytes });
163
+ onProgress?.({
164
+ file: label,
165
+ receivedBytes: received.bytes,
166
+ totalBytes: totalBytes ?? undefined,
167
+ });
60
168
  });
61
169
  try {
62
- await pipeline(body, createWriteStream(tmp));
63
- const { renameSync } = await import("node:fs");
170
+ await pipeline(body, createWriteStream(tmp, { flags: append ? "a" : "w" }));
171
+ // Undici rejects the body stream on a premature close (measured: a short
172
+ // Content-Length body, with either a destroyed socket or a clean FIN, and a
173
+ // truncated chunked body all reject), so a cut transfer never reaches this
174
+ // line. A short but *valid* 206 does: a server may answer an open-ended
175
+ // `bytes=N-` with any narrower range, and isMatchingRangeResponse accepts
176
+ // that because it requires end >= start, not end === totalBytes - 1.
177
+ // Renaming then would publish a truncated model as a complete one. The
178
+ // partial survives the throw below, so the next attempt resumes from the
179
+ // bytes this one did add.
180
+ const writtenBytes = statSync(tmp).size;
181
+ if (totalBytes !== null && totalBytes > 0 && writtenBytes !== totalBytes) {
182
+ throw new Error(`download incomplete for ${url}: wrote ${writtenBytes} of ${totalBytes} bytes`);
183
+ }
64
184
  renameSync(tmp, destPath);
185
+ rmSync(partialMetadataPath(tmp), { force: true });
65
186
  }
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 });
187
+ catch (error) {
188
+ // Keep only a partial that has a recorded immutable identity and full
189
+ // length. Everything else is deliberately discarded on the next attempt.
190
+ if (!resumablePartial(tmp))
191
+ clearPartial(tmp);
192
+ throw error;
70
193
  }
71
194
  return true;
72
195
  }
196
+ /** Exact, manifest-driven files for a bundle selection. No quant discovery is
197
+ * involved, so a component pull can never select an arbitrary projector. */
198
+ export function bundleDownloadPlan(model, componentIds = [], primaryFiles, primaryBytes) {
199
+ const selected = new Set(componentIds);
200
+ const known = new Set((model.components ?? []).map((component) => component.id));
201
+ const unknown = componentIds.filter((id) => !known.has(id));
202
+ if (unknown.length)
203
+ throw new Error(`unknown bundle components: ${unknown.join(", ")}`);
204
+ const components = (model.components ?? []).filter((component) => component.required || selected.has(component.id));
205
+ const foreign = components.find((component) => (component.hfRepo ?? model.hfRepo) !== model.hfRepo);
206
+ if (foreign)
207
+ throw new Error(`component ${foreign.id} uses a separate repository and needs its own plan`);
208
+ return {
209
+ repo: model.hfRepo,
210
+ files: [
211
+ ...(primaryFiles ?? [resolveFileName(model)]),
212
+ ...components.map((component) => component.file),
213
+ ],
214
+ totalBytes: (primaryBytes ?? model.approxWeightsBytes) === undefined ||
215
+ components.some((component) => component.bytes == null)
216
+ ? null
217
+ : (primaryBytes ?? model.approxWeightsBytes) +
218
+ components.reduce((sum, component) => sum + (component.bytes ?? 0), 0),
219
+ };
220
+ }
73
221
  /** Download the model file; returns the local path it was written to. */
74
222
  export async function pullModel({ model, destRoot, file, token, onProgress, }) {
75
223
  const fileName = resolveFileName(model, file);
@@ -1,24 +1,11 @@
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";
3
+ /**
4
+ * Normalize a GGUF identity into the catalog's hosting-profile vocabulary.
5
+ * Architecture is a stable structural field; names are only fallbacks for
6
+ * headers whose architecture is absent or unrecognised.
7
+ */
8
+ export declare function familyFromGgufMetadata(model: Model): string | undefined;
22
9
  /**
23
10
  * Find the catalog entry a scanned model belongs to, or null. A model matches
24
11
  * when its id path sits directly under the entry's hfRepo directory. When a repo
@@ -1,3 +1,68 @@
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";
22
+ /**
23
+ * Hosting-profile families deliberately use a small, curated vocabulary. It is
24
+ * a mix of publisher and model-line identities, so GGUF architecture names
25
+ * must be folded into the existing buckets rather than exposed directly.
26
+ */
27
+ const FAMILY_BY_GGUF_IDENTIFIER = {
28
+ chatglm: "chatglm",
29
+ deepseek: "deepseek",
30
+ gemma: "gemma",
31
+ gptoss: "openai",
32
+ llama: "meta",
33
+ meta: "meta",
34
+ microsoft: "microsoft",
35
+ mistral: "mistral",
36
+ mixtral: "mistral",
37
+ nemotron: "nvidia",
38
+ nvidia: "nvidia",
39
+ openai: "openai",
40
+ phi: "microsoft",
41
+ qwen: "qwen",
42
+ };
43
+ /**
44
+ * Normalize a GGUF identity into the catalog's hosting-profile vocabulary.
45
+ * Architecture is a stable structural field; names are only fallbacks for
46
+ * headers whose architecture is absent or unrecognised.
47
+ */
48
+ export function familyFromGgufMetadata(model) {
49
+ const metadata = model.metadata;
50
+ if (!metadata)
51
+ return undefined;
52
+ for (const value of [metadata.arch, metadata.basename, metadata.name]) {
53
+ if (typeof value !== "string")
54
+ continue;
55
+ const identifier = value
56
+ .trim()
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9]/gu, "");
59
+ for (const [prefix, family] of Object.entries(FAMILY_BY_GGUF_IDENTIFIER)) {
60
+ if (identifier.startsWith(prefix))
61
+ return family;
62
+ }
63
+ }
64
+ return undefined;
65
+ }
1
66
  /** Normalize a repo/id path: forward slashes, lowercased, trailing slashes trimmed. */
2
67
  function normalizePath(value) {
3
68
  const normalized = value.replaceAll("\\", "/");
@@ -53,16 +118,25 @@ export function matchCatalogEntry(model, catalog) {
53
118
  * through untouched. Never throws.
54
119
  */
55
120
  export function enrichWithCatalog(models, catalog) {
56
- if (catalog.models.length === 0)
57
- return models;
58
121
  return models.map((model) => {
59
122
  const entry = matchCatalogEntry(model, catalog);
60
- if (!entry)
61
- return model;
123
+ if (!entry) {
124
+ const enriched = enrichDiscoveredProjector(model);
125
+ const family = familyFromGgufMetadata(enriched);
126
+ return family ? { ...enriched, family } : enriched;
127
+ }
128
+ const components = resolveComponents(model, entry);
129
+ const projector = components?.find((component) => component.role === "vision_projector");
62
130
  return {
63
131
  ...model,
64
132
  catalogId: entry.id,
65
133
  catalogHfRepo: entry.hfRepo,
134
+ family: entry.family,
135
+ components,
136
+ // A manifest is authoritative. Do not pair a random same-directory
137
+ // projector when the catalog declares the exact companion artifact.
138
+ mmprojPath: projector?.path ?? (components ? null : model.mmprojPath),
139
+ mmprojBytes: projector?.bytes ?? (components ? 0 : model.mmprojBytes),
66
140
  useCases: entry.useCases,
67
141
  tier: entry.tier,
68
142
  thinking: entry.thinking,
@@ -71,4 +145,64 @@ export function enrichWithCatalog(models, catalog) {
71
145
  };
72
146
  });
73
147
  }
148
+ /** Promote a scanner-paired projector in an arbitrary Hugging Face repository
149
+ * into the same component inventory shape used by curated bundles. */
150
+ function enrichDiscoveredProjector(model) {
151
+ if (!model.mmprojPath)
152
+ return model;
153
+ return {
154
+ ...model,
155
+ components: [
156
+ {
157
+ id: "vision-projector",
158
+ label: "Vision projector",
159
+ description: "Adds image understanding",
160
+ role: "vision_projector",
161
+ path: model.mmprojPath,
162
+ bytes: model.mmprojBytes,
163
+ required: false,
164
+ defaultDownload: false,
165
+ defaultLoad: true,
166
+ available: true,
167
+ },
168
+ ],
169
+ };
170
+ }
171
+ function resolveComponents(model, entry) {
172
+ if (!entry.components)
173
+ return undefined;
174
+ const modelDir = path.dirname(model.modelPath);
175
+ return entry.components.map((component) => {
176
+ const componentRepo = component.hfRepo ?? entry.hfRepo;
177
+ // A selected catalog primary and its declared companions share a repo in the
178
+ // managed layout. For a companion repository, derive its absolute path from
179
+ // the scanned model's models root rather than accepting a client path.
180
+ const repoTail = componentRepo.split("/").join(path.sep);
181
+ const marker = entry.hfRepo.split("/").join(path.sep);
182
+ const root = modelDir.endsWith(marker) ? modelDir.slice(0, -marker.length) : modelDir;
183
+ const candidate = path.resolve(root, repoTail, component.file);
184
+ let bytes = 0;
185
+ try {
186
+ bytes = fs.statSync(candidate).size;
187
+ }
188
+ catch {
189
+ bytes = component.bytes ?? 0;
190
+ }
191
+ const available = fs.existsSync(candidate);
192
+ return {
193
+ id: component.id,
194
+ label: component.label,
195
+ description: component.description,
196
+ role: component.role,
197
+ path: available ? candidate : null,
198
+ bytes,
199
+ required: component.required,
200
+ defaultDownload: component.defaultDownload,
201
+ defaultLoad: component.defaultLoad,
202
+ available,
203
+ ...(available ? {} : { unavailableReason: "Not downloaded" }),
204
+ ...(component.minRuntimeBuild ? { minRuntimeBuild: component.minRuntimeBuild } : {}),
205
+ };
206
+ });
207
+ }
74
208
  //# 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.