@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.
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import { addRestartOptions, addServeOptions, addStartOptions, addStatusOptions,
15
15
  import { addPullOptions, runPullCommand } from "./commands/pull.js";
16
16
  import { addReportOptions, runReportCommand } from "./commands/report.js";
17
17
  import { addRescoreOptions, runRescoreCommand } from "./commands/rescore.js";
18
- import { addRuntimeInstallOptions, addRuntimeListOptions, runRuntimeInstallCommand, runRuntimeListCommand, } from "./commands/runtime.js";
18
+ import { addRuntimeInstallOptions, addRuntimeListOptions, addRuntimeRemoveOptions, runRuntimeInstallCommand, runRuntimeListCommand, runRuntimeRemoveCommand, } from "./commands/runtime.js";
19
19
  import { addScanOptions, runScanCommand } from "./commands/scan.js";
20
20
  import { addAddOptions, addSearchOptions, runAddCommand, runSearchCommand, } from "./commands/search.js";
21
21
  import { addSweepOptions, runSweepCommand } from "./commands/sweep.js";
@@ -57,6 +57,7 @@ export function registerBrainCommands(program) {
57
57
  const runtime = program.command("runtime").description("Manage the llama.cpp runtime");
58
58
  addRuntimeListOptions(runtime.command("list")).action(withOutput(runRuntimeListCommand));
59
59
  addRuntimeInstallOptions(runtime.command("install")).action(withOutput(runRuntimeInstallCommand));
60
+ addRuntimeRemoveOptions(runtime.command("remove")).action(withOutput(runRuntimeRemoveCommand));
60
61
  // config subgroup.
61
62
  const config = program.command("config").description("Inspect and edit brain config");
62
63
  addConfigShowOptions(config.command("show")).action(withOutput(runConfigShowCommand));
@@ -8,6 +8,7 @@
8
8
  * params, tier, use-cases) that only the JSON output surfaces.
9
9
  */
10
10
  import type { Command } from "commander";
11
+ import type { CatalogModel } from "../config/schema.js";
11
12
  import type { AnyCommandResult, OutputSchema } from "../output/index.js";
12
13
  export interface CatalogRow {
13
14
  id: string;
@@ -25,6 +26,7 @@ export interface CatalogRow {
25
26
  tier: string;
26
27
  useCases: string[];
27
28
  why: string;
29
+ components?: CatalogModel["components"];
28
30
  }
29
31
  export declare const catalogSchema: OutputSchema<CatalogRow>;
30
32
  export declare function addCatalogOptions(cmd: Command): Command;
@@ -42,6 +42,7 @@ export async function runCatalogCommand(_options, _command) {
42
42
  tier: model.tier ?? "",
43
43
  useCases: model.useCases ?? [],
44
44
  why: model.why ?? "",
45
+ components: model.components,
45
46
  }));
46
47
  return { type: "list", data: rows, schema: catalogSchema };
47
48
  }
@@ -17,6 +17,7 @@ export interface PullOptionsInput {
17
17
  file?: string;
18
18
  quant?: string;
19
19
  listQuants?: boolean;
20
+ component?: string[];
20
21
  }
21
22
  export declare function runPullCommand(modelArg: string, options: PullOptionsInput, _command: Command): Promise<AnyCommandResult<PullRow>>;
22
23
  //# sourceMappingURL=pull.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { loadBrainConfig, loadCatalog } from "../config/index.js";
2
- import { downloadRepoFiles, listRepoQuants, managedModelsDir, pullModel, resolveHfToken, } from "../models/index.js";
2
+ import { downloadRepoFiles, bundleDownloadPlan, listRepoQuants, managedModelsDir, pullModel, resolveHfToken, } from "../models/index.js";
3
3
  import { CommandError } from "../output/types.js";
4
4
  import { formatBytes } from "../models/scan.js";
5
5
  import { withActivity } from "../service/activity.js";
@@ -44,6 +44,7 @@ export function addPullOptions(cmd) {
44
44
  .argument("<model>", "catalog id or name fragment")
45
45
  .option("--file <name.gguf>", "explicit GGUF file name in the repo")
46
46
  .option("--quant <label>", "download a specific quantization (e.g. Q5_K_M)")
47
+ .option("--component <id...>", "download optional bundle component ids")
47
48
  .option("--list-quants", "list the quantizations the repo offers and exit");
48
49
  }
