@otto-code/brain 0.8.1 → 0.8.3

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.
@@ -5,7 +5,7 @@
5
5
  * they are always explicit user actions, never auto-started.
6
6
  */
7
7
  import { spawn } from "node:child_process";
8
- import { openSync } from "node:fs";
8
+ import { existsSync, openSync } from "node:fs";
9
9
  import http from "node:http";
10
10
  import https from "node:https";
11
11
  import { loadBrainConfig } from "../config/index.js";
@@ -30,7 +30,9 @@ export async function runServeCommand(options, _command) {
30
30
  });
31
31
  const scheme = handle.secure ? "https" : "http";
32
32
  process.stdout.write(`router listening on ${scheme}://${handle.displayHost}:${handle.port}\n`);
33
- process.stdout.write(`ready: ${handle.model.displayName}, ${vram.formatGiB(handle.supervisor.vramAtReadyBytes ?? 0)} VRAM in use\n`);
33
+ process.stdout.write(handle.model
34
+ ? `ready: ${handle.model.displayName}, ${vram.formatGiB(handle.supervisor.vramAtReadyBytes ?? 0)} VRAM in use\n`
35
+ : "ready: no model loaded; use the Library tab or `otto brain pull` to download one\n");
34
36
  process.stdout.write("press Ctrl+C to stop\n");
35
37
  const shutdown = async () => {
36
38
  process.stdout.write("\nstopping…\n");
@@ -83,7 +85,19 @@ export async function runStartCommand(options, command) {
83
85
  });
84
86
  }
85
87
  const { logFile } = resolveBrainPaths();
88
+ // process.argv[1] is the entry script of whatever host is running us (the npm
89
+ // CLI's bin, the desktop bundle's dist/index.js, bin/otto-brain). If it is not
90
+ // a file, we are running somewhere that does not lay argv out like Node - the
91
+ // detached child would silently get a verb where the script belongs and parse
92
+ // as garbage, so say so instead.
86
93
  const entry = process.argv[1];
94
+ if (!entry || !existsSync(entry)) {
95
+ throw new CommandError({
96
+ code: "NO_ENTRYPOINT",
97
+ message: "cannot start the brain detached: this host does not expose a CLI entry script",
98
+ details: "run `otto brain serve` in the foreground, or use the npm CLI (npm i -g @otto-code/cli)",
99
+ });
100
+ }
87
101
  const args = [...invocationVerbPrefix(command), "serve"];
88
102
  if (options.model)
89
103
  args.push("--model", options.model);
@@ -4,11 +4,13 @@ export interface BrainPaths {
4
4
  configFile: string;
5
5
  profilesFile: string;
6
6
  catalogFile: string;
7
+ renameMapFile: string;
7
8
  modelsDir: string;
8
9
  runtimesDir: string;
9
10
  pidFile: string;
10
11
  activityFile: string;
11
12
  logFile: string;
13
+ logsDir: string;
12
14
  resultsDir: string;
13
15
  }
14
16
  export declare function resolveBrainPaths(env?: NodeJS.ProcessEnv): BrainPaths;
@@ -15,6 +15,7 @@ export function resolveBrainPaths(env = process.env) {
15
15
  configFile: path.join(root, "config.json"),
16
16
  profilesFile: path.join(root, "profiles.json"),
17
17
  catalogFile: path.join(root, "catalog.json"),
18
+ renameMapFile: path.join(root, "rename-map.json"),
18
19
  modelsDir: path.join(root, "models"),
19
20
  runtimesDir: path.join(root, "runtimes"),
20
21
  pidFile: path.join(root, "otto-brain.pid"),
@@ -23,6 +24,7 @@ export function resolveBrainPaths(env = process.env) {
23
24
  // service - which is what answers /__host/status - never sees them otherwise.
24
25
  activityFile: path.join(root, "otto-brain.activity"),
25
26
  logFile: path.join(root, "otto-brain.log"),
27
+ logsDir: path.join(root, "logs"),
26
28
  resultsDir: path.join(root, "results"),
27
29
  };
28
30
  }
