@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
@@ -0,0 +1,33 @@
1
+ import type { BrainPaths } from "./paths.js";
2
+ import type { HostingProfile, Profile, ProfilesStore } from "./schema.js";
3
+ /**
4
+ * The bucket a model's hosting profiles live in. Families are catalog-authoritative
5
+ * when available, otherwise discovery derives them from GGUF metadata; models
6
+ * with neither use one real bucket rather than each being unaddressable. This
7
+ * must be the single definition: a writer that stored under "generic" while a
8
+ * reader looked up `null` was why a family default could be set on an unfamilied
9
+ * model and never take effect.
10
+ */
11
+ export declare const GENERIC_HOSTING_FAMILY = "generic";
12
+ export declare function hostingFamily(family: string | null | undefined): string;
13
+ export declare function familyHostingProfileId(store: ProfilesStore, family: string | null | undefined): string | null;
14
+ export declare function effectiveHostingProfile(store: ProfilesStore, profile: Profile, family: string | null | undefined): HostingProfile | null;
15
+ /**
16
+ * Materialize a selected profile immediately before launch: the template as a
17
+ * file llama-server reads, the system addendum as a string the router injects
18
+ * into each completion request.
19
+ *
20
+ * The addendum is deliberately *not* spliced into the Jinja template, which was
21
+ * the first design. Doing that means rewriting the `messages` list from inside
22
+ * the template, and there is no portable way to do it: minja's support for list
23
+ * mutation differs from Jinja2's, every model family's template consumes the
24
+ * system turn differently, and a message's `content` is an array of parts for
25
+ * multimodal requests rather than a string to concatenate onto. Injecting into
26
+ * the parsed request body is the same composition (it appends to the agent's
27
+ * existing system message instead of replacing it) done where the shape is
28
+ * known and testable.
29
+ */
30
+ export declare function resolveHostingProfileForLaunch(paths: BrainPaths, store: ProfilesStore, profile: Profile, family: string | null | undefined): Profile;
31
+ /** Remove a deleted profile's generated template. Missing or locked files are non-fatal. */
32
+ export declare function removeHostingProfileMaterialization(paths: BrainPaths, id: string): void;
33
+ //# sourceMappingURL=hosting-profiles.d.ts.map
@@ -0,0 +1,71 @@
1
+ /** Brain-owned named chat-template compositions for llama-server. */
2
+ import { rmSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { writePrivateFileAtomicSync } from "./private-files.js";
5
+ const SAFE_FILE = /[^a-zA-Z0-9_-]/gu;
6
+ function materializedTemplatePath(paths, id) {
7
+ return path.join(paths.templatesDir, `${id.replace(SAFE_FILE, "_")}.jinja`);
8
+ }
9
+ /**
10
+ * The bucket a model's hosting profiles live in. Families are catalog-authoritative
11
+ * when available, otherwise discovery derives them from GGUF metadata; models
12
+ * with neither use one real bucket rather than each being unaddressable. This
13
+ * must be the single definition: a writer that stored under "generic" while a
14
+ * reader looked up `null` was why a family default could be set on an unfamilied
15
+ * model and never take effect.
16
+ */
17
+ export const GENERIC_HOSTING_FAMILY = "generic";
18
+ export function hostingFamily(family) {
19
+ return family || GENERIC_HOSTING_FAMILY;
20
+ }
21
+ export function familyHostingProfileId(store, family) {
22
+ return store.familyHostingProfileIds[hostingFamily(family)] ?? null;
23
+ }
24
+ export function effectiveHostingProfile(store, profile, family) {
25
+ const selected = profile.hostingProfileMode === "inherit"
26
+ ? familyHostingProfileId(store, family)
27
+ : profile.hostingProfileMode === "custom"
28
+ ? profile.hostingProfileId
29
+ : null;
30
+ return selected ? (store.hostingProfiles[selected] ?? null) : null;
31
+ }
32
+ /**
33
+ * Materialize a selected profile immediately before launch: the template as a
34
+ * file llama-server reads, the system addendum as a string the router injects
35
+ * into each completion request.
36
+ *
37
+ * The addendum is deliberately *not* spliced into the Jinja template, which was
38
+ * the first design. Doing that means rewriting the `messages` list from inside
39
+ * the template, and there is no portable way to do it: minja's support for list
40
+ * mutation differs from Jinja2's, every model family's template consumes the
41
+ * system turn differently, and a message's `content` is an array of parts for
42
+ * multimodal requests rather than a string to concatenate onto. Injecting into
43
+ * the parsed request body is the same composition (it appends to the agent's
44
+ * existing system message instead of replacing it) done where the shape is
45
+ * known and testable.
46
+ */
47
+ export function resolveHostingProfileForLaunch(paths, store, profile, family) {
48
+ const selected = effectiveHostingProfile(store, profile, family);
49
+ const chatSystemAddendum = selected?.systemPromptAddendum?.trim() || null;
50
+ if (!selected?.template) {
51
+ return { ...profile, chatTemplateFile: null, chatTemplateKwargs: {}, chatSystemAddendum };
52
+ }
53
+ const file = materializedTemplatePath(paths, selected.id);
54
+ writePrivateFileAtomicSync(file, selected.template);
55
+ return {
56
+ ...profile,
57
+ chatTemplateFile: file,
58
+ chatTemplateKwargs: selected.templateKwargs,
59
+ chatSystemAddendum,
60
+ };
61
+ }
62
+ /** Remove a deleted profile's generated template. Missing or locked files are non-fatal. */
63
+ export function removeHostingProfileMaterialization(paths, id) {
64
+ try {
65
+ rmSync(materializedTemplatePath(paths, id), { force: true });
66
+ }
67
+ catch {
68
+ // Profile deletion must not fail because a previous launch left a file locked.
69
+ }
70
+ }
71
+ //# sourceMappingURL=hosting-profiles.js.map
@@ -5,6 +5,7 @@ export { resolveBrainPaths, packageRoot, type BrainPaths } from "./paths.js";
5
5
  export { parseBooleanEnv, applyEnvOverrides } from "./env.js";
6
6
  export { loadBrainConfig, loadPersistedConfig, saveBrainConfig, loadProfilesStore, saveProfilesStore, loadCatalog, } from "./store.js";
7
7
  export { defaultProfile, forModel, put, calibrationKey, geometryKey, getCalibration, putCalibration, hasStaleCalibration, } from "./profiles.js";
8
+ export { effectiveHostingProfile, resolveHostingProfileForLaunch } from "./hosting-profiles.js";
8
9
  export { calibrationInfo, nativeContextLimit, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, formatReasoningBudget, CACHE_TYPE_CYCLE, REASONING_BUDGET_CYCLE, UNRESTRICTED_REASONING_BUDGET, type CalibrationInfo, type CalibrationState, type ProfileFieldDescriptor, type ProfileWarning, } from "./profile-edit.js";
9
10
  export * from "./schema.js";
10
11
  //# sourceMappingURL=index.d.ts.map
@@ -5,6 +5,7 @@ export { resolveBrainPaths, packageRoot } from "./paths.js";
5
5
  export { parseBooleanEnv, applyEnvOverrides } from "./env.js";
6
6
  export { loadBrainConfig, loadPersistedConfig, saveBrainConfig, loadProfilesStore, saveProfilesStore, loadCatalog, } from "./store.js";
7
7
  export { defaultProfile, forModel, put, calibrationKey, geometryKey, getCalibration, putCalibration, hasStaleCalibration, } from "./profiles.js";
8
+ export { effectiveHostingProfile, resolveHostingProfileForLaunch } from "./hosting-profiles.js";
8
9
  export { calibrationInfo, nativeContextLimit, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, formatReasoningBudget, CACHE_TYPE_CYCLE, REASONING_BUDGET_CYCLE, UNRESTRICTED_REASONING_BUDGET, } from "./profile-edit.js";
9
10
  export * from "./schema.js";
10
11
  //# sourceMappingURL=index.js.map
@@ -12,6 +12,8 @@ export interface BrainPaths {
12
12
  logFile: string;
13
13
  logsDir: string;
14
14
  resultsDir: string;
15
+ /** Brain-owned Jinja files materialized from named hosting profiles. */
16
+ templatesDir: string;
15
17
  }
16
18
  export declare function resolveBrainPaths(env?: NodeJS.ProcessEnv): BrainPaths;
17
19
  /**
@@ -26,6 +26,7 @@ export function resolveBrainPaths(env = process.env) {
26
26
  logFile: path.join(root, "otto-brain.log"),
27
27
  logsDir: path.join(root, "logs"),
28
28
  resultsDir: path.join(root, "results"),
29
+ templatesDir: path.join(root, "templates"),
29
30
  };
30
31
  }
31
32
  /**
@@ -45,10 +45,12 @@ export declare const MAX_PARALLEL_SLOTS = 16;
45
45
  export declare const MAX_GPU_LAYERS = 999;
46
46
  export declare const MIN_CONTEXT_SIZE = 1024;
47
47
  export declare const CONTEXT_STEP = 8192;
48
+ export declare const CONTEXT_MULTIPLIERS: number[];
48
49
  /** The context ceiling: the model's native window, or a generous bound if unknown. */
49
50
  export declare function nativeContextLimit(model: Model | null): number;
51
+ export declare function contextLimit(model: Model | null, multiplier?: number): number;
50
52
  /** The editable fields, resolved against one model's capabilities. */
51
- export declare function profileFieldDescriptors(model: Model | null): ProfileFieldDescriptor[];
53
+ export declare function profileFieldDescriptors(model: Model | null, profile?: Profile | null): ProfileFieldDescriptor[];
52
54
  /** A note attached to a field, or to the profile as a whole when `field` is null. */
53
55
  export interface ProfileWarning {
54
56
  field: string | null;
@@ -83,12 +85,12 @@ export interface SanitizeResult {
83
85
  * Apply an editable patch to a profile, clamping every field to its range and
84
86
  * dropping anything the model cannot use.
85
87
  *
86
- * Only the eight editable keys are honoured. `modelPath`/`mmprojPath`/`modelId`
88
+ * Only the supported editable keys are honoured. `modelPath`/`mmprojPath`/`modelId`
87
89
  * are re-derived from the model on every read (`profiles.forModel`), so letting
88
90
  * a caller set them would be a lie at best and a path-traversal at worst.
89
91
  * `reasoningBudgetMessage`, `batchSize`, `ubatchSize` and `extraArgs` stay
90
92
  * CLI-only: they have no measured effect worth exposing and `extraArgs` is
91
93
  * arbitrary process arguments.
92
94
  */
93
- export declare function sanitizeProfilePatch(current: Profile, patch: unknown, model: Model | null): SanitizeResult;
95
+ export declare function sanitizeProfilePatch(current: Profile, patch: unknown, model: Model | null, runtimeBuild?: number | null): SanitizeResult;
94
96
  //# sourceMappingURL=profile-edit.d.ts.map
@@ -47,22 +47,38 @@ export const MAX_PARALLEL_SLOTS = 16;
47
47
  export const MAX_GPU_LAYERS = 999;
48
48
  export const MIN_CONTEXT_SIZE = 1024;
49
49
  export const CONTEXT_STEP = 8192;
50
+ export const CONTEXT_MULTIPLIERS = [1, 2, 4];
50
51
  /** The context ceiling: the model's native window, or a generous bound if unknown. */
51
52
  export function nativeContextLimit(model) {
52
53
  const native = model?.metadata?.contextLength;
53
54
  return typeof native === "number" && native > 0 ? native : 1000000;
54
55
  }
56
+ export function contextLimit(model, multiplier = 1) {
57
+ return nativeContextLimit(model) * multiplier;
58
+ }
55
59
  /** The editable fields, resolved against one model's capabilities. */
56
- export function profileFieldDescriptors(model) {
57
- const hasProjector = Boolean(model?.mmprojPath);
58
- return [
60
+ export function profileFieldDescriptors(model, profile) {
61
+ const projector = model?.components?.find((component) => component.role === "vision_projector");
62
+ const hasProjector = model?.components
63
+ ? Boolean(projector?.available)
64
+ : Boolean(model?.mmprojPath);
65
+ const fields = [
66
+ {
67
+ key: "contextMultiplier",
68
+ label: "Context multiplier",
69
+ kind: "cycle",
70
+ options: CONTEXT_MULTIPLIERS,
71
+ optionLabels: ["Off", "2× (YaRN)", "4× (YaRN)"],
72
+ available: Boolean(model?.metadata?.contextLength),
73
+ ...(model?.metadata?.contextLength ? {} : { unavailableReason: "native context unknown" }),
74
+ },
59
75
  {
60
76
  key: "contextSize",
61
77
  label: "Context",
62
78
  kind: "number",
63
79
  step: CONTEXT_STEP,
64
80
  min: MIN_CONTEXT_SIZE,
65
- max: nativeContextLimit(model),
81
+ max: contextLimit(model, profile?.contextMultiplier ?? 1),
66
82
  available: true,
67
83
  },
68
84
  {
@@ -80,13 +96,6 @@ export function profileFieldDescriptors(model) {
80
96
  available: true,
81
97
  },
82
98
  { 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
99
  {
91
100
  key: "reasoningBudget",
92
101
  label: "Reasoning budget",
@@ -115,6 +124,23 @@ export function profileFieldDescriptors(model) {
115
124
  available: true,
116
125
  },
117
126
  ];
127
+ // Bundle models expose the projector in the component section below. Keep
128
+ // the legacy profile field for hand-scanned single-file models, but do not
129
+ // render two controls that write the same vision setting for bundles.
130
+ if (!model?.components) {
131
+ fields.splice(4, 0, {
132
+ key: "vision",
133
+ label: "Vision",
134
+ kind: "toggle",
135
+ available: hasProjector,
136
+ ...(hasProjector
137
+ ? {}
138
+ : {
139
+ unavailableReason: projector ? "download the vision component first" : "no projector",
140
+ }),
141
+ });
142
+ }
143
+ return fields;
118
144
  }
119
145
  /** Whether a KV cache type is quantised (anything that is not a float type). */
120
146
  function isQuantised(cacheType) {
@@ -147,6 +173,17 @@ export function profileWarnings(profile, model, store) {
147
173
  blocksStart: false,
148
174
  });
149
175
  }
176
+ if (profile.contextMultiplier > 1) {
177
+ warnings.push({
178
+ field: "contextMultiplier",
179
+ severity: "warn",
180
+ message: `YaRN ×${profile.contextMultiplier} extrapolates beyond the native context. Recalibrate before relying on this profile.`,
181
+ blocksStart: false,
182
+ });
183
+ }
184
+ // Distinct from `calibrationRequired`, which says this profile has never been
185
+ // measured in its current shape. This says a measurement exists but was taken
186
+ // for other cache types, so it names the reason rather than just the verdict.
150
187
  if (model && store && hasStaleCalibration(store, model, profile)) {
151
188
  warnings.push({
152
189
  field: "cacheTypeK",
@@ -163,6 +200,17 @@ export function profileWarnings(profile, model, store) {
163
200
  * model's layer count, which the UI must never present as measured on this file.
164
201
  */
165
202
  export function calibrationInfo(store, model, profile) {
203
+ // A historical measurement is invalid as soon as any VRAM-affecting setting
204
+ // changes. Keep the data for comparison, but do not present or use it as the
205
+ // current model budget until a calibration commits the new profile.
206
+ if (profile.calibrationRequired) {
207
+ return {
208
+ state: "theoretical",
209
+ kvBytesPerToken: null,
210
+ measuredAt: null,
211
+ measuredOn: null,
212
+ };
213
+ }
166
214
  const calibration = getCalibration(store, model, profile);
167
215
  if (!calibration) {
168
216
  return {
@@ -182,18 +230,37 @@ export function calibrationInfo(store, model, profile) {
182
230
  function clamp(value, min, max) {
183
231
  return Math.max(min, Math.min(max, value));
184
232
  }
233
+ /** The settings whose value changes what a calibration would measure. */
234
+ const CALIBRATION_INPUTS = [
235
+ "contextMultiplier",
236
+ "contextSize",
237
+ "cacheTypeK",
238
+ "cacheTypeV",
239
+ "flashAttention",
240
+ "gpuLayers",
241
+ "parallelSlots",
242
+ "vision",
243
+ "enabledComponents",
244
+ ];
245
+ /** Every calibration input is a scalar except the component id list, which is a set. */
246
+ function sameCalibrationInput(before, after) {
247
+ if (Array.isArray(before) && Array.isArray(after)) {
248
+ return [...before].sort().join("\0") === [...after].sort().join("\0");
249
+ }
250
+ return before === after;
251
+ }
185
252
  /**
186
253
  * Apply an editable patch to a profile, clamping every field to its range and
187
254
  * dropping anything the model cannot use.
188
255
  *
189
- * Only the eight editable keys are honoured. `modelPath`/`mmprojPath`/`modelId`
256
+ * Only the supported editable keys are honoured. `modelPath`/`mmprojPath`/`modelId`
190
257
  * are re-derived from the model on every read (`profiles.forModel`), so letting
191
258
  * a caller set them would be a lie at best and a path-traversal at worst.
192
259
  * `reasoningBudgetMessage`, `batchSize`, `ubatchSize` and `extraArgs` stay
193
260
  * CLI-only: they have no measured effect worth exposing and `extraArgs` is
194
261
  * arbitrary process arguments.
195
262
  */
196
- export function sanitizeProfilePatch(current, patch, model) {
263
+ export function sanitizeProfilePatch(current, patch, model, runtimeBuild = null) {
197
264
  const adjustments = [];
198
265
  const next = { ...current };
199
266
  if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
@@ -232,7 +299,14 @@ export function sanitizeProfilePatch(current, patch, model) {
232
299
  }
233
300
  next[key] = value;
234
301
  };
235
- takeNumber("contextSize", MIN_CONTEXT_SIZE, nativeContextLimit(model));
302
+ if ("contextMultiplier" in p) {
303
+ const raw = p.contextMultiplier;
304
+ if (typeof raw !== "number" || !CONTEXT_MULTIPLIERS.includes(raw)) {
305
+ throw new Error("contextMultiplier must be one of 1, 2, or 4");
306
+ }
307
+ next.contextMultiplier = raw;
308
+ }
309
+ takeNumber("contextSize", MIN_CONTEXT_SIZE, contextLimit(model, next.contextMultiplier));
236
310
  takeCacheType("cacheTypeK");
237
311
  takeCacheType("cacheTypeV");
238
312
  takeBoolean("flashAttention");
@@ -264,6 +338,52 @@ export function sanitizeProfilePatch(current, patch, model) {
264
338
  next.vision = p.vision;
265
339
  }
266
340
  }
341
+ if ("enabledComponents" in p) {
342
+ if (!Array.isArray(p.enabledComponents) ||
343
+ !p.enabledComponents.every((id) => typeof id === "string")) {
344
+ throw new Error("enabledComponents must be an array of component ids");
345
+ }
346
+ const requested = [...new Set(p.enabledComponents)];
347
+ const requiredBuild = requested
348
+ .map((id) => model?.components?.find((component) => component.id === id))
349
+ .find((component) => component?.minRuntimeBuild !== undefined &&
350
+ (runtimeBuild === null || runtimeBuild < component.minRuntimeBuild))?.minRuntimeBuild;
351
+ if (requiredBuild !== undefined) {
352
+ const active = runtimeBuild === null ? "unknown" : `b${runtimeBuild}`;
353
+ throw new Error(`components require llama.cpp build b${requiredBuild} or newer (active build: ${active})`);
354
+ }
355
+ const available = new Set(model?.components
356
+ ?.filter((component) => component.available)
357
+ .map((component) => component.id) ?? []);
358
+ const unavailable = requested.filter((id) => !available.has(id));
359
+ if (unavailable.length)
360
+ throw new Error(`components are not downloaded: ${unavailable.join(", ")}`);
361
+ next.enabledComponents = requested;
362
+ const projector = model?.components?.find((component) => component.role === "vision_projector" && requested.includes(component.id));
363
+ if (model?.components)
364
+ next.vision = Boolean(projector);
365
+ }
366
+ // A partial patch may lower the multiplier without naming contextSize. Clamp
367
+ // the saved value after every field has settled so launch args can never keep
368
+ // an extended context after YaRN has been turned off.
369
+ const contextSize = clamp(next.contextSize, MIN_CONTEXT_SIZE, contextLimit(model, next.contextMultiplier));
370
+ if (contextSize !== next.contextSize) {
371
+ next.contextSize = contextSize;
372
+ adjustments.push(`contextSize clamped to ${contextSize}`);
373
+ }
374
+ // The saved profile carries the calibration verdict. Do not infer it from
375
+ // whether an old measurement happens to exist: a person who changes settings,
376
+ // leaves, and returns must still be told to calibrate this new configuration.
377
+ //
378
+ // Compare values, never key presence. The editor autosaves the whole draft on
379
+ // every edit, so every one of these keys is in `p` every time; keying off
380
+ // presence threw away a real measurement whenever any unrelated field - or a
381
+ // hosting-profile choice, which does not touch VRAM at all - was saved.
382
+ const before = current;
383
+ const after = next;
384
+ if (CALIBRATION_INPUTS.some((key) => !sameCalibrationInput(before[key], after[key]))) {
385
+ next.calibrationRequired = true;
386
+ }
267
387
  return { profile: next, adjustments };
268
388
  }
269
389
  //# 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,
@@ -9,17 +17,32 @@ export function defaultProfile(model, defaults) {
9
17
  // Long context is the point of this hardware; start at the native limit and
10
18
  // let the VRAM budget pull it down.
11
19
  contextSize: Math.min(nativeContext, contextCap),
20
+ contextMultiplier: 1,
21
+ calibrationRequired: true,
12
22
  cacheTypeK: defaults?.cacheTypeK ?? "q8_0",
13
23
  cacheTypeV: defaults?.cacheTypeV ?? "q8_0",
14
24
  flashAttention: defaults?.flashAttention ?? true, // required for a quantised V cache
15
25
  gpuLayers: 999, // everything on GPU; the budget check guards this
16
- vision: Boolean(model?.mmprojPath),
26
+ // Existing hand-scanned models keep the historical vision default. Bundles
27
+ // instead opt in only to installed components whose manifest says so.
28
+ enabledComponents,
29
+ componentPaths,
30
+ vision: model?.components
31
+ ? model.components.some((component) => component.role === "vision_projector" && component.available && component.defaultLoad)
32
+ : Boolean(model?.mmprojPath),
17
33
  reasoningBudget: defaults?.reasoningBudget ?? 1536,
18
34
  reasoningBudgetMessage: DEFAULT_REASONING_MESSAGE,
19
35
  parallelSlots: defaults?.parallelSlots ?? 1, // one agent at a time: max context per request
20
36
  batchSize: null,
21
37
  ubatchSize: null,
22
38
  extraArgs: [],
39
+ hostingProfileId: null,
40
+ // Inherit, not off: a new model in a family that has a default should use
41
+ // it. With no family default configured this resolves to nothing anyway.
42
+ hostingProfileMode: "inherit",
43
+ chatTemplateFile: null,
44
+ chatTemplateKwargs: {},
45
+ chatSystemAddendum: null,
23
46
  };
24
47
  }
25
48
  /** Stored profile for a model, falling back to computed defaults. */
@@ -29,7 +52,36 @@ export function forModel(store, model, defaults) {
29
52
  if (!stored)
30
53
  return base;
31
54
  // Paths are re-derived so a moved model library does not break the profile.
32
- return { ...base, ...stored, modelPath: model.modelPath, mmprojPath: model.mmprojPath };
55
+ const enabledComponents = (stored.enabledComponents ?? []).filter((id) => model.components?.some((component) => component.id === id && component.available));
56
+ // COMPAT(bundleProfiles): added in v0.8.7, remove after 2027-02-11.
57
+ // Old vision profiles become the manifest's vision component when it exists.
58
+ if (model.components && enabledComponents.length === 0 && stored.vision) {
59
+ enabledComponents.push(...model.components
60
+ .filter((component) => component.role === "vision_projector" && component.available)
61
+ .map((component) => component.id));
62
+ }
63
+ const mmproj = model.components?.find((component) => component.role === "vision_projector" && enabledComponents.includes(component.id));
64
+ const componentPaths = Object.fromEntries((model.components ?? [])
65
+ .filter((component) => enabledComponents.includes(component.id) && component.path)
66
+ .map((component) => [component.role, component.path]));
67
+ return {
68
+ ...base,
69
+ ...stored,
70
+ // COMPAT(hostingProfileMode): added in v0.8.8, remove after 2027-02-12.
71
+ // The first hosting-profile store only had an id, and a non-null id there
72
+ // was an explicit custom choice. Such a profile parses as `inherit` (the
73
+ // schema default), so promote it. Writers hold the inverse invariant - the
74
+ // id is nulled whenever the mode is not `custom` - so a stored id can only
75
+ // mean a legacy record, never a stale leftover of a newer explicit choice.
76
+ hostingProfileMode: stored.hostingProfileId && stored.hostingProfileMode === "inherit"
77
+ ? "custom"
78
+ : stored.hostingProfileMode,
79
+ enabledComponents,
80
+ modelPath: model.modelPath,
81
+ mmprojPath: mmproj?.path ?? (model.components ? null : model.mmprojPath),
82
+ componentPaths,
83
+ vision: model.components ? Boolean(mmproj) : stored.vision,
84
+ };
33
85
  }
34
86
  export function put(store, model, profile) {
35
87
  store.profiles[model.id] = { ...profile, modelId: model.id };
@@ -37,7 +89,14 @@ export function put(store, model, profile) {
37
89
  }
38
90
  /** Calibration is keyed by cache types, since those change bytes/token. */
39
91
  export function calibrationKey(profile) {
40
- return `${profile.cacheTypeK}:${profile.cacheTypeV}`;
92
+ const components = [...(profile.enabledComponents ?? [])].sort();
93
+ const multiplier = profile.contextMultiplier > 1 ? `:contextMultiplier=${profile.contextMultiplier}` : "";
94
+ // COMPAT(bundleCalibrationKey): added in v0.8.7, remove after 2027-02-11.
95
+ // A main-model-only load remains the historical identity; any enabled bundle
96
+ // artifact gets a distinct key and therefore cannot claim that measurement.
97
+ return components.length
98
+ ? `${profile.cacheTypeK}:${profile.cacheTypeV}${multiplier}:components=${components.join(",")}`
99
+ : `${profile.cacheTypeK}:${profile.cacheTypeV}${multiplier}`;
41
100
  }
42
101
  /**
43
102
  * True when this model has a stored calibration, but for different cache types
@@ -127,6 +186,10 @@ export function putCalibration(store, model, profile, measurement) {
127
186
  writable: true,
128
187
  });
129
188
  }
189
+ // Calibration is a durable verdict about the persisted model profile, not a
190
+ // transient UI hint. Every completion path (CLI, TUI, and host job) shares
191
+ // this helper, so clearing it here keeps the state consistent everywhere.
192
+ put(store, model, { ...profile, calibrationRequired: false });
130
193
  return store;
131
194
  }
132
195
  //# sourceMappingURL=profiles.js.map