@otto-code/brain 0.8.1 → 0.8.2

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.
@@ -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,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
@@ -48,6 +48,10 @@ export interface HostCapabilities {
48
48
  resources: boolean;
49
49
  /** GET /__host/models */
50
50
  inventory: boolean;
51
+ /** POST /__host/model/rename */
52
+ rename: boolean;
53
+ /** POST /__host/model/rename/reset */
54
+ reset: boolean;
51
55
  /** Whether writes are currently permitted (allowRemoteConfig). */
52
56
  writable: boolean;
53
57
  }
@@ -1,9 +1,11 @@
1
1
  import { calibrationInfo, profileFieldDescriptors, profileWarnings, sanitizeProfilePatch, } from "../config/profile-edit.js";
2
2
  import { forModel, getCalibration, put } from "../config/profiles.js";
3
3
  import { deleteModelFiles, diskUsage, planDelete, totalModelBytes } from "../models/manage.js";
4
+ import { deleteDisplayName, updateDisplayName } from "../models/rename-map.js";
4
5
  import * as vram from "../vram.js";
5
6
  import { errorMessage, readJsonBody, sendError, sendJson } from "./http-util.js";
6
7
  const MAX_PATCH_BYTES = 256 * 1024;
8
+ const MAX_DISPLAY_NAME = 200;
7
9
  const DEFAULT_LOG_LINES = 200;
8
10
  function stateOf(supervisor, model) {
9
11
  if (!supervisor.model || supervisor.model.id !== model.id)
@@ -101,6 +103,8 @@ export function createHostApi(deps) {
101
103
  load: true,
102
104
  resources: true,
103
105
  inventory: true,
106
+ rename: true,
107
+ reset: true,
104
108
  writable: deps.getAllowWrite(),
105
109
  });
106
110
  /** Refuse a write unless the owner opted into remote configuration. */
@@ -180,6 +184,53 @@ export function createHostApi(deps) {
180
184
  })();
181
185
  });
182
186
  };