@@ -4,7 +4,7 @@
4
4
  * `<managedModelsDir>/<publisher>/<repo>/<file>` to mirror the LM Studio layout
5
5
  * the scanner already understands.
6
6
  */
7
- import { createWriteStream, existsSync, mkdirSync } from "node:fs";
7
+ import { createWriteStream, existsSync, mkdirSync, rmSync } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { Readable } from "node:stream";
10
10
  import { pipeline } from "node:stream/promises";
@@ -41,6 +41,10 @@ function authHeaders(token) {
41
41
  * killed download never leaves a truncated file that looks complete.
42
42
  */
43
43
  async function streamRepoFile(url, destPath, label, token, onProgress, received) {
44
+ const tmp = `${destPath}.part`;
45
+ // Remove leftovers from an earlier interrupted attempt before starting a
46
+ // fresh request, including when this request fails before opening a stream.
47
+ rmSync(tmp, { force: true });
44
48
  mkdirSync(path.dirname(destPath), { recursive: true });
45
49
  if (existsSync(destPath))
46
50
  return false;
@@ -54,10 +58,16 @@ async function streamRepoFile(url, destPath, label, token, onProgress, received)
54
58
  received.bytes += chunk.length;
55
59
  onProgress?.({ file: label, receivedBytes: received.bytes, totalBytes });
56
60
  });
57
- const tmp = `${destPath}.part`;
58
- await pipeline(body, createWriteStream(tmp));
59
- const { renameSync } = await import("node:fs");
60
- renameSync(tmp, destPath);
61
+ try {
62
+ await pipeline(body, createWriteStream(tmp));
63
+ const { renameSync } = await import("node:fs");
64
+ renameSync(tmp, destPath);
65
+ }
66
+ finally {
67
+ // Cancellation kills the CLI child while the stream is still writing. Do
68
+ // not leave a truncated `.part` behind for the next quant attempt.
69
+ rmSync(tmp, { force: true });
70
+ }
61
71
  return true;
62
72
  }
63
73
  /** Download the model file; returns the local path it was written to. */
@@ -4,6 +4,7 @@ import { CatalogSchema } from "../config/schema.js";
4
4
  import { loadCatalog } from "../config/store.js";
5
5
  import { resolveModelsDirs } from "./dirs.js";
6
6
  import { enrichWithCatalog } from "./enrich.js";
7
+ import { loadRenameMap } from "./rename-map.js";
7
8
  import { scan } from "./scan.js";
8
9
  export * from "./scan.js";
9
10
  export { pickModel, pickAutoModel } from "./pick.js";
@@ -30,6 +31,12 @@ export function scanModels(config, env = process.env, options = {}) {
30
31
  }
31
32
  }
32
33
  const enriched = enrichWithCatalog(all, loadCatalogSafe(env));
34
+ const renameMap = loadRenameMap(resolveBrainPaths(env));
35
+ for (const model of enriched) {
36
+ if (renameMap[model.id]) {
37
+ model.displayName = renameMap[model.id];
38
+ }
39
+ }
33
40
  enriched.sort((a, b) => a.displayName.localeCompare(b.displayName));
34
41
  return enriched;
35
42
  }
