@otto-code/brain 0.7.6 → 0.8.1

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 (87) hide show
  1. package/dist/bench/context-corpus.js +3 -3
  2. package/dist/bench/corpus.js +2 -2
  3. package/dist/bench/curated-repos.js +3 -3
  4. package/dist/bench/health.d.ts +1 -1
  5. package/dist/bench/health.js +2 -2
  6. package/dist/bench/tasks.js +2 -2
  7. package/dist/cli.d.ts +1 -1
  8. package/dist/cli.js +1 -1
  9. package/dist/commands/bench.d.ts +1 -1
  10. package/dist/commands/bench.js +27 -4
  11. package/dist/commands/calibrate.d.ts +1 -1
  12. package/dist/commands/calibrate.js +5 -2
  13. package/dist/commands/catalog.d.ts +1 -1
  14. package/dist/commands/config.d.ts +1 -1
  15. package/dist/commands/lifecycle.js +1 -1
  16. package/dist/commands/pull.d.ts +1 -1
  17. package/dist/commands/pull.js +9 -4
  18. package/dist/commands/report.d.ts +1 -1
  19. package/dist/commands/rescore.d.ts +1 -1
  20. package/dist/commands/rescore.js +1 -1
  21. package/dist/commands/runtime.d.ts +2 -1
  22. package/dist/commands/runtime.js +32 -3
  23. package/dist/commands/scan.d.ts +1 -1
  24. package/dist/commands/scan.js +6 -1
  25. package/dist/commands/search.d.ts +1 -1
  26. package/dist/commands/share.js +2 -2
  27. package/dist/commands/sweep.d.ts +2 -2
  28. package/dist/commands/sweep.js +22 -8
  29. package/dist/commands/ui.d.ts +1 -1
  30. package/dist/config/index.d.ts +2 -1
  31. package/dist/config/index.js +2 -1
  32. package/dist/config/otto-home.js +1 -1
  33. package/dist/config/paths.d.ts +1 -0
  34. package/dist/config/paths.js +4 -0
  35. package/dist/config/profile-edit.d.ts +94 -0
  36. package/dist/config/profile-edit.js +269 -0
  37. package/dist/config/profiles.d.ts +2 -2
  38. package/dist/config/profiles.js +1 -1
  39. package/dist/config/schema.d.ts +4 -4
  40. package/dist/config/schema.js +6 -6
  41. package/dist/config/store.d.ts +1 -1
  42. package/dist/config/store.js +1 -1
  43. package/dist/models/download.js +1 -1
  44. package/dist/models/enrich.d.ts +2 -2
  45. package/dist/models/index.d.ts +1 -1
  46. package/dist/models/index.js +1 -1
  47. package/dist/models/pick.d.ts +9 -0
  48. package/dist/models/pick.js +24 -1
  49. package/dist/ops/report.js +28 -28
  50. package/dist/ops/results.d.ts +128 -9
  51. package/dist/ops/results.js +77 -5
  52. package/dist/output/render.js +1 -1
  53. package/dist/output/types.d.ts +1 -1
  54. package/dist/runtime/args.d.ts +17 -4
  55. package/dist/runtime/args.js +20 -14
  56. package/dist/runtime/index.d.ts +9 -3
  57. package/dist/runtime/index.js +21 -3
  58. package/dist/runtime/lmstudio.d.ts +2 -2
  59. package/dist/runtime/lmstudio.js +115 -28
  60. package/dist/runtime/managed.d.ts +72 -8
  61. package/dist/runtime/managed.js +404 -38
  62. package/dist/service/activity.d.ts +83 -0
  63. package/dist/service/activity.js +216 -0
  64. package/dist/service/host-api.d.ts +132 -0
  65. package/dist/service/host-api.js +397 -0
  66. package/dist/service/http-util.d.ts +27 -0
  67. package/dist/service/http-util.js +72 -0
  68. package/dist/service/model-selector.d.ts +2 -2
  69. package/dist/service/model-selector.js +6 -6
  70. package/dist/service/router.d.ts +28 -4
  71. package/dist/service/router.js +128 -94
  72. package/dist/service/scheduler.d.ts +2 -2
  73. package/dist/service/scheduler.js +1 -1
  74. package/dist/service/serve.d.ts +2 -2
  75. package/dist/service/serve.js +51 -8
  76. package/dist/service/supervisor.d.ts +6 -0
  77. package/dist/service/supervisor.js +2 -0
  78. package/dist/service/tailscale.js +1 -1
  79. package/dist/service/tls.d.ts +4 -4
  80. package/dist/service/tls.js +3 -3
  81. package/dist/sysmon.d.ts +20 -4
  82. package/dist/sysmon.js +42 -18
  83. package/dist/tui/app.d.ts +12 -2
  84. package/dist/tui/app.js +46 -22
  85. package/dist/vram.d.ts +8 -1
  86. package/dist/vram.js +6 -3
  87. package/package.json +1 -1
