@otto-code/brain 0.7.5 → 0.8.0

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 (83) hide show
  1. package/dist/bench/context-corpus.js +3 -3
  2. package/dist/bench/corpus.js +2 -2
  3. package/dist/bench/curated-repos.js +3 -3
  4. package/dist/bench/health.d.ts +1 -1
  5. package/dist/bench/health.js +2 -2
  6. package/dist/bench/tasks.js +2 -2
  7. package/dist/cli.d.ts +1 -1
  8. package/dist/cli.js +1 -1
  9. package/dist/commands/bench.d.ts +1 -1
  10. package/dist/commands/bench.js +27 -4
  11. package/dist/commands/calibrate.d.ts +1 -1
  12. package/dist/commands/calibrate.js +5 -2
  13. package/dist/commands/catalog.d.ts +1 -1
  14. package/dist/commands/config.d.ts +1 -1
  15. package/dist/commands/config.js +9 -0
  16. package/dist/commands/lifecycle.js +1 -1
  17. package/dist/commands/pull.d.ts +1 -1
  18. package/dist/commands/pull.js +9 -4
  19. package/dist/commands/report.d.ts +1 -1
  20. package/dist/commands/rescore.d.ts +1 -1
  21. package/dist/commands/rescore.js +1 -1
  22. package/dist/commands/runtime.d.ts +1 -1
  23. package/dist/commands/scan.d.ts +1 -1
  24. package/dist/commands/scan.js +6 -1
  25. package/dist/commands/search.d.ts +1 -1
  26. package/dist/commands/share.js +2 -2
  27. package/dist/commands/sweep.d.ts +2 -2
  28. package/dist/commands/sweep.js +22 -8
  29. package/dist/commands/ui.d.ts +1 -1
  30. package/dist/commands/ui.js +37 -5
  31. package/dist/config/index.d.ts +2 -1
  32. package/dist/config/index.js +2 -1
  33. package/dist/config/otto-home.js +1 -1
  34. package/dist/config/paths.d.ts +1 -0
  35. package/dist/config/paths.js +4 -0
  36. package/dist/config/profile-edit.d.ts +94 -0
  37. package/dist/config/profile-edit.js +269 -0
  38. package/dist/config/profiles.d.ts +2 -2
  39. package/dist/config/profiles.js +1 -1
  40. package/dist/config/schema.d.ts +4 -4
  41. package/dist/config/schema.js +6 -6
  42. package/dist/config/store.d.ts +1 -1
  43. package/dist/config/store.js +1 -1
  44. package/dist/models/download.js +1 -1
  45. package/dist/models/enrich.d.ts +2 -2
  46. package/dist/models/index.d.ts +1 -1
  47. package/dist/models/index.js +1 -1
  48. package/dist/models/pick.d.ts +9 -0
  49. package/dist/models/pick.js +24 -1
  50. package/dist/ops/report.js +28 -28
  51. package/dist/ops/results.d.ts +128 -9
  52. package/dist/ops/results.js +77 -5
  53. package/dist/output/render.js +1 -1
  54. package/dist/output/types.d.ts +1 -1
  55. package/dist/runtime/args.d.ts +1 -1
  56. package/dist/runtime/args.js +1 -1
  57. package/dist/runtime/managed.js +4 -4
  58. package/dist/service/activity.d.ts +83 -0
  59. package/dist/service/activity.js +216 -0
  60. package/dist/service/host-api.d.ts +132 -0
  61. package/dist/service/host-api.js +397 -0
  62. package/dist/service/http-util.d.ts +27 -0
  63. package/dist/service/http-util.js +72 -0
  64. package/dist/service/model-selector.d.ts +2 -2
  65. package/dist/service/model-selector.js +6 -6
  66. package/dist/service/router.d.ts +28 -4
  67. package/dist/service/router.js +128 -94
  68. package/dist/service/scheduler.d.ts +2 -2
  69. package/dist/service/scheduler.js +1 -1
  70. package/dist/service/serve.d.ts +8 -1
  71. package/dist/service/serve.js +69 -16
  72. package/dist/service/supervisor.d.ts +6 -0
  73. package/dist/service/supervisor.js +2 -0
  74. package/dist/service/tailscale.js +1 -1
  75. package/dist/service/tls.d.ts +4 -4
  76. package/dist/service/tls.js +3 -3
  77. package/dist/sysmon.d.ts +20 -4
  78. package/dist/sysmon.js +42 -18
  79. package/dist/tui/app.d.ts +12 -2
  80. package/dist/tui/app.js +48 -23
  81. package/dist/vram.d.ts +8 -1
  82. package/dist/vram.js +6 -3
  83. package/package.json +1 -1