49
50
  export async function runPullCommand(modelArg, options, _command) {
@@ -51,6 +52,56 @@ export async function runPullCommand(modelArg, options, _command) {
51
52
  const catalog = loadCatalog();
52
53
  const model = findCatalogModel(catalog.models, modelArg);
53
54
  const token = resolveHfToken(config);
55
+ // A bundle combines one selected primary quant and explicit optional
56
+ // companions. It must not pull the generic discovery projector as a side
57
+ // effect, because that makes the download plan ambiguous.
58
+ if (model.components && !options.listQuants && !options.file) {
59
+ const { quants } = await listRepoQuants(model.hfRepo, token);
60
+ const choice = options.quant
61
+ ? quants.find((quant) => quant.quant.toLowerCase() === options.quant.toLowerCase())
62
+ : undefined;
63
+ if (options.quant && !choice) {
64
+ throw new CommandError({
65
+ code: "NO_QUANT",
66
+ message: `${model.hfRepo} has no ${options.quant}`,
67
+ });
68
+ }
69
+ const plan = bundleDownloadPlan(model, options.component ?? [], choice?.files, choice?.sizeBytes);
70
+ let lastPct = -1;
71
+ const written = await withActivity("download", { target: model.name }, (activity) => downloadRepoFiles({
72
+ repo: plan.repo,
73
+ files: plan.files,
74
+ destRoot: managedModelsDir(config),
75
+ token,
76
+ onProgress: (progress) => {
77
+ activity.update(plan.totalBytes ? progress.receivedBytes / plan.totalBytes : null);
78
+ // The daemon owns the UI job record and only sees this child's
79
+ // stdout/stderr. Activity also feeds the host status, but emitting
80
+ // here is what keeps the Library's bundle progress ring live.
81
+ const pct = plan.totalBytes
82
+ ? Math.floor((progress.receivedBytes / plan.totalBytes) * 100)
83
+ : 0;
84
+ // Network chunks do not land exactly on 5% boundaries. Emit every
85
+ // new integer so the daemon receives continuous progress instead of
86
+ // often seeing only the initial and final updates.
87
+ if (pct > lastPct) {
88
+ lastPct = pct;
89
+ process.stderr.write(` ${model.name}${choice ? ` ${choice.quant}` : ""}: ${pct}%\r`);
90
+ }
91
+ },
92
+ }));
93
+ process.stderr.write("\n");
94
+ return {
95
+ type: "single",
96
+ data: {
97
+ model: choice ? `${model.name} (${choice.quant})` : model.name,
98
+ repo: model.hfRepo,
99
+ path: written[0] ?? "(already present)",
100
+ size: plan.totalBytes ? formatBytes(plan.totalBytes) : "-",
101
+ },
102
+ schema: pullSchema,
103
+ };
104
+ }
54
105
  // Discover-and-choose paths both need the repo's quant listing.
55
106
  if (options.listQuants || options.quant) {
56
107
  const { quants, mmproj } = await listRepoQuants(model.hfRepo, token);
@@ -61,6 +112,7 @@ export async function runPullCommand(modelArg, options, _command) {
61
112
  quant: q.quant,
62
113
  size: formatBytes(q.sizeBytes),
63
114
  files: q.files.length,
115
+ fileNames: q.files,
64
116
  })),
65
117
  schema: quantSchema,
66
118
  };