@@ -0,0 +1,94 @@
1
+ import type { Model } from "../types.js";
2
+ import type { Profile, ProfilesStore } from "./schema.js";
3
+ /** How a client should render a field. */
4
+ export type ProfileFieldKind = "number" | "toggle" | "cycle";
5
+ export interface ProfileFieldDescriptor {
6
+ key: string;
7
+ label: string;
8
+ kind: ProfileFieldKind;
9
+ /** For `number`: the increment a stepper should use. */
10
+ step?: number;
11
+ min?: number;
12
+ max?: number;
13
+ /** For `cycle`: the values to offer, in order. */
14
+ options?: (string | number)[];
15
+ /** Labels for `options`, index-aligned, when the raw value is not presentable. */
16
+ optionLabels?: string[];
17
+ /** False when this model cannot use the field at all (vision with no projector). */
18
+ available: boolean;
19
+ /** Why it is unavailable, for the disabled-state hint. */
20
+ unavailableReason?: string;
21
+ }
22
+ /**
23
+ * The cache types the editor cycles through. `CACHE_TYPE_BYTES` knows more
24
+ * (f32, bf16, q5_0, q4_1) and a write naming one of those is accepted; these
25
+ * four are the ones worth offering, matching the TUI's cycle.
26
+ */
27
+ export declare const CACHE_TYPE_CYCLE: string[];
28
+ /**
29
+ * Reasoning budgets worth offering. 0 disables thinking; -1 is unrestricted and
30
+ * is the documented failure mode, so it is last and carries a warning. A sweep
31
+ * may persist any value, so the write path accepts any integer >= -1.
32
+ */
33
+ export declare const REASONING_BUDGET_CYCLE: number[];
34
+ /** The sentinel llama-server reads as "do not cap thinking at all". */
35
+ export declare const UNRESTRICTED_REASONING_BUDGET = -1;
36
+ /**
37
+ * The user-facing name for a budget. `-1` is never shown raw: it is a sentinel,
38
+ * not a token count, and printing it next to a `0` row invites the reading that
39
+ * one of them means "less thinking" than the other when `-1` means the most
40
+ * possible. "unrestricted" is the one word for it everywhere (TUI, sweep table,
41
+ * warnings) - do not introduce a synonym.
42
+ */
43
+ export declare function formatReasoningBudget(budget: number): string;
44
+ export declare const MAX_PARALLEL_SLOTS = 16;
45
+ export declare const MAX_GPU_LAYERS = 999;
46
+ export declare const MIN_CONTEXT_SIZE = 1024;
47
+ export declare const CONTEXT_STEP = 8192;
48
+ /** The context ceiling: the model's native window, or a generous bound if unknown. */
49
+ export declare function nativeContextLimit(model: Model | null): number;
50
+ /** The editable fields, resolved against one model's capabilities. */
51
+ export declare function profileFieldDescriptors(model: Model | null): ProfileFieldDescriptor[];
52
+ /** A note attached to a field, or to the profile as a whole when `field` is null. */
53
+ export interface ProfileWarning {
54
+ field: string | null;
55
+ severity: "info" | "warn";
56
+ message: string;
57
+ /** True when this combination cannot start, as opposed to merely being unwise. */
58
+ blocksStart: boolean;
59
+ }
60
+ /** The notes a UI shows beside the fields, and the reason a load would be refused. */
61
+ export declare function profileWarnings(profile: Profile, model: Model | null, store?: ProfilesStore | null): ProfileWarning[];
62
+ /** Where a model's KV bytes/token figure came from. */
63
+ export type CalibrationState = "measured" | "inherited" | "stale" | "theoretical" | "unknown";
64
+ export interface CalibrationInfo {
65
+ state: CalibrationState;
66
+ kvBytesPerToken: number | null;
67
+ measuredAt: string | null;
68
+ /** For `inherited`, the relative the measurement came from. */
69
+ measuredOn: string | null;
70
+ }
71
+ /**
72
+ * Classify the calibration backing this profile's budget. `inherited` means the
73
+ * figure came from a relative with the same attention geometry rescaled to this
74
+ * model's layer count, which the UI must never present as measured on this file.
75
+ */
76
+ export declare function calibrationInfo(store: ProfilesStore, model: Model, profile: Profile): CalibrationInfo;
77
+ export interface SanitizeResult {
78
+ profile: Profile;
79
+ /** Fields the patch named that were clamped, coerced, or dropped. */
80
+ adjustments: string[];
81
+ }
82
+ /**
83
+ * Apply an editable patch to a profile, clamping every field to its range and
84
+ * dropping anything the model cannot use.
85
+ *
86
+ * Only the eight editable keys are honoured. `modelPath`/`mmprojPath`/`modelId`
87
+ * are re-derived from the model on every read (`profiles.forModel`), so letting
88
+ * a caller set them would be a lie at best and a path-traversal at worst.
89
+ * `reasoningBudgetMessage`, `batchSize`, `ubatchSize` and `extraArgs` stay
90
+ * CLI-only: they have no measured effect worth exposing and `extraArgs` is
91
+ * arbitrary process arguments.
92
+ */
93
+ export declare function sanitizeProfilePatch(current: Profile, patch: unknown, model: Model | null): SanitizeResult;
94
+ //# sourceMappingURL=profile-edit.d.ts.map
@@ -0,0 +1,269 @@
1
+ /**
2
+ * The editable surface of a per-model profile: which fields a UI may change,
3
+ * what values each accepts, and what warnings a given combination earns.
4
+ *
5
+ * This exists so the ranges live in exactly one place. The TUI enforced them
6
+ * inline in its field table (`tui/app.ts`), which was fine while the TUI was the
7
+ * only editor. Now `/__host/models/:id/profile` accepts writes from Otto's Brain
8
+ * page too, and a validator that disagreed with the editor would either reject
9
+ * something the UI offered or accept something that cannot start.
10
+ *
11
+ * The warnings are the empirical part and must survive edits:
12
+ * - A quantised V cache requires flash attention. Without it llama-server
13
+ * refuses to allocate the cache and the model never reaches ready.
14
+ * - An unrestricted reasoning budget (-1) is the failure this package exists to
15
+ * prevent: thinking models spend an entire token allowance reasoning and
16
+ * return no content at all.
17
+ * - Changing a cache type invalidates a measured calibration, because KV
18
+ * bytes/token is a function of the cache types (see `vram.ts`).
19
+ */
20
+ import { CACHE_TYPE_BYTES } from "../vram.js";
21
+ import { getCalibration, hasStaleCalibration } from "./profiles.js";
22
+ /**
23
+ * The cache types the editor cycles through. `CACHE_TYPE_BYTES` knows more
24
+ * (f32, bf16, q5_0, q4_1) and a write naming one of those is accepted; these
25
+ * four are the ones worth offering, matching the TUI's cycle.
26
+ */
27
+ export const CACHE_TYPE_CYCLE = ["q4_0", "q5_1", "q8_0", "f16"];
28
+ /**
29
+ * Reasoning budgets worth offering. 0 disables thinking; -1 is unrestricted and
30
+ * is the documented failure mode, so it is last and carries a warning. A sweep
31
+ * may persist any value, so the write path accepts any integer >= -1.
32
+ */
33
+ export const REASONING_BUDGET_CYCLE = [0, 512, 1024, 1536, 3072, -1];
34
+ /** The sentinel llama-server reads as "do not cap thinking at all". */
35
+ export const UNRESTRICTED_REASONING_BUDGET = -1;
36
+ /**
37
+ * The user-facing name for a budget. `-1` is never shown raw: it is a sentinel,
38
+ * not a token count, and printing it next to a `0` row invites the reading that
39
+ * one of them means "less thinking" than the other when `-1` means the most
40
+ * possible. "unrestricted" is the one word for it everywhere (TUI, sweep table,
41
+ * warnings) - do not introduce a synonym.
42
+ */
43
+ export function formatReasoningBudget(budget) {
44
+ return budget === UNRESTRICTED_REASONING_BUDGET ? "unrestricted" : String(budget);
45
+ }
46
+ export const MAX_PARALLEL_SLOTS = 16;
47
+ export const MAX_GPU_LAYERS = 999;
48
+ export const MIN_CONTEXT_SIZE = 1024;
49
+ export const CONTEXT_STEP = 8192;
50
+ /** The context ceiling: the model's native window, or a generous bound if unknown. */
51
+ export function nativeContextLimit(model) {
52
+ const native = model?.metadata?.contextLength;
53
+ return typeof native === "number" && native > 0 ? native : 1000000;
54
+ }
55
+ /** The editable fields, resolved against one model's capabilities. */
56
+ export function profileFieldDescriptors(model) {
57
+ const hasProjector = Boolean(model?.mmprojPath);
58
+ return [
59
+ {
60
+ key: "contextSize",
61
+ label: "Context",
62
+ kind: "number",
63
+ step: CONTEXT_STEP,
64
+ min: MIN_CONTEXT_SIZE,
65
+ max: nativeContextLimit(model),
66
+ available: true,
67
+ },
68
+ {
69
+ key: "cacheTypeK",
70
+ label: "KV cache K",
71
+ kind: "cycle",
72
+ options: CACHE_TYPE_CYCLE,
73
+ available: true,
74
+ },
75
+ {
76
+ key: "cacheTypeV",
77
+ label: "KV cache V",
78
+ kind: "cycle",
79
+ options: CACHE_TYPE_CYCLE,
80
+ available: true,
81
+ },
82
+ { 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
+ {
91
+ key: "reasoningBudget",
92
+ label: "Reasoning budget",
93
+ kind: "cycle",
94
+ options: REASONING_BUDGET_CYCLE,
95
+ optionLabels: ["Thinking Off", "512", "1024", "1536", "3072", "Unrestricted"],
96
+ min: -1,
97
+ available: true,
98
+ },
99
+ {
100
+ key: "gpuLayers",
101
+ label: "GPU layers",
102
+ kind: "number",
103
+ step: 1,
104
+ min: 0,
105
+ max: MAX_GPU_LAYERS,
106
+ available: true,
107
+ },
108
+ {
109
+ key: "parallelSlots",
110
+ label: "Parallel slots",
111
+ kind: "number",
112
+ step: 1,
113
+ min: 1,
114
+ max: MAX_PARALLEL_SLOTS,
115
+ available: true,
116
+ },
117
+ ];
118
+ }
119
+ /** Whether a KV cache type is quantised (anything that is not a float type). */
120
+ function isQuantised(cacheType) {
121
+ return /^q/i.test(cacheType);
122
+ }
123
+ /** The notes a UI shows beside the fields, and the reason a load would be refused. */
124
+ export function profileWarnings(profile, model, store) {
125
+ const warnings = [];
126
+ if (!profile.flashAttention && isQuantised(profile.cacheTypeV)) {
127
+ warnings.push({
128
+ field: "flashAttention",
129
+ severity: "warn",
130
+ message: "A quantised V cache requires flash attention.",
131
+ blocksStart: true,
132
+ });
133
+ }
134
+ if (profile.reasoningBudget === -1) {
135
+ warnings.push({
136
+ field: "reasoningBudget",
137
+ severity: "warn",
138
+ message: "Unrestricted: a thinking model can spend its whole token allowance reasoning and return no content.",
139
+ blocksStart: false,
140
+ });
141
+ }
142
+ if (profile.parallelSlots > 1) {
143
+ warnings.push({
144
+ field: "parallelSlots",
145
+ severity: "info",
146
+ message: `${profile.parallelSlots} concurrent requests, sharing one KV pool.`,
147
+ blocksStart: false,
148
+ });
149
+ }
150
+ if (model && store && hasStaleCalibration(store, model, profile)) {
151
+ warnings.push({
152
+ field: "cacheTypeK",
153
+ severity: "info",
154
+ message: "Cache types changed since the last measurement. Recalibrate for a real budget.",
155
+ blocksStart: false,
156
+ });
157
+ }
158
+ return warnings;
159
+ }
160
+ /**
161
+ * Classify the calibration backing this profile's budget. `inherited` means the
162
+ * figure came from a relative with the same attention geometry rescaled to this
163
+ * model's layer count, which the UI must never present as measured on this file.
164
+ */
165
+ export function calibrationInfo(store, model, profile) {
166
+ const calibration = getCalibration(store, model, profile);
167
+ if (!calibration) {
168
+ return {
169
+ state: hasStaleCalibration(store, model, profile) ? "stale" : "theoretical",
170
+ kvBytesPerToken: null,
171
+ measuredAt: null,
172
+ measuredOn: null,
173
+ };
174
+ }
175
+ return {
176
+ state: calibration.inherited ? "inherited" : "measured",
177
+ kvBytesPerToken: calibration.kvBytesPerToken,
178
+ measuredAt: calibration.measuredAt ?? null,
179
+ measuredOn: calibration.measuredOn ?? null,
180
+ };
181
+ }
182
+ function clamp(value, min, max) {
183
+ return Math.max(min, Math.min(max, value));
184
+ }
185
+ /**
186
+ * Apply an editable patch to a profile, clamping every field to its range and
187
+ * dropping anything the model cannot use.
188
+ *
189
+ * Only the eight editable keys are honoured. `modelPath`/`mmprojPath`/`modelId`
190
+ * are re-derived from the model on every read (`profiles.forModel`), so letting
191
+ * a caller set them would be a lie at best and a path-traversal at worst.
192
+ * `reasoningBudgetMessage`, `batchSize`, `ubatchSize` and `extraArgs` stay
193
+ * CLI-only: they have no measured effect worth exposing and `extraArgs` is
194
+ * arbitrary process arguments.
195
+ */
196
+ export function sanitizeProfilePatch(current, patch, model) {
197
+ const adjustments = [];
198
+ const next = { ...current };
199
+ if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
200
+ throw new Error("profile patch must be an object");
201
+ }
202
+ const p = patch;
203
+ const takeNumber = (key, min, max) => {
204
+ if (!(key in p))
205
+ return;
206
+ const raw = p[key];
207
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
208
+ throw new Error(`${key} must be a number`);
209
+ }
210
+ const rounded = Math.round(raw);
211
+ const clamped = clamp(rounded, min, max);
212
+ if (clamped !== rounded)
213
+ adjustments.push(`${key} clamped to ${clamped}`);
214
+ next[key] = clamped;
215
+ };
216
+ const takeBoolean = (key) => {
217
+ if (!(key in p))
218
+ return;
219
+ if (typeof p[key] !== "boolean")
220
+ throw new Error(`${key} must be a boolean`);
221
+ next[key] = p[key];
222
+ };
223
+ const takeCacheType = (key) => {
224
+ if (!(key in p))
225
+ return;
226
+ const raw = p[key];
227
+ if (typeof raw !== "string")
228
+ throw new Error(`${key} must be a string`);
229
+ const value = raw.toLowerCase();
230
+ if (!(value in CACHE_TYPE_BYTES)) {
231
+ throw new Error(`unknown KV cache type "${raw}"`);
232
+ }
233
+ next[key] = value;
234
+ };
235
+ takeNumber("contextSize", MIN_CONTEXT_SIZE, nativeContextLimit(model));
236
+ takeCacheType("cacheTypeK");
237
+ takeCacheType("cacheTypeV");
238
+ takeBoolean("flashAttention");
239
+ takeNumber("gpuLayers", 0, MAX_GPU_LAYERS);
240
+ takeNumber("parallelSlots", 1, MAX_PARALLEL_SLOTS);
241
+ // -1 (unrestricted) is the floor; anything below it is meaningless to
242
+ // llama-server. There is no upper bound worth inventing: a budget larger than
243
+ // the context is simply never reached.
244
+ if ("reasoningBudget" in p) {
245
+ const raw = p.reasoningBudget;
246
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
247
+ throw new Error("reasoningBudget must be a number");
248
+ }
249
+ const rounded = Math.round(raw);
250
+ const value = rounded < -1 ? -1 : rounded;
251
+ if (value !== rounded)
252
+ adjustments.push(`reasoningBudget clamped to ${value}`);
253
+ next.reasoningBudget = value;
254
+ }
255
+ // Vision is only real when the model actually has a projector paired with it.
256
+ if ("vision" in p) {
257
+ if (typeof p.vision !== "boolean")
258
+ throw new Error("vision must be a boolean");
259
+ if (p.vision && !model?.mmprojPath) {
260
+ adjustments.push("vision ignored: this model has no projector");
261
+ next.vision = false;
262
+ }
263
+ else {
264
+ next.vision = p.vision;
265
+ }
266
+ }
267
+ return { profile: next, adjustments };
268
+ }
269
+ //# sourceMappingURL=profile-edit.js.map
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Per-model hosting profiles and the measured-calibration lookup, ported from the
3
3
  * original profiles.js. Only settings with a demonstrated effect on stability or
