@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
package/src/app.ts CHANGED
@@ -1,38 +1,62 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Context } from "hono";
3
+ import type { Database } from "bun:sqlite";
3
4
  import { logger } from "hono/logger";
4
5
  import { cors } from "hono/cors";
5
6
  import { serveStatic } from "hono/bun";
6
7
  import { join } from "path";
7
- import { healthRoutes, statusRoutes } from "./routes/health.js";
8
- import { brainRoutes } from "./routes/brain.js";
9
- import { sessionRoutes } from "./routes/sessions.js";
10
- import { voiceRoutes } from "./routes/voice.js";
11
- import { filesRoutes } from "./routes/files.js";
12
- import { shareRoutes, shareTargetFallbackRoutes } from "./routes/share.js";
8
+ import { resolveServerConfig, type ServerConfig } from "./config/env.js";
9
+ import { healthRoutes, createStatusRoutes } from "./routes/health.js";
10
+ import { createBrainRoutes } from "./routes/brain.js";
11
+ import { createSessionRoutes } from "./routes/sessions.js";
12
+ import { createVoiceRoutes } from "./routes/voice.js";
13
+ import { createFilesRoutes } from "./routes/files.js";
14
+ import { createShareRoutes, shareTargetFallbackRoutes } from "./routes/share.js";
13
15
  import { createRenderRoutes, type AppRenderer } from "./routes/render.js";
14
- import { providerRoutes } from "./routes/providers.js";
15
- import { modelRoutes } from "./routes/models.js";
16
- import { graphRoutes } from "./routes/graph.js";
16
+ import { createProviderRoutes } from "./routes/providers.js";
17
+ import { createModelRoutes } from "./routes/models.js";
18
+ import { createGraphRoutes } from "./routes/graph.js";
17
19
  import {
18
20
  resolveAuthMode,
19
21
  assertAuthConfig,
20
22
  authGuard,
21
23
  authRoutes,
22
24
  isWsAuthorized,
25
+ type AuthMode,
26
+ type AuthRuntime,
23
27
  } from "./middleware/auth.js";
24
28
  import {
25
29
  passkeyPublicRoutes,
26
30
  passkeyManagementRoutes,
27
31
  passwordLoginDisabled,
28
32
  assertPasskeyConfig,
33
+ type PasskeyContext,
29
34
  } from "./middleware/passkeys.js";
30
- import { configureDb } from "./db/client.js";
31
- import { configureWsHost, wsUpgrade, websocket } from "./ws/handler.js";
35
+ import { createUiDb } from "./db/client.js";
36
+ import { getHiddenModelIds } from "./db/settings.js";
37
+ import {
38
+ assertBackendResolvable,
39
+ createBackendRegistry,
40
+ type BackendRegistry,
41
+ } from "./agent/backend.js";
42
+ import { createBrainClient } from "./brain/client.js";
43
+ import { createCronScheduler } from "./cron/scheduler.js";
44
+ import { WsHost } from "./ws/host.js";
45
+ import { createWsUpgrade, websocket } from "./ws/connection.js";
46
+ import { createSessionCatalog } from "./ws/session-catalog.js";
47
+ import type { KeytermSettings } from "./voice/keyterm-builder.js";
32
48
 
33
49
  export type { AppRenderer };
34
50
 
