@otto-code/brain 0.8.8 → 0.8.10

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 (45) hide show
  1. package/dist/commands/bench.js +19 -5
  2. package/dist/commands/catalog.d.ts +1 -0
  3. package/dist/commands/catalog.js +1 -0
  4. package/dist/commands/pull.js +14 -33
  5. package/dist/commands/repo-download.d.ts +14 -0
  6. package/dist/commands/repo-download.js +29 -0
  7. package/dist/commands/search.js +6 -14
  8. package/dist/config/builtin-hosting-profiles.d.ts +8 -0
  9. package/dist/config/builtin-hosting-profiles.js +32 -0
  10. package/dist/config/hosting-profiles.d.ts +33 -0
  11. package/dist/config/hosting-profiles.js +71 -0
  12. package/dist/config/index.d.ts +1 -0
  13. package/dist/config/index.js +1 -0
  14. package/dist/config/paths.d.ts +2 -0
  15. package/dist/config/paths.js +1 -0
  16. package/dist/config/profile-edit.d.ts +4 -2
  17. package/dist/config/profile-edit.js +94 -4
  18. package/dist/config/profiles.js +25 -2
  19. package/dist/config/schema.d.ts +494 -24
  20. package/dist/config/schema.js +55 -0
  21. package/dist/config/store.js +12 -2
  22. package/dist/gguf.d.ts +1 -0
  23. package/dist/gguf.js +1 -0
  24. package/dist/models/download.js +5 -0
  25. package/dist/models/enrich.d.ts +6 -0
  26. package/dist/models/enrich.js +50 -2
  27. package/dist/ops/calibrate.d.ts +4 -1
  28. package/dist/ops/calibrate.js +10 -7
  29. package/dist/ops/sweep.d.ts +3 -1
  30. package/dist/ops/sweep.js +3 -3
  31. package/dist/runtime/args.d.ts +2 -2
  32. package/dist/runtime/args.js +13 -1
  33. package/dist/runtime/index.d.ts +7 -0
  34. package/dist/runtime/index.js +10 -0
  35. package/dist/service/host-api.d.ts +17 -1
  36. package/dist/service/host-api.js +196 -13
  37. package/dist/service/router.d.ts +18 -0
  38. package/dist/service/router.js +89 -4
  39. package/dist/service/serve.js +184 -12
  40. package/dist/service/supervisor.d.ts +32 -4
  41. package/dist/service/supervisor.js +30 -6
  42. package/dist/tui/app.js +1 -1
  43. package/dist/types.d.ts +4 -0
  44. package/dist/vram.js +3 -3
  45. package/package.json +1 -1
@@ -13,7 +13,7 @@ import { execFile, spawn } from "node:child_process";
13
13
  import { randomUUID } from "node:crypto";
14
14
  import http from "node:http";
15
15
  import https from "node:https";
16
- import { getCalibration, forModel, loadPersistedConfig, loadProfilesStore, saveBrainConfig, saveProfilesStore, } from "../config/index.js";
16
+ import { getCalibration, forModel, loadPersistedConfig, loadProfilesStore, put, putCalibration, saveBrainConfig, saveProfilesStore, } from "../config/index.js";
17
17
  import { resolveBrainPaths } from "../config/paths.js";
18
18
  import { query as queryGpu } from "../gpu.js";
19
19
  import { managedModelsDir, pickAutoModel, pickModel, scanModels } from "../models/index.js";
@@ -22,6 +22,10 @@ import { resolveRuntime } from "../runtime/index.js";
22
22
  import * as vram from "../vram.js";
23
23
  import { resolveVersion } from "../version.js";
24
24
  import * as results from "../ops/results.js";
25
+ import * as archive from "../ops/archive.js";
26
+ import { calibrate } from "../ops/calibrate.js";
27
+ import { sweep } from "../ops/sweep.js";
28
+ import * as bench from "../bench/index.js";
25
29
  import { createCpuSampler, sample as sampleSystem } from "../sysmon.js";
26
30
  import { createHostApi } from "./host-api.js";
27
31
  import { errorMessage } from "./http-util.js";
@@ -58,14 +62,10 @@ function collectEvals() {
58
62
  }
59
63
  }
60
64
  const REMOTE_JOB_RETENTION_MS = 5 * 60000;
