@schlessera/brain-ui-server 0.13.1 → 0.15.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 (182) hide show
  1. package/README.md +51 -12
  2. package/dist/agent/backend.d.ts +163 -37
  3. package/dist/agent/backend.d.ts.map +1 -1
  4. package/dist/agent/backend.js +281 -286
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts +46 -5
  7. package/dist/app.d.ts.map +1 -1
  8. package/dist/app.js +94 -35
  9. package/dist/app.js.map +1 -1
  10. package/dist/brain/client.d.ts +41 -30
  11. package/dist/brain/client.d.ts.map +1 -1
  12. package/dist/brain/client.js +138 -132
  13. package/dist/brain/client.js.map +1 -1
  14. package/dist/config/env.d.ts +104 -0
  15. package/dist/config/env.d.ts.map +1 -0
  16. package/dist/config/env.js +296 -0
  17. package/dist/config/env.js.map +1 -0
  18. package/dist/cron/scheduler.d.ts +16 -8
  19. package/dist/cron/scheduler.d.ts.map +1 -1
  20. package/dist/cron/scheduler.js +54 -53
  21. package/dist/cron/scheduler.js.map +1 -1
  22. package/dist/db/brain-db.d.ts +50 -0
  23. package/dist/db/brain-db.d.ts.map +1 -0
  24. package/dist/db/brain-db.js +75 -0
  25. package/dist/db/brain-db.js.map +1 -0
  26. package/dist/db/client.d.ts +8 -6
  27. package/dist/db/client.d.ts.map +1 -1
  28. package/dist/db/client.js +12 -25
  29. package/dist/db/client.js.map +1 -1
  30. package/dist/db/settings.d.ts +5 -4
  31. package/dist/db/settings.d.ts.map +1 -1
  32. package/dist/db/settings.js +9 -12
  33. package/dist/db/settings.js.map +1 -1
  34. package/dist/files/walker.d.ts +6 -7
  35. package/dist/files/walker.d.ts.map +1 -1
  36. package/dist/files/walker.js +6 -9
  37. package/dist/files/walker.js.map +1 -1
  38. package/dist/graph/reader.d.ts +6 -6
  39. package/dist/graph/reader.d.ts.map +1 -1
  40. package/dist/graph/reader.js +22 -12
  41. package/dist/graph/reader.js.map +1 -1
  42. package/dist/index.d.ts +15 -9
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +20 -10
  45. package/dist/index.js.map +1 -1
  46. package/dist/middleware/auth.d.ts +17 -6
  47. package/dist/middleware/auth.d.ts.map +1 -1
  48. package/dist/middleware/auth.js +34 -43
  49. package/dist/middleware/auth.js.map +1 -1
  50. package/dist/middleware/origin.d.ts +1 -2
  51. package/dist/middleware/origin.d.ts.map +1 -1
  52. package/dist/middleware/origin.js +1 -7
  53. package/dist/middleware/origin.js.map +1 -1
  54. package/dist/middleware/passkeys.d.ts +29 -5
  55. package/dist/middleware/passkeys.d.ts.map +1 -1
  56. package/dist/middleware/passkeys.js +47 -76
  57. package/dist/middleware/passkeys.js.map +1 -1
  58. package/dist/middleware/tailscale.d.ts +14 -11
  59. package/dist/middleware/tailscale.d.ts.map +1 -1
  60. package/dist/middleware/tailscale.js +11 -10
  61. package/dist/middleware/tailscale.js.map +1 -1
  62. package/dist/routes/brain.d.ts +9 -154
  63. package/dist/routes/brain.d.ts.map +1 -1
  64. package/dist/routes/brain.js +187 -188
  65. package/dist/routes/brain.js.map +1 -1
  66. package/dist/routes/files.d.ts +4 -88
  67. package/dist/routes/files.d.ts.map +1 -1
  68. package/dist/routes/files.js +74 -71
  69. package/dist/routes/files.js.map +1 -1
  70. package/dist/routes/graph.d.ts +4 -255
  71. package/dist/routes/graph.d.ts.map +1 -1
  72. package/dist/routes/graph.js +67 -64
  73. package/dist/routes/graph.js.map +1 -1
  74. package/dist/routes/health.d.ts +8 -22
  75. package/dist/routes/health.d.ts.map +1 -1
  76. package/dist/routes/health.js +10 -10
  77. package/dist/routes/health.js.map +1 -1
  78. package/dist/routes/models.d.ts +7 -90
  79. package/dist/routes/models.d.ts.map +1 -1
  80. package/dist/routes/models.js +53 -51
  81. package/dist/routes/models.js.map +1 -1
  82. package/dist/routes/providers.d.ts +5 -34
  83. package/dist/routes/providers.d.ts.map +1 -1
  84. package/dist/routes/providers.js +13 -11
  85. package/dist/routes/providers.js.map +1 -1
  86. package/dist/routes/sessions.d.ts +7 -78
  87. package/dist/routes/sessions.d.ts.map +1 -1
  88. package/dist/routes/sessions.js +35 -34
  89. package/dist/routes/sessions.js.map +1 -1
  90. package/dist/routes/share.d.ts +7 -60
  91. package/dist/routes/share.d.ts.map +1 -1
  92. package/dist/routes/share.js +80 -77
  93. package/dist/routes/share.js.map +1 -1
  94. package/dist/routes/voice.d.ts +8 -91
  95. package/dist/routes/voice.d.ts.map +1 -1
  96. package/dist/routes/voice.js +62 -59
  97. package/dist/routes/voice.js.map +1 -1
  98. package/dist/share/staging.d.ts +4 -4
  99. package/dist/share/staging.d.ts.map +1 -1
  100. package/dist/share/staging.js +7 -8
  101. package/dist/share/staging.js.map +1 -1
  102. package/dist/voice/deepgram-token.d.ts +1 -1
  103. package/dist/voice/deepgram-token.d.ts.map +1 -1
  104. package/dist/voice/deepgram-token.js +1 -2
  105. package/dist/voice/deepgram-token.js.map +1 -1
  106. package/dist/voice/keyterm-builder.d.ts +23 -8
  107. package/dist/voice/keyterm-builder.d.ts.map +1 -1
  108. package/dist/voice/keyterm-builder.js +67 -43
  109. package/dist/voice/keyterm-builder.js.map +1 -1
  110. package/dist/voice/speech-providers.d.ts +6 -4
  111. package/dist/voice/speech-providers.d.ts.map +1 -1
  112. package/dist/voice/speech-providers.js +28 -25
  113. package/dist/voice/speech-providers.js.map +1 -1
  114. package/dist/ws/bridge.d.ts.map +1 -1
  115. package/dist/ws/bridge.js +2 -3
  116. package/dist/ws/bridge.js.map +1 -1
  117. package/dist/ws/clients.d.ts +18 -10
  118. package/dist/ws/clients.d.ts.map +1 -1
  119. package/dist/ws/clients.js +35 -37
  120. package/dist/ws/clients.js.map +1 -1
  121. package/dist/ws/connection.d.ts.map +1 -1
  122. package/dist/ws/connection.js +5 -7
  123. package/dist/ws/connection.js.map +1 -1
  124. package/dist/ws/dispatch.d.ts.map +1 -1
  125. package/dist/ws/dispatch.js +1 -2
  126. package/dist/ws/dispatch.js.map +1 -1
  127. package/dist/ws/host.d.ts +16 -12
  128. package/dist/ws/host.d.ts.map +1 -1
  129. package/dist/ws/host.js +14 -23
  130. package/dist/ws/host.js.map +1 -1
  131. package/dist/ws/routing.d.ts +2 -1
  132. package/dist/ws/routing.d.ts.map +1 -1
  133. package/dist/ws/routing.js +5 -6
  134. package/dist/ws/routing.js.map +1 -1
  135. package/dist/ws/run-session.js +1 -1
  136. package/dist/ws/run-session.js.map +1 -1
  137. package/dist/ws/session-catalog.d.ts +2 -2
  138. package/dist/ws/session-catalog.d.ts.map +1 -1
  139. package/dist/ws/session-catalog.js +2 -3
  140. package/dist/ws/session-catalog.js.map +1 -1
  141. package/package.json +8 -5
  142. package/src/agent/backend.ts +509 -340
  143. package/src/app.ts +157 -36
  144. package/src/brain/client.ts +166 -143
  145. package/src/config/env.ts +401 -0
  146. package/src/cron/scheduler.ts +77 -59
  147. package/src/db/brain-db.ts +104 -0
  148. package/src/db/client.ts +12 -31
  149. package/src/db/settings.ts +12 -14
  150. package/src/files/walker.ts +6 -10
  151. package/src/graph/reader.ts +26 -15
  152. package/src/index.ts +56 -17
  153. package/src/middleware/auth.ts +51 -45
  154. package/src/middleware/origin.ts +1 -8
  155. package/src/middleware/passkeys.ts +72 -68
  156. package/src/middleware/tailscale.ts +22 -17
  157. package/src/routes/brain.ts +31 -30
  158. package/src/routes/files.ts +10 -7
  159. package/src/routes/graph.ts +9 -6
  160. package/src/routes/health.ts +17 -10
  161. package/src/routes/models.ts +19 -15
  162. package/src/routes/providers.ts +14 -15
  163. package/src/routes/sessions.ts +18 -11
  164. package/src/routes/share.ts +35 -24
  165. package/src/routes/voice.ts +78 -68
  166. package/src/share/staging.ts +10 -8
  167. package/src/voice/deepgram-token.ts +4 -2
  168. package/src/voice/keyterm-builder.ts +90 -56
  169. package/src/voice/speech-providers.ts +28 -24
  170. package/src/ws/bridge.ts +2 -3
  171. package/src/ws/clients.ts +38 -41
  172. package/src/ws/connection.ts +5 -7
  173. package/src/ws/dispatch.ts +1 -2
  174. package/src/ws/host.ts +24 -27
  175. package/src/ws/routing.ts +6 -10
  176. package/src/ws/run-session.ts +1 -1
  177. package/src/ws/session-catalog.ts +2 -3
  178. package/dist/ws/handler.d.ts +0 -34
  179. package/dist/ws/handler.d.ts.map +0 -1
  180. package/dist/ws/handler.js +0 -33
  181. package/dist/ws/handler.js.map +0 -1
  182. package/src/ws/handler.ts +0 -50
