@schlessera/brain-ui-server 0.13.1 → 0.14.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,13 +1,5 @@
1
1
  import { createRequire } from "module";
2
- import {
3
- createClaudeBackend,
4
- createModelSource,
5
- defineProfiles,
6
- type InferenceProfile,
7
- type InferenceProfileInput,
8
- type ModelSource,
9
- } from "@schlessera/brain-backend-claude";
10
- import { getHiddenModelIds } from "../db/settings.js";
2
+ import type { AgentConfig } from "../config/env.js";
11
3
  import type { ProviderInfo } from "@schlessera/brain-ui-sdk";
12
4
  import type {
13
5
  AgentBackend,
@@ -15,9 +7,15 @@ import type {
15
7
  } from "@schlessera/brain-ui-sdk/server";
16
8
 
17
9
  /**
18
- * Backend registry for the deployment. Claude is always available.
19
- * `AGENT_BACKEND=pi` retains the legacy single-backend deployment mode when the
20
- * optional pi package is installed.
10
+ * Backend registry for one app instance. Built by `createApp()` from the
11
+ * resolved configuration no ambient environment and no module-level state,
12
+ * so two apps with different backend configuration coexist in one process and
13
+ * tests install fakes by constructing a registry, not by mutating a module.
14
+ *
15
+ * BOTH backend packages are optional peers loaded lazily (the same
16
+ * `createRequire` path): a deployment installs the one its AGENT_BACKEND
17
+ * names, and the other never has to be present — at runtime AND at
18
+ * type-check time (see the structural mirrors below).
21
19
  *
22
20
  * The old five hardcoded personal inference profiles are gone: the Claude
23
21
  * backend ships the single "claude" default, and additional Anthropic-compatible
@@ -25,16 +23,163 @@ import type {
25
23
  * .env.example) rather than in code.
26
24
  */
27
25
 
28
- const BRAIN_PATH =
29
- process.env.BRAIN_PATH || `${process.env.HOME || "/root"}/brain`;
26
+ /*
27
+ * Structural mirrors of @schlessera/brain-backend-claude.
28
+ *
29
+ * Both backend packages are optional peers, and NOTHING here may reference
30
+ * their specifiers — not even in a type position. A type import (or a
31
+ * `typeof import(...)`) is resolved by TypeScript, so it would either survive
32
+ * into the emitted `.d.ts` (breaking the `types`-condition consumer) or, once
33
+ * scrubbed there, still break the `bun`-condition consumer whose tsconfig
34
+ * follows `src/index.ts` (this repo's own root tsconfig does exactly that).
35
+ * Either way a pi-only TypeScript deployment would need the Claude package —
36
+ * and its Anthropic Agent SDK — installed just to typecheck. Half of F1
37
+ * undone.
38
+ *
39
+ * So the slice of the Claude module this registry drives is mirrored here by
40
+ * hand, in exchange for two mechanical guards in
41
+ * tests/declaration-surface.test.ts: type-level assertions that the REAL
42
+ * module (tests may import it; only src must stay clean) is assignable to
43
+ * these mirrors — so drift fails the build — and a scan proving no backend
44
+ * specifier appears anywhere under src/ or in any emitted declaration.
45
+ * Function members are property-style on purpose: strictFunctionTypes checks
46
+ * them contravariantly, where method syntax would be bivariant and let an
47
+ * incompatible drift slide.
48
+ */
49
+
50
+ /** Mirror of the Claude package's `InferenceProfileInput` (declarative shape). */
51
+ export interface ClaudeProfileInput {
52
+ id: string;
53
+ label: string;
54
+ vendor?: string;
55
+ /** Model id. Undefined = the SDK/CLI default model. */
56
+ model?: string;
57
+ /** Anthropic-compatible endpoint. */
58
+ baseUrl?: string;
59
+ /** Name of the env var holding a bearer token. */
60
+ authTokenEnv?: string;
61
+ /** Name of the env var holding an x-api-key. */
62
+ apiKeyEnv?: string;
63
+ /** Remap the CLI's model-alias envs to `model`. Requires `model`. */
64
+ modelAliases?: boolean;
65
+ allowedTools?: string[];
66
+ /** Context window in tokens, when known. Presentation only. */
67
+ contextWindow?: number;
68
+ /** Where this profile came from. Presentation only. */
69
+ source?: "builtin" | "declared" | "discovered";
70
+ }
71
+
72
+ /** Mirror of the Claude package's resolved `InferenceProfile`. */
73
+ export interface ClaudeProfile {
74
+ id: string;
75
+ label: string;
76
+ vendor?: string;
77
+ model?: string;
78
+ allowedTools?: string[];
79
+ contextWindow?: number;
80
+ source?: "builtin" | "declared" | "discovered";
81
+ /** Env vars that must be present (non-empty) for this profile to be usable. */
82
+ requiredEnvKeys: string[];
83
+ /** Environment overrides merged over the host environment. */
84
+ buildEnv: () => Record<string, string>;
85
+ }
86
+
87
+ /**
88
+ * The slice of the lazily-required Claude module this registry drives. The
89
+ * real module carries more (and looser-optional) options; assignability is
90
+ * asserted in tests/declaration-surface.test.ts.
91
+ */
92
+ export interface ClaudeBackendModule {
93
+ createClaudeBackend: (options: {
94
+ brainPath: string;
95
+ claudeCodePath?: string;
96
+ profiles?: ClaudeProfile[] | (() => ClaudeProfile[]);
97
+ }) => AgentBackend;
98
+ createModelSource: (options: {
99
+ brainPath: string;
100
+ ttlMs?: number;
101
+ enabled?: boolean;
102
+ }) => ClaudeModelSource;
103
+ defineProfiles: (inputs: ClaudeProfileInput[]) => ClaudeProfile[];
104
+ }
105
+
106
+ /** Discovery source as the registry internals see it: the public slice + list(). */
107
+ export interface ClaudeModelSource extends ModelDiscoverySource {
108
+ list: () => ClaudeProfileInput[];
109
+ }
110
+
111
+ /**
112
+ * Discovery freshness, as exposed on the public registry surface.
113
+ *
114
+ * `list()` is omitted on purpose — it returns Claude profile inputs, only the
115
+ * registry internals consume it (see {@link ClaudeModelSource}), and exposing
116
+ * it would put those types on every consumer's plate for no reader.
117
+ */
118
+ export interface ModelDiscoveryState {
119
+ enabled: boolean;
120
+ /** When discovery last succeeded; null when it never has. */
121
+ refreshedAt: number | null;
122
+ /** The cached result is older than the TTL (or absent). */
123
+ stale: boolean;
124
+ /** Last discovery failure, if the current list is served despite one. */
125
+ error?: string;
126
+ }
30
127
 
31
- // The Claude Code native binary is not in node_modules it is installed via
32
- // the official curl-bash installer (~/.local/bin/claude, symlinked to
33
- // /usr/local/bin/claude in the container). The Agent SDK can't always
34
- // auto-discover it, so hand it the explicit path; override with CLAUDE_CODE_PATH.
35
- const CLAUDE_CODE_PATH = process.env.CLAUDE_CODE_PATH || "/usr/local/bin/claude";
128
+ /** The slice of the Claude backend's discovery source the routes consume. */
129
+ export interface ModelDiscoverySource {
130
+ state(): ModelDiscoveryState;
131
+ /** Refresh if stale. Awaits only when there is nothing cached to serve. */
132
+ ensureFresh(): Promise<void>;
133
+ /** Force a refresh regardless of TTL. Rejects on failure. */
134
+ refresh(): Promise<void>;
135
+ }
36
136
 
37
- interface BackendRegistry {
137
+ export interface BackendRegistryOptions {
138
+ /** Where the brain repo lives (handed to every backend). */
139
+ brainPath: string;
140
+ /** Resolved agent configuration (AGENT_BACKEND, profiles, discovery...). */
141
+ agent: AgentConfig;
142
+ /**
143
+ * Profile ids the user keeps out of the model picker (from the app's
144
+ * settings table). Presentation only — a hidden profile still resolves for
145
+ * sessions pinned to it. Defaults to "nothing hidden".
146
+ */
147
+ getHiddenModelIds?: () => string[];
148
+ }
149
+
150
+ export interface BackendRegistry {
151
+ /** All configured backends, with the default backend first. */
152
+ getBackends(): Promise<AgentBackend[]>;
153
+ /** Find a configured backend by its stable id. */
154
+ getBackendById(id: string): Promise<AgentBackend | undefined>;
155
+ /** The default backend used for new and legacy sessions. */
156
+ getDefaultBackend(): Promise<AgentBackend>;
157
+ /** The stable id of the default backend. */
158
+ getDefaultBackendId(): Promise<string>;
159
+ /** Resolve the backend that owns a globally unique profile id. */
160
+ getBackendForProfile(profileId: string): Promise<AgentBackend | undefined>;
161
+ /** Resolve a persisted backend id, falling back for legacy/unknown sessions. */
162
+ getBackendForSession(backendId: string | null | undefined): Promise<AgentBackend>;
163
+ /**
164
+ * Every available profile, tagged with its owning backend id. Hidden
165
+ * profiles are omitted by default (this feeds the picker); the settings
166
+ * screen passes `includeHidden` to render the full catalog.
167
+ */
168
+ listAllProviders(options?: { includeHidden?: boolean }): Promise<ProviderInfo[]>;
169
+ /** Per-backend capability metadata exposed by the providers route. */
170
+ getBackendsInfo(): Promise<
171
+ Record<string, { id: string; capabilities: BackendCapabilities }>
172
+ >;
173
+ /**
174
+ * The Claude backend's discovery source, once the registry is built. Null
175
+ * when discovery is disabled or the deployment runs a different backend.
176
+ */
177
+ getModelSource(): Promise<ModelDiscoverySource | null>;
178
+ /** Drop the profile memo so the next read reflects a refresh or settings change. */
179
+ invalidateProfiles(): void;
180
+ }
181
+
182
+ interface RegistrySnapshot {
38
183
  backends: AgentBackend[];
39
184
  byId: Map<string, AgentBackend>;
40
185
  defaultBackendId: string;
@@ -53,14 +198,10 @@ interface ProfileSnapshot {
53
198
 
54
199
  const PROFILE_MEMO_MS = 5_000;
55
200
 
56
- let cachedRegistry: Promise<BackendRegistry> | null = null;
57
- let profileSnapshot: ProfileSnapshot | null = null;
58
- let modelSource: ModelSource | null = null;
59
-
60
- async function createRegistry(
201
+ function buildSnapshot(
61
202
  backends: AgentBackend[],
62
203
  defaultBackendId: string
63
- ): Promise<BackendRegistry> {
204
+ ): RegistrySnapshot {
64
205
  if (backends.length === 0) {
65
206
  throw new Error("Backend registry requires at least one backend.");
66
207
  }
@@ -77,367 +218,395 @@ async function createRegistry(
77
218
  return { backends: ordered, byId, defaultBackendId: resolvedDefaultId };
78
219
  }
79
220
 
80
- /** Recompute the profile roster (memoized), asserting cross-backend id uniqueness. */
81
- async function getProfileSnapshot(): Promise<ProfileSnapshot> {
82
- if (profileSnapshot && Date.now() - profileSnapshot.at < PROFILE_MEMO_MS) {
83
- return profileSnapshot;
84
- }
85
221
 
86
- const registry = await getRegistry();
87
- const byBackend = new Map<string, ProviderInfo[]>();
88
- const owners = new Map<string, string>();
222
+ const BACKEND_SPECIFIERS = {
223
+ claude: "@schlessera/brain-backend-claude",
224
+ pi: "@schlessera/brain-backend-pi",
225
+ } as const;
89
226
 
90
- for (const backend of registry.backends) {
91
- const profiles = await backend.listProfiles();
92
- byBackend.set(backend.id, profiles);
93
- for (const profile of profiles) {
94
- const owner = owners.get(profile.id);
95
- if (owner && owner !== backend.id) {
96
- throw new Error(
97
- `Profile id collision across backends: "${profile.id}" is exposed by "${owner}" and "${backend.id}".`
98
- );
99
- }
100
- owners.set(profile.id, backend.id);
101
- }
102
- }
103
-
104
- profileSnapshot = { at: Date.now(), byBackend, owners };
105
- return profileSnapshot;
106
- }
107
-
108
- /** Drop the memo so the next read reflects a refresh or a settings change. */
109
- export function invalidateProfiles(): void {
110
- profileSnapshot = null;
227
+ function unknownBackendError(primary: string): Error {
228
+ return new Error(
229
+ `AGENT_BACKEND="${primary}" does not match any configured backend ` +
230
+ `(expected "claude" or "pi").`
231
+ );
111
232
  }
112
233
 
113
- /**
114
- * The Claude backend's discovery source, once the registry is built. Null when
115
- * discovery is disabled or the deployment runs a different backend.
116
- */
117
- export async function getModelSource(): Promise<ModelSource | null> {
118
- await getRegistry();
119
- return modelSource;
234
+ function missingBackendError(primary: "claude" | "pi"): Error {
235
+ if (primary === "pi") {
236
+ return new Error(
237
+ 'AGENT_BACKEND=pi but "@schlessera/brain-backend-pi" is not installed. ' +
238
+ "Add it (with its pi SDK dependencies) or set AGENT_BACKEND=claude."
239
+ );
240
+ }
241
+ return new Error(
242
+ `AGENT_BACKEND=${primary} but "@schlessera/brain-backend-claude" is not installed. ` +
243
+ "Add it (it carries the Claude Agent SDK) or set AGENT_BACKEND=pi."
244
+ );
120
245
  }
121
246
 
122
247
  /**
123
- * Hidden profile ids, from the settings table. Presentation only a hidden
124
- * profile still resolves for sessions pinned to it. A db read failure must not
125
- * take the picker down, so it degrades to "nothing hidden".
248
+ * Boot-time guard: the SELECTED backend's package must at least RESOLVE, so a
249
+ * broken install refuses to start instead of reporting healthy and failing
250
+ * every agent turn (the guarantee the old static import gave, restored without
251
+ * giving up the lazy load — `require.resolve` never executes the module, so
252
+ * the Agent SDK still loads on first use only). Called by `createApp()` next
253
+ * to the auth assertions; skipped when an explicit registry is injected.
254
+ *
255
+ * An unrecognized AGENT_BACKEND fails here too, for the same reason.
256
+ *
257
+ * The `resolve` parameter exists for tests (simulating an absent package);
258
+ * production callers pass nothing.
126
259
  */
127
- function hiddenIds(): Set<string> {
260
+ export function assertBackendResolvable(
261
+ agent: AgentConfig,
262
+ resolve: (specifier: string) => void = (specifier) => {
263
+ createRequire(import.meta.url).resolve(specifier);
264
+ }
265
+ ): void {
266
+ const primary = agent.backend || "claude";
267
+ if (!(primary in BACKEND_SPECIFIERS)) throw unknownBackendError(primary);
268
+ const key = primary as keyof typeof BACKEND_SPECIFIERS;
128
269
  try {
129
- return new Set(getHiddenModelIds());
130
- } catch (err) {
131
- console.warn(
132
- `[models] Could not read hidden models: ${
133
- err instanceof Error ? err.message : String(err)
134
- }`
135
- );
136
- return new Set();
270
+ resolve(BACKEND_SPECIFIERS[key]);
271
+ } catch {
272
+ throw missingBackendError(key);
137
273
  }
138
274
  }
139
275
 
140
- /**
141
- * Model discovery is on unless explicitly disabled — except under a test
142
- * runner, where the default flips to off: discovery is a live call to the
143
- * Anthropic API, and a suite that silently depends on network access (and on
144
- * whatever credentials the developer happens to have exported) is flaky by
145
- * construction. Tests that want it set BRAIN_UI_MODEL_DISCOVERY=1 explicitly.
146
- */
147
- function modelDiscoveryEnabled(): boolean {
148
- const raw = process.env.BRAIN_UI_MODEL_DISCOVERY?.trim().toLowerCase();
149
- if (raw) return !(raw === "0" || raw === "off" || raw === "false");
150
- return process.env.NODE_ENV !== "test";
151
- }
276
+ export function createBackendRegistry(
277
+ options: BackendRegistryOptions
278
+ ): BackendRegistry {
279
+ const { brainPath, agent } = options;
280
+ const getHidden = options.getHiddenModelIds ?? (() => []);
281
+
282
+ let cachedRegistry: Promise<RegistrySnapshot> | null = null;
283
+ let modelSource: ClaudeModelSource | null = null;
284
+
285
+ /**
286
+ * Declared profiles (built-in default + BRAIN_UI_CLAUDE_PROFILES) plus every
287
+ * discovered model whose id isn't already declared. Declared wins: the env
288
+ * stays an override mechanism, and a hand-pinned entry keeps its label and
289
+ * endpoint.
290
+ *
291
+ * Memoized on the discovered array's identity — `createModelSource` swaps the
292
+ * array only on a successful refresh, so steady state is one comparison.
293
+ */
294
+ let mergeCache: {
295
+ declared: ClaudeProfile[];
296
+ discovered: ClaudeProfileInput[];
297
+ result: ClaudeProfile[];
298
+ } | null = null;
299
+
300
+ function mergeDiscovered(
301
+ claude: ClaudeBackendModule,
302
+ declared: ClaudeProfile[],
303
+ discovered: ClaudeProfileInput[]
304
+ ): ClaudeProfile[] {
305
+ if (
306
+ mergeCache &&
307
+ mergeCache.declared === declared &&
308
+ mergeCache.discovered === discovered
309
+ ) {
310
+ return mergeCache.result;
311
+ }
152
312
 
153
- /** How long a discovery result stays fresh. Default 24h. */
154
- function modelTtlMs(): number {
155
- const raw = Number(process.env.BRAIN_UI_MODEL_TTL_HOURS);
156
- const hours = Number.isFinite(raw) && raw > 0 ? raw : 24;
157
- return hours * 60 * 60 * 1000;
158
- }
313
+ const declaredIds = new Set(declared.map((profile) => profile.id));
314
+ const extra = discovered.filter((input) => !declaredIds.has(input.id));
315
+ const result = [...declared, ...claude.defineProfiles(extra)];
316
+ mergeCache = { declared, discovered, result };
317
+ return result;
318
+ }
159
319
 
160
- /**
161
- * Declared profiles (built-in default + BRAIN_UI_CLAUDE_PROFILES) plus every
162
- * discovered model whose id isn't already declared. Declared wins: the env stays
163
- * an override mechanism, and a hand-pinned entry keeps its label and endpoint.
164
- *
165
- * Memoized on the discovered array's identity `createModelSource` swaps the
166
- * array only on a successful refresh, so steady state is one comparison.
167
- */
168
- let mergeCache: {
169
- declared: InferenceProfile[];
170
- discovered: InferenceProfileInput[];
171
- result: InferenceProfile[];
172
- } | null = null;
173
-
174
- function mergeDiscovered(
175
- declared: InferenceProfile[],
176
- discovered: InferenceProfileInput[]
177
- ): InferenceProfile[] {
178
- if (
179
- mergeCache &&
180
- mergeCache.declared === declared &&
181
- mergeCache.discovered === discovered
182
- ) {
183
- return mergeCache.result;
320
+ // The built-in default profile's model. The Claude backend historically
321
+ // pinned the default to a specific model; after the SDK extraction the pin
322
+ // was lost and resumed default-profile sessions drifted to "whatever the CLI
323
+ // defaults to", silently changing model/behaviour/billing on every CLI bump.
324
+ // Re-pin it (a committed default, overridable per deploy via
325
+ // BRAIN_UI_CLAUDE_DEFAULT_MODEL) so the default stays on a known model.
326
+ function builtinDefaultProfiles(claude: ClaudeBackendModule): ClaudeProfile[] {
327
+ return claude.defineProfiles([
328
+ {
329
+ id: "claude",
330
+ label: "Claude",
331
+ vendor: "anthropic",
332
+ model: agent.defaultModel,
333
+ modelAliases: true,
334
+ source: "builtin",
335
+ },
336
+ ]);
184
337
  }
185
338
 
186
- const declaredIds = new Set(declared.map((profile) => profile.id));
187
- const extra = discovered.filter((input) => !declaredIds.has(input.id));
188
- const result = [...declared, ...defineProfiles(extra)];
189
- mergeCache = { declared, discovered, result };
190
- return result;
191
- }
339
+ /**
340
+ * The Claude roster: the pinned built-in "claude" default first, plus any
341
+ * extra profiles from BRAIN_UI_CLAUDE_PROFILES (a JSON array of profile
342
+ * inputs: {id,label,model?,baseUrl?,authTokenEnv?,apiKeyEnv?,modelAliases?}).
343
+ *
344
+ * A malformed or duplicate-id roster THROWS — caught at boot by the registry
345
+ * fail-fast — rather than silently degrading to default-only and rebilling
346
+ * every pinned session to the subscription with nothing in the logs.
347
+ */
348
+ function loadClaudeProfiles(claude: ClaudeBackendModule): ClaudeProfile[] {
349
+ const base = builtinDefaultProfiles(claude);
350
+ const raw = agent.profilesJson;
351
+ if (!raw) return base;
352
+
353
+ let inputs: unknown;
354
+ try {
355
+ inputs = JSON.parse(raw);
356
+ } catch (err) {
357
+ throw new Error(
358
+ `BRAIN_UI_CLAUDE_PROFILES is not valid JSON: ${
359
+ err instanceof Error ? err.message : String(err)
360
+ }`
361
+ );
362
+ }
363
+ if (!Array.isArray(inputs)) {
364
+ throw new Error("BRAIN_UI_CLAUDE_PROFILES must be a JSON array.");
365
+ }
192
366
 
193
- async function buildRegistry(): Promise<BackendRegistry> {
194
- const primary = (process.env.AGENT_BACKEND || "claude")
195
- .trim()
196
- .toLowerCase();
367
+ // Reject duplicate ids (including collisions with the built-in "claude"): a
368
+ // duplicate silently shadows and can resolve to the wrong credentials.
369
+ const seen = new Set(base.map((profile) => profile.id));
370
+ for (const input of inputs as ClaudeProfileInput[]) {
371
+ if (!input || typeof input.id !== "string" || input.id.length === 0) {
372
+ throw new Error(
373
+ "Each BRAIN_UI_CLAUDE_PROFILES entry needs a non-empty string id."
374
+ );
375
+ }
376
+ if (seen.has(input.id)) {
377
+ throw new Error(
378
+ `Duplicate profile id in BRAIN_UI_CLAUDE_PROFILES: "${input.id}".`
379
+ );
380
+ }
381
+ seen.add(input.id);
382
+ }
197
383
 
198
- if (primary === "pi") {
199
- const pi = buildPiBackend();
200
- return createRegistry([pi], pi.id);
384
+ const declared = (inputs as ClaudeProfileInput[]).map((input) => ({
385
+ source: "declared" as const,
386
+ ...input,
387
+ }));
388
+ return [...base, ...claude.defineProfiles(declared)];
201
389
  }
202
390
 
203
- // Declared profiles are resolved EAGERLY so a malformed
204
- // BRAIN_UI_CLAUDE_PROFILES still fails at boot rather than on first request.
205
- const declared = loadClaudeProfiles();
206
-
207
- modelSource = createModelSource({
208
- brainPath: BRAIN_PATH,
209
- enabled: modelDiscoveryEnabled(),
210
- ttlMs: modelTtlMs(),
211
- });
391
+ /**
392
+ * NEITHER backend package is a hard dependency a deployment installs the
393
+ * one its AGENT_BACKEND names (both, if it switches). Loaded lazily through
394
+ * the same `createRequire` path so the server still boots without the unused
395
+ * one; when the configured backend's package is absent, fail with an
396
+ * actionable message.
397
+ */
398
+ function buildClaudeBackend(): AgentBackend {
399
+ const require = createRequire(import.meta.url);
400
+ let claude: ClaudeBackendModule;
401
+ try {
402
+ claude = require("@schlessera/brain-backend-claude");
403
+ } catch {
404
+ throw missingBackendError("claude");
405
+ }
406
+ if (typeof claude.createClaudeBackend !== "function") {
407
+ throw new Error(
408
+ '"@schlessera/brain-backend-claude" does not export createClaudeBackend.'
409
+ );
410
+ }
212
411
 
213
- const claude = createClaudeBackend({
214
- brainPath: BRAIN_PATH,
215
- claudeCodePath: CLAUDE_CODE_PATH,
216
- // A function, not an array: discovery refreshes in the background and the
217
- // new roster has to be visible without restarting the process.
218
- profiles: () => mergeDiscovered(declared, modelSource?.list() ?? []),
219
- });
220
- const backends = [claude];
221
-
222
- // AGENT_BACKEND must name a configured backend. The in-process options are
223
- // "claude" (default) and "pi" (handled above). An unrecognized value is a
224
- // misconfiguration — fail loudly instead of silently coercing to claude and
225
- // running on the wrong backend with nothing in the logs.
226
- if (process.env.AGENT_BACKEND && !backends.some((b) => b.id === primary)) {
227
- throw new Error(
228
- `AGENT_BACKEND="${primary}" does not match any configured backend ` +
229
- `(expected "claude" or "pi").`
230
- );
412
+ // Declared profiles are resolved EAGERLY so a malformed
413
+ // BRAIN_UI_CLAUDE_PROFILES still fails at boot rather than on first request.
414
+ const declared = loadClaudeProfiles(claude);
415
+
416
+ modelSource = claude.createModelSource({
417
+ brainPath,
418
+ enabled: agent.modelDiscovery,
419
+ ttlMs: agent.modelTtlMs,
420
+ });
421
+
422
+ return claude.createClaudeBackend({
423
+ brainPath,
424
+ claudeCodePath: agent.claudeCodePath,
425
+ // A function, not an array: discovery refreshes in the background and the
426
+ // new roster has to be visible without restarting the process.
427
+ profiles: () =>
428
+ mergeDiscovered(claude, declared, modelSource?.list() ?? []),
429
+ });
231
430
  }
232
- return createRegistry(backends, primary);
233
- }
234
-
235
- async function getRegistry(): Promise<BackendRegistry> {
236
- if (!cachedRegistry) cachedRegistry = buildRegistry();
237
- return cachedRegistry;
238
- }
239
431
 
240
- /** All configured backends, with the default backend first. */
241
- export async function getBackends(): Promise<AgentBackend[]> {
242
- return (await getRegistry()).backends;
243
- }
432
+ function buildPiBackend(): AgentBackend {
433
+ const require = createRequire(import.meta.url);
434
+ let mod: { createPiBackend?: (opts: { brainPath: string }) => AgentBackend };
435
+ try {
436
+ mod = require("@schlessera/brain-backend-pi");
437
+ } catch {
438
+ throw missingBackendError("pi");
439
+ }
440
+ if (typeof mod.createPiBackend !== "function") {
441
+ throw new Error(
442
+ '"@schlessera/brain-backend-pi" does not export createPiBackend.'
443
+ );
444
+ }
445
+ return mod.createPiBackend({ brainPath });
446
+ }
244
447
 
245
- /** Find a configured backend by its stable id. */
246
- export async function getBackendById(
247
- id: string
248
- ): Promise<AgentBackend | undefined> {
249
- return (await getRegistry()).byId.get(id);
250
- }
448
+ async function buildRegistry(): Promise<RegistrySnapshot> {
449
+ const primary = agent.backend || "claude";
251
450
 
252
- /** The default backend used for new and legacy sessions. */
253
- export async function getDefaultBackend(): Promise<AgentBackend> {
254
- const registry = await getRegistry();
255
- return registry.byId.get(registry.defaultBackendId)!;
256
- }
451
+ if (primary === "pi") {
452
+ const pi = buildPiBackend();
453
+ return buildSnapshot([pi], pi.id);
454
+ }
257
455
 
258
- /** The stable id of the default backend. */
259
- export async function getDefaultBackendId(): Promise<string> {
260
- return (await getRegistry()).defaultBackendId;
261
- }
456
+ const backends = [buildClaudeBackend()];
262
457
 
263
- /** Resolve the backend that owns a globally unique profile id. */
264
- export async function getBackendForProfile(
265
- profileId: string
266
- ): Promise<AgentBackend | undefined> {
267
- const registry = await getRegistry();
268
- // Deliberately resolved against the UNFILTERED roster: a session pinned to a
269
- // profile the user later hid must keep running.
270
- const snapshot = await getProfileSnapshot();
271
- const backendId = snapshot.owners.get(profileId);
272
- return backendId ? registry.byId.get(backendId) : undefined;
273
- }
458
+ // AGENT_BACKEND must name a configured backend. The in-process options are
459
+ // "claude" (default) and "pi" (handled above). An unrecognized value is a
460
+ // misconfiguration — fail loudly instead of silently coercing to claude and
461
+ // running on the wrong backend with nothing in the logs.
462
+ if (agent.backend && !backends.some((b) => b.id === primary)) {
463
+ throw unknownBackendError(primary);
464
+ }
465
+ return buildSnapshot(backends, primary);
466
+ }
274
467
 
275
- /** Resolve a persisted backend id, falling back for legacy/unknown sessions. */
276
- export async function getBackendForSession(
277
- backendId: string | null | undefined
278
- ): Promise<AgentBackend> {
279
- if (backendId) {
280
- const backend = await getBackendById(backendId);
281
- if (backend) return backend;
468
+ async function getRegistry(): Promise<RegistrySnapshot> {
469
+ if (!cachedRegistry) cachedRegistry = buildRegistry();
470
+ return cachedRegistry;
282
471
  }
283
- return getDefaultBackend();
472
+
473
+ return makeRegistry(getRegistry, getHidden, async () => {
474
+ await getRegistry();
475
+ return modelSource;
476
+ });
284
477
  }
285
478
 
286
479
  /**
287
- * Every available profile, tagged with its owning backend id.
288
- *
289
- * Hidden profiles are omitted by default (this feeds the picker); the settings
290
- * screen passes `includeHidden` to render the full catalog.
480
+ * A registry over an explicit backend list the seam tests (and embedders
481
+ * with their own backend wiring) use instead of mutating module state. Takes
482
+ * the same optional hidden-ids reader so visibility behavior matches
483
+ * production.
291
484
  */
292
- export async function listAllProviders(
293
- options: { includeHidden?: boolean } = {}
294
- ): Promise<ProviderInfo[]> {
295
- const registry = await getRegistry();
296
- const snapshot = await getProfileSnapshot();
297
- const hidden = options.includeHidden ? new Set<string>() : hiddenIds();
298
-
299
- return registry.backends.flatMap((backend) =>
300
- (snapshot.byBackend.get(backend.id) ?? [])
301
- .filter((profile) => !hidden.has(profile.id))
302
- .map((profile) => ({ ...profile, backendId: backend.id }))
485
+ export function createStaticBackendRegistry(
486
+ backends: AgentBackend[],
487
+ defaultBackendId = backends[0]?.id ?? "",
488
+ options: { getHiddenModelIds?: () => string[] } = {}
489
+ ): BackendRegistry {
490
+ const snapshot = buildSnapshot(backends, defaultBackendId);
491
+ return makeRegistry(
492
+ async () => snapshot,
493
+ options.getHiddenModelIds ?? (() => []),
494
+ async () => null
303
495
  );
304
496
  }
305
497
 
306
- /** Per-backend capability metadata exposed by the providers route. */
307
- export async function getBackendsInfo(): Promise<
308
- Record<string, { id: string; capabilities: BackendCapabilities }>
309
- > {
310
- const backends = await getBackends();
311
- return Object.fromEntries(
312
- backends.map((backend) => [
313
- backend.id,
314
- { id: backend.id, capabilities: backend.capabilities },
315
- ])
316
- );
317
- }
498
+ /** The accessor surface, shared by the config-driven and static registries. */
499
+ function makeRegistry(
500
+ getRegistry: () => Promise<RegistrySnapshot>,
501
+ getHidden: () => string[],
502
+ getModelSource: () => Promise<ModelDiscoverySource | null>
503
+ ): BackendRegistry {
504
+ let profileSnapshot: ProfileSnapshot | null = null;
505
+
506
+ /** Recompute the profile roster (memoized), asserting cross-backend id uniqueness. */
507
+ async function getProfileSnapshot(): Promise<ProfileSnapshot> {
508
+ if (profileSnapshot && Date.now() - profileSnapshot.at < PROFILE_MEMO_MS) {
509
+ return profileSnapshot;
510
+ }
318
511
 
319
- /** @deprecated Use getDefaultBackend() or a routing-specific accessor. */
320
- export async function getBackend(): Promise<AgentBackend> {
321
- return getDefaultBackend();
322
- }
512
+ const registry = await getRegistry();
513
+ const byBackend = new Map<string, ProviderInfo[]>();
514
+ const owners = new Map<string, string>();
515
+
516
+ for (const backend of registry.backends) {
517
+ const profiles = await backend.listProfiles();
518
+ byBackend.set(backend.id, profiles);
519
+ for (const profile of profiles) {
520
+ const owner = owners.get(profile.id);
521
+ if (owner && owner !== backend.id) {
522
+ throw new Error(
523
+ `Profile id collision across backends: "${profile.id}" is exposed by "${owner}" and "${backend.id}".`
524
+ );
525
+ }
526
+ owners.set(profile.id, backend.id);
527
+ }
528
+ }
323
529
 
324
- /** Test seam: drop the cached registry so later access rebuilds from env. */
325
- export function resetBackendForTests(): void {
326
- cachedRegistry = null;
327
- profileSnapshot = null;
328
- modelSource = null;
329
- }
530
+ profileSnapshot = { at: Date.now(), byBackend, owners };
531
+ return profileSnapshot;
532
+ }
330
533
 
331
- /** Test seam: install one fake backend as the complete registry. */
332
- export function setBackendForTests(backend: AgentBackend): void {
333
- cachedRegistry = createRegistry([backend], backend.id);
334
- profileSnapshot = null;
335
- }
534
+ /** A settings read failure must not take the picker down — degrade to "nothing hidden". */
535
+ function hiddenIds(): Set<string> {
536
+ try {
537
+ return new Set(getHidden());
538
+ } catch (err) {
539
+ console.warn(
540
+ `[models] Could not read hidden models: ${
541
+ err instanceof Error ? err.message : String(err)
542
+ }`
543
+ );
544
+ return new Set();
545
+ }
546
+ }
336
547
 
337
- /** Test seam: install a complete fake registry with an explicit default. */
338
- export function setBackendsForTests(
339
- backends: AgentBackend[],
340
- defaultBackendId = backends[0]?.id ?? ""
341
- ): void {
342
- cachedRegistry = createRegistry(backends, defaultBackendId);
343
- profileSnapshot = null;
344
- }
548
+ return {
549
+ async getBackends() {
550
+ return (await getRegistry()).backends;
551
+ },
345
552
 
346
- // The built-in default profile's model. The Claude backend historically pinned
347
- // the default to a specific model; after the SDK extraction the pin was lost and
348
- // resumed default-profile sessions drifted to "whatever the CLI defaults to",
349
- // silently changing model/behaviour/billing on every CLI bump. Re-pin it here
350
- // (a committed default, overridable per deploy via BRAIN_UI_CLAUDE_DEFAULT_MODEL)
351
- // so the default stays on a known model.
352
- const BUILTIN_DEFAULT_MODEL =
353
- process.env.BRAIN_UI_CLAUDE_DEFAULT_MODEL?.trim() || "claude-sonnet-4-6";
354
-
355
- function builtinDefaultProfiles(): InferenceProfile[] {
356
- return defineProfiles([
357
- {
358
- id: "claude",
359
- label: "Claude",
360
- vendor: "anthropic",
361
- model: BUILTIN_DEFAULT_MODEL,
362
- modelAliases: true,
363
- source: "builtin",
553
+ async getBackendById(id) {
554
+ return (await getRegistry()).byId.get(id);
364
555
  },
365
- ]);
366
- }
367
556
 
368
- /**
369
- * The Claude roster: the pinned built-in "claude" default first, plus any extra
370
- * profiles from BRAIN_UI_CLAUDE_PROFILES (a JSON array of InferenceProfileInput
371
- * {id,label,model?,baseUrl?,authTokenEnv?,apiKeyEnv?,modelAliases?}).
372
- *
373
- * A malformed or duplicate-id roster THROWS — caught at boot by the registry
374
- * fail-fast — rather than silently degrading to default-only and rebilling
375
- * every pinned session to the subscription with nothing in the logs.
376
- */
377
- function loadClaudeProfiles(): InferenceProfile[] {
378
- const base = builtinDefaultProfiles();
379
- const raw = process.env.BRAIN_UI_CLAUDE_PROFILES?.trim();
380
- if (!raw) return base;
557
+ async getDefaultBackend() {
558
+ const registry = await getRegistry();
559
+ return registry.byId.get(registry.defaultBackendId)!;
560
+ },
381
561
 
382
- let inputs: unknown;
383
- try {
384
- inputs = JSON.parse(raw);
385
- } catch (err) {
386
- throw new Error(
387
- `BRAIN_UI_CLAUDE_PROFILES is not valid JSON: ${
388
- err instanceof Error ? err.message : String(err)
389
- }`
390
- );
391
- }
392
- if (!Array.isArray(inputs)) {
393
- throw new Error("BRAIN_UI_CLAUDE_PROFILES must be a JSON array.");
394
- }
562
+ async getDefaultBackendId() {
563
+ return (await getRegistry()).defaultBackendId;
564
+ },
395
565
 
396
- // Reject duplicate ids (including collisions with the built-in "claude"): a
397
- // duplicate silently shadows and can resolve to the wrong credentials.
398
- const seen = new Set(base.map((profile) => profile.id));
399
- for (const input of inputs as InferenceProfileInput[]) {
400
- if (!input || typeof input.id !== "string" || input.id.length === 0) {
401
- throw new Error(
402
- "Each BRAIN_UI_CLAUDE_PROFILES entry needs a non-empty string id."
566
+ async getBackendForProfile(profileId) {
567
+ const registry = await getRegistry();
568
+ // Deliberately resolved against the UNFILTERED roster: a session pinned
569
+ // to a profile the user later hid must keep running.
570
+ const snapshot = await getProfileSnapshot();
571
+ const backendId = snapshot.owners.get(profileId);
572
+ return backendId ? registry.byId.get(backendId) : undefined;
573
+ },
574
+
575
+ async getBackendForSession(backendId) {
576
+ const registry = await getRegistry();
577
+ if (backendId) {
578
+ const backend = registry.byId.get(backendId);
579
+ if (backend) return backend;
580
+ }
581
+ return registry.byId.get(registry.defaultBackendId)!;
582
+ },
583
+
584
+ async listAllProviders(options = {}) {
585
+ const registry = await getRegistry();
586
+ const snapshot = await getProfileSnapshot();
587
+ const hidden = options.includeHidden ? new Set<string>() : hiddenIds();
588
+
589
+ return registry.backends.flatMap((backend) =>
590
+ (snapshot.byBackend.get(backend.id) ?? [])
591
+ .filter((profile) => !hidden.has(profile.id))
592
+ .map((profile) => ({ ...profile, backendId: backend.id }))
403
593
  );
404
- }
405
- if (seen.has(input.id)) {
406
- throw new Error(
407
- `Duplicate profile id in BRAIN_UI_CLAUDE_PROFILES: "${input.id}".`
594
+ },
595
+
596
+ async getBackendsInfo() {
597
+ const backends = (await getRegistry()).backends;
598
+ return Object.fromEntries(
599
+ backends.map((backend) => [
600
+ backend.id,
601
+ { id: backend.id, capabilities: backend.capabilities },
602
+ ])
408
603
  );
409
- }
410
- seen.add(input.id);
411
- }
604
+ },
412
605
 
413
- const declared = (inputs as InferenceProfileInput[]).map((input) => ({
414
- source: "declared" as const,
415
- ...input,
416
- }));
417
- return [...base, ...defineProfiles(declared)];
418
- }
606
+ getModelSource,
419
607
 
420
- /**
421
- * Optional pi backend. @schlessera/brain-backend-pi is NOT a hard dependency — a
422
- * deployment that never opts into it does not need it (or its pi SDK peer deps)
423
- * installed. Loaded lazily so the server still boots without it; when requested
424
- * but absent, fail with an actionable message.
425
- */
426
- function buildPiBackend(): AgentBackend {
427
- const require = createRequire(import.meta.url);
428
- let mod: { createPiBackend?: (opts: { brainPath: string }) => AgentBackend };
429
- try {
430
- mod = require("@schlessera/brain-backend-pi");
431
- } catch {
432
- throw new Error(
433
- 'AGENT_BACKEND=pi but "@schlessera/brain-backend-pi" is not installed. ' +
434
- "Add it (with its pi SDK dependencies) or set AGENT_BACKEND=claude."
435
- );
436
- }
437
- if (typeof mod.createPiBackend !== "function") {
438
- throw new Error(
439
- '"@schlessera/brain-backend-pi" does not export createPiBackend.'
440
- );
441
- }
442
- return mod.createPiBackend({ brainPath: BRAIN_PATH });
608
+ invalidateProfiles() {
609
+ profileSnapshot = null;
610
+ },
611
+ };
443
612
  }