61
- /**
62
- * Runs a benchmark as a child of the brain service. This is intentionally here
63
- * rather than in the connecting daemon: its process, model store, results
64
- * directory and GPU all belong to the host that is being benchmarked.
65
- */
66
65
  class ServiceJobRunner {
67
- constructor(onPullCompleted) {
66
+ constructor(onPullCompleted, runResidentJob) {
68
67
  this.onPullCompleted = onPullCompleted;
68
+ this.runResidentJob = runResidentJob;
69
69
  this.jobs = new Map();
70
70
  }
71
71
  start(kind, target, args) {
@@ -88,7 +88,26 @@ class ServiceJobRunner {
88
88
  startedAt: new Date().toISOString(),
89
89
  finishedAt: null,
90
90
  child: null,
91
+ controller: null,
91
92
  };
93
+ this.jobs.set(job.id, job);
94
+ if (kind === "calibrate" || kind === "sweep" || kind === "bench") {
95
+ const controller = new AbortController();
96
+ job.controller = controller;
97
+ void this.runResidentJob(kind, target, {
98
+ message: (value) => {
99
+ if (job.status === "running")
100
+ job.message = value.slice(-1000);
101
+ },
102
+ percent: (value) => {
103
+ if (job.status === "running")
104
+ job.percent = value;
105
+ },
106
+ }, controller.signal)
107
+ .then(() => this.finish(job, "succeeded", null))
108
+ .catch((error) => this.finish(job, controller.signal.aborted ? "canceled" : "failed", errorMessage(error)));
109
+ return this.publicJob(job);
110
+ }
92
111
  // The service is launched by the same CLI entry point as `otto-brain bench`.
93
112
  // Reusing that entry point keeps its config/path resolution on this host.
94
113
  const entry = process.argv[1];
@@ -101,7 +120,6 @@ class ServiceJobRunner {
101
120
  windowsHide: true,
102
121
  });
103
122
  job.child = child;
104
- this.jobs.set(job.id, job);
105
123
  child.stderr?.setEncoding("utf8");
106
124
  child.stderr?.on("data", (chunk) => this.ingestOutput(job, chunk));
107
125
  child.once("error", (error) => this.finish(job, "failed", error.message));
@@ -166,8 +184,13 @@ class ServiceJobRunner {
166
184
  }
167
185
  async cancel(jobId) {
168
186
  const job = this.jobs.get(jobId);
169
- if (!job || job.status !== "running" || !job.child)
187
+ if (!job || job.status !== "running")
188
+ return this.list();
189
+ if (!job.child) {
190
+ job.controller?.abort();
191
+ this.finish(job, "canceled", "Canceled.");
170
192
  return this.list();
193
+ }
171
194
  const child = job.child;
172
195
  if (process.platform === "win32" && child.pid) {
173
196
  await new Promise((resolve) => execFile("taskkill", ["/pid", String(child.pid), "/t", "/f"], () => resolve()));
@@ -196,13 +219,14 @@ class ServiceJobRunner {
196
219
  if (job.status !== "running")
197
220
  return;
198
221
  job.child = null;
222
+ job.controller = null;
199
223
  job.status = status;
200
224
  job.error = error;
201
225
  job.finishedAt = new Date().toISOString();
202
226
  if (status === "succeeded")
203
227
  job.percent = 100;
204
228
  }
205
- publicJob({ child: _child, ...job }) {
229
+ publicJob({ child: _child, controller: _controller, ...job }) {
206
230
  return job;
207
231
  }
208
232
  }
@@ -334,7 +358,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
334
358
  }
335
359
  }
336
360
  const telemetry = new Telemetry();
337
- const supervisor = new Supervisor({ runtime });
361
+ const supervisor = new Supervisor({ runtime, paths, getProfilesStore: () => store });
338
362
  supervisor.on("log", (line) => {
339
363
  runLog.write(line);
340
364
  if (/error|failed|warn/i.test(line))
@@ -367,6 +391,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
367
391
  fitProfile = fit.profile;
368
392
  }
369
393
  await supervisor.start(target, fitProfile);
394
+ delete store.pendingReloadModelIds[target.id];
370
395
  store.lastModelId = target.id;
371
396
  saveProfilesStore(store, paths);
372
397
  };
@@ -419,7 +444,153 @@ export async function startService({ config, modelNeedle, env = process.env, onL
419
444
  // /__host/events. One instance, so `capabilities.events` and the stream can
420
445
  // never disagree about whether this brain publishes.
421
446
  const statusEvents = new BrainStatusPublisher();
422
- const jobs = new ServiceJobRunner(rescanCatalog);
447
+ const runResidentJob = async (kind, target, update, signal) => {
448
+ const ensureActive = () => {
449
+ if (signal.aborted)
450
+ throw new Error("Operation canceled.");
451
+ };
452
+ const modelId = target ?? supervisor.model?.id ?? store.lastModelId ?? null;
453
+ const targetModel = modelId
454
+ ? catalog.find((candidate) => candidate.id === modelId || candidate.displayName === modelId)
455
+ : null;
456
+ if (!targetModel)
457
+ throw new Error("No installed model is available for this operation.");
458
+ const restoreModel = supervisor.model;
459
+ const restoreProfile = supervisor.profile;
460
+ const restore = async () => {
461
+ if (restoreModel && restoreProfile && !signal.aborted) {
462
+ await supervisor.start(restoreModel, restoreProfile, { preserveLogs: true });
463
+ }
464
+ };
465
+ if (kind === "calibrate") {
466
+ const runtime = supervisor.runtime ?? resolveRuntime(config, env);
467
+ if (!runtime)
468
+ throw new Error("no llama.cpp runtime available; install one from the Library tab");
469
+ const profile = forModel(store, targetModel, config.defaults);
470
+ update.message(`Calibrating ${targetModel.displayName}`);
471
+ supervisor.recordLog(`operation calibrate: ${targetModel.displayName}`);
472
+ try {
473
+ const measurement = await calibrate({
474
+ runtime,
475
+ model: targetModel,
476
+ profile,
477
+ supervisor,
478
+ onProgress: (event) => {
479
+ ensureActive();
480
+ const message = event.phase === "loading"
481
+ ? `Calibrating ${event.contextSize.toLocaleString()} context`
482
+ : event.phase === "measured"
483
+ ? `Measured ${event.contextSize.toLocaleString()} context`
484
+ : (event.reason ??
485
+ event.error ??
486
+ `Skipped ${event.contextSize.toLocaleString()} context`);
487
+ update.message(message);
488
+ supervisor.recordLog(`operation calibrate: ${message}`);
489
+ },
490
+ });
491
+ ensureActive();
492
+ putCalibration(store, targetModel, profile, measurement);
493
+ saveProfilesStore(store, paths);
494
+ update.percent(100);
495
+ supervisor.recordLog(`operation calibrate: saved measurement for ${targetModel.displayName}`);
496
+ }
497
+ finally {
498
+ await restore();
499
+ }
500
+ return;
501
+ }
502
+ if (kind === "sweep") {
503
+ const runtime = supervisor.runtime ?? resolveRuntime(config, env);
504
+ if (!runtime)
505
+ throw new Error("no llama.cpp runtime available; install one from the Library tab");
506
+ const profile = forModel(store, targetModel, config.defaults);
507
+ update.message(`Sweeping ${targetModel.displayName}`);
508
+ supervisor.recordLog(`operation sweep: ${targetModel.displayName}`);
509
+ try {
510
+ const report = await sweep({
511
+ runtime,
512
+ model: targetModel,
513
+ profile,
514
+ supervisor,
515
+ onProgress: (event) => {
516
+ ensureActive();
517
+ const message = event.phase === "loading"
518
+ ? `Budget ${event.budget}: loading`
519
+ : event.phase === "generating"
520
+ ? `Budget ${event.budget}: generating`
521
+ : event.phase === "done"
522
+ ? `Budget ${event.budget}: complete`
523
+ : `Budget ${event.budget}: ${event.error ?? "failed"}`;
524
+ update.message(message);
525
+ supervisor.recordLog(`operation sweep: ${message}`);
526
+ },
527
+ });
528
+ ensureActive();
529
+ if (report.recommended !== null) {
530
+ profile.reasoningBudget = report.recommended;
531
+ put(store, targetModel, profile);
532
+ saveProfilesStore(store, paths);
533
+ supervisor.recordLog(`operation sweep: saved budget ${report.recommended}`);
534
+ }
535
+ update.percent(100);
536
+ }
537
+ finally {
538
+ await restore();
539
+ }
540
+ return;
541
+ }
542
+ update.message(`Benchmarking ${targetModel.displayName}`);
543
+ supervisor.recordLog(`operation benchmark: ${targetModel.displayName}`);
544
+ await loadModel(targetModel);
545
+ ensureActive();
546
+ supervisor.recordLog(`operation benchmark: resident model ready`);
547
+ const profile = supervisor.profile ?? forModel(store, targetModel, config.defaults);
548
+ const gpuInfo = await queryGpu();
549
+ const calibration = getCalibration(store, targetModel, profile);
550
+ const fit = gpuInfo
551
+ ? vram.fitToBudget({
552
+ model: targetModel,
553
+ profile,
554
+ calibration,
555
+ totalVramBytes: gpuInfo.totalBytes,
556
+ })
557
+ : null;
558
+ const archiveId = archive.runId(targetModel);
559
+ const report = await bench.runSuite({
560
+ host: supervisor.host,
561
+ port: supervisor.internalPort,
562
+ concurrency: 3,
563
+ reasoningBudget: profile.reasoningBudget ?? null,
564
+ contextWindow: profile.contextSize ?? null,
565
+ archiveId,
566
+ onProgress: (event) => {
567
+ ensureActive();
568
+ const message = event.title
569
+ ? `${event.title}: ${event.phase}`
570
+ : (event.summary ?? event.phase);
571
+ update.message(message);
572
+ supervisor.recordLog(`operation benchmark: ${message}`);
573
+ },
574
+ });
575
+ ensureActive();
576
+ results.save({
577
+ model: targetModel,
578
+ profile,
579
+ report,
580
+ gpu: gpuInfo,
581
+ runtime: supervisor.runtime
582
+ ? `${supervisor.runtime.label} v${supervisor.runtime.version}`
583
+ : "unknown runtime",
584
+ archiveId,
585
+ args: supervisor.args,
586
+ fit,
587
+ calibration,
588
+ suite: { execute: true, concurrency: 3, depths: null, only: null, mined: false },
589
+ });
590
+ update.percent(100);
591
+ supervisor.recordLog(`operation benchmark: saved result for ${targetModel.displayName}`);
592
+ };
593
+ const jobs = new ServiceJobRunner(rescanCatalog, runResidentJob);
423
594
  // Assigned once `stop` exists below. This indirection lets the management API
424
595
  // answer a remote restart request before closing its own socket.
425
596
  let requestRestart = () => { };
@@ -496,6 +667,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
496
667
  certManager?.start();
497
668
  if (model && profile && runtime) {
498
669
  await supervisor.start(model, profile);
670
+ delete store.pendingReloadModelIds[model.id];
499
671
  store.lastModelId = model.id;
500
672
  saveProfilesStore(store, paths);
501
673
  }
@@ -1,7 +1,8 @@
1
1
  import { type ChildProcess } from "node:child_process";
2
2
  import { EventEmitter } from "node:events";
3
+ import { type BrainPaths } from "../config/paths.js";
3
4
  import type { Model, Runtime } from "../types.js";
4
- import type { Profile } from "../config/schema.js";
5
+ import type { Profile, ProfilesStore } from "../config/schema.js";
5
6
  /**
6
7
  * Default loopback port for the private llama-server child. Deliberately clear
7
8
  * of Otto's space: 8081 (the old default) is the Expo/Metro dev port, so a brain
@@ -19,6 +20,14 @@ export interface SupervisorOptions {
19
20
  internalPort?: number;
20
21
  host?: string;
21
22
  readyTimeoutMs?: number;
23
+ /**
24
+ * Long-lived hosts provide their live store so profile edits applied just
25
+ * before a model switch are visible without a second disk read. Standalone
26
+ * operations use the current persisted store, which still preserves the
27
+ * launch-resolution invariant.
28
+ */
29
+ paths?: BrainPaths;
30
+ getProfilesStore?: () => ProfilesStore;
22
31
  }
23
32
  export interface SupervisorStatus {
24
33
  state: SupervisorState;
@@ -44,6 +53,8 @@ export declare class Supervisor extends EventEmitter {
44
53
  internalPort: number;
45
54
  host: string;
46
55
  readyTimeoutMs: number;
56
+ paths: BrainPaths;
57
+ getProfilesStore: () => ProfilesStore;
47
58
  state: SupervisorState;
48
59
  child: ChildProcess | null;
49
60
  model: Model | null;
@@ -61,10 +72,27 @@ export declare class Supervisor extends EventEmitter {
61
72
  * shell line is for reading, not for re-parsing.
62
73
  */
63
74
  args: string[] | null;
64
- constructor({ runtime, internalPort, host, readyTimeoutMs, }: SupervisorOptions);
75
+ constructor({ runtime, internalPort, host, readyTimeoutMs, paths, getProfilesStore, }: SupervisorOptions);
65
76
  get upstreamBase(): string;
66
- /** Start (or restart) the server for a model + profile. */
67
- start(model: Model, profile: Profile): Promise<this>;
77
+ /**
78
+ * Add a host-operation event to the same bounded tail as llama-server output.
79
+ *
80
+ * Calibrate, sweep, and benchmark deliberately reuse this supervisor rather
81
+ * than creating invisible sidecar servers. Their lifecycle markers belong in
82
+ * the same log stream as the child they exercise.
83
+ */
84
+ recordLog(line: string): void;
85
+ /**
86
+ * Start (or restart) the server for a model + profile.
87
+ *
88
+ * This is the sole llama-server launch boundary, so it materializes the
89
+ * selected hosting profile here. Keeping it beside `buildArgs()` makes the
90
+ * Jinja template and router-visible system addendum mandatory for every
91
+ * caller, including future maintenance operations that start a sidecar.
92
+ */
93
+ start(model: Model, profile: Profile, options?: {
94
+ preserveLogs?: boolean;
95
+ }): Promise<this>;
68
96
  /** Fetch /props from the running server (modalities, template caps, defaults). */
69
97
  props(): Promise<unknown>;
70
98
  stop(): Promise<void>;
@@ -8,6 +8,9 @@ import http from "node:http";
8
8
  import { spawn } from "node:child_process";
9
9
  import { EventEmitter } from "node:events";
10
10
  import { buildArgs, buildEnv, formatCommand } from "../runtime/index.js";
11
+ import { resolveHostingProfileForLaunch } from "../config/hosting-profiles.js";
12
+ import { resolveBrainPaths } from "../config/paths.js";
13
+ import { loadProfilesStore } from "../config/store.js";
11
14
  import { usedBytes } from "../gpu.js";
12
15
  const LOG_LINES_KEPT = 300;
13
16
  /**
@@ -28,13 +31,15 @@ export const DEFAULT_INTERNAL_PORT = 20800;
28
31
  * stable one so switching models never asks a client to reconnect elsewhere.
29
32
  */
30
33
  export class Supervisor extends EventEmitter {
31
- constructor({ runtime, internalPort = DEFAULT_INTERNAL_PORT, host = "127.0.0.1", readyTimeoutMs = 300000, }) {
34
+ constructor({ runtime, internalPort = DEFAULT_INTERNAL_PORT, host = "127.0.0.1", readyTimeoutMs = 300000, paths = resolveBrainPaths(), getProfilesStore = loadProfilesStore, }) {
32
35
  super();
33
36
  _Supervisor_instances.add(this);
34
37
  this.runtime = runtime;
35
38
  this.internalPort = internalPort;
36
39
  this.host = host;
37
40
  this.readyTimeoutMs = readyTimeoutMs;
41
+ this.paths = paths;
42
+ this.getProfilesStore = getProfilesStore;
38
43
  this.state = "stopped"; // stopped | starting | ready | failed
39
44
  this.child = null;
40
45
  this.model = null;
@@ -51,8 +56,25 @@ export class Supervisor extends EventEmitter {
51
56
  get upstreamBase() {
52
57
  return `http://${this.host}:${this.internalPort}`;
53
58
  }
54
- /** Start (or restart) the server for a model + profile. */
55
- async start(model, profile) {
59
+ /**
60
+ * Add a host-operation event to the same bounded tail as llama-server output.
61
+ *
62
+ * Calibrate, sweep, and benchmark deliberately reuse this supervisor rather
63
+ * than creating invisible sidecar servers. Their lifecycle markers belong in
64
+ * the same log stream as the child they exercise.
65
+ */
66
+ recordLog(line) {
67
+ __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, line);
68
+ }
69
+ /**
70
+ * Start (or restart) the server for a model + profile.
71
+ *
72
+ * This is the sole llama-server launch boundary, so it materializes the
73
+ * selected hosting profile here. Keeping it beside `buildArgs()` makes the
74
+ * Jinja template and router-visible system addendum mandatory for every
75
+ * caller, including future maintenance operations that start a sidecar.
76
+ */
77
+ async start(model, profile, options = {}) {
56
78
  await this.stop();
57
79
  if (!this.runtime) {
58
80
  this.lastError = "no llama.cpp runtime available";
@@ -60,13 +82,15 @@ export class Supervisor extends EventEmitter {
60
82
  throw new Error(this.lastError);
61
83
  }
62
84
  const runtime = this.runtime;
85
+ const launchProfile = resolveHostingProfileForLaunch(this.paths, this.getProfilesStore(), profile, model.family);
63
86
  this.model = model;
64
- this.profile = profile;
87
+ this.profile = launchProfile;
65
88
  this.lastError = null;
66
- this.logLines = [];
89
+ if (!options.preserveLogs)
90
+ this.logLines = [];
67
91
  this.vramBaselineBytes = await usedBytes();
68
92
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_setState).call(this, "starting");
69
- const args = buildArgs({ ...profile, modelPath: model.modelPath, mmprojPath: model.mmprojPath }, { port: this.internalPort, host: this.host });
93
+ const args = buildArgs({ ...launchProfile, modelPath: model.modelPath, mmprojPath: model.mmprojPath }, { port: this.internalPort, host: this.host }, model);
70
94
  this.args = args;
71
95
  this.command = formatCommand(runtime, args);
72
96
  __classPrivateFieldGet(this, _Supervisor_instances, "m", _Supervisor_log).call(this, `launching: ${this.command}`);
package/dist/tui/app.js CHANGED
@@ -162,7 +162,7 @@ export class App {
162
162
  this.rankings = new Map(); // model id/name -> averaged benchmark rank + score
163
163
  this.rankedModels = []; // ranked list (mean of runs), best first | help
164
164
  this.telemetry = new Telemetry();
165
- this.supervisor = new Supervisor({ runtime });
165
+ this.supervisor = new Supervisor({ runtime, getProfilesStore: () => this.store });
166
166
  this.routerServer = null;
167
167
  this.supervisor.on("state", () => this.draw());
168
168
  this.supervisor.on("log", () => {
package/dist/types.d.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  */
6
6
  export interface ModelMetadata {
7
7
  arch?: string | null;
8
+ name?: string | null;
9
+ basename?: string | null;
8
10
  contextLength?: number | null;
9
11
  blockCount?: number | null;
10
12
  headCount?: number | null;
@@ -73,6 +75,8 @@ export interface Model {
73
75
  catalogId?: string;
74
76
  /** Back-reference: the hfRepo of the reconciled catalog entry, if matched. */
75
77
  catalogHfRepo?: string;
78
+ /** Hosting-profile family from the catalog or normalized GGUF metadata. */
79
+ family?: string;
76
80
  /** Present only when this catalog entry declares a component manifest. */
77
81
  components?: ModelComponent[];
78
82
  }
package/dist/vram.js CHANGED
@@ -108,9 +108,9 @@ export function maxContextThatFits({ model, profile, calibration, totalVramBytes
108
108
  if (room <= 0)
109
109
  return null;
110
110
  const tokens = Math.floor(room / probe.kvBytesPerToken);
111
- const capped = model.metadata?.contextLength
112
- ? Math.min(tokens, model.metadata.contextLength)
113
- : tokens;
111
+ const native = model.metadata?.contextLength;
112
+ const contextLimit = typeof native === "number" && native > 0 ? native * profile.contextMultiplier : tokens;
113
+ const capped = Math.min(tokens, contextLimit);
114
114
  return Math.max(0, Math.floor(capped / step) * step);
115
115
  }
116
116
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otto-code/brain",
3
- "version": "0.8.8",
3
+ "version": "0.8.10",
4
4
  "description": "Otto Brain - self-contained host for local GGUF models, with measured VRAM budgeting and reasoning-budget control",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "bin": {