@@ -0,0 +1,12 @@
1
+ import { type BrainPaths } from "../config/paths.js";
2
+ /** Every function here only ever touches renameMapFile; narrowed to that one
3
+ * field (rather than the full BrainPaths) so a test's fake paths object is
4
+ * actually type-checked instead of passing only because tsconfig excludes
5
+ * *.test.ts from the build. */
6
+ type RenameMapPaths = Pick<BrainPaths, "renameMapFile">;
7
+ export declare function loadRenameMap(paths?: RenameMapPaths): Record<string, string>;
8
+ export declare function saveRenameMap(map: Record<string, string>, paths?: RenameMapPaths): void;
9
+ export declare function updateDisplayName(modelId: string, displayName: string, paths?: RenameMapPaths): Record<string, string>;
10
+ export declare function deleteDisplayName(modelId: string, paths?: RenameMapPaths): Record<string, string>;
11
+ export {};
12
+ //# sourceMappingURL=rename-map.d.ts.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * User-defined model display-name overrides, keyed by model id, persisted at
3
+ * `$OTTO_HOME/otto-brain/rename-map.json`. Kept as its own file (rather than a
4
+ * field on the profiles store) so renaming a model never touches the
5
+ * calibration/profile data profiles.json carries.
6
+ */
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { z } from "zod";
9
+ import { resolveBrainPaths } from "../config/paths.js";
10
+ import { writePrivateFileAtomicSync } from "../config/private-files.js";
11
+ const RenameMapSchema = z.record(z.string());
12
+ export function loadRenameMap(paths = resolveBrainPaths()) {
13
+ if (!existsSync(paths.renameMapFile))
14
+ return {};
15
+ try {
16
+ const parsed = JSON.parse(readFileSync(paths.renameMapFile, "utf8"));
17
+ const result = RenameMapSchema.safeParse(parsed);
18
+ return result.success ? result.data : {};
19
+ }
20
+ catch {
21
+ return {};
22
+ }
23
+ }
24
+ export function saveRenameMap(map, paths = resolveBrainPaths()) {
25
+ writePrivateFileAtomicSync(paths.renameMapFile, `${JSON.stringify(map, null, 2)}\n`);
26
+ }
27
+ export function updateDisplayName(modelId, displayName, paths = resolveBrainPaths()) {
28
+ const map = loadRenameMap(paths);
29
+ map[modelId] = displayName;
30
+ saveRenameMap(map, paths);
31
+ return map;
32
+ }
33
+ export function deleteDisplayName(modelId, paths = resolveBrainPaths()) {
34
+ const map = loadRenameMap(paths);
35
+ delete map[modelId];
36
+ saveRenameMap(map, paths);
37
+ return map;
38
+ }
39
+ //# sourceMappingURL=rename-map.js.map
@@ -58,6 +58,13 @@ export declare function withActivity<T>(kind: BrainActivityKind, options: {
58
58
  */
59
59
  export declare function chunkHasReasoning(text: string): boolean;
60
60
  export declare function chunkHasContent(text: string): boolean;
61
+ export type InferenceStage = "processing" | "thinking" | "generating";
62
+ export interface InferenceActivitySnapshot {
63
+ activeRequests: number;
64
+ processing: number;
65
+ thinking: number;
66
+ generating: number;
67
+ }
61
68
  /**
62
69
  * Which in-flight completions are currently mid-thought.
63
70
  *
@@ -68,16 +75,29 @@ export declare function chunkHasContent(text: string): boolean;
68
75
  * if more reasoning follows - a stream that is producing readable output should
69
76
  * not report as though it were still silent.
70
77
  *
71
- * A set rather than a boolean because llama-server runs several slots at once,
72
- * and one request finishing its thought must not clear the flag for another.
78
+ * A map rather than one global phase because llama-server runs several slots at
79
+ * once. One request can be processing a prompt while another thinks and a third
80
+ * generates content; the aggregate counts must preserve all three.
73
81
  */
74
82
  export declare class ReasoningTracker {
75
83
  #private;
84
+ /**
85
+ * Watch stage counts, not the per-chunk traffic behind them.
86
+ *
87
+ * The status event stream needs to publish request start, the moment a model
88
+ * goes silent to think and the moment it starts answering. Repeated chunks in
89
+ * one stage do not notify; slot sampling owns bounded token-rate updates.
90
+ */
91
+ onChange(listener: () => void): () => void;
92
+ /** A completion was dispatched to llama-server and awaits its first output delta. */
93
+ begin(requestId: string): void;
76
94
  /** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
77
95
  observe(requestId: string, text: string): void;
78
96
  /** Forget the request. Must be called on end *and* on error, or the flag sticks. */
79
97
  end(requestId: string): void;
80
98
  get active(): boolean;
81
99
  get count(): number;
100
+ /** Aggregate request stages. Counts stay exact even with several parallel slots. */
101
+ get snapshot(): InferenceActivitySnapshot;
82
102
  }
83
103
  //# sourceMappingURL=activity.d.ts.map
@@ -3,10 +3,16 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
3
3
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
4
4
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
5
5
  };
6
- var _ReasoningTracker_reasoning, _ReasoningTracker_content;
6
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
7
+ if (kind === "m") throw new TypeError("Private method is not writable");
8
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
9
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
10
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
11
+ };
12
+ var _ReasoningTracker_instances, _ReasoningTracker_requests, _ReasoningTracker_tails, _ReasoningTracker_inlineReasoning, _ReasoningTracker_listeners, _ReasoningTracker_lastSnapshot, _ReasoningTracker_announce;
7
13
  /**
8
- * What long-running work currently owns the brain, and whether the loaded model
9
- * is mid-reasoning.
14
+ * What long-running work currently owns the brain, and which stage each live
15
+ * inference request has reached.
10
16
  *
11
17
  * Two trackers with deliberately different lifetimes:
12
18
  *
@@ -18,11 +24,11 @@ var _ReasoningTracker_reasoning, _ReasoningTracker_content;
18
24
  * was killed with Ctrl-C never gets to clean up after itself, and a status
19
25
  * that stays stuck on "calibrating" forever is worse than no status at all.
20
26
  *
21
- * - **Reasoning** is per-request and lives only as long as the stream does, so
27
+ * - **Inference** is per-request and lives only as long as the stream does, so
22
28
  * it is plain in-process state on the router. It never touches disk.
23
29
  *
24
- * Both feed the one `activity` field on the host status, which the client turns
25
- * into the Brain rail's icon.
30
+ * Both ride on host status: ops under `activity`, inference under `inference`.
31
+ * The client uses those independent signals to drive the Overview and rail.
26
32
  */
