@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
@@ -149,6 +149,7 @@ export function describeModel(model, options = {}) {
149
149
  // OpenAI-compatible clients send `id`; Otto uses `name` for presentation.
150
150
  id: model.id,
151
151
  name: model.displayName,
152
+ ...(model.family ? { family: model.family } : {}),
152
153
  object: "model",
153
154
  created: Math.floor((createdAt ? createdAt.getTime() : Date.now()) / 1000),
154
155
  owned_by: model.publisher || "local",
@@ -159,7 +160,13 @@ export function describeModel(model, options = {}) {
159
160
  compatibility_type: "gguf",
160
161
  quantization: model.quant || null,
161
162
  state,
162
- max_context_length: md.contextLength ?? null,
163
+ // The GGUF header remains the native limit, but an actively loaded YaRN
164
+ // profile intentionally extends the server's usable maximum. Publish that
165
+ // effective ceiling so OpenAI-compatible clients do not reject a context
166
+ // that this very llama-server instance has been configured to accept.
167
+ max_context_length: typeof md.contextLength === "number"
168
+ ? md.contextLength * (profile?.contextMultiplier ?? 1)
169
+ : null,
163
170
  // GGUF template detection is deliberately conservative. A false result
164
171
  // means "not detected", not proof that a catalog-marked reasoner is not
165
172
  // one, so preserve the catalog's positive capability metadata.
@@ -290,13 +297,17 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
290
297
  resolve();
291
298
  }
292
299
  };
300
+ // Injected here, not at queue time: the scheduler may switch models between
301
+ // buffering and dispatch, and the addendum belongs to whichever model ends
302
+ // up resident, which is the one `supervisor.profile` now describes.
303
+ const outbound = injectSystemAddendum(body, supervisor.profile?.chatSystemAddendum ?? null, completionShape(req.url));
293
304
  const headers = {};
294
305
  for (const [name, value] of Object.entries(req.headers)) {
295
306
  if (!HOP_BY_HOP.has(name.toLowerCase()))
296
307
  headers[name] = value;
297
308
  }
298
309
  headers.host = `${supervisor.host}:${supervisor.internalPort}`;
299
- headers["content-length"] = Buffer.byteLength(body);
310
+ headers["content-length"] = Buffer.byteLength(outbound);
300
311
  const started = Date.now();
301
312
  const upstream = http.request({
302
313
  host: supervisor.host,
@@ -406,9 +417,78 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
406
417
  // being dispatched to llama-server and is waiting for prompt processing or
407
418
  // its first output delta.
408
419
  reasoning?.begin(streamId);
409
- upstream.end(body);
420
+ upstream.end(outbound);
410
421
  });
411
422
  }