35
51
  export interface CreateAppOptions {
52
+ /**
53
+ * Fully-resolved configuration. When omitted, `createApp` resolves it from
54
+ * the process environment ONCE, here at the edge — nothing deeper in the
55
+ * package touches the ambient environment. Pass an explicit object (e.g. from
56
+ * `resolveServerConfig(customEnv)`) to run two differently-configured apps
57
+ * in one process or to vary configuration in tests without env mutation.
58
+ */
59
+ config?: ServerConfig;
36
60
  /**
37
61
  * Directory of a built SPA to serve at `/*` with an index.html fallback.
38
62
  * The deployment shell decides whether (and what) to serve — the package
@@ -41,7 +65,11 @@ export interface CreateAppOptions {
41
65
  staticRoot?: string;
42
66
  /** Display name used in connection/status copy. Default "Brain UI". */
43
67
  appName?: string;
44
- /** SQLite path override; falls back to DB_PATH, then ./brain-ui.db. */
68
+ /**
69
+ * SQLite path override; folded into the effective config, so `app.config.dbPath`
70
+ * always names the database actually opened. Falls back to config.dbPath
71
+ * (DB_PATH, ./brain-ui.db).
72
+ */
45
73
  dbPath?: string;
46
74
  /**
47
75
  * PNG/PDF renderer for `POST /api/render`. The deployment owns the actual
@@ -51,6 +79,32 @@ export interface CreateAppOptions {
51
79
  renderer?: AppRenderer;
52
80
  /** Per-turn timeout in ms (default 10 minutes). */
53
81
  turnTimeoutMs?: number;
82
+ /** Backend registry override (tests/embedders); default is built from config. */
83
+ registry?: BackendRegistry;
84
+ }
85
+
86
+ /** What `createApp` hands back to the deployment shell. */
87
+ export interface BrainUiApp {
88
+ fetch: Hono["fetch"];
89
+ websocket: typeof websocket;
90
+ /** The configuration this instance runs on (resolved or injected). */
91
+ config: ServerConfig;
92
+ /**
93
+ * The auth mode this instance resolved and validated at boot — so the
94
+ * deployment shell can log it without re-deriving the AuthRuntime that
95
+ * resolveAuthMode needs.
96
+ */
97
+ authMode: AuthMode;
98
+ /** The app's own SQLite handle (sessions, passkeys, settings). */
99
+ db: Database;
100
+ /** The WebSocket coordinator (turn state, clients, catalog, registry). */
101
+ wsHost: WsHost;
102
+ /** True while any session has a running turn. */
103
+ isTurnActive(): boolean;
104
+ /** Cancel every running turn (used on shutdown). Returns true if any was. */
105
+ cancelActiveTurns(): boolean;
106
+ /** Release process-held resources (the SQLite handle). */
107
+ close(): void;
54
108
  }
55
109
 
56
110
  // Cross-site WebSocket hijacking (CSWSH) defense. CORS does not apply to the WS
@@ -72,20 +126,63 @@ function isAllowedWsOrigin(c: Context, allowedOrigins: string[]): boolean {
72
126
  }
73
127
  }
74
128
 