27
33
  import { existsSync, readFileSync, rmSync } from "node:fs";
28
34
  import { resolveBrainPaths } from "../config/paths.js";
@@ -164,10 +170,14 @@ function clampProgress(progress) {
164
170
  * would cost more than the signal is worth.
165
171
  */
166
172
  export function chunkHasReasoning(text) {
167
- return text.includes("thinking") || text.includes("reasoning");
173
+ return (/"type"\s*:\s*"(?:thinking|reasoning)_delta"/u.test(text) ||
174
+ /"(?:thinking|reasoning_content)"\s*:\s*"[^"]/u.test(text));
168
175
  }
169
176
  export function chunkHasContent(text) {
170
- return text.includes('"text_delta"') || /"content"\s*:\s*"[^"]/.test(text);
177
+ return (/"type"\s*:\s*"text_delta"/u.test(text) ||
178
+ /"content"\s*:\s*"[^"]/u.test(text) ||
179
+ /"tool_calls"\s*:\s*\[\s*\{/u.test(text) ||
180
+ /"type"\s*:\s*"(?:tool_use|input_json_delta)"/u.test(text));
171
181
  }
172
182
  /**
173
183
  * Which in-flight completions are currently mid-thought.
@@ -179,38 +189,114 @@ export function chunkHasContent(text) {
179
189
  * if more reasoning follows - a stream that is producing readable output should
180
190
  * not report as though it were still silent.
181
191
  *
182
- * A set rather than a boolean because llama-server runs several slots at once,
183
- * and one request finishing its thought must not clear the flag for another.
192
+ * A map rather than one global phase because llama-server runs several slots at
193
+ * once. One request can be processing a prompt while another thinks and a third
194
+ * generates content; the aggregate counts must preserve all three.
184
195
  */