@@ -1,27 +1,6 @@
1
1
  import { createRequire } from "module";
2
- import { createClaudeBackend, createModelSource, defineProfiles, } from "@schlessera/brain-backend-claude";
3
- import { getHiddenModelIds } from "../db/settings.js";
4
- /**
5
- * Backend registry for the deployment. Claude is always available.
6
- * `AGENT_BACKEND=pi` retains the legacy single-backend deployment mode when the
7
- * optional pi package is installed.
8
- *
9
- * The old five hardcoded personal inference profiles are gone: the Claude
10
- * backend ships the single "claude" default, and additional Anthropic-compatible
11
- * endpoints are declared via the BRAIN_UI_CLAUDE_PROFILES env var (documented in
12
- * .env.example) rather than in code.
13
- */
14
- const BRAIN_PATH = process.env.BRAIN_PATH || `${process.env.HOME || "/root"}/brain`;
15
- // The Claude Code native binary is not in node_modules — it is installed via
16
- // the official curl-bash installer (~/.local/bin/claude, symlinked to
17
- // /usr/local/bin/claude in the container). The Agent SDK can't always
18
- // auto-discover it, so hand it the explicit path; override with CLAUDE_CODE_PATH.
19
- const CLAUDE_CODE_PATH = process.env.CLAUDE_CODE_PATH || "/usr/local/bin/claude";
20
2
  const PROFILE_MEMO_MS = 5_000;
