@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.
- package/README.md +51 -12
- package/dist/agent/backend.d.ts +163 -37
- package/dist/agent/backend.d.ts.map +1 -1
- package/dist/agent/backend.js +281 -286
- package/dist/agent/backend.js.map +1 -1
- package/dist/app.d.ts +46 -5
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +94 -35
- package/dist/app.js.map +1 -1
- package/dist/brain/client.d.ts +41 -30
- package/dist/brain/client.d.ts.map +1 -1
- package/dist/brain/client.js +138 -132
- package/dist/brain/client.js.map +1 -1
- package/dist/config/env.d.ts +104 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +296 -0
- package/dist/config/env.js.map +1 -0
- package/dist/cron/scheduler.d.ts +16 -8
- package/dist/cron/scheduler.d.ts.map +1 -1
- package/dist/cron/scheduler.js +54 -53
- package/dist/cron/scheduler.js.map +1 -1
- package/dist/db/brain-db.d.ts +50 -0
- package/dist/db/brain-db.d.ts.map +1 -0
- package/dist/db/brain-db.js +75 -0
- package/dist/db/brain-db.js.map +1 -0
- package/dist/db/client.d.ts +8 -6
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +12 -25
- package/dist/db/client.js.map +1 -1
- package/dist/db/settings.d.ts +5 -4
- package/dist/db/settings.d.ts.map +1 -1
- package/dist/db/settings.js +9 -12
- package/dist/db/settings.js.map +1 -1
- package/dist/files/walker.d.ts +6 -7
- package/dist/files/walker.d.ts.map +1 -1
- package/dist/files/walker.js +6 -9
- package/dist/files/walker.js.map +1 -1
- package/dist/graph/reader.d.ts +6 -6
- package/dist/graph/reader.d.ts.map +1 -1
- package/dist/graph/reader.js +22 -12
- package/dist/graph/reader.js.map +1 -1
- package/dist/index.d.ts +15 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -10
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +17 -6
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +34 -43
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/origin.d.ts +1 -2
- package/dist/middleware/origin.d.ts.map +1 -1
- package/dist/middleware/origin.js +1 -7
- package/dist/middleware/origin.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +29 -5
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +47 -76
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/middleware/tailscale.d.ts +14 -11
- package/dist/middleware/tailscale.d.ts.map +1 -1
- package/dist/middleware/tailscale.js +11 -10
- package/dist/middleware/tailscale.js.map +1 -1
- package/dist/routes/brain.d.ts +9 -154
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +187 -188
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.d.ts +4 -88
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +74 -71
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/graph.d.ts +4 -255
- package/dist/routes/graph.d.ts.map +1 -1
- package/dist/routes/graph.js +67 -64
- package/dist/routes/graph.js.map +1 -1
- package/dist/routes/health.d.ts +8 -22
- package/dist/routes/health.d.ts.map +1 -1
- package/dist/routes/health.js +10 -10
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/models.d.ts +7 -90
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +53 -51
- package/dist/routes/models.js.map +1 -1
- package/dist/routes/providers.d.ts +5 -34
- package/dist/routes/providers.d.ts.map +1 -1
- package/dist/routes/providers.js +13 -11
- package/dist/routes/providers.js.map +1 -1
- package/dist/routes/sessions.d.ts +7 -78
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +35 -34
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/share.d.ts +7 -60
- package/dist/routes/share.d.ts.map +1 -1
- package/dist/routes/share.js +80 -77
- package/dist/routes/share.js.map +1 -1
- package/dist/routes/voice.d.ts +8 -91
- package/dist/routes/voice.d.ts.map +1 -1
- package/dist/routes/voice.js +62 -59
- package/dist/routes/voice.js.map +1 -1
- package/dist/share/staging.d.ts +4 -4
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +7 -8
- package/dist/share/staging.js.map +1 -1
- package/dist/voice/deepgram-token.d.ts +1 -1
- package/dist/voice/deepgram-token.d.ts.map +1 -1
- package/dist/voice/deepgram-token.js +1 -2
- package/dist/voice/deepgram-token.js.map +1 -1
- package/dist/voice/keyterm-builder.d.ts +23 -8
- package/dist/voice/keyterm-builder.d.ts.map +1 -1
- package/dist/voice/keyterm-builder.js +67 -43
- package/dist/voice/keyterm-builder.js.map +1 -1
- package/dist/voice/speech-providers.d.ts +6 -4
- package/dist/voice/speech-providers.d.ts.map +1 -1
- package/dist/voice/speech-providers.js +28 -25
- package/dist/voice/speech-providers.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +2 -3
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/clients.d.ts +18 -10
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +35 -37
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +5 -7
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +1 -2
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/host.d.ts +16 -12
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +14 -23
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/routing.d.ts +2 -1
- package/dist/ws/routing.d.ts.map +1 -1
- package/dist/ws/routing.js +5 -6
- package/dist/ws/routing.js.map +1 -1
- package/dist/ws/run-session.js +1 -1
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/session-catalog.d.ts +2 -2
- package/dist/ws/session-catalog.d.ts.map +1 -1
- package/dist/ws/session-catalog.js +2 -3
- package/dist/ws/session-catalog.js.map +1 -1
- package/package.json +8 -5
- package/src/agent/backend.ts +509 -340
- package/src/app.ts +157 -36
- package/src/brain/client.ts +166 -143
- package/src/config/env.ts +401 -0
- package/src/cron/scheduler.ts +77 -59
- package/src/db/brain-db.ts +104 -0
- package/src/db/client.ts +12 -31
- package/src/db/settings.ts +12 -14
- package/src/files/walker.ts +6 -10
- package/src/graph/reader.ts +26 -15
- package/src/index.ts +56 -17
- package/src/middleware/auth.ts +51 -45
- package/src/middleware/origin.ts +1 -8
- package/src/middleware/passkeys.ts +72 -68
- package/src/middleware/tailscale.ts +22 -17
- package/src/routes/brain.ts +31 -30
- package/src/routes/files.ts +10 -7
- package/src/routes/graph.ts +9 -6
- package/src/routes/health.ts +17 -10
- package/src/routes/models.ts +19 -15
- package/src/routes/providers.ts +14 -15
- package/src/routes/sessions.ts +18 -11
- package/src/routes/share.ts +35 -24
- package/src/routes/voice.ts +78 -68
- package/src/share/staging.ts +10 -8
- package/src/voice/deepgram-token.ts +4 -2
- package/src/voice/keyterm-builder.ts +90 -56
- package/src/voice/speech-providers.ts +28 -24
- package/src/ws/bridge.ts +2 -3
- package/src/ws/clients.ts +38 -41
- package/src/ws/connection.ts +5 -7
- package/src/ws/dispatch.ts +1 -2
- package/src/ws/host.ts +24 -27
- package/src/ws/routing.ts +6 -10
- package/src/ws/run-session.ts +1 -1
- package/src/ws/session-catalog.ts +2 -3
- package/dist/ws/handler.d.ts +0 -34
- package/dist/ws/handler.d.ts.map +0 -1
- package/dist/ws/handler.js +0 -33
- package/dist/ws/handler.js.map +0 -1
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
+
export function loadOverrides(brainPath: string): PronunciationOverride[] {
|
|
363
|
+
const path = overridesPath(brainPath);
|
|
364
|
+
if (!existsSync(path)) return [];
|
|
352
365
|
try {
|
|
353
|
-
const md = readFileSync(
|
|
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(
|
|
371
|
-
const
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
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(
|
|
435
|
+
writeFileSync(path, JSON.stringify(cache, null, 2), "utf-8");
|
|
405
436
|
}
|
|
406
437
|
|
|
407
|
-
export function readCache(): KeytermsCache | null {
|
|
408
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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 =
|
|
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 (!
|
|
98
|
+
if (!voice.deepgramApiKey) {
|
|
95
99
|
throw new Error("VOICE_PROVIDER=deepgram but DEEPGRAM_API_KEY is not set.");
|
|
96
100
|
}
|
|
97
|
-
return
|
|
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 (
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
}
|
package/src/ws/connection.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/ws/dispatch.ts
CHANGED
|
@@ -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 {
|
|
2
|
+
import { ClientSet, sendTo, type WSContext } from "./clients.js";
|
|
3
3
|
import { TurnCoordinator } from "./turns.js";
|
|
4
|
-
import {
|
|
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
|
-
/**
|
|
36
|
-
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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,
|
|
56
|
-
*
|
|
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.
|
|
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 =
|
|
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
|
}
|
package/src/ws/run-session.ts
CHANGED
|
@@ -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
|
|
46
|
-
export function createSessionCatalog(db: () => Database
|
|
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()
|