185
196
  export class ReasoningTracker {
186
197
  constructor() {
187
- _ReasoningTracker_reasoning.set(this, new Set());
188
- _ReasoningTracker_content.set(this, new Set());
198
+ _ReasoningTracker_instances.add(this);
199
+ _ReasoningTracker_requests.set(this, new Map());
200
+ /** Tail of the last transport chunk, so a field name split by TCP is still detected. */
201
+ _ReasoningTracker_tails.set(this, new Map());
202
+ /** Models/runtimes that leave reasoning inline as `<think>…</think>`. */
203
+ _ReasoningTracker_inlineReasoning.set(this, new Set());
204
+ _ReasoningTracker_listeners.set(this, new Set());
205
+ _ReasoningTracker_lastSnapshot.set(this, "0:0:0:0");
206
+ }
207
+ /**
208
+ * Watch stage counts, not the per-chunk traffic behind them.
209
+ *
210
+ * The status event stream needs to publish request start, the moment a model
211
+ * goes silent to think and the moment it starts answering. Repeated chunks in
212
+ * one stage do not notify; slot sampling owns bounded token-rate updates.
213
+ */
214
+ onChange(listener) {
215
+ __classPrivateFieldGet(this, _ReasoningTracker_listeners, "f").add(listener);
216
+ return () => __classPrivateFieldGet(this, _ReasoningTracker_listeners, "f").delete(listener);
217
+ }
218
+ /** A completion was dispatched to llama-server and awaits its first output delta. */
219
+ begin(requestId) {
220
+ if (__classPrivateFieldGet(this, _ReasoningTracker_requests, "f").has(requestId))
221
+ return;
222
+ __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "processing");
223
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
189
224
  }
190
225
  /** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
191
226
  observe(requestId, text) {
192
- if (__classPrivateFieldGet(this, _ReasoningTracker_content, "f").has(requestId))
227
+ const current = __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").get(requestId);
228
+ if (current === "generating")
193
229
  return;
194
- if (chunkHasContent(text)) {
195
- __classPrivateFieldGet(this, _ReasoningTracker_content, "f").add(requestId);
196
- __classPrivateFieldGet(this, _ReasoningTracker_reasoning, "f").delete(requestId);
230
+ if (!current)
231
+ __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "processing");
232
+ // Node can split an SSE JSON field name at any byte. Keeping a small tail
233
+ // makes stage recognition independent of transport chunk boundaries without
234
+ // parsing or retaining the generated content itself.
235
+ const combined = `${__classPrivateFieldGet(this, _ReasoningTracker_tails, "f").get(requestId) ?? ""}${text}`;
236
+ __classPrivateFieldGet(this, _ReasoningTracker_tails, "f").set(requestId, combined.slice(-128));
237
+ if (__classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").has(requestId)) {
238
+ if (combined.includes("</think>")) {
239
+ __classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").delete(requestId);
240
+ __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "generating");
241
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
242
+ }
243
+ return;
244
+ }
245
+ if (combined.includes("<think>")) {
246
+ __classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").add(requestId);
247
+ __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "thinking");
248
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
249
+ return;
250
+ }
251
+ if (chunkHasContent(combined)) {
252
+ __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "generating");
253
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
197
254
  return;
198
255
  }
199
- if (chunkHasReasoning(text)) {
200
- __classPrivateFieldGet(this, _ReasoningTracker_reasoning, "f").add(requestId);
256
+ if (chunkHasReasoning(combined) && current !== "thinking") {
257
+ __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").set(requestId, "thinking");
258
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
201
259
  }
202
260
  }
203
261
  /** Forget the request. Must be called on end *and* on error, or the flag sticks. */
204
262
  end(requestId) {
205
- __classPrivateFieldGet(this, _ReasoningTracker_reasoning, "f").delete(requestId);
206
- __classPrivateFieldGet(this, _ReasoningTracker_content, "f").delete(requestId);
263
+ __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").delete(requestId);
264
+ __classPrivateFieldGet(this, _ReasoningTracker_tails, "f").delete(requestId);
265
+ __classPrivateFieldGet(this, _ReasoningTracker_inlineReasoning, "f").delete(requestId);
266
+ __classPrivateFieldGet(this, _ReasoningTracker_instances, "m", _ReasoningTracker_announce).call(this);
207
267
  }
208
268
  get active() {
209
- return __classPrivateFieldGet(this, _ReasoningTracker_reasoning, "f").size > 0;
269
+ return this.snapshot.thinking > 0;
210
270
  }
211
271
  get count() {
212
- return __classPrivateFieldGet(this, _ReasoningTracker_reasoning, "f").size;
272
+ return this.snapshot.thinking;
273
+ }
274
+ /** Aggregate request stages. Counts stay exact even with several parallel slots. */
275
+ get snapshot() {
276
+ const result = {
277
+ activeRequests: __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").size,
278
+ processing: 0,
279
+ thinking: 0,
280
+ generating: 0,
281
+ };
282
+ for (const stage of __classPrivateFieldGet(this, _ReasoningTracker_requests, "f").values())
283
+ result[stage] += 1;
284
+ return result;
213
285
  }