423
+ export function completionShape(url) {
424
+ return /\/v1\/messages/.test(url ?? "") ? "anthropic" : "openai";
425
+ }
426
+ /** One text block, as both API shapes spell it inside a structured content array. */
427
+ function textBlock(text) {
428
+ return { type: "text", text };
429
+ }
430
+ /**
431
+ * Append the active hosting profile's system-prompt addendum to a buffered
432
+ * completion body.
433
+ *
434
+ * Appending rather than prepending or replacing is the whole point: the agent's
435
+ * own system prompt still leads, and the profile's instructions are read last.
436
+ * A body this cannot understand is forwarded untouched - a malformed or
437
+ * unfamiliar request must still reach llama-server and get llama-server's own
438
+ * error, not a 400 invented here.
439
+ *
440
+ * Cost is one extra parse/serialize per request, paid only by models whose
441
+ * profile actually sets an addendum.
442
+ */
443
+ export function injectSystemAddendum(body, addendum, shape) {
444
+ if (!addendum)
445
+ return body;
446
+ let parsed;
447
+ try {
448
+ parsed = JSON.parse(body.toString("utf8"));
449
+ }
450
+ catch {
451
+ return body;
452
+ }
453
+ if (!isRecord(parsed))
454
+ return body;
455
+ if (shape === "anthropic") {
456
+ // Anthropic carries the system turn beside `messages`, never inside it.
457
+ const system = parsed.system;
458
+ if (system === undefined || system === null || system === "")
459
+ parsed.system = addendum;
460
+ else if (typeof system === "string")
461
+ parsed.system = `${system}\n\n${addendum}`;
462
+ else if (Array.isArray(system))
463
+ parsed.system = [...system, textBlock(addendum)];
464
+ else
465
+ return body;
466
+ return Buffer.from(JSON.stringify(parsed), "utf8");
467
+ }
468
+ const messages = parsed.messages;
469
+ if (!Array.isArray(messages))
470
+ return body;
471
+ // `developer` is the newer OpenAI spelling of the same turn; either one is
472
+ // the message this addendum belongs on.
473
+ const index = messages.findIndex((message) => isRecord(message) && (message.role === "system" || message.role === "developer"));
474
+ if (index === -1) {
475
+ parsed.messages = [{ role: "system", content: addendum }, ...messages];
476
+ return Buffer.from(JSON.stringify(parsed), "utf8");
477
+ }
478
+ const existing = messages[index];
479
+ const content = existing.content;
480
+ let merged;
481
+ if (content === undefined || content === null || content === "")
482
+ merged = addendum;
483
+ else if (typeof content === "string")
484
+ merged = `${content}\n\n${addendum}`;
485
+ else if (Array.isArray(content))
486
+ merged = [...content, textBlock(addendum)];
487
+ else
488
+ return body;
489
+ parsed.messages = messages.map((message, at) => at === index ? { ...existing, content: merged } : message);
490
+ return Buffer.from(JSON.stringify(parsed), "utf8");
491
+ }
412
492
  /**
413
493
  * Pure model-admission decision, factored out of the router so it is unit
414
494
  * testable. `pinned` is the single model a locked host serves; `resolved` is the
@@ -497,7 +577,12 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
497
577
  // window - the cheap time-based trigger.
498
578
  const RANKING_TTL_MS = 60000;
499
579
  export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, applyConfigPatch = null, getAllowConfigWrite = () => false, hostApi = null, getResources = null, statusEvents = null, }) {
500
- const agent = new http.Agent({ keepAlive: true, maxSockets: 32 });
580
+ // llama-server may close an idle response socket while this scheduler holds
581
+ // the next request in queue. A reused keep-alive socket then fails as
582
+ // ECONNRESET ("socket hang up") before the queued request reaches inference.
583
+ // Inference time dwarfs localhost connection setup, so isolate each request
584
+ // instead of letting a second client inherit a stale upstream connection.
585
+ const agent = new http.Agent({ keepAlive: false, maxSockets: 32 });
501
586
  const scheduler = loadModel
502
587
  ? new Scheduler({
503
588
  supervisor,
@@ -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,8 +22,13 @@ 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";
31
+ import { errorMessage } from "./http-util.js";
27
32
  import { createRouter, Telemetry } from "./router.js";
28
33
  import { BrainStatusPublisher } from "./status-events.js";
29
34
  import { Supervisor } from "./supervisor.js";
@@ -57,13 +62,10 @@ function collectEvals() {
57
62
  }
58
63
  }
59
64
  const REMOTE_JOB_RETENTION_MS = 5 * 60000;
60
- /**
61
- * Runs a benchmark as a child of the brain service. This is intentionally here
62
- * rather than in the connecting daemon: its process, model store, results
63
- * directory and GPU all belong to the host that is being benchmarked.
64
- */
65
65
  class ServiceJobRunner {
66
- constructor() {
66
+ constructor(onPullCompleted, runResidentJob) {
67
+ this.onPullCompleted = onPullCompleted;
68
+ this.runResidentJob = runResidentJob;
67
69
  this.jobs = new Map();
68
70
  }
69
71
  start(kind, target, args) {
@@ -86,7 +88,26 @@ class ServiceJobRunner {
86
88
  startedAt: new Date().toISOString(),
87
89
  finishedAt: null,
88
90
  child: null,
91
+ controller: null,
89
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
+ }
90
111
  // The service is launched by the same CLI entry point as `otto-brain bench`.
91
112
  // Reusing that entry point keeps its config/path resolution on this host.
92
113
  const entry = process.argv[1];
@@ -99,18 +120,25 @@ class ServiceJobRunner {
99
120
  windowsHide: true,
100
121
  });
101
122
  job.child = child;
102
- this.jobs.set(job.id, job);
103
123
  child.stderr?.setEncoding("utf8");
104
- child.stderr?.on("data", (chunk) => {
105
- const last = chunk.trim().split(/\r?\n/).at(-1)?.trim();
106
- if (last)
107
- job.message = last.slice(-1000);
108
- });
124
+ child.stderr?.on("data", (chunk) => this.ingestOutput(job, chunk));
109
125
  child.once("error", (error) => this.finish(job, "failed", error.message));
110
126
  child.once("close", (code) => {
111
127
  if (job.status !== "running")
112
128
  return;
113
- this.finish(job, code === 0 ? "succeeded" : "failed", code === 0 ? null : `Exited with code ${code}.`);
129
+ if (code === 0 && kind === "pull") {
130
+ try {
131
+ // Downloads happen in a child process, but inventory is served from
132
+ // this process's in-memory scan. Reconcile before reporting success
133
+ // so a newly downloaded bundle component is immediately available.
134
+ this.onPullCompleted();
135
+ }
136
+ catch (error) {
137
+ this.finish(job, "failed", `Downloaded files, but could not refresh inventory: ${errorMessage(error)}`);
138
+ return;
139
+ }
140
+ }
141
+ this.finish(job, code === 0 ? "succeeded" : "failed", code === 0 ? null : (job.message ?? `Exited with code ${code}.`));
114
142
  });
115
143
  return this.publicJob(job);
116
144
  }
@@ -156,8 +184,13 @@ class ServiceJobRunner {
156
184
  }
157
185
  async cancel(jobId) {
158
186
  const job = this.jobs.get(jobId);
159
- 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.");
160
192
  return this.list();
193
+ }
161
194
  const child = job.child;
162
195
  if (process.platform === "win32" && child.pid) {
163
196
  await new Promise((resolve) => execFile("taskkill", ["/pid", String(child.pid), "/t", "/f"], () => resolve()));
@@ -168,15 +201,32 @@ class ServiceJobRunner {
168
201
  this.finish(job, "canceled", "Canceled.");
169
202
  return this.list();
170
203
  }
204
+ ingestOutput(job, chunk) {
205
+ for (const line of chunk
206
+ .split(/[\r\n]+/u)
207
+ .map((value) => value.trim())
208
+ .filter(Boolean)) {
209
+ const progress = /(\d{1,3})\s*%/u.exec(line);
210
+ if (progress)
211
+ job.percent = Math.max(0, Math.min(100, Number(progress[1])));
212
+ // The final JSON result is not a useful status label. Keep progress and
213
+ // actionable text instead, so a failed bundle pull tells the user why.
214
+ if (line !== "[" && !/^[\]{}",]+$/u.test(line))
215
+ job.message = line.slice(-1000);
216
+ }
217
+ }
171
218
  finish(job, status, error) {
172
219
  if (job.status !== "running")
173
220
  return;
174
221
  job.child = null;
222
+ job.controller = null;
175
223
  job.status = status;
176
224
  job.error = error;
177
225
  job.finishedAt = new Date().toISOString();
226
+ if (status === "succeeded")
227
+ job.percent = 100;
178
228
  }
179
- publicJob({ child: _child, ...job }) {
229
+ publicJob({ child: _child, controller: _controller, ...job }) {
180
230
  return job;
181
231
  }
182
232
  }
@@ -266,6 +316,10 @@ export async function startService({ config, modelNeedle, env = process.env, onL
266
316
  // Not const: deleting a model through the management API re-scans and replaces
267
317
  // this, and every reader goes through a getter so nobody holds a stale array.
268
318
  let catalog = scanModels(config, env);
319
+ const rescanCatalog = () => {
320
+ catalog = scanModels(config, env);
321
+ return catalog;
322
+ };
269
323
  const needle = modelNeedle ?? config.defaultModel ?? store.lastModelId ?? undefined;
270
324
  let model = null;
271
325
  if (catalog.length > 0) {
@@ -304,7 +358,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
304
358
  }
305
359
  }
306
360
  const telemetry = new Telemetry();
307
- const supervisor = new Supervisor({ runtime });
361
+ const supervisor = new Supervisor({ runtime, paths, getProfilesStore: () => store });
308
362
  supervisor.on("log", (line) => {
309
363
  runLog.write(line);
310
364
  if (/error|failed|warn/i.test(line))
@@ -337,6 +391,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
337
391
  fitProfile = fit.profile;
338
392
  }
339
393
  await supervisor.start(target, fitProfile);
394
+ delete store.pendingReloadModelIds[target.id];
340
395
  store.lastModelId = target.id;
341
396
  saveProfilesStore(store, paths);
342
397
  };
@@ -389,17 +444,160 @@ export async function startService({ config, modelNeedle, env = process.env, onL
389
444
  // /__host/events. One instance, so `capabilities.events` and the stream can
390
445
  // never disagree about whether this brain publishes.
391
446
  const statusEvents = new BrainStatusPublisher();
392
- const jobs = new ServiceJobRunner();
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);
393
594
  // Assigned once `stop` exists below. This indirection lets the management API
394
595
  // answer a remote restart request before closing its own socket.
395
596
  let requestRestart = () => { };
396
597
  const hostApi = createHostApi({
397
598
  supervisor,
398
599
  getCatalog: () => catalog,
399
- rescan: () => {
400
- catalog = scanModels(config, env);
401
- return catalog;
402
- },
600
+ rescan: rescanCatalog,
403
601
  getProfilesStore: () => store,
404
602
  saveProfiles: (next) => saveProfilesStore(next, paths),
405
603
  getProfileDefaults: () => config.defaults,
@@ -469,6 +667,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
469
667
  certManager?.start();
470
668
  if (model && profile && runtime) {
471
669
  await supervisor.start(model, profile);
670
+ delete store.pendingReloadModelIds[model.id];
472
671
  store.lastModelId = model.id;
473
672
  saveProfilesStore(store, paths);
474
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", () => {