@schlessera/brain-ui-server 0.13.0 → 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,24 +1,34 @@
1
- import { Database } from "bun:sqlite";
1
+ import type { Database } from "bun:sqlite";
2
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
3
3
  import { join, dirname } from "path";
4
4
  import type { Keyterm, PronunciationOverride } from "@schlessera/brain-ui-sdk/protocol";
5
-
6
- const BRAIN_PATH =
7
- process.env.BRAIN_PATH || join(process.env.HOME || "/root", "brain");
8
-
9
- const DEFAULT_LIMIT = Number(process.env.VOICE_KEYTERM_LIMIT || 500);
10
-
11
- const CACHE_PATH = join(
12
- process.env.VOICE_CACHE_DIR || join(BRAIN_PATH, ".brain-ui"),
13
- "keyterms.json"
14
- );
5
+ import { BrainDbUnavailableError, withBrainDb } from "../db/brain-db.js";
6
+
7
+ /**
8
+ * Everything the keyterm builder needs to know about its surroundings —
9
+ * derived from the resolved ServerConfig by the caller (createApp / the
10
+ * deployment shell). No ambient environment.
11
+ */
12
+ export interface KeytermSettings {
13
+ brainPath: string;
14
+ /** Directory holding keyterms.json (VOICE_CACHE_DIR). */
15
+ cacheDir: string;
16
+ /** Maximum vocabulary size (VOICE_KEYTERM_LIMIT). */
17
+ limit: number;
18
+ }
15
19
 
16
20
  // Bump whenever the extractor logic, scoring, or stoplists change so that
17
21
  // post-deploy the first request rebuilds the cache instead of serving a
18
22
  // stale snapshot baked under the old algorithm.
19
23
  const CACHE_VERSION = 2;
20
24
 
21
- const OVERRIDES_PATH = join(BRAIN_PATH, ".voice-overrides.md");
25
+ function cachePath(settings: KeytermSettings): string {
26
+ return join(settings.cacheDir, "keyterms.json");
27
+ }
28
+
29
+ function overridesPath(brainPath: string): string {
30
+ return join(brainPath, ".voice-overrides.md");
31
+ }
22
32
 
23
33
  // Common English words to exclude from extracted vocab.
24
34
  // Kept short and aggressive — anything ambiguous, drop it.
@@ -107,17 +117,19 @@ interface LinkRow {
107
117
  target: string;
108
118
  }
109
119
 
110
- export interface BuildOptions {
111
- limit?: number;
112
- brainPath?: string;
113
- }
114
-
115
120
  export interface KeytermsCache {
116
121
  version: number;
117
122
  keyterms: string[];
118
123
  generatedAt: number;
119
124
  count: number;
120
125
  overrides: PronunciationOverride[];
126
+ /**
127
+ * Set when the brain database's schema is older than this package's SQL —
128
+ * the vocabulary degrades to empty (voice keeps working, just without
129
+ * domain bias) instead of erroring. Degraded results are never persisted,
130
+ * so the first request after `brain index` upgrades the schema rebuilds.
131
+ */
132
+ degraded?: true;
121
133
  }
122
134
 