214
286
  }
215
- _ReasoningTracker_reasoning = new WeakMap(), _ReasoningTracker_content = new WeakMap();
287
+ _ReasoningTracker_requests = new WeakMap(), _ReasoningTracker_tails = new WeakMap(), _ReasoningTracker_inlineReasoning = new WeakMap(), _ReasoningTracker_listeners = new WeakMap(), _ReasoningTracker_lastSnapshot = new WeakMap(), _ReasoningTracker_instances = new WeakSet(), _ReasoningTracker_announce = function _ReasoningTracker_announce() {
288
+ const snapshot = this.snapshot;
289
+ const key = `${snapshot.activeRequests}:${snapshot.processing}:${snapshot.thinking}:${snapshot.generating}`;
290
+ if (key === __classPrivateFieldGet(this, _ReasoningTracker_lastSnapshot, "f"))
291
+ return;
292
+ __classPrivateFieldSet(this, _ReasoningTracker_lastSnapshot, key, "f");
293
+ for (const listener of __classPrivateFieldGet(this, _ReasoningTracker_listeners, "f")) {
294
+ try {
295
+ listener();
296
+ }
297
+ catch {
298
+ // Status reporting must never break a proxied completion.
299
+ }
300
+ }
301
+ };
216
302
  //# sourceMappingURL=activity.js.map
@@ -26,7 +26,16 @@ import type { RankedModel } from "../ops/results.js";
26
26
  import type { GpuInfo, Model } from "../types.js";
27
27
  import * as vram from "../vram.js";
28
28
  import type { SystemSample } from "../sysmon.js";
29
+ import type { BrainStatusPublisher } from "./status-events.js";
29
30
  import type { Supervisor } from "./supervisor.js";
31
+ /**
32
+ * The management API's own version, additive to the capability flags.
33
+ *
34
+ * Capabilities answer "can this brain do X"; this answers "which generation of
35
+ * the API is this" for the rare change that no single flag describes. A daemon
36
+ * reads both and never requires an exact package-version match.
37
+ */
38
+ export declare const HOST_API_VERSION = 2;
30
39
  /**
31
40
  * What this brain can serve. The daemon folds this into `brain.host.status` and
32
41
  * Otto gates each tab on it, because the daemon and the brain version
@@ -48,6 +57,21 @@ export interface HostCapabilities {
48
57
  resources: boolean;
49
58
  /** GET /__host/models */
50
59
  inventory: boolean;
60
+ /** POST /__host/model/rename */
61
+ rename: boolean;
62
+ /** POST /__host/model/rename/reset */
63
+ reset: boolean;
64
+ /**
65
+ * GET /__host/events: a live SSE stream of complete status snapshots.
66
+ *
67
+ * The one capability a daemon reads *before* deciding how to watch this brain.
68
+ * False (including on every brain that predates the stream) means the daemon
69
+ * keeps polling `/__host/status`, which is why nothing about the older
70
+ * management API had to change for this to ship.
71
+ */
72
+ events: boolean;
73
+ /** Bounded live inference stages, token counts and throughput on status events. */
74
+ liveInference: boolean;
51
75
  /** Whether writes are currently permitted (allowRemoteConfig). */
52
76
  writable: boolean;
53
77
  }
@@ -68,6 +92,12 @@ export interface HostApiDeps {
68
92
  /** The managed models directory, for disk accounting. Null when unresolvable. */
69
93
  getModelsDir: () => string | null;
70
94
  sampleResources: () => Promise<SystemSample>;
95
+ /**
96
+ * The live status source behind `GET /__host/events`. Absent (or not yet
97
+ * carrying a snapshot source) means this brain does not advertise events and
98
+ * its daemon keeps polling status.
99
+ */
100
+ statusEvents?: BrainStatusPublisher | null;
71
101
  }
72
102
  /** One row of the model inventory: the scan, metadata, profile and score joined. */
73
103
  export interface InventoryRow {