@@ -87,7 +139,7 @@ export async function runPullCommand(modelArg, options, _command) {
87
139
  onProgress: (p) => {
88
140
  activity.update(total ? p.receivedBytes / total : null);
89
141
  const pct = total ? Math.floor((p.receivedBytes / total) * 100) : 0;
90
- if (pct !== lastPct && pct % 5 === 0) {
142
+ if (pct > lastPct) {
91
143
  lastPct = pct;
92
144
  process.stderr.write(` ${model.name} ${choice.quant}: ${pct}%\r`);
93
145
  }
@@ -119,7 +171,7 @@ export async function runPullCommand(modelArg, options, _command) {
119
171
  return;
120
172
  activity.update(p.receivedBytes / p.totalBytes);
121
173
  const pct = Math.floor((p.receivedBytes / p.totalBytes) * 100);
122
- if (pct !== lastPct && pct % 5 === 0) {
174
+ if (pct > lastPct) {
123
175
  lastPct = pct;
124
176
  process.stderr.write(` ${model.name}: ${pct}%\r`);
125
177
  }
@@ -8,6 +8,7 @@ import type { Command } from "commander";
8
8
  import type { AnyCommandResult } from "../output/index.js";
9
9
  export interface RuntimeRow {
10
10
  label: string;
11
+ displayName: string;
11
12
  version: string;
12
13
  source: string;
13
14
  dir: string;
@@ -15,6 +16,8 @@ export interface RuntimeRow {
15
16
  export declare function addRuntimeListOptions(cmd: Command): Command;
16
17
  export declare function runRuntimeListCommand(_options: unknown, _command: Command): Promise<AnyCommandResult<RuntimeRow>>;
17
18
  export declare function addRuntimeInstallOptions(cmd: Command): Command;
19
+ export declare function addRuntimeRemoveOptions(cmd: Command): Command;
20
+ export declare function runRuntimeRemoveCommand(name: string, _options: unknown, _command: Command): Promise<AnyCommandResult<RuntimeRow>>;
18
21
  export declare function runRuntimeInstallCommand(options: {
19
22
  build?: string;
20
23
  variant?: string;
@@ -1,7 +1,7 @@
1
1
  import { loadBrainConfig } from "../config/index.js";
2
2
  import { resolveBrainPaths } from "../config/paths.js";
3
3
  import { CommandError } from "../output/types.js";
4
- import { defaultRuntimeSpec, installManagedRuntime, listAllRuntimes, listRuntimeDevices, probeNvidiaGpu, supportedVariants, } from "../runtime/index.js";
4
+ import { DEFAULT_LLAMA_BUILD, defaultRuntimeSpec, installManagedRuntime, MissingAssetError, removeManagedRuntime, resolveLatestBuildOrPin, listAllRuntimes, listRuntimeDevices, probeNvidiaGpu, supportedVariants, } from "../runtime/index.js";
5
5
  const runtimeSchema = {
6
6
  idField: "dir",
7
7
  columns: [
@@ -19,6 +19,7 @@ const runtimeSchema = {
19
19
  function toRows() {
20
20
  return listAllRuntimes().map((r) => ({
21
21
  label: r.label,
22
+ displayName: r.displayName ?? `${r.label} · ${r.version}`,
22
23
  version: r.version,
23
24
  source: r.source,
24
25
  dir: r.dir,
@@ -33,9 +34,40 @@ export async function runRuntimeListCommand(_options, _command) {
33
34
  export function addRuntimeInstallOptions(cmd) {
34
35
  return cmd
35
36
  .description("Download a self-contained llama.cpp runtime")
36
- .option("--build <tag>", "llama.cpp release build tag")
37
+ .option("--build <tag>", "llama.cpp release build tag (for example b10355)")
37
38
  .option("--variant <name>", `accelerator to install (${supportedVariants().join("|")}); defaults to the best one this machine can use`);
38
39
  }
40
+ export function addRuntimeRemoveOptions(cmd) {
41
+ return cmd.description("Remove one Otto-managed runtime").argument("<name>");
42
+ }
43
+ export async function runRuntimeRemoveCommand(name, _options, _command) {
44
+ loadBrainConfig();
45
+ removeManagedRuntime(resolveBrainPaths().runtimesDir, name);
46
+ return { type: "list", data: toRows(), schema: runtimeSchema };
47
+ }
48
+ /**
49
+ * Every warning this command emits is one line on purpose. The daemon's
50
+ * BrainOpsManager keeps the *last* stderr line as the job's message, so a
51
+ * multi-line warning would surface in the UI as a dangling fragment. As one line
52
+ * it survives intact on both the CLI and the GUI.
53
+ */
54
+ function warn(message) {
55
+ process.stderr.write(` warning: ${message.replace(/\s+/gu, " ").trim()}\n`);
56
+ }
57
+ /** Download + extract a spec, reporting percentage and phase on stderr. */
58
+ async function install(spec, runtimesDir) {
59
+ process.stderr.write(` installing ${spec.label} (${spec.version})…\n`);
60
+ const runtime = await installManagedRuntime(spec, runtimesDir, (p) => {
61
+ if (p.phase === "downloading" && p.totalBytes) {
62
+ const pct = Math.floor(((p.receivedBytes ?? 0) / p.totalBytes) * 100);
63
+ process.stderr.write(` ${pct}%\r`);
64
+ }
65
+ if (p.phase === "extracting")
66
+ process.stderr.write("\n extracting…\n");
67
+ });
68
+ process.stderr.write("\n");
69
+ return runtime;
70
+ }
39
71
  export async function runRuntimeInstallCommand(options, _command) {
40
72
  loadBrainConfig();
41
73
  const { runtimesDir } = resolveBrainPaths();
@@ -49,38 +81,53 @@ export async function runRuntimeInstallCommand(options, _command) {
49
81
  }
50
82
  // Only "auto" needs the GPU probe, and only to choose between CUDA and Vulkan
51
83
  // on the platforms that have both.
52
- const spec = defaultRuntimeSpec(options.build, {
84
+ const target = {
53
85
  variant: options.variant ?? "auto",
54
86
  hasNvidiaGpu: options.variant ? undefined : await probeNvidiaGpu(),
55
- });
56
- process.stderr.write(` installing ${spec.label} (${spec.version})…\n`);
57
- const runtime = await installManagedRuntime(spec, runtimesDir, (p) => {
58
- if (p.phase === "downloading" && p.totalBytes) {
59
- const pct = Math.floor(((p.receivedBytes ?? 0) / p.totalBytes) * 100);
60
- process.stderr.write(` ${pct}%\r`);
87
+ };
88
+ // "latest" is a request to update, not a promise upstream can always keep: the
89
+ // lookup is unauthenticated and rate limited, so it falls back to the pin.
90
+ const resolvedLatest = options.build === "latest" ? await resolveLatestBuildOrPin() : null;
91
+ if (resolvedLatest?.warning)
92
+ warn(resolvedLatest.warning);
93
+ let spec = defaultRuntimeSpec(resolvedLatest ? resolvedLatest.build : options.build, target);
94
+ let runtime;
95
+ try {
96
+ runtime = await install(spec, runtimesDir);
97
+ }
98
+ catch (error) {
99
+ // Asset names are not derivable from the build tag, and the scheme has
100
+ // already changed once (b4600 shipped `win-cuda-cu12.4-x64`, b10265 ships
101
+ // `win-cuda-12.4-x64`), so a tag resolved at run time can name assets that
102
+ // do not exist under it. The pin's names are the ones managed.test.ts holds,
103
+ // which makes it the only build this can fall back to. An explicit --build
104
+ // still fails loudly: the user named that tag on purpose.
105
+ if (!(error instanceof MissingAssetError) ||
106
+ !resolvedLatest ||
107
+ spec.version === DEFAULT_LLAMA_BUILD) {
108
+ throw error;
61
109
  }
62
- if (p.phase === "extracting")
63
- process.stderr.write("\n extracting…\n");
64
- });
65
- process.stderr.write("\n");
110
+ warn(`llama.cpp build ${spec.version} does not publish the asset this platform needs,` +
111
+ ` so Otto installed the pinned build ${DEFAULT_LLAMA_BUILD} instead.`);
112
+ spec = defaultRuntimeSpec(DEFAULT_LLAMA_BUILD, target);
113
+ runtime = await install(spec, runtimesDir);
114
+ }
66
115
  // A GPU variant that finds no device still runs - on the CPU, at roughly a
67
116
  // fortieth of the prefill throughput, and says nothing about it. Measured on
68
117
  // WSL2, which carries no NVIDIA Vulkan ICD. Warn rather than fail: the runtime
69
118
  // is genuinely installed and usable, just not accelerated.
70
- // One line on purpose. The daemon's BrainOpsManager keeps the *last* stderr line
71
- // as the job's message, so a multi-line warning would surface in the UI as a
72
- // dangling fragment. As one line it survives intact on both the CLI and the GUI.
73
119
  if (spec.variant !== "cpu") {
74
120
  const devices = await listRuntimeDevices(runtime);
75
121
  if (devices.length === 0) {
76
- process.stderr.write(` warning: this ${spec.variant} runtime reports no GPU device, so inference will run` +
77
- ` on the CPU until a working ${spec.variant} driver for this GPU is installed.\n`);
122
+ warn(`this ${spec.variant} runtime reports no GPU device, so inference will run` +
123
+ ` on the CPU until a working ${spec.variant} driver for this GPU is installed.`);
78
124
  }
79
125
  }
80
126
  return {
81
127
  type: "single",
82
128
  data: {
83
129
  label: runtime.label,
130
+ displayName: runtime.displayName ?? `${runtime.label} · ${runtime.version}`,
84
131
  version: runtime.version,
85
132
  source: runtime.source,
86
133
  dir: runtime.dir,
@@ -12,6 +12,7 @@ export interface SearchRow {
12
12
  likes: number;
13
13
  gated: string;
14
14
  installed: boolean;
15
+ summary: string | null;
15
16
  }
16
17
  export declare function addSearchOptions(cmd: Command): Command;
17
18
  export declare function runSearchCommand(query: string, options: {
@@ -27,5 +28,7 @@ export declare function addAddOptions(cmd: Command): Command;
27
28
  export declare function runAddCommand(repo: string, options: {
28
29
  quant?: string;
29
30
  listQuants?: boolean;
31
+ component?: string[];
32
+ primaryOnly?: boolean;
30
33
  }, _command: Command): Promise<AnyCommandResult<AddRow>>;
31
34
  //# sourceMappingURL=search.d.ts.map
@@ -5,16 +5,19 @@ import { CommandError } from "../output/types.js";
5
5
  /** What is already on disk, so search + quant listings can mark installed rows. */
6
6
  function installedIndex(config) {
7
7
  const repos = new Set();
8
- const quants = new Set();
8
+ const quants = new Map();
9
+ const projectorRepos = new Set();
9
10
  for (const model of scanModels(config)) {
10
11
  const repo = repoOfModel(model);
11
12
  if (!repo)
12
13
  continue;
13
14
  repos.add(repo.toLowerCase());
14
15
  if (model.quant)
15
- quants.add(`${repo.toLowerCase()} ${model.quant.toUpperCase()}`);
16
+ quants.set(`${repo.toLowerCase()} ${model.quant.toUpperCase()}`, model.id);
17
+ if (model.mmprojPath)
18
+ projectorRepos.add(repo.toLowerCase());
16
19
  }
17
- return { repos, quants };
20
+ return { repos, quants, projectorRepos };
18
21
  }
19
22
  const searchSchema = {
20
23
  idField: "repo",
@@ -46,6 +49,7 @@ export async function runSearchCommand(query, options, _command) {
46
49
  likes: r.likes,
47
50
  gated: r.gated ? "yes" : "",
48
51
  installed: repos.has(r.repo.toLowerCase()),
52
+ summary: r.summary,
49
53
  })),
50
54
  schema: searchSchema,
51
55
  };
@@ -73,6 +77,8 @@ export function addAddOptions(cmd) {
73
77
  .description("Download a model from an arbitrary Hugging Face repo")
74
78
  .argument("<repo>", "owner/repo on Hugging Face")
75
79
  .option("--quant <label>", "quantization to download (e.g. Q5_K_M)")
80
+ .option("--component <id...>", "download optional discovered component ids")
81
+ .option("--primary-only", "download only the selected primary quant")
76
82
  .option("--list-quants", "list the quantizations the repo offers and exit");
77
83
  }
78
84
  export async function runAddCommand(repo, options, _command) {
@@ -80,7 +86,7 @@ export async function runAddCommand(repo, options, _command) {
80
86
  const token = resolveHfToken(config);
81
87
  const { quants, mmproj } = await listRepoQuants(repo, token);
82
88
  if (options.listQuants || !options.quant) {
83
- const { quants: installedQuants } = installedIndex(config);
89
+ const { quants: installedQuants, projectorRepos } = installedIndex(config);
84
90
  const listing = {
85
91
  type: "list",
86
92
  data: quants.map((q) => ({
@@ -88,7 +94,17 @@ export async function runAddCommand(repo, options, _command) {
88
94
  size: formatBytes(q.sizeBytes),
89
95
  sizeBytes: q.sizeBytes,
90
96
  files: q.files.length,
97
+ modelId: installedQuants.get(`${repo.toLowerCase()} ${q.quant.toUpperCase()}`) ?? null,
91
98
  installed: installedQuants.has(`${repo.toLowerCase()} ${q.quant.toUpperCase()}`),
99
+ ...(mmproj
100
+ ? {
101
+ projector: {
102
+ file: mmproj.files[0],
103
+ sizeBytes: mmproj.sizeBytes,
104
+ installed: projectorRepos.has(repo.toLowerCase()),
105
+ },
106
+ }
107
+ : {}),
92
108
  })),
93
109
  schema: addQuantSchema,
94
110
  };
@@ -104,8 +120,18 @@ export async function runAddCommand(repo, options, _command) {
104
120
  message: `${repo} has no ${options.quant} - available: ${quants.map((q) => q.quant).join(", ") || "none"}`,
105
121
  });
106
122
  }
107
- const files = [...choice.files, ...(mmproj ? mmproj.files : [])];
108
- const total = choice.sizeBytes + (mmproj?.sizeBytes ?? 0);
123
+ const requested = new Set(options.component ?? []);
124
+ const unknown = [...requested].filter((id) => id !== "vision-projector");
125
+ if (unknown.length)
126
+ throw new CommandError({
127
+ code: "NO_COMPONENT",
128
+ message: `unknown bundle components: ${unknown.join(", ")}`,
129
+ });
130
+ const includeProjector = Boolean(mmproj) &&
131
+ (requested.has("vision-projector") ||
132
+ (!options.primaryOnly && options.component === undefined));
133
+ const files = [...choice.files, ...(includeProjector ? mmproj.files : [])];
134
+ const total = choice.sizeBytes + (includeProjector ? mmproj.sizeBytes : 0);
109
135
  let lastPct = -1;
110
136
  const written = await downloadRepoFiles({
111
137
  repo,
@@ -114,7 +140,10 @@ export async function runAddCommand(repo, options, _command) {
114
140
  token,
115
141
  onProgress: (p) => {
116
142
  const pct = total ? Math.floor((p.receivedBytes / total) * 100) : 0;
117
- if (pct !== lastPct && pct % 5 === 0) {
143
+ // A download chunk can jump straight over a five-percent boundary.
144
+ // Report each new integer percent so the UI ring never stalls until
145
+ // completion simply because no chunk hit an exact multiple of five.
146
+ if (pct > lastPct) {
118
147
  lastPct = pct;
119
148
  process.stderr.write(` ${repo} ${choice.quant}: ${pct}%\r`);
120
149
  }
@@ -83,7 +83,7 @@ export interface SanitizeResult {
83
83
  * Apply an editable patch to a profile, clamping every field to its range and
84
84
  * dropping anything the model cannot use.
85
85
  *
86
- * Only the eight editable keys are honoured. `modelPath`/`mmprojPath`/`modelId`
86
+ * Only the supported editable keys are honoured. `modelPath`/`mmprojPath`/`modelId`
87
87
  * are re-derived from the model on every read (`profiles.forModel`), so letting
88
88
  * a caller set them would be a lie at best and a path-traversal at worst.
89
89
  * `reasoningBudgetMessage`, `batchSize`, `ubatchSize` and `extraArgs` stay
@@ -54,8 +54,11 @@ export function nativeContextLimit(model) {
54
54
  }
55
55
  /** The editable fields, resolved against one model's capabilities. */
56
56
  export function profileFieldDescriptors(model) {
57
- const hasProjector = Boolean(model?.mmprojPath);
58
- return [
57
+ const projector = model?.components?.find((component) => component.role === "vision_projector");
58
+ const hasProjector = model?.components
59
+ ? Boolean(projector?.available)
60
+ : Boolean(model?.mmprojPath);
61
+ const fields = [
59
62
  {
60
63
  key: "contextSize",
61
64
  label: "Context",
@@ -80,13 +83,6 @@ export function profileFieldDescriptors(model) {
80
83
  available: true,
81
84
  },
82
85
  { key: "flashAttention", label: "Flash attention", kind: "toggle", available: true },
83
- {
84
- key: "vision",
85
- label: "Vision",
86
- kind: "toggle",
87
- available: hasProjector,
88
- ...(hasProjector ? {} : { unavailableReason: "no projector" }),
89
- },
90
86
  {
91
87
  key: "reasoningBudget",
92
88
  label: "Reasoning budget",
@@ -115,6 +111,23 @@ export function profileFieldDescriptors(model) {
115
111
  available: true,
116
112
  },
117
113
  ];
114
+ // Bundle models expose the projector in the component section below. Keep
115
+ // the legacy profile field for hand-scanned single-file models, but do not
116
+ // render two controls that write the same vision setting for bundles.
117
+ if (!model?.components) {
118
+ fields.splice(4, 0, {
119
+ key: "vision",
120
+ label: "Vision",
121
+ kind: "toggle",
122
+ available: hasProjector,
123
+ ...(hasProjector
124
+ ? {}
125
+ : {
126
+ unavailableReason: projector ? "download the vision component first" : "no projector",
127
+ }),
128
+ });
129
+ }
130
+ return fields;
118
131
  }
119
132
  /** Whether a KV cache type is quantised (anything that is not a float type). */
120
133
  function isQuantised(cacheType) {
@@ -186,7 +199,7 @@ function clamp(value, min, max) {
186
199
  * Apply an editable patch to a profile, clamping every field to its range and
187
200
  * dropping anything the model cannot use.
188
201
  *
189
- * Only the eight editable keys are honoured. `modelPath`/`mmprojPath`/`modelId`
202
+ * Only the supported editable keys are honoured. `modelPath`/`mmprojPath`/`modelId`
190
203
  * are re-derived from the model on every read (`profiles.forModel`), so letting
191
204
  * a caller set them would be a lie at best and a path-traversal at worst.
192
205
  * `reasoningBudgetMessage`, `batchSize`, `ubatchSize` and `extraArgs` stay
@@ -264,6 +277,23 @@ export function sanitizeProfilePatch(current, patch, model) {
264
277
  next.vision = p.vision;
265
278
  }
266
279
  }
280
+ if ("enabledComponents" in p) {
281
+ if (!Array.isArray(p.enabledComponents) ||
282
+ !p.enabledComponents.every((id) => typeof id === "string")) {
283
+ throw new Error("enabledComponents must be an array of component ids");
284
+ }
285
+ const requested = [...new Set(p.enabledComponents)];
286
+ const available = new Set(model?.components
287
+ ?.filter((component) => component.available)
288
+ .map((component) => component.id) ?? []);
289
+ const unavailable = requested.filter((id) => !available.has(id));
290
+ if (unavailable.length)
291
+ throw new Error(`components are not downloaded: ${unavailable.join(", ")}`);
292
+ next.enabledComponents = requested;
293
+ const projector = model?.components?.find((component) => component.role === "vision_projector" && requested.includes(component.id));
294
+ if (model?.components)
295
+ next.vision = Boolean(projector);
296
+ }
267
297
  return { profile: next, adjustments };
268
298
  }
269
299
  //# sourceMappingURL=profile-edit.js.map
@@ -2,6 +2,14 @@ import { DEFAULT_REASONING_MESSAGE, } from "./schema.js";
2
2
  export function defaultProfile(model, defaults) {
3
3
  const nativeContext = model?.metadata?.contextLength || 32768;
4
4
  const contextCap = defaults?.contextCap ?? 225000;
5
+ const enabledComponents = model?.components
6
+ ? model.components
7
+ .filter((component) => component.available && component.defaultLoad)
8
+ .map((component) => component.id)
9
+ : [];
10
+ const componentPaths = Object.fromEntries((model?.components ?? [])
11
+ .filter((component) => enabledComponents.includes(component.id) && component.path)
12
+ .map((component) => [component.role, component.path]));
5
13
  return {
6
14
  modelId: model?.id ?? null,
7
15
  modelPath: model?.modelPath ?? null,
@@ -13,7 +21,13 @@ export function defaultProfile(model, defaults) {
13
21
  cacheTypeV: defaults?.cacheTypeV ?? "q8_0",
14
22
  flashAttention: defaults?.flashAttention ?? true, // required for a quantised V cache
15
23
  gpuLayers: 999, // everything on GPU; the budget check guards this
16
- vision: Boolean(model?.mmprojPath),
24
+ // Existing hand-scanned models keep the historical vision default. Bundles
25
+ // instead opt in only to installed components whose manifest says so.
26
+ enabledComponents,
27
+ componentPaths,
28
+ vision: model?.components
29
+ ? model.components.some((component) => component.role === "vision_projector" && component.available && component.defaultLoad)
30
+ : Boolean(model?.mmprojPath),
17
31
  reasoningBudget: defaults?.reasoningBudget ?? 1536,
18
32
  reasoningBudgetMessage: DEFAULT_REASONING_MESSAGE,
19
33
  parallelSlots: defaults?.parallelSlots ?? 1, // one agent at a time: max context per request
@@ -29,7 +43,27 @@ export function forModel(store, model, defaults) {
29
43
  if (!stored)
30
44
  return base;
31
45
  // Paths are re-derived so a moved model library does not break the profile.
32
- return { ...base, ...stored, modelPath: model.modelPath, mmprojPath: model.mmprojPath };
46
+ const enabledComponents = (stored.enabledComponents ?? []).filter((id) => model.components?.some((component) => component.id === id && component.available));
47
+ // COMPAT(bundleProfiles): added in v0.8.7, remove after 2027-02-11.
48
+ // Old vision profiles become the manifest's vision component when it exists.
49
+ if (model.components && enabledComponents.length === 0 && stored.vision) {
50
+ enabledComponents.push(...model.components
51
+ .filter((component) => component.role === "vision_projector" && component.available)
52
+ .map((component) => component.id));
53
+ }
54
+ const mmproj = model.components?.find((component) => component.role === "vision_projector" && enabledComponents.includes(component.id));
55
+ const componentPaths = Object.fromEntries((model.components ?? [])
56
+ .filter((component) => enabledComponents.includes(component.id) && component.path)
57
+ .map((component) => [component.role, component.path]));
58
+ return {
59
+ ...base,
60
+ ...stored,
61
+ enabledComponents,
62
+ modelPath: model.modelPath,
63
+ mmprojPath: mmproj?.path ?? (model.components ? null : model.mmprojPath),
64
+ componentPaths,
65
+ vision: model.components ? Boolean(mmproj) : stored.vision,
66
+ };
33
67
  }
34
68
  export function put(store, model, profile) {
35
69
  store.profiles[model.id] = { ...profile, modelId: model.id };
@@ -37,7 +71,13 @@ export function put(store, model, profile) {
37
71
  }
38
72
  /** Calibration is keyed by cache types, since those change bytes/token. */
39
73
  export function calibrationKey(profile) {
40
- return `${profile.cacheTypeK}:${profile.cacheTypeV}`;
74
+ const components = [...(profile.enabledComponents ?? [])].sort();
75
+ // COMPAT(bundleCalibrationKey): added in v0.8.7, remove after 2027-02-11.
76
+ // A main-model-only load remains the historical identity; any enabled bundle
77
+ // artifact gets a distinct key and therefore cannot claim that measurement.
78
+ return components.length
79
+ ? `${profile.cacheTypeK}:${profile.cacheTypeV}:components=${components.join(",")}`
80
+ : `${profile.cacheTypeK}:${profile.cacheTypeV}`;
41
81
  }
42
82
  /**
43
83
  * True when this model has a stored calibration, but for different cache types
@@ -95,21 +135,37 @@ export function getCalibration(store, model, profile) {
95
135
  export function putCalibration(store, model, profile, measurement) {
96
136
  if (!store.calibrations)
97
137
  store.calibrations = {};
98
- if (!store.calibrations[model.id])
99
- store.calibrations[model.id] = {};
100
- store.calibrations[model.id][calibrationKey(profile)] = measurement;
138
+ const calibrations = store.calibrations;
139
+ if (!Object.hasOwn(calibrations, model.id)) {
140
+ Object.defineProperty(calibrations, model.id, {
141
+ configurable: true,
142
+ enumerable: true,
143
+ value: {},
144
+ });
145
+ }
146
+ Object.defineProperty(calibrations[model.id], calibrationKey(profile), {
147
+ configurable: true,
148
+ enumerable: true,
149
+ value: measurement,
150
+ writable: true,
151
+ });
101
152
  const key = geometryKey(model, profile);
102
153
  const layers = model?.metadata?.blockCount;
103
154
  if (key && layers) {
104
155
  if (!store.geometryCalibrations)
105
156
  store.geometryCalibrations = {};
106
- store.geometryCalibrations[key] = {
107
- kvBytesPerTokenPerLayer: measurement.kvBytesPerToken / layers,
108
- baseOverheadBytes: measurement.baseOverheadBytes,
109
- measuredAt: measurement.measuredAt,
110
- measuredOn: model.displayName,
111
- measuredLayers: layers,
112
- };
157
+ Object.defineProperty(store.geometryCalibrations, key, {
158
+ configurable: true,
159
+ enumerable: true,
160
+ value: {
161
+ kvBytesPerTokenPerLayer: measurement.kvBytesPerToken / layers,
162
+ baseOverheadBytes: measurement.baseOverheadBytes,
163
+ measuredAt: measurement.measuredAt,
164
+ measuredOn: model.displayName,
165
+ measuredLayers: layers,
166
+ },
167
+ writable: true,
168
+ });
113
169
  }
114
170
  return store;
115
171
  }