123
135
  function normalize(s: string): string {
@@ -347,10 +359,11 @@ function dedupeAndRank(terms: Keyterm[], limit: number): string[] {
347
359
  return sorted.slice(0, limit).map((k) => k.term);
348
360
  }
349
361
 
350
- export function loadOverrides(): PronunciationOverride[] {
351
- if (!existsSync(OVERRIDES_PATH)) return [];
362
+ export function loadOverrides(brainPath: string): PronunciationOverride[] {
363
+ const path = overridesPath(brainPath);
364
+ if (!existsSync(path)) return [];
352
365
  try {
353
- const md = readFileSync(OVERRIDES_PATH, "utf-8");
366
+ const md = readFileSync(path, "utf-8");
354
367
  const out: PronunciationOverride[] = [];
355
368
  for (const line of md.split("\n")) {
356
369
  // Format: - Doe → DOH (also accepts "->")
@@ -367,47 +380,66 @@ export function loadOverrides(): PronunciationOverride[] {
367
380
  }
368
381
  }
369
382
 
370
- export function buildKeyterms(opts: BuildOptions = {}): KeytermsCache {
371
- const limit = opts.limit ?? DEFAULT_LIMIT;
372
- const brainPath = opts.brainPath ?? BRAIN_PATH;
373
- const dbPath = join(brainPath, "brain.db");
374
-
375
- if (!existsSync(dbPath)) {
376
- throw new Error(`brain.db not found at ${dbPath}`);
377
- }
383
+ export function buildKeyterms(settings: KeytermSettings): KeytermsCache {
384
+ const { brainPath, limit } = settings;
378
385
 
379
- const db = new Database(dbPath, { readonly: true });
380
386
  try {
381
- const all: Keyterm[] = [
382
- ...extractTags(db),
383
- ...extractTitles(db),
384
- ...extractPaths(db),
385
- ...extractLinks(db),
386
- ...extractFromContent(db),
387
- ];
388
- const keyterms = dedupeAndRank(all, limit);
389
- return {
390
- version: CACHE_VERSION,
391
- keyterms,
392
- generatedAt: Date.now(),
393
- count: keyterms.length,
394
- overrides: loadOverrides(),
395
- };
396
- } finally {
397
- db.close();
387
+ return withBrainDb(brainPath, {}, (db) => {
388
+ const all: Keyterm[] = [
389
+ ...extractTags(db),
390
+ ...extractTitles(db),
391
+ ...extractPaths(db),
392
+ ...extractLinks(db),
393
+ ...extractFromContent(db),
394
+ ];
395
+ const keyterms = dedupeAndRank(all, limit);
396
+ return {
397
+ version: CACHE_VERSION,
398
+ keyterms,
399
+ generatedAt: Date.now(),
400
+ count: keyterms.length,
401
+ overrides: loadOverrides(brainPath),
402
+ };
403
+ });
404
+ } catch (err) {
405
+ if (err instanceof BrainDbUnavailableError) {
406
+ if (err.reason === "missing") {
407
+ // Preserved behavior: no brain.db is a hard error the caller reports.
408
+ throw new Error(`brain.db not found at ${join(brainPath, "brain.db")}`);
409
+ }
410
+ // Schema too old for this package's SQL: degrade to no custom
411
+ // vocabulary rather than breaking voice entirely. Pronunciation
412
+ // overrides live in markdown, so they survive.
413
+ console.warn(
414
+ `[voice] brain.db schema_version=${err.schemaVersion} is older than this ` +
415
+ "server's keyterm SQL; serving an empty custom vocabulary until the " +
416
+ "repo is re-indexed."
417
+ );
418
+ return {
419
+ version: CACHE_VERSION,
420
+ keyterms: [],
421
+ generatedAt: Date.now(),
422
+ count: 0,
423
+ overrides: loadOverrides(brainPath),
424
+ degraded: true,
425
+ };
426
+ }
427
+ throw err;
398
428
  }
399
429
  }
400
430
 
401
- export function writeCache(cache: KeytermsCache): void {
402
- const dir = dirname(CACHE_PATH);
431
+ export function writeCache(settings: KeytermSettings, cache: KeytermsCache): void {
432
+ const path = cachePath(settings);
433
+ const dir = dirname(path);
403
434
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
404
- writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2), "utf-8");
435
+ writeFileSync(path, JSON.stringify(cache, null, 2), "utf-8");
405
436
  }
406
437
 
407
- export function readCache(): KeytermsCache | null {
408
- if (!existsSync(CACHE_PATH)) return null;
438
+ export function readCache(settings: KeytermSettings): KeytermsCache | null {
439
+ const path = cachePath(settings);
440
+ if (!existsSync(path)) return null;
409
441
  try {
410
- const cache = JSON.parse(readFileSync(CACHE_PATH, "utf-8")) as KeytermsCache;
442
+ const cache = JSON.parse(readFileSync(path, "utf-8")) as KeytermsCache;
411
443
  if (cache.version !== CACHE_VERSION) return null;
412
444
  return cache;
413
445
  } catch {
@@ -415,12 +447,14 @@ export function readCache(): KeytermsCache | null {
415
447
  }
416
448
  }
417
449
 
418
- export function getKeyterms(forceRebuild = false): KeytermsCache {
450
+ export function getKeyterms(settings: KeytermSettings, forceRebuild = false): KeytermsCache {
419
451
  if (!forceRebuild) {
420
- const cached = readCache();
452
+ const cached = readCache(settings);
421
453
  if (cached) return cached;
422
454
  }
423
- const fresh = buildKeyterms();
424
- writeCache(fresh);
455
+ const fresh = buildKeyterms(settings);
456
+ // A degraded (schema-too-old) result is served but never persisted — the
457
+ // cache must not outlive the condition that produced it.
458
+ if (!fresh.degraded) writeCache(settings, fresh);
425
459
  return fresh;
426
460
  }
@@ -8,11 +8,13 @@
8
8
  * work, so it returns empty url/token.
9
9
  *
10
10
  * VOICE_PROVIDER selects the provider; default is deepgram when
11
- * DEEPGRAM_API_KEY is set, else webspeech.
11
+ * DEEPGRAM_API_KEY is set, else webspeech. Both arrive here as the resolved
12
+ * {@link VoiceConfig} — this module reads no environment.
12
13
  */
13
14
 
14
15
  import type { SpeechProvider } from "@schlessera/brain-ui-sdk/server";
15
16
  import { defineSpeechProvider } from "@schlessera/brain-ui-sdk/server";
17
+ import type { VoiceConfig } from "../config/env.js";
16
18
  import { mintDeepgramToken } from "./deepgram-token.js";
17
19
 
18
20
  const DEEPGRAM_WS_URL = "wss://api.deepgram.com/v1/listen";
@@ -47,23 +49,25 @@ function buildDeepgramUrl(keyterms: string[]): string {
47
49
  return url;
48
50
  }
49
51
 
50
- export const deepgramSpeechProvider: SpeechProvider = defineSpeechProvider({
51
- id: "deepgram",
52
- capabilities: {
53
- streaming: true,
54
- interimResults: true,
55
- keyterms: true,
56
- endpointing: true,
57
- },
58
- async createSession({ keyterms }) {
59
- const { token, expiresAt } = await mintDeepgramToken(TOKEN_TTL_SECONDS);
60
- return {
61
- url: buildDeepgramUrl(keyterms),
62
- token,
63
- expiresAt,
64
- };
65
- },
66
- });
52
+ export function createDeepgramSpeechProvider(apiKey: string | null): SpeechProvider {
53
+ return defineSpeechProvider({
54
+ id: "deepgram",
55
+ capabilities: {
56
+ streaming: true,
57
+ interimResults: true,
58
+ keyterms: true,
59
+ endpointing: true,
60
+ },
61
+ async createSession({ keyterms }) {
62
+ const { token, expiresAt } = await mintDeepgramToken(apiKey, TOKEN_TTL_SECONDS);
63
+ return {
64
+ url: buildDeepgramUrl(keyterms),
65
+ token,
66
+ expiresAt,
67
+ };
68
+ },
69
+ });
70
+ }
67
71
 
68
72
  export const webspeechSpeechProvider: SpeechProvider = defineSpeechProvider({
69
73
  id: "webspeech",
@@ -80,21 +84,21 @@ export const webspeechSpeechProvider: SpeechProvider = defineSpeechProvider({
80
84
  });
81
85
 
82
86
  /**
83
- * Select the active speech provider from env.
87
+ * Select the active speech provider from the resolved voice config.
84
88
  *
85
89
  * webspeech is OPT-IN ONLY (`VOICE_PROVIDER=webspeech`): on Chromium it streams
86
90
  * microphone audio to Google, so it must never be a silent fallback when the
87
91
  * Deepgram key goes missing. Every other unresolved case throws so the caller
88
92
  * 500s loudly instead of quietly degrading to third-party egress.
89
93
  */
90
- export function pickSpeechProvider(): SpeechProvider {
91
- const configured = process.env.VOICE_PROVIDER?.trim().toLowerCase();
94
+ export function pickSpeechProvider(voice: VoiceConfig): SpeechProvider {
95
+ const configured = voice.provider;
92
96
  if (configured === "webspeech") return webspeechSpeechProvider;
93
97
  if (configured === "deepgram") {
94
- if (!process.env.DEEPGRAM_API_KEY) {
98
+ if (!voice.deepgramApiKey) {
95
99
  throw new Error("VOICE_PROVIDER=deepgram but DEEPGRAM_API_KEY is not set.");
96
100
  }
97
- return deepgramSpeechProvider;
101
+ return createDeepgramSpeechProvider(voice.deepgramApiKey);
98
102
  }
99
103
  if (configured) {
100
104
  throw new Error(
@@ -102,7 +106,7 @@ export function pickSpeechProvider(): SpeechProvider {
102
106
  );
103
107
  }
104
108
  // Auto-detect: Deepgram when its key is present; otherwise fail loudly.
105
- if (process.env.DEEPGRAM_API_KEY) return deepgramSpeechProvider;
109
+ if (voice.deepgramApiKey) return createDeepgramSpeechProvider(voice.deepgramApiKey);
106
110
  throw new Error(
107
111
  'No speech provider configured. Set DEEPGRAM_API_KEY, or set ' +
108
112
  'VOICE_PROVIDER=webspeech to explicitly opt into the browser speech API ' +
package/src/ws/bridge.ts CHANGED
@@ -5,7 +5,6 @@ import type {
5
5
  LocationFix,
6
6
  } from "@schlessera/brain-ui-sdk/server";
7
7
  import { BackendBusyError, BackendRequestError } from "@schlessera/brain-ui-sdk/server";
8
- import { hasClients } from "./clients.js";
9
8
  import { withTurnScope } from "./frames.js";
10
9
  import type { RunningTurn } from "./turns.js";
11
10
  import type { WsHost } from "./host.js";
@@ -87,7 +86,7 @@ export function makeBridge(
87
86
  });
88
87
  },
89
88
  getLocation: (options) => {
90
- if (!hasClients()) {
89
+ if (!host.clients.hasClients()) {
91
90
  return Promise.reject(
92
91
  new Error(`No ${host.appName} client is connected to read the location from.`)
93
92
  );
@@ -112,7 +111,7 @@ export function makeBridge(
112
111
  });
113
112
  },
114
113
  requestMask: (imagePath, instruction) => {
115
- if (!hasClients()) {
114
+ if (!host.clients.hasClients()) {
116
115
  return Promise.reject(
117
116
  new Error(`No ${host.appName} client is connected to paint a mask in.`)
118
117
  );
package/src/ws/clients.ts CHANGED
@@ -4,54 +4,51 @@ import { shrinkForReplication } from "./shrink.js";
4
4
  /** Minimal structural view of a live socket — all we need to write to it. */
5
5
  export type WSContext = { send: (data: string) => void };
6
6
 
7
- /**
8
- * Every currently-attached client socket. The active agent turn is a
9
- * process-global singleton (server/src/ws/handler.ts), so all sockets observe
10
- * the same conversation; streamed output fans out to all of them.
11
- * Previously the server held a single `activeWs`, so a second connection
12
- * silently orphaned the first (it stayed open but never received output).
13
- */
14
- const clients = new Set<WSContext>();
15
-
16
- export function addClient(ws: WSContext): void {
17
- clients.add(ws);
18
- }
19
-
20
- export function removeClient(ws: WSContext): void {
21
- clients.delete(ws);
22
- }
23
-
24
- export function clientCount(): number {
25
- return clients.size;
26
- }
27
-
28
- export function hasClients(): boolean {
29
- return clients.size > 0;
30
- }
31
-
32
- /** Test-only: drop all registered sockets. */
33
- export function resetClientsForTests(): void {
34
- clients.clear();
35
- }
36
-
37
7
  /** Serialize + size-bound a frame, then send it to one specific socket. */
38
8
  export function sendTo(ws: WSContext, msg: ServerMessage): void {
39
9
  ws.send(JSON.stringify(shrinkForReplication(msg)));
40
10
  }
41
11
 
42
12
  /**
43
- * Broadcast a frame to every attached client. Serializes once. A failing
44
- * socket is skipped (its `onClose` will prune it) so one dead peer can't
45
- * block delivery to the others.
13
+ * The set of currently-attached client sockets belonging to ONE WsHost. All
14
+ * sockets of a host observe the same conversations; streamed output fans out
15
+ * to all of them. (Previously the server held a single `activeWs`, so a second
16
+ * connection silently orphaned the first — and later a module-global set,
17
+ * which would have cross-wired two coexisting app instances.)
46
18
  */
47
- export function broadcast(msg: ServerMessage): void {
48
- if (clients.size === 0) return;
49
- const payload = JSON.stringify(shrinkForReplication(msg));
50
- for (const ws of clients) {
51
- try {
52
- ws.send(payload);
53
- } catch {
54
- // Drop; the socket's onClose handler removes it from the set.
19
+ export class ClientSet {
20
+ private readonly clients = new Set<WSContext>();
21
+
22
+ add(ws: WSContext): void {
23
+ this.clients.add(ws);
24
+ }
25
+
26
+ remove(ws: WSContext): void {
27
+ this.clients.delete(ws);
28
+ }
29
+
30
+ count(): number {
31
+ return this.clients.size;
32
+ }
33
+
34
+ hasClients(): boolean {
35
+ return this.clients.size > 0;
36
+ }
37
+
38
+ /**
39
+ * Broadcast a frame to every attached client. Serializes once. A failing
40
+ * socket is skipped (its `onClose` will prune it) so one dead peer can't
41
+ * block delivery to the others.
42
+ */
43
+ broadcast(msg: ServerMessage): void {
44
+ if (this.clients.size === 0) return;
45
+ const payload = JSON.stringify(shrinkForReplication(msg));
46
+ for (const ws of this.clients) {
47
+ try {
48
+ ws.send(payload);
49
+ } catch {
50
+ // Drop; the socket's onClose handler removes it from the set.
51
+ }
55
52
  }
56
53
  }
57
54
  }
@@ -1,8 +1,6 @@
1
1
  import { upgradeWebSocket, websocket } from "hono/bun";
2
2
  import { PROTOCOL_REV } from "@schlessera/brain-ui-sdk/protocol";
3
3
  import { parseClientMessage } from "@schlessera/brain-ui-sdk/schemas";
4
- import { getBackendForSession } from "../agent/backend.js";
5
- import { addClient, removeClient, hasClients } from "./clients.js";
6
4
  import { withTurnScope } from "./frames.js";
7
5
  import { sendSessionHistory } from "./history.js";
8
6
  import { handleClientMessage } from "./dispatch.js";
@@ -49,7 +47,7 @@ export function createWsUpgrade(host: WsHost) {
49
47
  )
50
48
  );
51
49
  try {
52
- const backend = await getBackendForSession(
50
+ const backend = await host.registry.getBackendForSession(
53
51
  catalog.getStoredBackendId(sid) ?? turn.backend.id
54
52
  );
55
53
  const history = await backend.getHistory(sid);
@@ -57,7 +55,7 @@ export function createWsUpgrade(host: WsHost) {
57
55
  } catch (err) {
58
56
  console.error("[ws] snapshot-on-connect failed:", err);
59
57
  } finally {
60
- addClient(ws);
58
+ host.clients.add(ws);
61
59
  host.sendMessage(
62
60
  ws,
63
61
  withTurnScope(
@@ -74,7 +72,7 @@ export function createWsUpgrade(host: WsHost) {
74
72
  return;
75
73
  }
76
74
 
77
- addClient(ws);
75
+ host.clients.add(ws);
78
76
  host.sendMessage(ws, {
79
77
  type: "status",
80
78
  status: "idle",
@@ -119,11 +117,11 @@ export function createWsUpgrade(host: WsHost) {
119
117
 
120
118
  onClose(_evt, ws) {
121
119
  console.log("[ws] Client disconnected");
122
- removeClient(ws);
120
+ host.clients.remove(ws);
123
121
  // Turns keep running in the background. Only reject pending interactive
124
122
  // requests once the LAST client leaves — while another client remains it
125
123
  // can still answer them.
126
- if (hasClients()) return;
124
+ if (host.clients.hasClients()) return;
127
125
  for (const turn of host.coordinator.running) {
128
126
  host.coordinator.drainPendingForTurn(turn, "Client disconnected");
129
127
  }
@@ -1,5 +1,4 @@
1
1
  import type { ClientMessage } from "@schlessera/brain-ui-sdk/protocol";
2
- import { getBackendForSession } from "../agent/backend.js";
3
2
  import type { WSContext } from "./clients.js";
4
3
  import { locationErrorText } from "./frames.js";
5
4
  import { sendSessionHistory } from "./history.js";
@@ -158,7 +157,7 @@ export async function handleClientMessage(
158
157
  });
159
158
 
160
159
  try {
161
- const backend = await getBackendForSession(catalog.getStoredBackendId(msg.sessionId));
160
+ const backend = await host.registry.getBackendForSession(catalog.getStoredBackendId(msg.sessionId));
162
161
  const messages = await backend.getHistory(msg.sessionId);
163
162
  sendSessionHistory(ws, msg.sessionId, messages);
164
163
  // A resume of a RUNNING session (reattach) must not report idle: idle
package/src/ws/host.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import type { ServerMessage } from "@schlessera/brain-ui-sdk/protocol";
2
- import { broadcast, sendTo, type WSContext } from "./clients.js";
2
+ import { ClientSet, sendTo, type WSContext } from "./clients.js";
3
3
  import { TurnCoordinator } from "./turns.js";
4
- import { createSessionCatalog, type SessionCatalog } from "./session-catalog.js";
4
+ import type { SessionCatalog } from "./session-catalog.js";
5
+ import type { BackendRegistry } from "../agent/backend.js";
5
6
 
6
7
  /** Host-side turn timeout. The backend no longer times out — the host owns it. */
7
8
  const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
8
9
 
10
+ /** Default cap on concurrently RUNNING sessions (MAX_CONCURRENT_SESSIONS). */
11
+ const DEFAULT_MAX_CONCURRENT_SESSIONS = 3;
12
+
9
13
  /**
10
14
  * Budget for the host-side follow-up queue of ONE session (backends without a
11
15
  * native `followUp`; with one, messages go into the running turn and none of
@@ -32,56 +36,49 @@ export const QUEUE_MAX_BYTES = 50 * 1024 * 1024;
32
36
  export const MAX_SESSION_QUEUE = 50;
33
37
 
34
38
  export interface WsHostOptions {
35
- /** Session persistence seam; defaults to the package's SQLite catalog. */
36
- catalog?: SessionCatalog;
39
+ /** Backend registry resolving profiles/sessions to agent backends. */
40
+ registry: BackendRegistry;
41
+ /** Session persistence seam (SQLite catalog in production). */
42
+ catalog: SessionCatalog;
37
43
  /** Display name used in connection/status copy. */
38
44
  appName?: string;
39
45
  /** Per-turn timeout in ms (default 10 minutes). */
40
46
  turnTimeoutMs?: number;
41
47
  /**
42
- * Deployment-time cap on concurrent RUNNING sessions. Each running turn is
43
- * roughly one CLI subprocess, so this bounds memory/CPU. Read per request so
44
- * the deploy-time env is honored without a restart.
48
+ * Cap on concurrent RUNNING sessions. Each running turn is roughly one CLI
49
+ * subprocess, so this bounds memory/CPU. A function so an embedder can make
50
+ * it dynamic; createApp passes the resolved config value.
45
51
  */
46
52
  maxConcurrentSessions?: () => number;
47
53
  }
48
54
 
49
- function envMaxConcurrentSessions(): number {
50
- return Math.max(1, Number(process.env.MAX_CONCURRENT_SESSIONS) || 3);
51
- }
52
-
53
55
  /**
54
56
  * Everything one WebSocket coordinator instance owns: turn state, the session
55
- * catalog, branding copy, and the frame senders. Handlers receive this host
56
- * explicitly instead of reaching for module globals.
57
+ * catalog, the backend registry, the attached client sockets, branding copy,
58
+ * and the frame senders. Handlers receive this host explicitly — there is no
59
+ * module-level default host, so two apps coexist without sharing state.
57
60
  */
58
61
  export class WsHost {
59
62
  readonly coordinator = new TurnCoordinator();
63
+ readonly clients = new ClientSet();
64
+ readonly registry: BackendRegistry;
60
65
  catalog: SessionCatalog;
61
66
  appName: string;
62
67
  turnTimeoutMs: number;
63
68
  maxConcurrentSessions: () => number;
64
69
 
65
- constructor(options: WsHostOptions = {}) {
66
- this.catalog = options.catalog ?? createSessionCatalog();
70
+ constructor(options: WsHostOptions) {
71
+ this.registry = options.registry;
72
+ this.catalog = options.catalog;
67
73
  this.appName = options.appName ?? "Brain UI";
68
74
  this.turnTimeoutMs = options.turnTimeoutMs ?? DEFAULT_TIMEOUT_MS;
69
- this.maxConcurrentSessions = options.maxConcurrentSessions ?? envMaxConcurrentSessions;
70
- }
71
-
72
- /** Re-apply embedder options (createApp configures the default host). */
73
- configure(options: WsHostOptions): void {
74
- if (options.catalog) this.catalog = options.catalog;
75
- if (options.appName) this.appName = options.appName;
76
- if (options.turnTimeoutMs) this.turnTimeoutMs = options.turnTimeoutMs;
77
- if (options.maxConcurrentSessions) {
78
- this.maxConcurrentSessions = options.maxConcurrentSessions;
79
- }
75
+ this.maxConcurrentSessions =
76
+ options.maxConcurrentSessions ?? (() => DEFAULT_MAX_CONCURRENT_SESSIONS);
80
77
  }
81
78
 
82
79
  /** Fan a frame out to every attached client (size-bounded per frame). */
83
80
  sendToClients(msg: ServerMessage): void {
84
- broadcast(msg);
81
+ this.clients.broadcast(msg);
85
82
  }
86
83
 
87
84
  /** Send a frame to one specific socket (size-bounded). */
package/src/ws/routing.ts CHANGED
@@ -1,10 +1,5 @@
1
1
  import type { AgentBackend } from "@schlessera/brain-ui-sdk/server";
2
- import {
3
- getBackendForProfile,
4
- getBackendForSession,
5
- getDefaultBackend,
6
- listAllProviders,
7
- } from "../agent/backend.js";
2
+ import type { BackendRegistry } from "../agent/backend.js";
8
3
  import type { SessionCatalog } from "./session-catalog.js";
9
4
 
10
5
  /**
@@ -12,12 +7,13 @@ import type { SessionCatalog } from "./session-catalog.js";
12
7
  * their stored backend id; new sessions route by the requested profile owner.
13
8
  */
14
9
  export async function resolveTurnTarget(
10
+ registry: BackendRegistry,
15
11
  catalog: SessionCatalog,
16
12
  sessionId: string | undefined,
17
13
  requested: string | undefined
18
14
  ): Promise<{ profileId?: string; backend: AgentBackend; droppedPin?: string }> {
19
15
  if (sessionId) {
20
- const backend = await getBackendForSession(catalog.getStoredBackendId(sessionId));
16
+ const backend = await registry.getBackendForSession(catalog.getStoredBackendId(sessionId));
21
17
  const stored = catalog.getStoredProviderId(sessionId);
22
18
  const available = await backend.listProfiles();
23
19
  if (stored && available.some((profile) => profile.id === stored)) {
@@ -30,14 +26,14 @@ export async function resolveTurnTarget(
30
26
  }
31
27
 
32
28
  if (requested) {
33
- const available = await listAllProviders();
29
+ const available = await registry.listAllProviders();
34
30
  if (available.some((profile) => profile.id === requested)) {
35
- const backend = await getBackendForProfile(requested);
31
+ const backend = await registry.getBackendForProfile(requested);
36
32
  if (backend) return { backend, profileId: requested };
37
33
  }
38
34
  }
39
35
 
40
- const backend = await getDefaultBackend();
36
+ const backend = await registry.getDefaultBackend();
41
37
  const profileId = (await backend.listProfiles())[0]?.id;
42
38
  return { backend, ...(profileId ? { profileId } : {}) };
43
39
  }
@@ -26,7 +26,7 @@ export async function runSession(
26
26
  coordinator.startingSessions += 1;
27
27
  let target: Awaited<ReturnType<typeof resolveTurnTarget>>;
28
28
  try {
29
- target = await resolveTurnTarget(host.catalog, initial.sessionId, initial.providerId);
29
+ target = await resolveTurnTarget(host.registry, host.catalog, initial.sessionId, initial.providerId);
30
30
  } catch (err) {
31
31
  host.sendToClients({
32
32
  type: "error",
@@ -1,6 +1,5 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
  import type { ServerResultMessage } from "@schlessera/brain-ui-sdk/protocol";
3
- import { getDb } from "../db/client.js";
4
3
 
5
4
  /**
6
5
  * Persistence seam for session ownership + accounting. The ws coordinator only
@@ -42,8 +41,8 @@ const UPSERT_SESSION_SQL = `INSERT INTO sessions (id, title, created_at, last_ac
42
41
  provider_id = COALESCE(excluded.provider_id, provider_id),
43
42
  backend_id = COALESCE(excluded.backend_id, backend_id)`;
44
43
 
45
- /** SQLite-backed catalog over the package's session database. */
46
- export function createSessionCatalog(db: () => Database = getDb): SessionCatalog {
44
+ /** SQLite-backed catalog over the app's own database (injected by createApp). */
45
+ export function createSessionCatalog(db: () => Database): SessionCatalog {
47
46
  return {
48
47
  getStoredProviderId(sessionId) {
49
48
  const row = db()