21
- let cachedRegistry = null;
22
- let profileSnapshot = null;
23
- let modelSource = null;
24
- async function createRegistry(backends, defaultBackendId) {
3
+ function buildSnapshot(backends, defaultBackendId) {
25
4
  if (backends.length === 0) {
26
5
  throw new Error("Backend registry requires at least one backend.");
27
6
  }
@@ -35,290 +14,306 @@ async function createRegistry(backends, defaultBackendId) {
35
14
  ];
36
15
  return { backends: ordered, byId, defaultBackendId: resolvedDefaultId };
37
16
  }
38
- /** Recompute the profile roster (memoized), asserting cross-backend id uniqueness. */
39
- async function getProfileSnapshot() {
40
- if (profileSnapshot && Date.now() - profileSnapshot.at < PROFILE_MEMO_MS) {
41
- return profileSnapshot;
42
- }
43
- const registry = await getRegistry();
44
- const byBackend = new Map();
45
- const owners = new Map();
46
- for (const backend of registry.backends) {
47
- const profiles = await backend.listProfiles();
48
- byBackend.set(backend.id, profiles);
49
- for (const profile of profiles) {
50
- const owner = owners.get(profile.id);
51
- if (owner && owner !== backend.id) {
52
- throw new Error(`Profile id collision across backends: "${profile.id}" is exposed by "${owner}" and "${backend.id}".`);
53
- }
54
- owners.set(profile.id, backend.id);
55
- }
56
- }
57
- profileSnapshot = { at: Date.now(), byBackend, owners };
58
- return profileSnapshot;
59
- }
60
- /** Drop the memo so the next read reflects a refresh or a settings change. */
61
- export function invalidateProfiles() {
62
- profileSnapshot = null;
63
- }
64
- /**
65
- * The Claude backend's discovery source, once the registry is built. Null when
66
- * discovery is disabled or the deployment runs a different backend.
67
- */
68
- export async function getModelSource() {
69
- await getRegistry();
70
- return modelSource;
17
+ const BACKEND_SPECIFIERS = {
18
+ claude: "@schlessera/brain-backend-claude",
19
+ pi: "@schlessera/brain-backend-pi",
20
+ };
21
+ function unknownBackendError(primary) {
22
+ return new Error(`AGENT_BACKEND="${primary}" does not match any configured backend ` +
23
+ `(expected "claude" or "pi").`);
71
24
  }
72
- /**
73
- * Hidden profile ids, from the settings table. Presentation only — a hidden
74
- * profile still resolves for sessions pinned to it. A db read failure must not
75
- * take the picker down, so it degrades to "nothing hidden".
76
- */
77
- function hiddenIds() {
78
- try {
79
- return new Set(getHiddenModelIds());
80
- }
81
- catch (err) {
82
- console.warn(`[models] Could not read hidden models: ${err instanceof Error ? err.message : String(err)}`);
83
- return new Set();
25
+ function missingBackendError(primary) {
26
+ if (primary === "pi") {
27
+ return new Error('AGENT_BACKEND=pi but "@schlessera/brain-backend-pi" is not installed. ' +
28
+ "Add it (with its pi SDK dependencies) or set AGENT_BACKEND=claude.");
84
29
  }
30
+ return new Error(`AGENT_BACKEND=${primary} but "@schlessera/brain-backend-claude" is not installed. ` +
31
+ "Add it (it carries the Claude Agent SDK) or set AGENT_BACKEND=pi.");
85
32
  }
86
33
  /**
87
- * Model discovery is on unless explicitly disabled except under a test
88
- * runner, where the default flips to off: discovery is a live call to the
89
- * Anthropic API, and a suite that silently depends on network access (and on
90
- * whatever credentials the developer happens to have exported) is flaky by
91
- * construction. Tests that want it set BRAIN_UI_MODEL_DISCOVERY=1 explicitly.
92
- */
93
- function modelDiscoveryEnabled() {
94
- const raw = process.env.BRAIN_UI_MODEL_DISCOVERY?.trim().toLowerCase();
95
- if (raw)
96
- return !(raw === "0" || raw === "off" || raw === "false");
97
- return process.env.NODE_ENV !== "test";
98
- }
99
- /** How long a discovery result stays fresh. Default 24h. */
100
- function modelTtlMs() {
101
- const raw = Number(process.env.BRAIN_UI_MODEL_TTL_HOURS);
102
- const hours = Number.isFinite(raw) && raw > 0 ? raw : 24;
103
- return hours * 60 * 60 * 1000;
104
- }
105
- /**
106
- * Declared profiles (built-in default + BRAIN_UI_CLAUDE_PROFILES) plus every
107
- * discovered model whose id isn't already declared. Declared wins: the env stays
108
- * an override mechanism, and a hand-pinned entry keeps its label and endpoint.
34
+ * Boot-time guard: the SELECTED backend's package must at least RESOLVE, so a
35
+ * broken install refuses to start instead of reporting healthy and failing
36
+ * every agent turn (the guarantee the old static import gave, restored without
37
+ * giving up the lazy load `require.resolve` never executes the module, so
38
+ * the Agent SDK still loads on first use only). Called by `createApp()` next
39
+ * to the auth assertions; skipped when an explicit registry is injected.
40
+ *
41
+ * An unrecognized AGENT_BACKEND fails here too, for the same reason.
109
42
  *
110
- * Memoized on the discovered array's identity `createModelSource` swaps the
111
- * array only on a successful refresh, so steady state is one comparison.
43
+ * The `resolve` parameter exists for tests (simulating an absent package);
44
+ * production callers pass nothing.
112
45
  */
113
- let mergeCache = null;
114
- function mergeDiscovered(declared, discovered) {
115
- if (mergeCache &&
116
- mergeCache.declared === declared &&
117
- mergeCache.discovered === discovered) {
118
- return mergeCache.result;
119
- }
120
- const declaredIds = new Set(declared.map((profile) => profile.id));
121
- const extra = discovered.filter((input) => !declaredIds.has(input.id));
122
- const result = [...declared, ...defineProfiles(extra)];
123
- mergeCache = { declared, discovered, result };
124
- return result;
125
- }
126
- async function buildRegistry() {
127
- const primary = (process.env.AGENT_BACKEND || "claude")
128
- .trim()
129
- .toLowerCase();
130
- if (primary === "pi") {
131
- const pi = buildPiBackend();
132
- return createRegistry([pi], pi.id);
46
+ export function assertBackendResolvable(agent, resolve = (specifier) => {
47
+ createRequire(import.meta.url).resolve(specifier);
48
+ }) {
49
+ const primary = agent.backend || "claude";
50
+ if (!(primary in BACKEND_SPECIFIERS))
51
+ throw unknownBackendError(primary);
52
+ const key = primary;
53
+ try {
54
+ resolve(BACKEND_SPECIFIERS[key]);
133
55
  }
134
- // Declared profiles are resolved EAGERLY so a malformed
135
- // BRAIN_UI_CLAUDE_PROFILES still fails at boot rather than on first request.
136
- const declared = loadClaudeProfiles();
137
- modelSource = createModelSource({
138
- brainPath: BRAIN_PATH,
139
- enabled: modelDiscoveryEnabled(),
140
- ttlMs: modelTtlMs(),
141
- });
142
- const claude = createClaudeBackend({
143
- brainPath: BRAIN_PATH,
144
- claudeCodePath: CLAUDE_CODE_PATH,
145
- // A function, not an array: discovery refreshes in the background and the
146
- // new roster has to be visible without restarting the process.
147
- profiles: () => mergeDiscovered(declared, modelSource?.list() ?? []),
148
- });
149
- const backends = [claude];
150
- // AGENT_BACKEND must name a configured backend. The in-process options are
151
- // "claude" (default) and "pi" (handled above). An unrecognized value is a
152
- // misconfiguration — fail loudly instead of silently coercing to claude and
153
- // running on the wrong backend with nothing in the logs.
154
- if (process.env.AGENT_BACKEND && !backends.some((b) => b.id === primary)) {
155
- throw new Error(`AGENT_BACKEND="${primary}" does not match any configured backend ` +
156
- `(expected "claude" or "pi").`);
56
+ catch {
57
+ throw missingBackendError(key);
157
58
  }
158
- return createRegistry(backends, primary);
159
- }
160
- async function getRegistry() {
161
- if (!cachedRegistry)
162
- cachedRegistry = buildRegistry();
163
- return cachedRegistry;
164
- }
165
- /** All configured backends, with the default backend first. */
166
- export async function getBackends() {
167
- return (await getRegistry()).backends;
168
- }
169
- /** Find a configured backend by its stable id. */
170
- export async function getBackendById(id) {
171
- return (await getRegistry()).byId.get(id);
172
- }
173
- /** The default backend used for new and legacy sessions. */
174
- export async function getDefaultBackend() {
175
- const registry = await getRegistry();
176
- return registry.byId.get(registry.defaultBackendId);
177
59
  }
178
- /** The stable id of the default backend. */
179
- export async function getDefaultBackendId() {
180
- return (await getRegistry()).defaultBackendId;
181
- }
182
- /** Resolve the backend that owns a globally unique profile id. */
183
- export async function getBackendForProfile(profileId) {
184
- const registry = await getRegistry();
185
- // Deliberately resolved against the UNFILTERED roster: a session pinned to a
186
- // profile the user later hid must keep running.
187
- const snapshot = await getProfileSnapshot();
188
- const backendId = snapshot.owners.get(profileId);
189
- return backendId ? registry.byId.get(backendId) : undefined;
190
- }
191
- /** Resolve a persisted backend id, falling back for legacy/unknown sessions. */
192
- export async function getBackendForSession(backendId) {
193
- if (backendId) {
194
- const backend = await getBackendById(backendId);
195
- if (backend)
196
- return backend;
60
+ export function createBackendRegistry(options) {
61
+ const { brainPath, agent } = options;
62
+ const getHidden = options.getHiddenModelIds ?? (() => []);
63
+ let cachedRegistry = null;
64
+ let modelSource = null;
65
+ /**
66
+ * Declared profiles (built-in default + BRAIN_UI_CLAUDE_PROFILES) plus every
67
+ * discovered model whose id isn't already declared. Declared wins: the env
68
+ * stays an override mechanism, and a hand-pinned entry keeps its label and
69
+ * endpoint.
70
+ *
71
+ * Memoized on the discovered array's identity — `createModelSource` swaps the
72
+ * array only on a successful refresh, so steady state is one comparison.
73
+ */
74
+ let mergeCache = null;
75
+ function mergeDiscovered(claude, declared, discovered) {
76
+ if (mergeCache &&
77
+ mergeCache.declared === declared &&
78
+ mergeCache.discovered === discovered) {
79
+ return mergeCache.result;
80
+ }
81
+ const declaredIds = new Set(declared.map((profile) => profile.id));
82
+ const extra = discovered.filter((input) => !declaredIds.has(input.id));
83
+ const result = [...declared, ...claude.defineProfiles(extra)];
84
+ mergeCache = { declared, discovered, result };
85
+ return result;
197
86
  }
198
- return getDefaultBackend();
199
- }
200
- /**
201
- * Every available profile, tagged with its owning backend id.
202
- *
203
- * Hidden profiles are omitted by default (this feeds the picker); the settings
204
- * screen passes `includeHidden` to render the full catalog.
205
- */
206
- export async function listAllProviders(options = {}) {
207
- const registry = await getRegistry();
208
- const snapshot = await getProfileSnapshot();
209
- const hidden = options.includeHidden ? new Set() : hiddenIds();
210
- return registry.backends.flatMap((backend) => (snapshot.byBackend.get(backend.id) ?? [])
211
- .filter((profile) => !hidden.has(profile.id))
212
- .map((profile) => ({ ...profile, backendId: backend.id })));
213
- }
214
- /** Per-backend capability metadata exposed by the providers route. */
215
- export async function getBackendsInfo() {
216
- const backends = await getBackends();
217
- return Object.fromEntries(backends.map((backend) => [
218
- backend.id,
219
- { id: backend.id, capabilities: backend.capabilities },
220
- ]));
221
- }
222
- /** @deprecated Use getDefaultBackend() or a routing-specific accessor. */
223
- export async function getBackend() {
224
- return getDefaultBackend();
225
- }
226
- /** Test seam: drop the cached registry so later access rebuilds from env. */
227
- export function resetBackendForTests() {
228
- cachedRegistry = null;
229
- profileSnapshot = null;
230
- modelSource = null;
231
- }
232
- /** Test seam: install one fake backend as the complete registry. */
233
- export function setBackendForTests(backend) {
234
- cachedRegistry = createRegistry([backend], backend.id);
235
- profileSnapshot = null;
236
- }
237
- /** Test seam: install a complete fake registry with an explicit default. */
238
- export function setBackendsForTests(backends, defaultBackendId = backends[0]?.id ?? "") {
239
- cachedRegistry = createRegistry(backends, defaultBackendId);
240
- profileSnapshot = null;
241
- }
242
- // The built-in default profile's model. The Claude backend historically pinned
243
- // the default to a specific model; after the SDK extraction the pin was lost and
244
- // resumed default-profile sessions drifted to "whatever the CLI defaults to",
245
- // silently changing model/behaviour/billing on every CLI bump. Re-pin it here
246
- // (a committed default, overridable per deploy via BRAIN_UI_CLAUDE_DEFAULT_MODEL)
247
- // so the default stays on a known model.
248
- const BUILTIN_DEFAULT_MODEL = process.env.BRAIN_UI_CLAUDE_DEFAULT_MODEL?.trim() || "claude-sonnet-4-6";
249
- function builtinDefaultProfiles() {
250
- return defineProfiles([
251
- {
252
- id: "claude",
253
- label: "Claude",
254
- vendor: "anthropic",
255
- model: BUILTIN_DEFAULT_MODEL,
256
- modelAliases: true,
257
- source: "builtin",
258
- },
259
- ]);
260
- }
261
- /**
262
- * The Claude roster: the pinned built-in "claude" default first, plus any extra
263
- * profiles from BRAIN_UI_CLAUDE_PROFILES (a JSON array of InferenceProfileInput
264
- * {id,label,model?,baseUrl?,authTokenEnv?,apiKeyEnv?,modelAliases?}).
265
- *
266
- * A malformed or duplicate-id roster THROWS — caught at boot by the registry
267
- * fail-fast — rather than silently degrading to default-only and rebilling
268
- * every pinned session to the subscription with nothing in the logs.
269
- */
270
- function loadClaudeProfiles() {
271
- const base = builtinDefaultProfiles();
272
- const raw = process.env.BRAIN_UI_CLAUDE_PROFILES?.trim();
273
- if (!raw)
274
- return base;
275
- let inputs;
276
- try {
277
- inputs = JSON.parse(raw);
87
+ // The built-in default profile's model. The Claude backend historically
88
+ // pinned the default to a specific model; after the SDK extraction the pin
89
+ // was lost and resumed default-profile sessions drifted to "whatever the CLI
90
+ // defaults to", silently changing model/behaviour/billing on every CLI bump.
91
+ // Re-pin it (a committed default, overridable per deploy via
92
+ // BRAIN_UI_CLAUDE_DEFAULT_MODEL) so the default stays on a known model.
93
+ function builtinDefaultProfiles(claude) {
94
+ return claude.defineProfiles([
95
+ {
96
+ id: "claude",
97
+ label: "Claude",
98
+ vendor: "anthropic",
99
+ model: agent.defaultModel,
100
+ modelAliases: true,
101
+ source: "builtin",
102
+ },
103
+ ]);
104
+ }
105
+ /**
106
+ * The Claude roster: the pinned built-in "claude" default first, plus any
107
+ * extra profiles from BRAIN_UI_CLAUDE_PROFILES (a JSON array of profile
108
+ * inputs: {id,label,model?,baseUrl?,authTokenEnv?,apiKeyEnv?,modelAliases?}).
109
+ *
110
+ * A malformed or duplicate-id roster THROWS — caught at boot by the registry
111
+ * fail-fast rather than silently degrading to default-only and rebilling
112
+ * every pinned session to the subscription with nothing in the logs.
113
+ */
114
+ function loadClaudeProfiles(claude) {
115
+ const base = builtinDefaultProfiles(claude);
116
+ const raw = agent.profilesJson;
117
+ if (!raw)
118
+ return base;
119
+ let inputs;
120
+ try {
121
+ inputs = JSON.parse(raw);
122
+ }
123
+ catch (err) {
124
+ throw new Error(`BRAIN_UI_CLAUDE_PROFILES is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
125
+ }
126
+ if (!Array.isArray(inputs)) {
127
+ throw new Error("BRAIN_UI_CLAUDE_PROFILES must be a JSON array.");
128
+ }
129
+ // Reject duplicate ids (including collisions with the built-in "claude"): a
130
+ // duplicate silently shadows and can resolve to the wrong credentials.
131
+ const seen = new Set(base.map((profile) => profile.id));
132
+ for (const input of inputs) {
133
+ if (!input || typeof input.id !== "string" || input.id.length === 0) {
134
+ throw new Error("Each BRAIN_UI_CLAUDE_PROFILES entry needs a non-empty string id.");
135
+ }
136
+ if (seen.has(input.id)) {
137
+ throw new Error(`Duplicate profile id in BRAIN_UI_CLAUDE_PROFILES: "${input.id}".`);
138
+ }
139
+ seen.add(input.id);
140
+ }
141
+ const declared = inputs.map((input) => ({
142
+ source: "declared",
143
+ ...input,
144
+ }));
145
+ return [...base, ...claude.defineProfiles(declared)];
278
146
  }
279
- catch (err) {
280
- throw new Error(`BRAIN_UI_CLAUDE_PROFILES is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
147
+ /**
148
+ * NEITHER backend package is a hard dependency a deployment installs the
149
+ * one its AGENT_BACKEND names (both, if it switches). Loaded lazily through
150
+ * the same `createRequire` path so the server still boots without the unused
151
+ * one; when the configured backend's package is absent, fail with an
152
+ * actionable message.
153
+ */
154
+ function buildClaudeBackend() {
155
+ const require = createRequire(import.meta.url);
156
+ let claude;
157
+ try {
158
+ claude = require("@schlessera/brain-backend-claude");
159
+ }
160
+ catch {
161
+ throw missingBackendError("claude");
162
+ }
163
+ if (typeof claude.createClaudeBackend !== "function") {
164
+ throw new Error('"@schlessera/brain-backend-claude" does not export createClaudeBackend.');
165
+ }
166
+ // Declared profiles are resolved EAGERLY so a malformed
167
+ // BRAIN_UI_CLAUDE_PROFILES still fails at boot rather than on first request.
168
+ const declared = loadClaudeProfiles(claude);
169
+ modelSource = claude.createModelSource({
170
+ brainPath,
171
+ enabled: agent.modelDiscovery,
172
+ ttlMs: agent.modelTtlMs,
173
+ });
174
+ return claude.createClaudeBackend({
175
+ brainPath,
176
+ claudeCodePath: agent.claudeCodePath,
177
+ // A function, not an array: discovery refreshes in the background and the
178
+ // new roster has to be visible without restarting the process.
179
+ profiles: () => mergeDiscovered(claude, declared, modelSource?.list() ?? []),
180
+ });
281
181
  }
282
- if (!Array.isArray(inputs)) {
283
- throw new Error("BRAIN_UI_CLAUDE_PROFILES must be a JSON array.");
182
+ function buildPiBackend() {
183
+ const require = createRequire(import.meta.url);
184
+ let mod;
185
+ try {
186
+ mod = require("@schlessera/brain-backend-pi");
187
+ }
188
+ catch {
189
+ throw missingBackendError("pi");
190
+ }
191
+ if (typeof mod.createPiBackend !== "function") {
192
+ throw new Error('"@schlessera/brain-backend-pi" does not export createPiBackend.');
193
+ }
194
+ return mod.createPiBackend({ brainPath });
284
195
  }
285
- // Reject duplicate ids (including collisions with the built-in "claude"): a
286
- // duplicate silently shadows and can resolve to the wrong credentials.
287
- const seen = new Set(base.map((profile) => profile.id));
288
- for (const input of inputs) {
289
- if (!input || typeof input.id !== "string" || input.id.length === 0) {
290
- throw new Error("Each BRAIN_UI_CLAUDE_PROFILES entry needs a non-empty string id.");
196
+ async function buildRegistry() {
197
+ const primary = agent.backend || "claude";
198
+ if (primary === "pi") {
199
+ const pi = buildPiBackend();
200
+ return buildSnapshot([pi], pi.id);
291
201
  }
292
- if (seen.has(input.id)) {
293
- throw new Error(`Duplicate profile id in BRAIN_UI_CLAUDE_PROFILES: "${input.id}".`);
202
+ const backends = [buildClaudeBackend()];
203
+ // AGENT_BACKEND must name a configured backend. The in-process options are
204
+ // "claude" (default) and "pi" (handled above). An unrecognized value is a
205
+ // misconfiguration — fail loudly instead of silently coercing to claude and
206
+ // running on the wrong backend with nothing in the logs.
207
+ if (agent.backend && !backends.some((b) => b.id === primary)) {
208
+ throw unknownBackendError(primary);
294
209
  }
295
- seen.add(input.id);
210
+ return buildSnapshot(backends, primary);
211
+ }
212
+ async function getRegistry() {
213
+ if (!cachedRegistry)
214
+ cachedRegistry = buildRegistry();
215
+ return cachedRegistry;
296
216
  }
297
- const declared = inputs.map((input) => ({
298
- source: "declared",
299
- ...input,
300
- }));
301
- return [...base, ...defineProfiles(declared)];
217
+ return makeRegistry(getRegistry, getHidden, async () => {
218
+ await getRegistry();
219
+ return modelSource;
220
+ });
302
221
  }
303
222
  /**
304
- * Optional pi backend. @schlessera/brain-backend-pi is NOT a hard dependency a
305
- * deployment that never opts into it does not need it (or its pi SDK peer deps)
306
- * installed. Loaded lazily so the server still boots without it; when requested
307
- * but absent, fail with an actionable message.
223
+ * A registry over an explicit backend list the seam tests (and embedders
224
+ * with their own backend wiring) use instead of mutating module state. Takes
225
+ * the same optional hidden-ids reader so visibility behavior matches
226
+ * production.
308
227
  */
309
- function buildPiBackend() {
310
- const require = createRequire(import.meta.url);
311
- let mod;
312
- try {
313
- mod = require("@schlessera/brain-backend-pi");
314
- }
315
- catch {
316
- throw new Error('AGENT_BACKEND=pi but "@schlessera/brain-backend-pi" is not installed. ' +
317
- "Add it (with its pi SDK dependencies) or set AGENT_BACKEND=claude.");
228
+ export function createStaticBackendRegistry(backends, defaultBackendId = backends[0]?.id ?? "", options = {}) {
229
+ const snapshot = buildSnapshot(backends, defaultBackendId);
230
+ return makeRegistry(async () => snapshot, options.getHiddenModelIds ?? (() => []), async () => null);
231
+ }
232
+ /** The accessor surface, shared by the config-driven and static registries. */
233
+ function makeRegistry(getRegistry, getHidden, getModelSource) {
234
+ let profileSnapshot = null;
235
+ /** Recompute the profile roster (memoized), asserting cross-backend id uniqueness. */
236
+ async function getProfileSnapshot() {
237
+ if (profileSnapshot && Date.now() - profileSnapshot.at < PROFILE_MEMO_MS) {
238
+ return profileSnapshot;
239
+ }
240
+ const registry = await getRegistry();
241
+ const byBackend = new Map();
242
+ const owners = new Map();
243
+ for (const backend of registry.backends) {
244
+ const profiles = await backend.listProfiles();
245
+ byBackend.set(backend.id, profiles);
246
+ for (const profile of profiles) {
247
+ const owner = owners.get(profile.id);
248
+ if (owner && owner !== backend.id) {
249
+ throw new Error(`Profile id collision across backends: "${profile.id}" is exposed by "${owner}" and "${backend.id}".`);
250
+ }
251
+ owners.set(profile.id, backend.id);
252
+ }
253
+ }
254
+ profileSnapshot = { at: Date.now(), byBackend, owners };
255
+ return profileSnapshot;
318
256
  }
319
- if (typeof mod.createPiBackend !== "function") {
320
- throw new Error('"@schlessera/brain-backend-pi" does not export createPiBackend.');
257
+ /** A settings read failure must not take the picker down — degrade to "nothing hidden". */
258
+ function hiddenIds() {
259
+ try {
260
+ return new Set(getHidden());
261
+ }
262
+ catch (err) {
263
+ console.warn(`[models] Could not read hidden models: ${err instanceof Error ? err.message : String(err)}`);
264
+ return new Set();
265
+ }
321
266
  }
322
- return mod.createPiBackend({ brainPath: BRAIN_PATH });
267
+ return {
268
+ async getBackends() {
269
+ return (await getRegistry()).backends;
270
+ },
271
+ async getBackendById(id) {
272
+ return (await getRegistry()).byId.get(id);
273
+ },
274
+ async getDefaultBackend() {
275
+ const registry = await getRegistry();
276
+ return registry.byId.get(registry.defaultBackendId);
277
+ },
278
+ async getDefaultBackendId() {
279
+ return (await getRegistry()).defaultBackendId;
280
+ },
281
+ async getBackendForProfile(profileId) {
282
+ const registry = await getRegistry();
283
+ // Deliberately resolved against the UNFILTERED roster: a session pinned
284
+ // to a profile the user later hid must keep running.
285
+ const snapshot = await getProfileSnapshot();
286
+ const backendId = snapshot.owners.get(profileId);
287
+ return backendId ? registry.byId.get(backendId) : undefined;
288
+ },
289
+ async getBackendForSession(backendId) {
290
+ const registry = await getRegistry();
291
+ if (backendId) {
292
+ const backend = registry.byId.get(backendId);
293
+ if (backend)
294
+ return backend;
295
+ }
296
+ return registry.byId.get(registry.defaultBackendId);
297
+ },
298
+ async listAllProviders(options = {}) {
299
+ const registry = await getRegistry();
300
+ const snapshot = await getProfileSnapshot();
301
+ const hidden = options.includeHidden ? new Set() : hiddenIds();
302
+ return registry.backends.flatMap((backend) => (snapshot.byBackend.get(backend.id) ?? [])
303
+ .filter((profile) => !hidden.has(profile.id))
304
+ .map((profile) => ({ ...profile, backendId: backend.id })));
305
+ },
306
+ async getBackendsInfo() {
307
+ const backends = (await getRegistry()).backends;
308
+ return Object.fromEntries(backends.map((backend) => [
309
+ backend.id,
310
+ { id: backend.id, capabilities: backend.capabilities },
311
+ ]));
312
+ },
313
+ getModelSource,
314
+ invalidateProfiles() {
315
+ profileSnapshot = null;
316
+ },
317
+ };
323
318
  }
324
319
  //# sourceMappingURL=backend.js.map