187
+ const handleRename = (req, res, model) => {
188
+ readJsonBody(req, MAX_DISPLAY_NAME + 64, (result) => {
189
+ if (!result.ok) {
190
+ sendError(res, 400, result.error);
191
+ return;
192
+ }
193
+ const body = result.body;
194
+ const displayName = body.displayName;
195
+ if (typeof displayName !== "string" || displayName.trim().length === 0) {
196
+ sendError(res, 400, "displayName must be a non-empty string");
197
+ return;
198
+ }
199
+ if (displayName.length > MAX_DISPLAY_NAME) {
200
+ sendError(res, 400, `displayName must be at most ${MAX_DISPLAY_NAME} characters`);
201
+ return;
202
+ }
203
+ if (/[^\x20-\x7E]/.test(displayName)) {
204
+ sendError(res, 400, "displayName must not contain control characters or non-ASCII");
205
+ return;
206
+ }
207
+ // /v1/models keys its `id` on displayName (router.ts) and both the
208
+ // completion path and defaultModel/switchTo resolve a model by
209
+ // `displayName === name || id === name` - a collision here would make
210
+ // one of the two models unreachable by name with no error anywhere.
211
+ const conflict = deps
212
+ .getCatalog()
213
+ .find((m) => m.id !== model.id && (m.displayName === displayName || m.id === displayName));
214
+ if (conflict) {
215
+ sendError(res, 409, `another model is already named "${displayName}"`);
216
+ return;
217
+ }
218
+ updateDisplayName(model.id, displayName);
219
+ sendJson(res, { displayName });
220
+ });
221
+ };
222
+ const handleReset = (req, res, model) => {
223
+ readJsonBody(req, 4096, (result) => {
224
+ if (!result.ok) {
225
+ sendError(res, 400, result.error);
226
+ return;
227
+ }
228
+ deleteDisplayName(model.id);
229
+ const catalog = deps.rescan();
230
+ const updated = resolveModel(catalog, model.id);
231
+ sendJson(res, { displayName: updated ? updated.displayName : model.displayName });
232
+ });
233
+ };
183
234
  const handleBudget = (res, model, params) => {
184
235
  void (async () => {
185
236
  try {
@@ -326,6 +377,8 @@ export function createHostApi(deps) {
326
377
  "/__host/model/budget",
327
378
  "/__host/model/load",
328
379
  "/__host/model/fields",
380
+ "/__host/model/rename",
381
+ "/__host/model/rename/reset",
329
382
  ]);
330
383
  if (!modelRoutes.has(route))
331
384
  return false;
@@ -359,6 +412,18 @@ export function createHostApi(deps) {
359
412
  handleLoad(res, model);
360
413
  return true;
361
414
  }
415
+ if (route === "/__host/model/rename" && method === "POST") {
416
+ if (!guardWrite(res))
417
+ return true;
418
+ handleRename(req, res, model);
419
+ return true;
420
+ }
421
+ if (route === "/__host/model/rename/reset" && method === "POST") {
422
+ if (!guardWrite(res))
423
+ return true;
424
+ handleReset(req, res, model);
425
+ return true;
426
+ }
362
427
  if (route === "/__host/model" && method === "DELETE") {
363
428
  if (!guardWrite(res))
364
429
  return true;
@@ -298,6 +298,25 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
298
298
  }
299
299
  res.writeHead(upstreamRes.statusCode ?? 502, outHeaders);
300
300
  const isStream = String(upstreamRes.headers["content-type"] || "").includes("event-stream");
301
+ const upstreamResponseFailed = (error) => {
302
+ if (settled)
303
+ return;
304
+ const message = `llama-server response ended unexpectedly: ${error.message}`;
305
+ telemetry.record({
306
+ at: new Date().toISOString(),
307
+ path: req.url,
308
+ verdict: "failed",
309
+ error: message,
310
+ });
311
+ logger?.warn?.(message);
312
+ // Headers may already be on the wire for an SSE response. Destroying
313
+ // it is the only honest result, but `done()` still releases the queue.
314
+ if (!res.writableEnded && !res.destroyed)
315
+ res.destroy(error);
316
+ done();
317
+ };
318
+ upstreamRes.once("aborted", () => upstreamResponseFailed(new Error("upstream response aborted")));
319
+ upstreamRes.once("error", upstreamResponseFailed);
301
320
  if (isStream) {
302
321
  let sawContent = false;
303
322
  let sawReasoning = false;
@@ -0,0 +1,8 @@
1
+ export interface BrainRunLog {
2
+ path: string;
3
+ write(line: string): void;
4
+ }
5
+ /** Start a fresh Brain log and prune only expired Brain run logs. */
6
+ export declare function createBrainRunLog(env?: NodeJS.ProcessEnv): BrainRunLog;
7
+ export declare function pruneBrainRunLogs(logsDir: string, now?: number): void;
8
+ //# sourceMappingURL=run-log.d.ts.map
@@ -0,0 +1,39 @@
1
+ /** Durable, per-service-run diagnostics for Otto Brain. */
2
+ import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveBrainPaths } from "../config/paths.js";
5
+ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
6
+ const RUN_LOG_SUFFIX = "-brain.log";
7
+ /** Start a fresh Brain log and prune only expired Brain run logs. */
8
+ export function createBrainRunLog(env = process.env) {
9
+ const { logsDir } = resolveBrainPaths(env);
10
+ const startedAt = new Date();
11
+ const stamp = startedAt
12
+ .toISOString()
13
+ .replace(/[-:]/g, "")
14
+ .replace(/\.\d{3}Z$/, "Z");
15
+ const filePath = path.join(logsDir, `${stamp}-${process.pid}${RUN_LOG_SUFFIX}`);
16
+ try {
17
+ mkdirSync(logsDir, { recursive: true });
18
+ pruneBrainRunLogs(logsDir, startedAt.getTime());
19
+ }
20
+ catch { }
21
+ const write = (line) => {
22
+ try {
23
+ appendFileSync(filePath, `${new Date().toISOString()} ${line}\n`, "utf8");
24
+ }
25
+ catch { }
26
+ };
27
+ write(`Brain service started (pid ${process.pid})`);
28
+ return { path: filePath, write };
29
+ }
30
+ export function pruneBrainRunLogs(logsDir, now = Date.now()) {
31
+ for (const entry of readdirSync(logsDir, { withFileTypes: true })) {
32
+ if (!entry.isFile() || !entry.name.endsWith(RUN_LOG_SUFFIX))
33
+ continue;
34
+ const filePath = path.join(logsDir, entry.name);
35
+ if (now - statSync(filePath).mtimeMs > RETENTION_MS)
36
+ rmSync(filePath, { force: true });
37
+ }
38
+ }
39
+ //# sourceMappingURL=run-log.js.map
@@ -27,6 +27,7 @@ import { Supervisor } from "./supervisor.js";
27
27
  import * as tailscale from "./tailscale.js";
28
28
  import { CertManager, resolveTlsOptions } from "./tls.js";
29
29
  import { removePidFile, writePidFile } from "./pid-lock.js";
30
+ import { createBrainRunLog } from "./run-log.js";
30
31
  /** The effective config with secrets masked, for the `/__host/config` read. */
31
32
  function redactConfig(config) {
32
33
  return {
@@ -93,6 +94,11 @@ function withAuth(inner, token) {
93
94
  };
94
95
  }
95
96
  export async function startService({ config, modelNeedle, env = process.env, onLog = () => { }, }) {
97
+ const runLog = createBrainRunLog(env);
98
+ const log = (line) => {
99
+ runLog.write(line);
100
+ onLog(line);
101
+ };
96
102
  const runtime = resolveRuntime(config, env);
97
103
  if (!runtime) {
98
104
  throw new CommandError({
@@ -156,15 +162,17 @@ export async function startService({ config, modelNeedle, env = process.env, onL
156
162
  });
157
163
  }
158
164
  if (fit.adjusted && fit.reason)
159
- onLog(`note: ${fit.reason}`);
165
+ log(`note: ${fit.reason}`);
160
166
  profile = fit.profile;
161
167
  }
162
168
  const telemetry = new Telemetry();
163
169
  const supervisor = new Supervisor({ runtime });
164
170
  supervisor.on("log", (line) => {
171
+ runLog.write(line);
165
172
  if (/error|failed|warn/i.test(line))
166
173
  onLog(line);
167
174
  });
175
+ supervisor.on("crashed", (error) => runLog.write(`FATAL ${error}`));
168
176
  // Serialize model switches: the router queues request-driven switches, but the
169
177
  // config path (POST /__host/config) calls loadModel directly. Chaining here
170
178
  // guarantees two switches (e.g. a config write racing a request-driven switch)
@@ -262,7 +270,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
262
270
  const handler = withAuth(createRouter({
263
271
  supervisor,
264
272
  telemetry,
265
- logger: { warn: (m) => onLog(`WARN ${m}`) },
273
+ logger: { warn: (m) => log(`WARN ${m}`) },
266
274
  getCatalog: () => catalog,
267
275
  loadModel,
268
276
  version: resolveVersion(),
@@ -311,8 +319,10 @@ export async function startService({ config, modelNeedle, env = process.env, onL
311
319
  secure: Boolean(tlsOptions),
312
320
  displayHost,
313
321
  }, env);
322
+ log(`ready: ${model.displayName} on ${bindHost}:${port}; run log ${runLog.path}`);
314
323
  const stop = async () => {
315
324
  certManager?.stop();
325
+ log("Brain service stopping");
316
326
  await supervisor.stop();
317
327
  await new Promise((resolve) => server.close(() => resolve()));
318
328
  removePidFile(env);
package/dist/sysmon.d.ts CHANGED
@@ -31,6 +31,14 @@ export interface SlotInfo {
31
31
  /** Slots emitting tokens. */
32
32
  decode: number;
33
33
  contexts: number[];
34
+ threads?: Array<{
35
+ slot: number;
36
+ phase: "prefill" | "decode";
37
+ promptTokens: number;
38
+ generatedTokens: number;
39
+ promptTokensPerSecond: number | null;
40
+ tokensPerSecond: number | null;
41
+ }>;
34
42
  }
35
43
  /** One combined reading for the status panel. */
36
44
  export interface SystemSample {
@@ -55,6 +63,11 @@ export declare function createCpuSampler(): CpuSampler;
55
63
  * the field spellings real llama.cpp builds emit, without a live server.
56
64
  */
57
65
  export declare function summariseSlots(rows: unknown[]): SlotInfo;
66
+ /** Measures throughput from successive `/slots` snapshots, without guessing. */
67
+ export declare class SlotActivityTracker {
68
+ #private;
69
+ sample(rows: unknown[], now?: number): SlotInfo;
70
+ }
58
71
  /** Slot occupancy from the running server. */
59
72
  declare function slots({ host, port }: {
60
73
  host: string;
package/dist/sysmon.js CHANGED
@@ -1,3 +1,9 @@
1
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
2
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
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
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
5
+ };
6
+ var _SlotActivityTracker_previous;
1
7
  import os from "node:os";
2
8
  import http from "node:http";
3
9
  import { query } from "./gpu.js";
@@ -98,12 +104,57 @@ export function summariseSlots(rows) {
98
104
  }),
99
105
  };
100
106
  }
107
+ /** Measures throughput from successive `/slots` snapshots, without guessing. */
108
+ export class SlotActivityTracker {
109
+ constructor() {
110
+ _SlotActivityTracker_previous.set(this, new Map());
111
+ }
112
+ sample(rows, now = Date.now()) {
113
+ const threads = rows.flatMap((row, slot) => {
114
+ const record = row;
115
+ if (!isProcessing(record)) {
116
+ __classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").delete(slot);
117
+ return [];
118
+ }
119
+ const promptTokens = counter(record, ["n_past", "n_prompt_tokens_processed"]);
120
+ const generatedTokens = decodedTokens(record);
121
+ const phase = generatedTokens === 0 ? "prefill" : "decode";
122
+ const previous = __classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").get(slot);
123
+ const elapsedSeconds = previous ? (now - previous.at) / 1000 : 0;
124
+ const rate = (current, before) => elapsedSeconds > 0 && before !== undefined && current >= before
125
+ ? (current - before) / elapsedSeconds
126
+ : null;
127
+ __classPrivateFieldGet(this, _SlotActivityTracker_previous, "f").set(slot, { at: now, promptTokens, generatedTokens });
128
+ return [
129
+ {
130
+ slot,
131
+ phase,
132
+ promptTokens,
133
+ generatedTokens,
134
+ promptTokensPerSecond: phase === "prefill" ? rate(promptTokens, previous?.promptTokens) : null,
135
+ tokensPerSecond: phase === "decode" ? rate(generatedTokens, previous?.generatedTokens) : null,
136
+ },
137
+ ];
138
+ });
139
+ return { ...summariseSlots(rows), threads };
140
+ }
141
+ }
142
+ _SlotActivityTracker_previous = new WeakMap();
143
+ function counter(record, keys) {
144
+ for (const key of keys) {
145
+ const value = record[key];
146
+ if (typeof value === "number" && Number.isFinite(value))
147
+ return value;
148
+ }
149
+ return 0;
150
+ }
151
+ const slotActivityTracker = new SlotActivityTracker();
101
152
  /** Slot occupancy from the running server. */
102
153
  async function slots({ host, port }) {
103
154
  const data = await fetchJson({ host, port, path: "/slots" });
104
155
  if (!Array.isArray(data))
105
156
  return null;
106
- return summariseSlots(data);
157
+ return slotActivityTracker.sample(data);
107
158
  }
108
159
  export { slots };
109
160
  /** One combined reading for the status panel. */
package/dist/tui/app.d.ts CHANGED
@@ -19,7 +19,7 @@ interface FieldContext {
19
19
  }
20
20
  /** A pending destructive action awaiting y/n. */
21
21
  interface ConfirmState {
22
- kind: "delete";
22
+ kind: "delete" | "reset-name";
23
23
  model: Model;
24
24
  }
25
25
  /** One downloadable quant in the picker, with whether it is already on disk. */
@@ -114,6 +114,11 @@ export declare class App {
114
114
  fit: vram.FitResult;
115
115
  } | null;
116
116
  filterMode: boolean;
117
+ renaming: boolean;
118
+ renameBuffer: string;
119
+ /** ids with a persisted rename-map override, so "reset name" can tell the
120
+ * user there is nothing to reset instead of round-tripping for a no-op. */
121
+ renamedIds: Set<string>;
117
122
  confirming: ConfirmState | null;
118
123
  picker: PickerState | null;
119
124
  search: SearchState | null;
@@ -127,6 +132,13 @@ export declare class App {
127
132
  /** Fit a model to the live VRAM budget and (re)start the server on it. */
128
133
  loadModelFitted(target: Model): Promise<void>;
129
134
  startRouter(): Promise<void>;
135
+ /** POST /__host/model/rename?id=… - the one host-api route this embedded
136
+ * router serves, so the TUI's rename form and a remote-brain client hit the
137
+ * same wire shape. */
138
+ handleRenameRequest(req: http.IncomingMessage, res: http.ServerResponse): void;
139
+ /** POST /__host/model/rename/reset?id=… - clears this model's rename-map
140
+ * override and returns the scan-derived default name it reverted to. */
141
+ handleResetRequest(req: http.IncomingMessage, res: http.ServerResponse): void;
130
142
  reload(): void;
131
143
  /** Mean-of-runs rank + score per model, for ordering and badging the list. */
132
144
  loadRankings(): void;
@@ -143,6 +155,16 @@ export declare class App {
143
155
  onKey(key: string): void;
144
156
  onFilterKey(key: string): void;
145
157
  onEditKey(key: string): void;
158
+ beginRename(): void;
159
+ onRenameKey(key: string): void;
160
+ /** POST the new display name to this TUI's own local router (see
161
+ * handleRenameRequest / startRouter), then rescan so the renamed catalog
162
+ * entry - and its persisted override - are reflected immediately. */
163
+ submitRename(): Promise<void>;
164
+ beginResetName(): void;
165
+ /** POST to this TUI's own local router (see handleResetRequest), then
166
+ * rescan so the reverted name is reflected immediately. */
167
+ submitResetName(model: Model): Promise<void>;
146
168
  move(delta: number): void;
147
169
  usableFields(): number[];
148
170
  /** Read a profile field by key, without narrowing to a single value type. */
@@ -189,6 +211,9 @@ export declare class App {
189
211
  */
190
212
  renderFitted(lines: string[]): void;
191
213
  draw(): void;
214
+ /** The rename input line shown above the footer while renaming is active:
215
+ * the current name in grey, the buffer typed so far, and a cursor. */
216
+ renameLine(cols: number): string;
192
217
  /**
193
218
  * Benchmark workspace: the model list on the left, the selected model's
194
219
  * scorecard where Configuration sits in serve mode, and the ranked leaderboard
package/dist/tui/app.js CHANGED
@@ -8,6 +8,8 @@ import { calibrate } from "../ops/calibrate.js";
8
8
  import { sweep } from "../ops/sweep.js";
9
9
  import * as results from "../ops/results.js";
10
10
  import * as archive from "../ops/archive.js";
11
+ import { deleteDisplayName, loadRenameMap, updateDisplayName } from "../models/rename-map.js";
12
+ import { readJsonBody, sendError, sendJson } from "../service/http-util.js";
11
13
  import { Supervisor } from "../service/supervisor.js";
12
14
  import { createRouter, Telemetry } from "../service/router.js";
13
15
  import * as sysmon from "../sysmon.js";
@@ -28,6 +30,9 @@ const REASONING_CYCLE = [0, 512, 1024, 1536, 3072, -1];
28
30
  // hardware; draft models likewise do not help, so nothing boosts for them.
29
31
  const VISION_RANK_BONUS = 0.03;
30
32
  const THINKING_RANK_BONUS = 0.02;
33
+ // Mirrors host-api.ts's MAX_DISPLAY_NAME - the rename form posts to the same
34
+ // wire shape (see startRouter), so both paths must enforce the same limit.
35
+ const MAX_DISPLAY_NAME = 200;
31
36
  /** Editable configuration fields, in display order. */
32
37
  export const FIELDS = [
33
38
  {
@@ -121,6 +126,11 @@ export class App {
121
126
  */
122
127
  this.lastFit = null;
123
128
  this.filterMode = false;
129
+ this.renaming = false;
130
+ this.renameBuffer = "";
131
+ /** ids with a persisted rename-map override, so "reset name" can tell the
132
+ * user there is nothing to reset instead of round-tripping for a no-op. */
133
+ this.renamedIds = new Set();
124
134
  this.confirming = null;
125
135
  this.picker = null;
126
136
  this.search = null;
@@ -231,7 +241,22 @@ export class App {
231
241
  getCatalog: () => this.catalog,
232
242
  loadModel: (m) => this.loadModelFitted(m),
233
243
  });
234
- const server = http.createServer(handler);
244
+ // The rename form posts to this local router (see submitRename) rather than
245
+ // calling updateDisplayName in-process, so a future remote-brain TUI hits the
246
+ // same code path this does. The full host-api surface is not wired in here -
247
+ // only these two routes - since nothing else in the TUI needs it over HTTP.
248
+ const server = http.createServer((req, res) => {
249
+ const path = (req.url ?? "").split("?")[0];
250
+ if (req.method === "POST" && path === "/__host/model/rename") {
251
+ this.handleRenameRequest(req, res);
252
+ return;
253
+ }
254
+ if (req.method === "POST" && path === "/__host/model/rename/reset") {
255
+ this.handleResetRequest(req, res);
256
+ return;
257
+ }
258
+ handler(req, res);
259
+ });
235
260
  this.routerServer = server;
236
261
  server.keepAliveTimeout = 75000;
237
262
  server.requestTimeout = 0;
@@ -244,10 +269,73 @@ export class App {
244
269
  this.routerServer = null;
245
270
  });
246
271
  }
272
+ /** POST /__host/model/rename?id=… - the one host-api route this embedded
273
+ * router serves, so the TUI's rename form and a remote-brain client hit the
274
+ * same wire shape. */
275
+ handleRenameRequest(req, res) {
276
+ const url = new URL(req.url ?? "", "http://brain.local");
277
+ const id = url.searchParams.get("id");
278
+ const model = id ? this.catalog.find((m) => m.id === id) : null;
279
+ if (!model) {
280
+ sendError(res, 404, id ? `model "${id}" was not found` : "an ?id= is required");
281
+ return;
282
+ }
283
+ readJsonBody(req, 4096, (result) => {
284
+ if (!result.ok) {
285
+ sendError(res, 400, result.error);
286
+ return;
287
+ }
288
+ const body = result.body;
289
+ const displayName = body.displayName;
290
+ if (typeof displayName !== "string" || displayName.trim().length === 0) {
291
+ sendError(res, 400, "displayName must be a non-empty string");
292
+ return;
293
+ }
294
+ if (displayName.length > MAX_DISPLAY_NAME) {
295
+ sendError(res, 400, `displayName must be at most ${MAX_DISPLAY_NAME} characters`);
296
+ return;
297
+ }
298
+ if (/[^\x20-\x7E]/.test(displayName)) {
299
+ sendError(res, 400, "displayName must not contain control characters or non-ASCII");
300
+ return;
301
+ }
302
+ // Same collision guard as host-api.ts's handleRename - both /v1/models
303
+ // and defaultModel/switchTo resolve a model by displayName, so a
304
+ // duplicate silently strands one of the two models unreachable by name.
305
+ const conflict = this.catalog.find((m) => m.id !== model.id && (m.displayName === displayName || m.id === displayName));
306
+ if (conflict) {
307
+ sendError(res, 409, `another model is already named "${displayName}"`);
308
+ return;
309
+ }
310
+ updateDisplayName(model.id, displayName);
311
+ sendJson(res, { displayName });
312
+ });
313
+ }
314
+ /** POST /__host/model/rename/reset?id=… - clears this model's rename-map
315
+ * override and returns the scan-derived default name it reverted to. */
316
+ handleResetRequest(req, res) {
317
+ const url = new URL(req.url ?? "", "http://brain.local");
318
+ const id = url.searchParams.get("id");
319
+ const model = id ? this.catalog.find((m) => m.id === id) : null;
320
+ if (!model) {
321
+ sendError(res, 404, id ? `model "${id}" was not found` : "an ?id= is required");
322
+ return;
323
+ }
324
+ readJsonBody(req, 4096, (result) => {
325
+ if (!result.ok) {
326
+ sendError(res, 400, result.error);
327
+ return;
328
+ }
329
+ deleteDisplayName(model.id);
330
+ const rescanned = scanModels(loadBrainConfig()).find((m) => m.id === model.id);
331
+ sendJson(res, { displayName: rescanned ? rescanned.displayName : model.displayName });
332
+ });
333
+ }
247
334
  // -------------------------------------------------------------------- state
248
335
  reload() {
249
336
  const config = loadBrainConfig();
250
337
  this.catalog = scanModels(config);
338
+ this.renamedIds = new Set(Object.keys(loadRenameMap()));
251
339
  this.loadRankings();
252
340
  this.selected = Math.min(this.selected, Math.max(0, this.visible.length - 1));
253
341
  this.syncProfile();
@@ -346,6 +434,8 @@ export class App {
346
434
  }
347
435
  // -------------------------------------------------------------------- input
348
436
  onKey(key) {
437
+ if (this.renaming)
438
+ return this.onRenameKey(key);
349
439
  if (this.editing)
350
440
  return this.onEditKey(key);
351
441
  if (this.filterMode)
@@ -434,6 +524,14 @@ export class App {
434
524
  case "D":
435
525
  this.beginDelete();
436
526
  break;
527
+ case "R":
528
+ if (this.focus === "models")
529
+ this.beginRename();
530
+ break;
531
+ case "u":
532
+ if (this.focus === "models")
533
+ this.beginResetName();
534
+ break;
437
535
  case "r":
438
536
  this.reload();
439
537
  break;
@@ -500,6 +598,117 @@ export class App {
500
598
  }
501
599
  this.draw();
502
600
  }
601
+ beginRename() {
602
+ const model = this.model;
603
+ if (!model)
604
+ return;
605
+ this.renaming = true;
606
+ this.renameBuffer = "";
607
+ this.setStatus(`rename "${model.displayName}" → `, "info");
608
+ }
609
+ onRenameKey(key) {
610
+ if (key === "escape") {
611
+ this.renaming = false;
612
+ this.renameBuffer = "";
613
+ this.setStatus("rename cancelled", "info");
614
+ return;
615
+ }
616
+ if (key === "enter") {
617
+ void this.submitRename();
618
+ return;
619
+ }
620
+ if (key === "backspace") {
621
+ this.renameBuffer = this.renameBuffer.slice(0, -1);
622
+ }
623
+ else if (key === "space") {
624
+ if (this.renameBuffer.length < 80)
625
+ this.renameBuffer += " ";
626
+ }
627
+ else if (key.length === 1 && key >= " " && this.renameBuffer.length < 80) {
628
+ this.renameBuffer += key;
629
+ }
630
+ this.draw();
631
+ }
632
+ /** POST the new display name to this TUI's own local router (see
633
+ * handleRenameRequest / startRouter), then rescan so the renamed catalog
634
+ * entry - and its persisted override - are reflected immediately. */
635
+ async submitRename() {
636
+ const model = this.model;
637
+ if (!model) {
638
+ this.renaming = false;
639
+ this.draw();
640
+ return;
641
+ }
642
+ const newName = this.renameBuffer.trim();
643
+ if (!newName) {
644
+ this.renaming = false;
645
+ this.renameBuffer = "";
646
+ this.setStatus("rename cancelled - name cannot be empty", "warn");
647
+ return;
648
+ }
649
+ await this.guard("rename", async () => {
650
+ const url = `http://127.0.0.1:${this.listenPort}/__host/model/rename?id=${encodeURIComponent(model.id)}`;
651
+ const res = await fetch(url, {
652
+ method: "POST",
653
+ headers: { "content-type": "application/json" },
654
+ body: JSON.stringify({ displayName: newName }),
655
+ });
656
+ if (!res.ok) {
657
+ let message = `HTTP ${res.status}`;
658
+ try {
659
+ const body = (await res.json());
660
+ if (body.error?.message)
661
+ message = body.error.message;
662
+ }
663
+ catch {
664
+ /* non-JSON error body */
665
+ }
666
+ throw new Error(message);
667
+ }
668
+ this.renaming = false;
669
+ this.renameBuffer = "";
670
+ this.reload();
671
+ this.setStatus(`renamed to ${newName}`, "good");
672
+ });
673
+ }
674
+ beginResetName() {
675
+ const model = this.model;
676
+ if (!model)
677
+ return;
678
+ if (!this.renamedIds.has(model.id)) {
679
+ this.setStatus(`"${model.displayName}" has no custom name to reset`, "info");
680
+ return;
681
+ }
682
+ this.confirming = { kind: "reset-name", model };
683
+ this.setStatus(`reset "${model.displayName}" to its default name? y / n`, "warn");
684
+ this.draw();
685
+ }
686
+ /** POST to this TUI's own local router (see handleResetRequest), then
687
+ * rescan so the reverted name is reflected immediately. */
688
+ async submitResetName(model) {
689
+ await this.guard("reset name", async () => {
690
+ const url = `http://127.0.0.1:${this.listenPort}/__host/model/rename/reset?id=${encodeURIComponent(model.id)}`;
691
+ const res = await fetch(url, {
692
+ method: "POST",
693
+ headers: { "content-type": "application/json" },
694
+ });
695
+ if (!res.ok) {
696
+ let message = `HTTP ${res.status}`;
697
+ try {
698
+ const body = (await res.json());
699
+ if (body.error?.message)
700
+ message = body.error.message;
701
+ }
702
+ catch {
703
+ /* non-JSON error body */
704
+ }
705
+ throw new Error(message);
706
+ }
707
+ const body = (await res.json());
708
+ this.reload();
709
+ this.setStatus(`reset to ${body.displayName ?? "its default name"}`, "good");
710
+ });
711
+ }
503
712
  move(delta) {
504
713
  if (this.focus === "models") {
505
714
  const list = this.visible;
@@ -635,16 +844,21 @@ export class App {
635
844
  return;
636
845
  if (key === "y") {
637
846
  this.confirming = null;
638
- void this.guard("delete", async () => {
639
- const plan = deleteModelFiles(confirming.model);
640
- this.reload();
641
- void this.refreshDisk();
642
- this.setStatus(`deleted ${confirming.model.displayName} - freed ${vram.formatGiB(plan.bytes)}`, "good");
643
- });
847
+ if (confirming.kind === "delete") {
848
+ void this.guard("delete", async () => {
849
+ const plan = deleteModelFiles(confirming.model);
850
+ this.reload();
851
+ void this.refreshDisk();
852
+ this.setStatus(`deleted ${confirming.model.displayName} - freed ${vram.formatGiB(plan.bytes)}`, "good");
853
+ });
854
+ }
855
+ else {
856
+ void this.submitResetName(confirming.model);
857
+ }
644
858
  }
645
859
  else if (key === "n" || key === "escape") {
646
860
  this.confirming = null;
647
- this.setStatus("delete cancelled", "info");
861
+ this.setStatus(confirming.kind === "delete" ? "delete cancelled" : "reset cancelled", "info");
648
862
  this.draw();
649
863
  }
650
864
  }
@@ -1250,10 +1464,22 @@ export class App {
1250
1464
  lines.push(...budget);
1251
1465
  lines.push("");
1252
1466
  lines.push(...status);
1467
+ if (this.renaming) {
1468
+ lines.push("");
1469
+ lines.push(this.renameLine(cols - 1));
1470
+ }
1253
1471
  lines.push("");
1254
1472
  lines.push(...footer);
1255
1473
  this.renderFitted(lines);
1256
1474
  }
1475
+ /** The rename input line shown above the footer while renaming is active:
1476
+ * the current name in grey, the buffer typed so far, and a cursor. */
1477
+ renameLine(cols) {
1478
+ const model = this.model;
1479
+ const name = model ? model.displayName : "";
1480
+ return truncate(`${style.brightYellow}rename${style.reset} ${style.grey}${name}${style.reset} → ` +
1481
+ `${this.renameBuffer}${style.brightCyan}▏${style.reset}`, cols);
1482
+ }
1257
1483
  /**
1258
1484
  * Benchmark workspace: the model list on the left, the selected model's
1259
1485
  * scorecard where Configuration sits in serve mode, and the ranked leaderboard
@@ -1693,6 +1919,8 @@ export class App {
1693
1919
  item("f", "find on Hugging Face - search and add a new model");
1694
1920
  item("g", "get a quant - pick Q4/Q5/Q6… to download for this repo");
1695
1921
  item("D", "delete the selected model (frees disk, asks to confirm)");
1922
+ item("R", "rename the selected model (Enter saves, Esc cancels)");
1923
+ item("u", "reset the selected model's name to its default (asks to confirm)");
1696
1924
  section("Views");
1697
1925
  item("b", "benchmark mode - rank models, run the coding suite");
1698
1926
  item("l", "view the live llama-server log");
@@ -1719,10 +1947,20 @@ export class App {
1719
1947
  */
1720
1948
  keybindings(cols) {
1721
1949
  let groups;
1722
- if (this.confirming) {
1950
+ if (this.renaming) {
1951
+ groups = [
1952
+ [
1953
+ ["type", "rename"],
1954
+ ["enter", "save"],
1955
+ ["esc", "cancel"],
1956
+ ],
1957
+ ];
1958
+ }
1959
+ else if (this.confirming) {
1960
+ const label = this.confirming.kind === "delete" ? "confirm delete" : "confirm reset";
1723
1961
  groups = [
1724
1962
  [
1725
- ["y", "confirm delete"],
1963
+ ["y", label],
1726
1964
  ["n", "cancel"],
1727
1965
  ],
1728
1966
  ];
@@ -1793,6 +2031,8 @@ export class App {
1793
2031
  ["f", "find on HF"],
1794
2032
  ["g", "get quant"],
1795
2033
  ["D", "delete"],
2034
+ ["R", "rename"],
2035
+ ["u", "reset name"],
1796
2036
  ["b", "benchmarks"],
1797
2037
  ["l", "logs"],
1798
2038
  ["/", "filter"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otto-code/brain",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
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": {