75
- export function createApp(options: CreateAppOptions = {}) {
129
+ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
130
+ // The edge: ambient environment becomes explicit configuration exactly once.
131
+ // options.dbPath folds into the config here, so the handle's `config` and
132
+ // the database actually opened can never disagree.
133
+ const resolved = options.config ?? resolveServerConfig();
134
+ // Checked against undefined, not truthiness: SQLite treats "" as a valid
135
+ // anonymous temporary database, and an explicit empty override must not
136
+ // silently fall back to the resolved path.
137
+ const config: ServerConfig =
138
+ options.dbPath !== undefined ? { ...resolved, dbPath: options.dbPath } : resolved;
139
+ const auth: AuthRuntime = { ...config.auth, host: config.host };
140
+
76
141
  const app = new Hono();
77
- const authMode = resolveAuthMode();
142
+ const authMode = resolveAuthMode(auth);
78
143
  // Validate inside the factory, not the bin entry: every consumer of the app
79
144
  // (a deployment bin, tests, another embedder) gets the same refuse-to-boot
80
145
  // guarantee on an unsafe auth configuration.
81
- assertAuthConfig(authMode);
82
- assertPasskeyConfig();
146
+ assertAuthConfig(authMode, auth);
147
+ assertPasskeyConfig(config.webauthn);
148
+ // A missing (or unrecognized) agent backend refuses to boot HERE, not on the
149
+ // first turn — otherwise /api/health reports healthy while every turn is
150
+ // guaranteed to fail. Resolution only; the module still loads lazily.
151
+ // Skipped when the embedder injects its own registry.
152
+ if (!options.registry) assertBackendResolvable(config.agent);
83
153
 
84
- if (options.dbPath) configureDb(options.dbPath);
85
- configureWsHost({
154
+ // Per-instance state: the app's own database, the brain CLI wrapper, the
155
+ // backend registry, and the WebSocket host. No module-level singletons —
156
+ // two apps with different configuration coexist in one process.
157
+ const db = createUiDb(config.dbPath);
158
+ const brain = createBrainClient({ brainPath: config.brainPath });
159
+ const cron = createCronScheduler({ db, brain });
160
+ const registry =
161
+ options.registry ??
162
+ createBackendRegistry({
163
+ brainPath: config.brainPath,
164
+ agent: config.agent,
165
+ getHiddenModelIds: () => getHiddenModelIds(db),
166
+ });
167
+ const host = new WsHost({
168
+ registry,
169
+ catalog: createSessionCatalog(() => db),
86
170
  ...(options.appName ? { appName: options.appName } : {}),
87
171
  ...(options.turnTimeoutMs ? { turnTimeoutMs: options.turnTimeoutMs } : {}),
172
+ maxConcurrentSessions: () => config.maxConcurrentSessions,
88
173
  });
174
+ const wsUpgrade = createWsUpgrade(host);
175
+ const passkeyCtx: PasskeyContext = {
176
+ db,
177
+ webauthn: config.webauthn,
178
+ auth,
179
+ allowedOrigins: config.allowedOrigins,
180
+ };
181
+ const keyterms: KeytermSettings = {
182
+ brainPath: config.brainPath,
183
+ cacheDir: config.voice.cacheDir,
184
+ limit: config.voice.keytermLimit,
185
+ };
89
186
 
90
187
  // Middleware
91
188
  app.use("*", logger());
@@ -94,10 +191,7 @@ export function createApp(options: CreateAppOptions = {}) {
94
191
  // different origin than the API. ALLOWED_ORIGINS is a comma-separated
95
192
  // allowlist; empty/unset means same-origin (the default), so the CORS
96
193
  // middleware is skipped entirely.
97
- const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? "")
98
- .split(",")
99
- .map((origin) => origin.trim())
100
- .filter(Boolean);
194
+ const allowedOrigins = config.allowedOrigins;
101
195
  if (allowedOrigins.length > 0) {
102
196
  app.use(
103
197
  "/api/*",
@@ -118,27 +212,45 @@ export function createApp(options: CreateAppOptions = {}) {
118
212
  // Not under /api, and not behind the guard: this is where a system share
119
213
  // lands when no service worker was around to intercept it. See the route.
120
214
  app.route("/", shareTargetFallbackRoutes);
121
- app.route("/api", authRoutes(authMode, { passwordDisabled: passwordLoginDisabled }));
122
- app.route("/api", passkeyPublicRoutes(authMode));
215
+ app.route(
216
+ "/api",
217
+ authRoutes(authMode, auth, {
218
+ passwordDisabled: (c) => passwordLoginDisabled(c, passkeyCtx),
219
+ })
220
+ );
221
+ app.route("/api", passkeyPublicRoutes(authMode, passkeyCtx));
123
222
 
124
223
  // Auth guard for every other /api/* route. The probe below is intentionally
125
224
  // behind it: an unauthenticated client gets 401 (password/proxy) or 403
126
225
  // (tailscale) from /api/vpn-check and shows the login / VPN screen.
127
- app.use("/api/*", authGuard(authMode));
226
+ app.use("/api/*", authGuard(authMode, auth));
128
227
  app.get("/api/vpn-check", (c) => c.json({ vpn: true }));
129
228
  // Passkey registration/management: after the guard, so a session is required
130
229
  // by mount position (the public assertion routes are registered above).
131
- app.route("/api", passkeyManagementRoutes(authMode));
132
- app.route("/api", statusRoutes);
133
- app.route("/api", brainRoutes);
134
- app.route("/api", sessionRoutes);
135
- app.route("/api", voiceRoutes);
136
- app.route("/api", filesRoutes);
137
- app.route("/api", shareRoutes);
230
+ app.route("/api", passkeyManagementRoutes(authMode, passkeyCtx));
231
+ app.route(
232
+ "/api",
233
+ createStatusRoutes({
234
+ sourceCommit: config.sourceCommit,
235
+ getCronStatus: () => cron.getCronStatus(),
236
+ isTurnActive: () => host.coordinator.isTurnActive(),
237
+ })
238
+ );
239
+ app.route(
240
+ "/api",
241
+ createBrainRoutes({ brain, brainPath: config.brainPath, keyterms })
242
+ );
243
+ app.route("/api", createSessionRoutes({ registry, db }));
244
+ app.route("/api", createVoiceRoutes({ voice: config.voice, keyterms }));
245
+ app.route("/api", createFilesRoutes({ brainRoot: config.brainPath }));
246
+ app.route(
247
+ "/api",
248
+ createShareRoutes({ brainRoot: config.brainPath, allowedOrigins })
249
+ );
138
250
  app.route("/api", createRenderRoutes(options.renderer));
139
- app.route("/api", providerRoutes);
140
- app.route("/api", modelRoutes);
141
- app.route("/api", graphRoutes);
251
+ app.route("/api", createProviderRoutes({ registry }));
252
+ app.route("/api", createModelRoutes({ registry, db }));
253
+ app.route("/api", createGraphRoutes({ brainRoot: config.brainPath }));
142
254
 
143
255
  // WebSocket endpoint. Browsers can't set headers on the WS handshake, so the
144
256
  // upgrade authenticates via the session cookie (or IP/proxy header) INSIDE
@@ -147,7 +259,7 @@ export function createApp(options: CreateAppOptions = {}) {
147
259
  if (!isAllowedWsOrigin(c, allowedOrigins)) {
148
260
  return c.json({ error: "Cross-origin WebSocket rejected" }, 403);
149
261
  }
150
- if (!(await isWsAuthorized(c, authMode))) {
262
+ if (!(await isWsAuthorized(c, authMode, auth))) {
151
263
  return c.json({ error: "Authentication required" }, 401);
152
264
  }
153
265
  return wsUpgrade(c, next);
@@ -164,5 +276,14 @@ export function createApp(options: CreateAppOptions = {}) {
164
276
  return {
165
277
  fetch: app.fetch,
166
278
  websocket,
279
+ config,
280
+ authMode,
281
+ db,
282
+ wsHost: host,
283
+ isTurnActive: () => host.coordinator.isTurnActive(),
284
+ cancelActiveTurns: () => host.coordinator.cancelAll("Server shutting down"),
285
+ close: () => {
286
+ db.close();
287
+ },
167
288
  };
168
289
  }
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "fs";
2
2
  import { join } from "path";
3
+ import { subprocessEnv } from "../config/env.js";
3
4
  import type {
4
5
  BrainSearchResult,
5
6
  BrainSearchResponse,
@@ -8,179 +9,201 @@ import type {
8
9
  BrainSyncResult,
9
10
  } from "./types.js";
10
11
 
11
- const BRAIN_PATH = process.env.BRAIN_PATH || join(process.env.HOME || "/root", "brain");
12
+ /**
13
+ * Wrapper around the brain CLI, bound to one brain repo. Constructed by
14
+ * `createApp()` (or an embedder) with the resolved brain path — no ambient
15
+ * environment, so two clients against different repos can coexist.
16
+ */
17
+ export interface BrainClient {
18
+ /** argv prefix for invoking the brain CLI inside the repo. */
19
+ cliCommand(): string[];
20
+ search(
21
+ query: string,
22
+ opts?: { type?: string; tag?: string; limit?: number; mode?: string }
23
+ ): Promise<BrainSearchResponse>;
24
+ briefing(): Promise<string>;
25
+ stats(): Promise<BrainStats>;
26
+ list(opts?: {
27
+ type?: string;
28
+ tag?: string;
29
+ status?: string;
30
+ relevance?: string;
31
+ limit?: number;
32
+ }): Promise<BrainDocument[]>;
33
+ read(path: string): Promise<string>;
34
+ sync(): Promise<BrainSyncResult>;
35
+ add(
36
+ content: string,
37
+ opts?: { type?: string; title?: string; tags?: string[] }
38
+ ): Promise<void>;
39
+ index(opts?: { force?: boolean }): Promise<void>;
40
+ validate(): Promise<string>;
41
+ }
42
+
43
+ interface ExecResult {
44
+ stdout: string;
45
+ stderr: string;
46
+ exitCode: number;
47
+ }
12
48
 
13
49
  /**
14
- * argv prefix for invoking the brain CLI inside BRAIN_PATH.
50
+ * argv prefix for invoking the brain CLI inside `brainPath`.
15
51
  *
16
52
  * Two brain-repo layouts exist and both must work: a repo that depends on
17
53
  * `@schlessera/brain` gets a real bin at `node_modules/.bin/brain`, while the
18
54
  * legacy layout vendors `scripts/brain-cli.ts`. Prefer the packaged bin.
19
55
  *
20
- * Resolved per call (a bare `existsSync` stat) rather than at import time: in
56
+ * Resolved per call (a bare `existsSync` stat) rather than at construction: in
21
57
  * the container the brain repo is cloned and `bun install`ed by entrypoint.sh,
22
58
  * and a repo can gain the dependency without restarting this server. The same
23
59
  * preference order is mirrored in `scripts/entrypoint.sh`, which links whichever
24
60
  * it finds onto PATH for cron.
25
61
  */
26
- export function brainCliCommand(): string[] {
27
- const packaged = join(BRAIN_PATH, "node_modules", ".bin", "brain");
62
+ export function brainCliCommand(brainPath: string): string[] {
63
+ const packaged = join(brainPath, "node_modules", ".bin", "brain");
28
64
  if (existsSync(packaged)) return [packaged];
29
65
  return ["bun", "scripts/brain-cli.ts"];
30
66
  }
31
67
 
32
- interface ExecResult {
33
- stdout: string;
34
- stderr: string;
35
- exitCode: number;
36
- }
68
+ export function createBrainClient(opts: { brainPath: string }): BrainClient {
69
+ const { brainPath } = opts;
37
70
 
38
- async function execBrain(args: string[]): Promise<ExecResult> {
39
- const proc = Bun.spawn([...brainCliCommand(), ...args], {
40
- cwd: BRAIN_PATH,
41
- stdout: "pipe",
42
- stderr: "pipe",
43
- env: {
44
- ...process.env,
71
+ async function execBrain(args: string[]): Promise<ExecResult> {
72
+ const proc = Bun.spawn([...brainCliCommand(brainPath), ...args], {
73
+ cwd: brainPath,
74
+ stdout: "pipe",
75
+ stderr: "pipe",
45
76
  // Force JSON output when not a TTY
46
- NO_COLOR: "1",
47
- },
48
- });
77
+ env: subprocessEnv({ NO_COLOR: "1" }),
78
+ });
49
79
 
50
- const [stdout, stderr] = await Promise.all([
51
- new Response(proc.stdout).text(),
52
- new Response(proc.stderr).text(),
53
- ]);
54
- const exitCode = await proc.exited;
80
+ const [stdout, stderr] = await Promise.all([
81
+ new Response(proc.stdout).text(),
82
+ new Response(proc.stderr).text(),
83
+ ]);
84
+ const exitCode = await proc.exited;
55
85
 
56
- return { stdout, stderr, exitCode };
57
- }
58
-
59
- function parseJsonOutput<T>(result: ExecResult): T {
60
- if (result.exitCode !== 0) {
61
- throw new Error(
62
- `brain CLI failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`
63
- );
86
+ return { stdout, stderr, exitCode };
64
87
  }
65
- try {
66
- return JSON.parse(result.stdout);
67
- } catch {
68
- // Some commands output plain text, not JSON
69
- return result.stdout as unknown as T;
88
+
89
+ function parseJsonOutput<T>(result: ExecResult): T {
90
+ if (result.exitCode !== 0) {
91
+ throw new Error(
92
+ `brain CLI failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`
93
+ );
94
+ }
95
+ try {
96
+ return JSON.parse(result.stdout);
97
+ } catch {
98
+ // Some commands output plain text, not JSON
99
+ return result.stdout as unknown as T;
100
+ }
70
101
  }
71
- }
72
102
 
73
- export async function brainSearch(
74
- query: string,
75
- opts?: { type?: string; tag?: string; limit?: number; mode?: string }
76
- ): Promise<BrainSearchResponse> {
77
- const args = ["search", query];
78
- if (opts?.type) args.push("--type", opts.type);
79
- if (opts?.tag) args.push("--tag", opts.tag);
80
- if (opts?.limit) args.push("--limit", String(opts.limit));
81
- if (opts?.mode) args.push("--mode", opts.mode);
82
-
83
- const result = await execBrain(args);
84
- const parsed = parseJsonOutput<BrainSearchResponse | BrainSearchResult[]>(result);
85
- // brain returns {results, warnings} since 22e82e1; tolerate the old bare array
86
- return Array.isArray(parsed) ? { results: parsed, warnings: [] } : parsed;
87
- }
103
+ return {
104
+ cliCommand() {
105
+ return brainCliCommand(brainPath);
106
+ },
88
107
 
89
- export async function brainBriefing(): Promise<string> {
90
- const result = await execBrain(["briefing"]);
91
- if (result.exitCode !== 0) {
92
- throw new Error(`brain briefing failed: ${result.stderr}`);
93
- }
94
- return result.stdout;
95
- }
108
+ async search(query, opts) {
109
+ const args = ["search", query];
110
+ if (opts?.type) args.push("--type", opts.type);
111
+ if (opts?.tag) args.push("--tag", opts.tag);
112
+ if (opts?.limit) args.push("--limit", String(opts.limit));
113
+ if (opts?.mode) args.push("--mode", opts.mode);
114
+
115
+ const result = await execBrain(args);
116
+ const parsed = parseJsonOutput<BrainSearchResponse | BrainSearchResult[]>(result);
117
+ // brain returns {results, warnings} since 22e82e1; tolerate the old bare array
118
+ return Array.isArray(parsed) ? { results: parsed, warnings: [] } : parsed;
119
+ },
96
120
 
97
- export async function brainStats(): Promise<BrainStats> {
98
- const result = await execBrain(["stats"]);
99
- return parseJsonOutput(result);
100
- }
121
+ async briefing() {
122
+ const result = await execBrain(["briefing"]);
123
+ if (result.exitCode !== 0) {
124
+ throw new Error(`brain briefing failed: ${result.stderr}`);
125
+ }
126
+ return result.stdout;
127
+ },
101
128
 
102
- export async function brainList(opts?: {
103
- type?: string;
104
- tag?: string;
105
- status?: string;
106
- relevance?: string;
107
- limit?: number;
108
- }): Promise<BrainDocument[]> {
109
- const args = ["list"];
110
- if (opts?.type) args.push("--type", opts.type);
111
- if (opts?.tag) args.push("--tag", opts.tag);
112
- if (opts?.status) args.push("--status", opts.status);
113
- if (opts?.relevance) args.push("--relevance", opts.relevance);
114
- if (opts?.limit) args.push("--limit", String(opts.limit));
115
-
116
- const result = await execBrain(args);
117
- return parseJsonOutput(result);
118
- }
129
+ async stats() {
130
+ const result = await execBrain(["stats"]);
131
+ return parseJsonOutput(result);
132
+ },
119
133
 
120
- export async function brainRead(path: string): Promise<string> {
121
- const result = await execBrain(["read", path]);
122
- if (result.exitCode !== 0) {
123
- throw new Error(`brain read failed: ${result.stderr}`);
124
- }
125
- return result.stdout;
126
- }
134
+ async list(opts) {
135
+ const args = ["list"];
136
+ if (opts?.type) args.push("--type", opts.type);
137
+ if (opts?.tag) args.push("--tag", opts.tag);
138
+ if (opts?.status) args.push("--status", opts.status);
139
+ if (opts?.relevance) args.push("--relevance", opts.relevance);
140
+ if (opts?.limit) args.push("--limit", String(opts.limit));
127
141
 
128
- export async function brainSync(): Promise<BrainSyncResult> {
129
- // Note: brain sync spawns Claude Code internally via the /sync skill.
130
- // For cron jobs, we use a simpler git-based sync instead.
131
- const result = await execBrain(["sync"]);
132
- if (result.exitCode !== 0) {
133
- return {
134
- success: false,
135
- commits: 0,
136
- conflicts: 0,
137
- message: result.stderr || result.stdout,
138
- };
139
- }
140
- try {
141
- return parseJsonOutput(result);
142
- } catch {
143
- return {
144
- success: true,
145
- commits: 0,
146
- conflicts: 0,
147
- message: result.stdout,
148
- };
149
- }
150
- }
142
+ const result = await execBrain(args);
143
+ return parseJsonOutput(result);
144
+ },
151
145
 
152
- export async function brainAdd(
153
- content: string,
154
- opts?: { type?: string; title?: string; tags?: string[] }
155
- ): Promise<void> {
156
- const args = ["add", content];
157
- if (opts?.type) args.push("--type", opts.type);
158
- if (opts?.title) args.push("--title", opts.title);
159
- if (opts?.tags) args.push("--tags", opts.tags.join(","));
160
-
161
- const result = await execBrain(args);
162
- if (result.exitCode !== 0) {
163
- throw new Error(`brain add failed: ${result.stderr}`);
164
- }
165
- }
146
+ async read(path) {
147
+ const result = await execBrain(["read", path]);
148
+ if (result.exitCode !== 0) {
149
+ throw new Error(`brain read failed: ${result.stderr}`);
150
+ }
151
+ return result.stdout;
152
+ },
166
153
 
167
- export async function brainIndex(opts?: {
168
- /** Full rebuild; indexing is incremental by default since brain 22e82e1. */
169
- force?: boolean;
170
- }): Promise<void> {
171
- const args = ["index"];
172
- if (opts?.force) args.push("--force");
154
+ async sync() {
155
+ // Note: brain sync spawns Claude Code internally via the /sync skill.
156
+ // For cron jobs, we use a simpler git-based sync instead.
157
+ const result = await execBrain(["sync"]);
158
+ if (result.exitCode !== 0) {
159
+ return {
160
+ success: false,
161
+ commits: 0,
162
+ conflicts: 0,
163
+ message: result.stderr || result.stdout,
164
+ };
165
+ }
166
+ try {
167
+ return parseJsonOutput(result);
168
+ } catch {
169
+ return {
170
+ success: true,
171
+ commits: 0,
172
+ conflicts: 0,
173
+ message: result.stdout,
174
+ };
175
+ }
176
+ },
173
177
 
174
- const result = await execBrain(args);
175
- if (result.exitCode !== 0) {
176
- throw new Error(`brain index failed: ${result.stderr}`);
177
- }
178
- }
178
+ async add(content, opts) {
179
+ const args = ["add", content];
180
+ if (opts?.type) args.push("--type", opts.type);
181
+ if (opts?.title) args.push("--title", opts.title);
182
+ if (opts?.tags) args.push("--tags", opts.tags.join(","));
179
183
 
180
- export async function brainValidate(): Promise<string> {
181
- const result = await execBrain(["validate"]);
182
- if (result.exitCode !== 0) {
183
- throw new Error(`brain validate failed: ${result.stderr}`);
184
- }
185
- return result.stdout;
184
+ const result = await execBrain(args);
185
+ if (result.exitCode !== 0) {
186
+ throw new Error(`brain add failed: ${result.stderr}`);
187
+ }
188
+ },
189
+
190
+ async index(opts) {
191
+ const args = ["index"];
192
+ // Full rebuild; indexing is incremental by default since brain 22e82e1.
193
+ if (opts?.force) args.push("--force");
194
+
195
+ const result = await execBrain(args);
196
+ if (result.exitCode !== 0) {
197
+ throw new Error(`brain index failed: ${result.stderr}`);
198
+ }
199
+ },
200
+
201
+ async validate() {
202
+ const result = await execBrain(["validate"]);
203
+ if (result.exitCode !== 0) {
204
+ throw new Error(`brain validate failed: ${result.stderr}`);
205
+ }
206
+ return result.stdout;
207
+ },
208
+ };
186
209
  }