4
- * throughput are stored notably `reasoningBudget`, which defaults to -1
4
+ * throughput are stored - notably `reasoningBudget`, which defaults to -1
5
5
  * (unrestricted) in llama-server and in that state makes thinking models spend an
6
6
  * entire token allowance reasoning and return no content at all.
7
7
  */
@@ -24,7 +24,7 @@ export declare function hasStaleCalibration(store: ProfilesStore, model: Model,
24
24
  * file: two models sharing an architecture and head dimensions cost the same per
25
25
  * layer regardless of quantisation or fine-tuning. Storing the measurement *per
26
26
  * layer* and keying it without the layer count lets one calibration serve a whole
27
- * family which matters on a library holding a dozen variants of one base, and is
27
+ * family - which matters on a library holding a dozen variants of one base, and is
28
28
  * necessary because MTP builds carry an extra multi-token-prediction layer (65
29
29
  * blocks where the base has 64) and would otherwise never match.
30
30
  */
@@ -56,7 +56,7 @@ export function hasStaleCalibration(store, model, profile) {
56
56
  * file: two models sharing an architecture and head dimensions cost the same per
57
57
  * layer regardless of quantisation or fine-tuning. Storing the measurement *per
58
58
  * layer* and keying it without the layer count lets one calibration serve a whole
59
- * family which matters on a library holding a dozen variants of one base, and is
59
+ * family - which matters on a library holding a dozen variants of one base, and is
60
60
  * necessary because MTP builds carry an extra multi-token-prediction layer (65
61
61
  * blocks where the base has 64) and would otherwise never match.
62
62
  */
@@ -562,10 +562,10 @@ export type Auth = z.infer<typeof AuthSchema>;
562
562
  /**
563
563
  * TLS termination, built into the brain so it can be exposed over HTTPS with no
564
564
  * relay in front. Four modes:
565
- * - `off` plain HTTP (the default; loopback-only is the safe posture).
566
- * - `files` bring your own cert/key (a real cert, or one you manage).
567
- * - `self-signed` generate a local keypair on first run, cached under `certDir`.
568
- * - `tailscale` issue and auto-renew a real Let's Encrypt cert for this
565
+ * - `off` - plain HTTP (the default; loopback-only is the safe posture).
566
+ * - `files` - bring your own cert/key (a real cert, or one you manage).
567
+ * - `self-signed` - generate a local keypair on first run, cached under `certDir`.
568
+ * - `tailscale` - issue and auto-renew a real Let's Encrypt cert for this
569
569
  * machine's MagicDNS name via `tailscaled` (no cert warnings on
570
570
  * the tailnet). `hostname` is auto-detected when null.
571
571
  * The service layer enforces that a non-loopback bind still carries auth.
@@ -86,10 +86,10 @@ export const AuthSchema = z
86
86
  /**
87
87
  * TLS termination, built into the brain so it can be exposed over HTTPS with no
88
88
  * relay in front. Four modes:
89
- * - `off` plain HTTP (the default; loopback-only is the safe posture).
90
- * - `files` bring your own cert/key (a real cert, or one you manage).
91
- * - `self-signed` generate a local keypair on first run, cached under `certDir`.
92
- * - `tailscale` issue and auto-renew a real Let's Encrypt cert for this
89
+ * - `off` - plain HTTP (the default; loopback-only is the safe posture).
90
+ * - `files` - bring your own cert/key (a real cert, or one you manage).
91
+ * - `self-signed` - generate a local keypair on first run, cached under `certDir`.
92
+ * - `tailscale` - issue and auto-renew a real Let's Encrypt cert for this
93
93
  * machine's MagicDNS name via `tailscaled` (no cert warnings on
94
94
  * the tailnet). `hostname` is auto-detected when null.
95
95
  * The service layer enforces that a non-loopback bind still carries auth.
@@ -147,11 +147,11 @@ export const BrainConfigSchema = z
147
147
  // refuse completion requests that name a different one, instead of queuing a
148
148
  // switch. For hosts that load one model and must not thrash between clients.
149
149
  lockModel: z.boolean().default(false),
150
- // Sharing/control gates (off by default a brain is not remotely
150
+ // Sharing/control gates (off by default - a brain is not remotely
151
151
  // controllable until its owner opts in). `allowRemoteConfig`: a client with
152
152
  // the token may CHANGE config over the network (POST /__host/config), not
153
153
  // just use/read. `allowInsecureBind`: permit a non-loopback bind with no
154
- // token (an "open, trusted network" share) otherwise the service refuses.
154
+ // token (an "open, trusted network" share) - otherwise the service refuses.
155
155
  allowRemoteConfig: z.boolean().default(false),
156
156
  allowInsecureBind: z.boolean().default(false),
157
157
  defaults: ProfileDefaultsSchema.default({}),
@@ -3,7 +3,7 @@ import { type BrainConfig, type Catalog, type ProfilesStore } from "./schema.js"
3
3
  export declare function loadProfilesStore(paths?: BrainPaths): ProfilesStore;
4
4
  export declare function saveProfilesStore(store: ProfilesStore, paths?: BrainPaths): void;
5
5
  export declare function loadCatalog(paths?: BrainPaths): Catalog;
6
- /** Persisted config only no env overrides. Used by writers. */
6
+ /** Persisted config only - no env overrides. Used by writers. */
7
7
  export declare function loadPersistedConfig(paths?: BrainPaths): BrainConfig;
8
8
  /** Effective config: persisted file with env overrides layered on top. */
9
9
  export declare function loadBrainConfig(env?: NodeJS.ProcessEnv, paths?: BrainPaths): BrainConfig;
@@ -53,7 +53,7 @@ export function loadCatalog(paths = resolveBrainPaths()) {
53
53
  return CatalogSchema.parse({ models: [] });
54
54
  }
55
55
  // --------------------------------------------------------------------- config
56
- /** Persisted config only no env overrides. Used by writers. */
56
+ /** Persisted config only - no env overrides. Used by writers. */
57
57
  export function loadPersistedConfig(paths = resolveBrainPaths()) {
58
58
  const current = readJson(paths.configFile, BrainConfigSchema);
59
59
  if (current)
@@ -15,7 +15,7 @@ function resolveUrl(repo, file) {
15
15
  }
16
16
  /**
17
17
  * The GGUF filename to download. Prefer an explicit override, then the catalog
18
- * entry's `quantFile`, then the basename of the catalog `id` which for the
18
+ * entry's `quantFile`, then the basename of the catalog `id` - which for the
19
19
  * seeded catalog is `<hfRepo>/<file>.gguf`, so the id already names the file.
20
20
  * Only if none of those yields a `.gguf` do we give up: community repos name
21
21
  * files inconsistently, so a bare id with no gguf basename still needs --file.
@@ -2,7 +2,7 @@
2
2
  * Reconciles scanned models back to their download-catalog entries so a model's
3
3
  * coding metadata (useCases, tier, thinking, contextMax) survives a `pull`. The
4
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
5
+ * Model from filename + GGUF header alone, dropping it - this is where it is
6
6
  * re-attached. Track B1 of the brain coding-capabilities work.
7
7
  *
8
8
  * The join key is the hfRepo path. download.ts writes each model to
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * Total and best-effort by design: an empty catalog, a model with no match, or a
16
16
  * repo carrying several quants all resolve without throwing. Discovery returns
17
- * things unenriched on absence rather than raising the caller decides whether
17
+ * things unenriched on absence rather than raising - the caller decides whether
18
18
  * absence matters.
19
19
  */
20
20
  import type { Catalog, CatalogModel } from "../config/schema.js";
@@ -1,7 +1,7 @@
1
1
  import type { BrainConfig } from "../config/schema.js";
2
2
  import type { Model } from "../types.js";
3
3
  export * from "./scan.js";
4
- export { pickModel } from "./pick.js";
4
+ export { pickModel, pickAutoModel } from "./pick.js";
5
5
  export { resolveModelsDirs, managedModelsDir, type ModelsDir } from "./dirs.js";
6
6
  export { pullModel, downloadRepoFiles, type PullOptions, type PullProgress, type DownloadFilesOptions, } from "./download.js";
7
7
  export { matchCatalogEntry, enrichWithCatalog } from "./enrich.js";
@@ -6,7 +6,7 @@ import { resolveModelsDirs } from "./dirs.js";
6
6
  import { enrichWithCatalog } from "./enrich.js";
7
7
  import { scan } from "./scan.js";
8
8
  export * from "./scan.js";
9
- export { pickModel } from "./pick.js";
9
+ export { pickModel, pickAutoModel } from "./pick.js";
10
10
  export { resolveModelsDirs, managedModelsDir } from "./dirs.js";
11
11
  export { pullModel, downloadRepoFiles, } from "./download.js";
12
12
  export { matchCatalogEntry, enrichWithCatalog } from "./enrich.js";
@@ -1,3 +1,12 @@
1
+ import { type RankedModel } from "../ops/results.js";
1
2
  import type { Model } from "../types.js";
2
3
  export declare function pickModel(catalog: Model[], needle: string | undefined): Model;
4
+ /**
5
+ * Choose a model when nothing named one: the best-ranked model from bench
6
+ * history that is still installed, or the first catalog entry when nothing
7
+ * has been benched yet. This is what "Automatic" (a null default model) means
8
+ * in the UI - it must always start something for a non-empty catalog. The
9
+ * VRAM fit check downstream in startService is what can still refuse the pick.
10
+ */
11
+ export declare function pickAutoModel(catalog: Model[], ranked?: RankedModel[]): Model;
3
12
  //# sourceMappingURL=pick.d.ts.map
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Resolve a model from an exact catalog id or a unique case-insensitive name
3
- * fragment. An ambiguous fragment is an error that lists the matches ported
3
+ * fragment. An ambiguous fragment is an error that lists the matches - ported
4
4
  * from the original CLI's pickModel, but throwing a CommandError instead of
5
5
  * calling process.exit, so the output layer renders it.
6
6
  */
7
7
  import { CommandError } from "../output/types.js";
8
+ import { rankModels } from "../ops/results.js";
8
9
  export function pickModel(catalog, needle) {
9
10
  if (!needle) {
10
11
  throw new CommandError({
@@ -33,4 +34,26 @@ export function pickModel(catalog, needle) {
33
34
  }
34
35
  return matches[0];
35
36
  }
37
+ /**
38
+ * Choose a model when nothing named one: the best-ranked model from bench
39
+ * history that is still installed, or the first catalog entry when nothing
40
+ * has been benched yet. This is what "Automatic" (a null default model) means
41
+ * in the UI - it must always start something for a non-empty catalog. The
42
+ * VRAM fit check downstream in startService is what can still refuse the pick.
43
+ */
44
+ export function pickAutoModel(catalog, ranked = rankModels()) {
45
+ if (catalog.length === 0) {
46
+ throw new CommandError({
47
+ code: "NO_MODEL",
48
+ message: "no models installed",
49
+ details: "run `otto brain pull <model>` to download one, or point at an LM Studio install",
50
+ });
51
+ }
52
+ for (const entry of ranked) {
53
+ const match = catalog.find((m) => m.id === entry.id);
54
+ if (match)
55
+ return match;
56
+ }
57
+ return catalog[0];
58
+ }
36
59
  //# sourceMappingURL=pick.js.map