@@ -0,0 +1,83 @@
1
+ /** The long-running ops worth reporting. Anything else is not worth a state. */
2
+ export type BrainActivityKind = "calibrate" | "sweep" | "benchmark" | "download" | "scan";
3
+ export interface BrainActivityRecord {
4
+ kind: BrainActivityKind;
5
+ /** The process running the op, so a crashed op does not pin the status. */
6
+ pid: number;
7
+ /** What it is working on - a model id, usually. Shown in the tooltip. */
8
+ target: string | null;
9
+ /** Completion in [0,1], for ops that can measure it. Null when they cannot. */
10
+ progress: number | null;
11
+ startedAt: string;
12
+ }
13
+ export interface BrainActivityHandle {
14
+ /** Publish progress. Throttled: this is a disk write on a hot loop otherwise. */
15
+ update(progress: number | null): void;
16
+ /** Clear the record. Safe to call more than once. */
17
+ end(): void;
18
+ }
19
+ /**
20
+ * Announce that this process has started a long-running op.
21
+ *
22
+ * Deliberately last-writer-wins rather than refusing when a record already
23
+ * exists: two ops at once is a real (if unwise) thing to do, the newer one is
24
+ * the more interesting answer, and a failed announce must never be a reason for
25
+ * the op itself to fail.
26
+ */
27
+ export declare function beginActivity(kind: BrainActivityKind, options?: {
28
+ target?: string | null;
29
+ env?: NodeJS.ProcessEnv;
30
+ }): BrainActivityHandle;
31
+ /**
32
+ * The op currently owning the host, or null.
33
+ *
34
+ * Clears the record as a side effect when the process behind it is gone, so a
35
+ * killed op self-heals on the next status poll rather than needing a restart.
36
+ */
37
+ export declare function readActivity(env?: NodeJS.ProcessEnv): BrainActivityRecord | null;
38
+ export declare function clearActivity(env?: NodeJS.ProcessEnv): void;
39
+ /**
40
+ * Run `fn` with the op announced, clearing the record however it finishes.
41
+ *
42
+ * The announce is never allowed to break the op: this is status reporting, and
43
+ * a read-only home directory is not a reason for a benchmark to fail.
44
+ */
45
+ export declare function withActivity<T>(kind: BrainActivityKind, options: {
46
+ target?: string | null;
47
+ env?: NodeJS.ProcessEnv;
48
+ }, fn: (handle: BrainActivityHandle) => Promise<T>): Promise<T>;
49
+ /**
50
+ * Whether a streamed chunk carries reasoning, and whether it carries content.
51
+ *
52
+ * These are the router's own predicates, lifted out so the live flag and the
53
+ * end-of-stream `reasoning-only` verdict cannot drift apart. They are
54
+ * deliberately crude substring tests over the raw SSE bytes: the brain proxies
55
+ * both the Anthropic and the OpenAI shapes, chunk boundaries fall wherever the
56
+ * network puts them, and parsing partial JSON per chunk on the hot proxy path
57
+ * would cost more than the signal is worth.
58
+ */
59
+ export declare function chunkHasReasoning(text: string): boolean;
60
+ export declare function chunkHasContent(text: string): boolean;
61
+ /**
62
+ * Which in-flight completions are currently mid-thought.
63
+ *
64
+ * A request counts as thinking once reasoning has gone past and before any
65
+ * content has: that is the window where the model is working and the user has
66
+ * nothing to read yet, which is exactly what the rail's "thinking" state is
67
+ * claiming. The moment content starts the request is generating instead, even
68
+ * if more reasoning follows - a stream that is producing readable output should
69
+ * not report as though it were still silent.
70
+ *
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.
73
+ */
74
+ export declare class ReasoningTracker {
75
+ #private;
76
+ /** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
77
+ observe(requestId: string, text: string): void;
78
+ /** Forget the request. Must be called on end *and* on error, or the flag sticks. */
79
+ end(requestId: string): void;
80
+ get active(): boolean;
81
+ get count(): number;
82
+ }
83
+ //# sourceMappingURL=activity.d.ts.map
@@ -0,0 +1,216 @@
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 _ReasoningTracker_reasoning, _ReasoningTracker_content;
7
+ /**
8
+ * What long-running work currently owns the brain, and whether the loaded model
9
+ * is mid-reasoning.
10
+ *
11
+ * Two trackers with deliberately different lifetimes:
12
+ *
13
+ * - **Ops** (`calibrate`, `sweep`, `benchmark`, `pull`, `scan`) run in their own
14
+ * CLI process. The service answering `/__host/status` is a different process
15
+ * and cannot see them, so the op announces itself through a small file under
16
+ * `$OTTO_HOME/otto-brain/`, the same way `pid-lock.ts` publishes the service
17
+ * itself. Staleness is decided by probing the recorded pid: a `calibrate` that
18
+ * was killed with Ctrl-C never gets to clean up after itself, and a status
19
+ * that stays stuck on "calibrating" forever is worse than no status at all.
20
+ *
21
+ * - **Reasoning** is per-request and lives only as long as the stream does, so
22
+ * it is plain in-process state on the router. It never touches disk.
23
+ *
24
+ * Both feed the one `activity` field on the host status, which the client turns
25
+ * into the Brain rail's icon.
26
+ */
27
+ import { existsSync, readFileSync, rmSync } from "node:fs";
28
+ import { resolveBrainPaths } from "../config/paths.js";
29
+ import { writePrivateFileAtomicSync } from "../config/private-files.js";
30
+ import { isProcessAlive } from "./pid-lock.js";
31
+ const ACTIVITY_KINDS = new Set(["calibrate", "sweep", "benchmark", "download", "scan"]);
32
+ /** How often progress may reach the disk. */
33
+ const PROGRESS_WRITE_INTERVAL_MS = 1000;
34
+ /**
35
+ * Announce that this process has started a long-running op.
36
+ *
37
+ * Deliberately last-writer-wins rather than refusing when a record already
38
+ * exists: two ops at once is a real (if unwise) thing to do, the newer one is
39
+ * the more interesting answer, and a failed announce must never be a reason for
40
+ * the op itself to fail.
41
+ */
42
+ export function beginActivity(kind, options = {}) {
43
+ const env = options.env ?? process.env;
44
+ const record = {
45
+ kind,
46
+ pid: process.pid,
47
+ target: options.target ?? null,
48
+ progress: null,
49
+ startedAt: new Date().toISOString(),
50
+ };
51
+ writeActivity(record, env);
52
+ let lastWriteMs = Date.now();
53
+ let ended = false;
54
+ return {
55
+ update(progress) {
56
+ if (ended)
57
+ return;
58
+ const now = Date.now();
59
+ if (now - lastWriteMs < PROGRESS_WRITE_INTERVAL_MS)
60
+ return;
61
+ lastWriteMs = now;
62
+ record.progress = clampProgress(progress);
63
+ writeActivity(record, env);
64
+ },
65
+ end() {
66
+ if (ended)
67
+ return;
68
+ ended = true;
69
+ // Only clear a record this process owns. An op that outlived us - or a
70
+ // newer one that overwrote ours - must keep reporting.
71
+ const current = readActivityFile(env);
72
+ if (current && current.pid !== process.pid)
73
+ return;
74
+ clearActivity(env);
75
+ },
76
+ };
77
+ }
78
+ /**
79
+ * The op currently owning the host, or null.
80
+ *
81
+ * Clears the record as a side effect when the process behind it is gone, so a
82
+ * killed op self-heals on the next status poll rather than needing a restart.
83
+ */
84
+ export function readActivity(env = process.env) {
85
+ const record = readActivityFile(env);
86
+ if (!record)
87
+ return null;
88
+ if (!isProcessAlive(record.pid)) {
89
+ clearActivity(env);
90
+ return null;
91
+ }
92
+ return record;
93
+ }
94
+ export function clearActivity(env = process.env) {
95
+ const { activityFile } = resolveBrainPaths(env);
96
+ rmSync(activityFile, { force: true });
97
+ }
98
+ /**
99
+ * Run `fn` with the op announced, clearing the record however it finishes.
100
+ *
101
+ * The announce is never allowed to break the op: this is status reporting, and
102
+ * a read-only home directory is not a reason for a benchmark to fail.
103
+ */
104
+ export async function withActivity(kind, options, fn) {
105
+ let handle;
106
+ try {
107
+ handle = beginActivity(kind, options);
108
+ }
109
+ catch {
110
+ handle = { update() { }, end() { } };
111
+ }
112
+ try {
113
+ return await fn(handle);
114
+ }
115
+ finally {
116
+ try {
117
+ handle.end();
118
+ }
119
+ catch {
120
+ // Ignore: see above.
121
+ }
122
+ }
123
+ }
124
+ function writeActivity(record, env) {
125
+ const { activityFile } = resolveBrainPaths(env);
126
+ writePrivateFileAtomicSync(activityFile, `${JSON.stringify(record, null, 2)}\n`);
127
+ }
128
+ function readActivityFile(env) {
129
+ const { activityFile } = resolveBrainPaths(env);
130
+ if (!existsSync(activityFile))
131
+ return null;
132
+ try {
133
+ const parsed = JSON.parse(readFileSync(activityFile, "utf8"));
134
+ if (!isActivityKind(parsed.kind) || typeof parsed.pid !== "number")
135
+ return null;
136
+ return {
137
+ kind: parsed.kind,
138
+ pid: parsed.pid,
139
+ target: typeof parsed.target === "string" ? parsed.target : null,
140
+ progress: clampProgress(parsed.progress ?? null),
141
+ startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : new Date().toISOString(),
142
+ };
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ }
148
+ function isActivityKind(value) {
149
+ return typeof value === "string" && ACTIVITY_KINDS.has(value);
150
+ }
151
+ function clampProgress(progress) {
152
+ if (typeof progress !== "number" || !Number.isFinite(progress))
153
+ return null;
154
+ return Math.min(1, Math.max(0, progress));
155
+ }
156
+ /**
157
+ * Whether a streamed chunk carries reasoning, and whether it carries content.
158
+ *
159
+ * These are the router's own predicates, lifted out so the live flag and the
160
+ * end-of-stream `reasoning-only` verdict cannot drift apart. They are
161
+ * deliberately crude substring tests over the raw SSE bytes: the brain proxies
162
+ * both the Anthropic and the OpenAI shapes, chunk boundaries fall wherever the
163
+ * network puts them, and parsing partial JSON per chunk on the hot proxy path
164
+ * would cost more than the signal is worth.
165
+ */
166
+ export function chunkHasReasoning(text) {
167
+ return text.includes("thinking") || text.includes("reasoning");
168
+ }
169
+ export function chunkHasContent(text) {
170
+ return text.includes('"text_delta"') || /"content"\s*:\s*"[^"]/.test(text);
171
+ }
172
+ /**
173
+ * Which in-flight completions are currently mid-thought.
174
+ *
175
+ * A request counts as thinking once reasoning has gone past and before any
176
+ * content has: that is the window where the model is working and the user has
177
+ * nothing to read yet, which is exactly what the rail's "thinking" state is
178
+ * claiming. The moment content starts the request is generating instead, even
179
+ * if more reasoning follows - a stream that is producing readable output should
180
+ * not report as though it were still silent.
181
+ *
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.
184
+ */
185
+ export class ReasoningTracker {
186
+ constructor() {
187
+ _ReasoningTracker_reasoning.set(this, new Set());
188
+ _ReasoningTracker_content.set(this, new Set());
189
+ }
190
+ /** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
191
+ observe(requestId, text) {
192
+ if (__classPrivateFieldGet(this, _ReasoningTracker_content, "f").has(requestId))
193
+ return;
194
+ if (chunkHasContent(text)) {
195
+ __classPrivateFieldGet(this, _ReasoningTracker_content, "f").add(requestId);
196
+ __classPrivateFieldGet(this, _ReasoningTracker_reasoning, "f").delete(requestId);
197
+ return;
198
+ }
199
+ if (chunkHasReasoning(text)) {
200
+ __classPrivateFieldGet(this, _ReasoningTracker_reasoning, "f").add(requestId);
201
+ }
202
+ }
203
+ /** Forget the request. Must be called on end *and* on error, or the flag sticks. */
204
+ end(requestId) {
205
+ __classPrivateFieldGet(this, _ReasoningTracker_reasoning, "f").delete(requestId);
206
+ __classPrivateFieldGet(this, _ReasoningTracker_content, "f").delete(requestId);
207
+ }
208
+ get active() {
209
+ return __classPrivateFieldGet(this, _ReasoningTracker_reasoning, "f").size > 0;
210
+ }
211
+ get count() {
212
+ return __classPrivateFieldGet(this, _ReasoningTracker_reasoning, "f").size;
213
+ }
214
+ }
215
+ _ReasoningTracker_reasoning = new WeakMap(), _ReasoningTracker_content = new WeakMap();
216
+ //# sourceMappingURL=activity.js.map
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The brain's management API: everything Otto's Brain page needs that is not
3
+ * inference.
4
+ *
5
+ * This is deliberately served by the brain rather than driven by the daemon
6
+ * shelling out to the CLI. The daemon's `BrainOpsManager` runs `otto-brain
7
+ * <verb> --json`, which is local-only by construction, so every capability built
8
+ * that way needs a second implementation before a remote brain can have it. The
9
+ * daemon already resolves this endpoint by mode (local child or remote host), so
10
+ * a capability added here works the same in both without a branch anywhere.
11
+ *
12
+ * Model ids are relative file paths and therefore contain slashes, so the
13
+ * model-scoped routes take `?id=` rather than a path segment. Encoding a slash
14
+ * as %2F inside a path is the kind of thing an intermediary silently normalises,
15
+ * and the failure would look like "model not found" rather than a routing bug.
16
+ *
17
+ * Writes are gated on the same `allowRemoteConfig` flag as POST /__host/config.
18
+ * A brain that may be *used* over the network is not thereby a brain whose model
19
+ * files may be deleted over the network.
20
+ */
21
+ import type http from "node:http";
22
+ import { calibrationInfo, profileWarnings } from "../config/profile-edit.js";
23
+ import type { Profile, ProfileDefaults, ProfilesStore } from "../config/schema.js";
24
+ import { planDelete } from "../models/manage.js";
25
+ import type { RankedModel } from "../ops/results.js";
26
+ import type { GpuInfo, Model } from "../types.js";
27
+ import * as vram from "../vram.js";
28
+ import type { SystemSample } from "../sysmon.js";
29
+ import type { Supervisor } from "./supervisor.js";
30
+ /**
31
+ * What this brain can serve. The daemon folds this into `brain.host.status` and
32
+ * Otto gates each tab on it, because the daemon and the brain version
33
+ * independently: a current daemon can be pointed at an older brain, and the
34
+ * honest answer is "update the brain on that host", not a degraded reimplementation.
35
+ */
36
+ export interface HostCapabilities {
37
+ /** GET/POST /__host/model/profile */
38
+ profiles: boolean;
39
+ /** GET /__host/model/budget */
40
+ budget: boolean;
41
+ /** GET /__host/logs */
42
+ logs: boolean;
43
+ /** DELETE /__host/model */
44
+ delete: boolean;
45
+ /** POST /__host/model/load and /__host/model/unload */
46
+ load: boolean;
47
+ /** The `resources` block on /__host/status */
48
+ resources: boolean;
49
+ /** GET /__host/models */
50
+ inventory: boolean;
51
+ /** Whether writes are currently permitted (allowRemoteConfig). */
52
+ writable: boolean;
53
+ }
54
+ export interface HostApiDeps {
55
+ supervisor: Supervisor;
56
+ /** The live catalog. Replaced wholesale by `rescan`. */
57
+ getCatalog: () => Model[];
58
+ /** Re-read the model directories, e.g. after a delete. Returns the new catalog. */
59
+ rescan: () => Model[];
60
+ getProfilesStore: () => ProfilesStore;
61
+ saveProfiles: (store: ProfilesStore) => void;
62
+ getProfileDefaults: () => ProfileDefaults | undefined;
63
+ queryGpuInfo: () => Promise<GpuInfo | null>;
64
+ getRanking: () => RankedModel[];
65
+ loadModel: (model: Model) => Promise<void>;
66
+ /** Mirrors POST /__host/config's gate: may a network caller change things? */
67
+ getAllowWrite: () => boolean;
68
+ /** The managed models directory, for disk accounting. Null when unresolvable. */
69
+ getModelsDir: () => string | null;
70
+ sampleResources: () => Promise<SystemSample>;
71
+ }
72
+ /** One row of the model inventory: the scan, metadata, profile and score joined. */
73
+ export interface InventoryRow {
74
+ id: string;
75
+ displayName: string;
76
+ publisher: string | null;
77
+ quant: string | null;
78
+ sizeBytes: number;
79
+ mmprojBytes: number;
80
+ origin: string | null;
81
+ arch: string | null;
82
+ contextLength: number | null;
83
+ blockCount: number | null;
84
+ headCountKv: number | null;
85
+ /** Capability flags, matching the TUI's V / M / R badges. */
86
+ hasProjector: boolean;
87
+ reasoning: boolean;
88
+ mtp: boolean;
89
+ distilled: boolean;
90
+ useCases: string[];
91
+ tier: string | null;
92
+ profile: Profile;
93
+ calibration: ReturnType<typeof calibrationInfo>;
94
+ budget: vram.Budget | null;
95
+ maxContextThatFits: number | null;
96
+ score: RankedModel | null;
97
+ state: "loaded" | "loading" | "not-loaded";
98
+ warnings: ReturnType<typeof profileWarnings>;
99
+ }
100
+ /**
101
+ * Join one model's scan row, GGUF metadata, saved profile, calibration, VRAM
102
+ * budget and benchmark score into the single shape the Models tab renders.
103
+ *
104
+ * Exported for testing: the join is the part worth pinning down, since the
105
+ * client would otherwise have to correlate three unrelated lists by display name.
106
+ */
107
+ export declare function buildInventoryRow(params: {
108
+ model: Model;
109
+ store: ProfilesStore;
110
+ defaults: ProfileDefaults | undefined;
111
+ gpu: GpuInfo | null;
112
+ ranking: RankedModel[];
113
+ supervisor: Supervisor;
114
+ }): InventoryRow;
115
+ export interface HostApi {
116
+ /** Returns true when it answered the request, false to fall through. */
117
+ handle: (req: http.IncomingMessage, res: http.ServerResponse) => boolean;
118
+ /**
119
+ * The current capability set. Exposed separately from the route so
120
+ * `/__host/status` can carry it inline: the daemon polls status constantly and
121
+ * would otherwise need a second round trip, or a cache that goes stale the
122
+ * moment `allowRemoteConfig` is toggled, since `writable` lives in here.
123
+ */
124
+ capabilities: () => HostCapabilities;
125
+ }
126
+ /**
127
+ * Build the `/__host/*` management handler.
128
+ */
129
+ export declare function createHostApi(deps: HostApiDeps): HostApi;
130
+ /** The delete plan without performing it, for a confirmation dialog. */
131
+ export declare function describeDelete(model: Model): ReturnType<typeof planDelete>;
132
+ //# sourceMappingURL=host-api.d.ts.map