@schlessera/brain-ui-server 0.4.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 (188) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/dist/agent/backend.d.ts +30 -0
  4. package/dist/agent/backend.d.ts.map +1 -0
  5. package/dist/agent/backend.js +226 -0
  6. package/dist/agent/backend.js.map +1 -0
  7. package/dist/app.d.ts +27 -0
  8. package/dist/app.d.ts.map +1 -0
  9. package/dist/app.js +116 -0
  10. package/dist/app.js.map +1 -0
  11. package/dist/brain/client.d.ts +43 -0
  12. package/dist/brain/client.d.ts.map +1 -0
  13. package/dist/brain/client.js +154 -0
  14. package/dist/brain/client.js.map +1 -0
  15. package/dist/brain/types.d.ts +41 -0
  16. package/dist/brain/types.d.ts.map +1 -0
  17. package/dist/brain/types.js +2 -0
  18. package/dist/brain/types.js.map +1 -0
  19. package/dist/cron/scheduler.d.ts +9 -0
  20. package/dist/cron/scheduler.d.ts.map +1 -0
  21. package/dist/cron/scheduler.js +58 -0
  22. package/dist/cron/scheduler.js.map +1 -0
  23. package/dist/db/client.d.ts +10 -0
  24. package/dist/db/client.d.ts.map +1 -0
  25. package/dist/db/client.js +73 -0
  26. package/dist/db/client.js.map +1 -0
  27. package/dist/files/walker.d.ts +63 -0
  28. package/dist/files/walker.d.ts.map +1 -0
  29. package/dist/files/walker.js +416 -0
  30. package/dist/files/walker.js.map +1 -0
  31. package/dist/index.d.ts +18 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +25 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/middleware/auth.d.ts +59 -0
  36. package/dist/middleware/auth.d.ts.map +1 -0
  37. package/dist/middleware/auth.js +246 -0
  38. package/dist/middleware/auth.js.map +1 -0
  39. package/dist/middleware/passkeys.d.ts +36 -0
  40. package/dist/middleware/passkeys.d.ts.map +1 -0
  41. package/dist/middleware/passkeys.js +427 -0
  42. package/dist/middleware/passkeys.js.map +1 -0
  43. package/dist/middleware/tailscale.d.ts +31 -0
  44. package/dist/middleware/tailscale.d.ts.map +1 -0
  45. package/dist/middleware/tailscale.js +64 -0
  46. package/dist/middleware/tailscale.js.map +1 -0
  47. package/dist/render/template.d.ts +7 -0
  48. package/dist/render/template.d.ts.map +1 -0
  49. package/dist/render/template.js +112 -0
  50. package/dist/render/template.js.map +1 -0
  51. package/dist/routes/brain.d.ts +155 -0
  52. package/dist/routes/brain.d.ts.map +1 -0
  53. package/dist/routes/brain.js +250 -0
  54. package/dist/routes/brain.js.map +1 -0
  55. package/dist/routes/files.d.ts +89 -0
  56. package/dist/routes/files.d.ts.map +1 -0
  57. package/dist/routes/files.js +91 -0
  58. package/dist/routes/files.js.map +1 -0
  59. package/dist/routes/health.d.ts +37 -0
  60. package/dist/routes/health.d.ts.map +1 -0
  61. package/dist/routes/health.js +27 -0
  62. package/dist/routes/health.js.map +1 -0
  63. package/dist/routes/providers.d.ts +33 -0
  64. package/dist/routes/providers.d.ts.map +1 -0
  65. package/dist/routes/providers.js +9 -0
  66. package/dist/routes/providers.js.map +1 -0
  67. package/dist/routes/render.d.ts +25 -0
  68. package/dist/routes/render.d.ts.map +1 -0
  69. package/dist/routes/render.js +47 -0
  70. package/dist/routes/render.js.map +1 -0
  71. package/dist/routes/sessions.d.ts +79 -0
  72. package/dist/routes/sessions.d.ts.map +1 -0
  73. package/dist/routes/sessions.js +37 -0
  74. package/dist/routes/sessions.js.map +1 -0
  75. package/dist/routes/voice.d.ts +92 -0
  76. package/dist/routes/voice.d.ts.map +1 -0
  77. package/dist/routes/voice.js +64 -0
  78. package/dist/routes/voice.js.map +1 -0
  79. package/dist/voice/deepgram-token.d.ts +5 -0
  80. package/dist/voice/deepgram-token.d.ts.map +1 -0
  81. package/dist/voice/deepgram-token.js +63 -0
  82. package/dist/voice/deepgram-token.js.map +1 -0
  83. package/dist/voice/keyterm-builder.d.ts +18 -0
  84. package/dist/voice/keyterm-builder.d.ts.map +1 -0
  85. package/dist/voice/keyterm-builder.js +391 -0
  86. package/dist/voice/keyterm-builder.js.map +1 -0
  87. package/dist/voice/speech-providers.d.ts +25 -0
  88. package/dist/voice/speech-providers.d.ts.map +1 -0
  89. package/dist/voice/speech-providers.js +104 -0
  90. package/dist/voice/speech-providers.js.map +1 -0
  91. package/dist/ws/attachments.d.ts +10 -0
  92. package/dist/ws/attachments.d.ts.map +1 -0
  93. package/dist/ws/attachments.js +63 -0
  94. package/dist/ws/attachments.js.map +1 -0
  95. package/dist/ws/bridge.d.ts +7 -0
  96. package/dist/ws/bridge.d.ts.map +1 -0
  97. package/dist/ws/bridge.js +86 -0
  98. package/dist/ws/bridge.js.map +1 -0
  99. package/dist/ws/clients.d.ts +20 -0
  100. package/dist/ws/clients.d.ts.map +1 -0
  101. package/dist/ws/clients.js +48 -0
  102. package/dist/ws/clients.js.map +1 -0
  103. package/dist/ws/connection.d.ts +8 -0
  104. package/dist/ws/connection.d.ts.map +1 -0
  105. package/dist/ws/connection.js +116 -0
  106. package/dist/ws/connection.js.map +1 -0
  107. package/dist/ws/dispatch.d.ts +5 -0
  108. package/dist/ws/dispatch.d.ts.map +1 -0
  109. package/dist/ws/dispatch.js +139 -0
  110. package/dist/ws/dispatch.js.map +1 -0
  111. package/dist/ws/frames.d.ts +17 -0
  112. package/dist/ws/frames.d.ts.map +1 -0
  113. package/dist/ws/frames.js +37 -0
  114. package/dist/ws/frames.js.map +1 -0
  115. package/dist/ws/handler.d.ts +34 -0
  116. package/dist/ws/handler.d.ts.map +1 -0
  117. package/dist/ws/handler.js +33 -0
  118. package/dist/ws/handler.js.map +1 -0
  119. package/dist/ws/history.d.ts +17 -0
  120. package/dist/ws/history.d.ts.map +1 -0
  121. package/dist/ws/history.js +45 -0
  122. package/dist/ws/history.js.map +1 -0
  123. package/dist/ws/host.d.ts +40 -0
  124. package/dist/ws/host.d.ts.map +1 -0
  125. package/dist/ws/host.js +49 -0
  126. package/dist/ws/host.js.map +1 -0
  127. package/dist/ws/routing.d.ts +12 -0
  128. package/dist/ws/routing.d.ts.map +1 -0
  129. package/dist/ws/routing.js +31 -0
  130. package/dist/ws/routing.js.map +1 -0
  131. package/dist/ws/run-session.d.ts +17 -0
  132. package/dist/ws/run-session.d.ts.map +1 -0
  133. package/dist/ws/run-session.js +160 -0
  134. package/dist/ws/run-session.js.map +1 -0
  135. package/dist/ws/session-catalog.d.ts +25 -0
  136. package/dist/ws/session-catalog.d.ts.map +1 -0
  137. package/dist/ws/session-catalog.js +45 -0
  138. package/dist/ws/session-catalog.js.map +1 -0
  139. package/dist/ws/shrink.d.ts +34 -0
  140. package/dist/ws/shrink.d.ts.map +1 -0
  141. package/dist/ws/shrink.js +91 -0
  142. package/dist/ws/shrink.js.map +1 -0
  143. package/dist/ws/turns.d.ts +77 -0
  144. package/dist/ws/turns.d.ts.map +1 -0
  145. package/dist/ws/turns.js +89 -0
  146. package/dist/ws/turns.js.map +1 -0
  147. package/migrations/001_initial.sql +29 -0
  148. package/migrations/002_session_provider.sql +1 -0
  149. package/migrations/003_session_backend.sql +5 -0
  150. package/migrations/004_drop_gemini_backend.sql +20 -0
  151. package/migrations/005_passkey_credentials.sql +18 -0
  152. package/package.json +72 -0
  153. package/src/agent/backend.ts +308 -0
  154. package/src/app.ts +159 -0
  155. package/src/brain/client.ts +186 -0
  156. package/src/brain/types.ts +44 -0
  157. package/src/cron/scheduler.ts +80 -0
  158. package/src/db/client.ts +85 -0
  159. package/src/files/walker.ts +418 -0
  160. package/src/index.ts +39 -0
  161. package/src/middleware/auth.ts +321 -0
  162. package/src/middleware/passkeys.ts +510 -0
  163. package/src/middleware/tailscale.ts +81 -0
  164. package/src/render/template.ts +124 -0
  165. package/src/routes/brain.ts +305 -0
  166. package/src/routes/files.ts +99 -0
  167. package/src/routes/health.ts +29 -0
  168. package/src/routes/providers.ts +9 -0
  169. package/src/routes/render.ts +68 -0
  170. package/src/routes/sessions.ts +49 -0
  171. package/src/routes/voice.ts +80 -0
  172. package/src/voice/deepgram-token.ts +80 -0
  173. package/src/voice/keyterm-builder.ts +426 -0
  174. package/src/voice/speech-providers.ts +111 -0
  175. package/src/ws/attachments.ts +86 -0
  176. package/src/ws/bridge.ts +139 -0
  177. package/src/ws/clients.ts +57 -0
  178. package/src/ws/connection.ts +132 -0
  179. package/src/ws/dispatch.ts +162 -0
  180. package/src/ws/frames.ts +42 -0
  181. package/src/ws/handler.ts +50 -0
  182. package/src/ws/history.ts +51 -0
  183. package/src/ws/host.ts +69 -0
  184. package/src/ws/routing.ts +43 -0
  185. package/src/ws/run-session.ts +192 -0
  186. package/src/ws/session-catalog.ts +97 -0
  187. package/src/ws/shrink.ts +95 -0
  188. package/src/ws/turns.ts +151 -0
@@ -0,0 +1,80 @@
1
+ import { Hono } from "hono";
2
+ import type {
3
+ VoiceKeytermsResponse,
4
+ VoiceTokenResponse,
5
+ VoiceSessionResponse,
6
+ } from "@schlessera/brain-ui-sdk/protocol";
7
+ import { mintDeepgramToken } from "../voice/deepgram-token.js";
8
+ import { getKeyterms } from "../voice/keyterm-builder.js";
9
+ import { pickSpeechProvider } from "../voice/speech-providers.js";
10
+
11
+ export const voiceRoutes = new Hono()
12
+ .post("/voice/session", async (c) => {
13
+ try {
14
+ const provider = pickSpeechProvider();
15
+ // Only fetch domain keyterms when the provider can use them.
16
+ const keyterms = provider.capabilities.keyterms
17
+ ? getKeyterms(false).keyterms
18
+ : [];
19
+ const session = await provider.createSession({ keyterms });
20
+ const body: VoiceSessionResponse = {
21
+ providerId: provider.id,
22
+ url: session.url,
23
+ ...(session.token !== undefined ? { token: session.token } : {}),
24
+ ...(session.params !== undefined ? { params: session.params } : {}),
25
+ expiresAt: session.expiresAt,
26
+ capabilities: provider.capabilities,
27
+ };
28
+ return c.json(body);
29
+ } catch (err) {
30
+ return c.json(
31
+ { error: err instanceof Error ? err.message : "Voice session failed" },
32
+ 500
33
+ );
34
+ }
35
+ })
36
+
37
+ // Deprecated alias, kept for client transition. Returns the old token shape
38
+ // by minting a Deepgram token directly; new clients use /voice/session.
39
+ .post("/voice/token", async (c) => {
40
+ try {
41
+ const { token, expiresAt } = await mintDeepgramToken(60);
42
+ const body: VoiceTokenResponse = { token, expiresAt };
43
+ return c.json(body);
44
+ } catch (err) {
45
+ return c.json(
46
+ { error: err instanceof Error ? err.message : "Token mint failed" },
47
+ 500
48
+ );
49
+ }
50
+ })
51
+
52
+ .get("/voice/keyterms", async (c) => {
53
+ const force = c.req.query("rebuild") === "1";
54
+ try {
55
+ const cache = getKeyterms(force);
56
+ const body: VoiceKeytermsResponse = {
57
+ keyterms: cache.keyterms,
58
+ generatedAt: cache.generatedAt,
59
+ count: cache.count,
60
+ };
61
+ return c.json(body);
62
+ } catch (err) {
63
+ return c.json(
64
+ { error: err instanceof Error ? err.message : "Keyterms failed" },
65
+ 500
66
+ );
67
+ }
68
+ })
69
+
70
+ .get("/voice/overrides", async (c) => {
71
+ try {
72
+ const cache = getKeyterms(false);
73
+ return c.json({ overrides: cache.overrides });
74
+ } catch (err) {
75
+ return c.json(
76
+ { error: err instanceof Error ? err.message : "Overrides failed" },
77
+ 500
78
+ );
79
+ }
80
+ });
@@ -0,0 +1,80 @@
1
+ // Deepgram token mint.
2
+ // Short-lived grant via /v1/auth/grant. The API key used to call grant needs at
3
+ // least *Member* project permission (NOT `keys:write`, which belongs to the
4
+ // older temporary-API-key flow). The minted token is scoped to usage::write on
5
+ // the core voice APIs and expires quickly, so it is safe to hand to the browser.
6
+ //
7
+ // We NEVER fall back to shipping the raw DEEPGRAM_API_KEY: it is full-scope
8
+ // (can create/delete keys, run batch jobs) and never expires, so a fallback
9
+ // would latch a full-power credential into every browser response on the first
10
+ // transient blip. If the grant cannot be minted we fail loudly (the caller
11
+ // 500s) — a temporarily broken voice feature is strictly better than a leaked
12
+ // master key.
13
+ // Docs: https://developers.deepgram.com/reference/token-based-auth-api/grant-token
14
+
15
+ interface DeepgramGrantResponse {
16
+ access_token: string;
17
+ expires_in: number;
18
+ }
19
+
20
+ // A terminal (non-retryable) grant failure — currently only a 403, meaning the
21
+ // key lacks the required permission. Thrown so it propagates past the retry.
22
+ class DeepgramGrantError extends Error {}
23
+
24
+ function requestGrant(apiKey: string, ttlSeconds: number): Promise<Response> {
25
+ return fetch("https://api.deepgram.com/v1/auth/grant", {
26
+ method: "POST",
27
+ headers: {
28
+ Authorization: `Token ${apiKey}`,
29
+ "Content-Type": "application/json",
30
+ },
31
+ body: JSON.stringify({ ttl_seconds: ttlSeconds }),
32
+ });
33
+ }
34
+
35
+ export async function mintDeepgramToken(ttlSeconds = 60): Promise<{
36
+ token: string;
37
+ expiresAt: number;
38
+ }> {
39
+ const apiKey = process.env.DEEPGRAM_API_KEY;
40
+ if (!apiKey) {
41
+ throw new Error("DEEPGRAM_API_KEY is not set");
42
+ }
43
+
44
+ // One retry for transient failures (5xx / 429 / network). A 403 is a hard
45
+ // config error and is not retried.
46
+ let lastError: unknown;
47
+ for (let attempt = 0; attempt < 2; attempt++) {
48
+ try {
49
+ const res = await requestGrant(apiKey, ttlSeconds);
50
+ if (res.ok) {
51
+ const data = (await res.json()) as DeepgramGrantResponse;
52
+ return {
53
+ token: data.access_token,
54
+ expiresAt: Date.now() + data.expires_in * 1000,
55
+ };
56
+ }
57
+ if (res.status === 403) {
58
+ throw new DeepgramGrantError(
59
+ "Deepgram grant rejected (403): the DEEPGRAM_API_KEY lacks Member " +
60
+ "permission. Grant it at least Member in the Deepgram console to " +
61
+ "enable short-lived voice tokens."
62
+ );
63
+ }
64
+ lastError = new Error(
65
+ `Deepgram grant failed (${res.status}): ${await res
66
+ .text()
67
+ .catch(() => "")}`
68
+ );
69
+ } catch (err) {
70
+ if (err instanceof DeepgramGrantError) throw err; // terminal — do not retry
71
+ lastError = err; // network error — allow one retry
72
+ }
73
+ }
74
+
75
+ throw new Error(
76
+ `Deepgram grant unavailable after retry: ${
77
+ lastError instanceof Error ? lastError.message : String(lastError)
78
+ }`
79
+ );
80
+ }
@@ -0,0 +1,426 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
3
+ import { join, dirname } from "path";
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
+ );
15
+
16
+ // Bump whenever the extractor logic, scoring, or stoplists change so that
17
+ // post-deploy the first request rebuilds the cache instead of serving a
18
+ // stale snapshot baked under the old algorithm.
19
+ const CACHE_VERSION = 2;
20
+
21
+ const OVERRIDES_PATH = join(BRAIN_PATH, ".voice-overrides.md");
22
+
23
+ // Common English words to exclude from extracted vocab.
24
+ // Kept short and aggressive — anything ambiguous, drop it.
25
+ const STOPLIST = new Set([
26
+ "the", "and", "for", "with", "from", "this", "that", "into", "your",
27
+ "have", "will", "been", "were", "they", "their", "them", "what", "when",
28
+ "where", "which", "while", "would", "could", "should", "about", "after",
29
+ "before", "between", "during", "through", "than", "then", "also", "such",
30
+ "some", "any", "all", "more", "most", "other", "over", "under",
31
+ "you", "are", "was", "but", "not", "now", "use", "via", "let", "get",
32
+ "yes", "no", "ok",
33
+ // common English ALL-CAPS that are not jargon
34
+ "USA", "UK", "EU", "AM", "PM", "OK", "TV", "DVD", "USB",
35
+ "NOT", "IT", "JD", "FACTS", "FAQ", "ID", "OS",
36
+ ]);
37
+
38
+ // Generic English nouns/adjectives that frequently appear in tags or
39
+ // document titles but carry no proper-noun signal worth biasing for.
40
+ // Lower-cased compare. A multi-word entry with ANY of these tokens (and no
41
+ // proper-noun tokens) is dropped from titles/tags.
42
+ const GENERIC_TERMS = new Set([
43
+ "summit", "focus", "schema", "integration", "evolution", "framework",
44
+ "strategy", "risk", "constraints", "architecture", "development",
45
+ "interview", "angle", "narrative", "listing", "unique", "key",
46
+ "takeaways", "title", "options", "core", "recommendation", "practical",
47
+ "implementation", "technical", "depth", "track", "fit", "reverse", "deep",
48
+ "dive", "measurement", "challenge", "balanced", "perspective", "global",
49
+ "relevance", "complexity", "developer", "job", "requisition",
50
+ "performance", "optimization", "data", "overhaul", "measurement",
51
+ "managed", "internal", "smart", "search", "vector", "database",
52
+ "gateway", "engine", "platform", "service", "system", "process",
53
+ "approach", "method", "concept", "feature", "decision", "summary",
54
+ "overview", "background", "context", "details", "notes", "content",
55
+ ]);
56
+
57
+ function looksLikeProperNoun(token: string): boolean {
58
+ if (!token) return false;
59
+ // ALL-CAPS acronym (>=2 chars)
60
+ if (/^[A-Z][A-Z0-9-]{1,7}$/.test(token)) return true;
61
+ // Contains accented/umlaut characters
62
+ if (/[À-ſ]/.test(token)) return true;
63
+ // CamelCase (capital after lowercase, e.g. "DiTomaso", "WordPress")
64
+ if (/[a-z][A-Z]/.test(token)) return true;
65
+ // Capitalized word that is not a generic common noun
66
+ if (/^[A-Z][a-z]+$/.test(token) && !GENERIC_TERMS.has(token.toLowerCase())) {
67
+ return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ function hasProperNounSignal(term: string): boolean {
73
+ const tokens = term.split(/\s+/);
74
+ return tokens.some(looksLikeProperNoun);
75
+ }
76
+
77
+ // Stricter check used for multi-word phrases: at least one token must be a
78
+ // real proper-noun-shaped capitalized word (not just an ALL-CAPS acronym),
79
+ // AND no token is a generic common English noun. This rejects phrases like
80
+ // "Listing URL", "Key Takeaways", "Core Narrative" that pass the loose check.
81
+ function isStrongProperNounPhrase(term: string): boolean {
82
+ const tokens = term.split(/\s+/);
83
+ if (tokens.length === 1) return looksLikeProperNoun(tokens[0]);
84
+ let hasNamedToken = false;
85
+ for (const t of tokens) {
86
+ if (GENERIC_TERMS.has(t.toLowerCase())) return false;
87
+ if (
88
+ /[À-ſ]/.test(t) ||
89
+ /[a-z][A-Z]/.test(t) ||
90
+ (/^[A-Z][a-z]{2,}$/.test(t) && !GENERIC_TERMS.has(t.toLowerCase()))
91
+ ) {
92
+ hasNamedToken = true;
93
+ }
94
+ }
95
+ return hasNamedToken;
96
+ }
97
+
98
+ const ALL_CAPS_KEEP = new Set<string>([
99
+ // Things you DO want even if they look generic
100
+ ]);
101
+
102
+ interface TagRow {
103
+ name: string;
104
+ }
105
+
106
+ interface LinkRow {
107
+ target: string;
108
+ }
109
+
110
+ export interface BuildOptions {
111
+ limit?: number;
112
+ brainPath?: string;
113
+ }
114
+
115
+ export interface KeytermsCache {
116
+ version: number;
117
+ keyterms: string[];
118
+ generatedAt: number;
119
+ count: number;
120
+ overrides: PronunciationOverride[];
121
+ }
122
+
123
+ function normalize(s: string): string {
124
+ return s.trim().replace(/\s+/g, " ");
125
+ }
126
+
127
+ function extractTags(db: Database): Keyterm[] {
128
+ const rows = db.query("SELECT name FROM tags").all() as TagRow[];
129
+ const out: Keyterm[] = [];
130
+ for (const r of rows) {
131
+ const t = normalize(r.name);
132
+ if (!t || t.length < 3) continue;
133
+ if (STOPLIST.has(t.toLowerCase())) continue;
134
+ if (GENERIC_TERMS.has(t.toLowerCase())) continue;
135
+ if (!hasProperNounSignal(t)) continue;
136
+ out.push({ term: t, score: 5, source: "tag" });
137
+ }
138
+ return out;
139
+ }
140
+
141
+ function extractTitles(db: Database): Keyterm[] {
142
+ const rows = db
143
+ .query(
144
+ "SELECT title FROM documents WHERE type IN ('network','project','talk','note','expertise','context')"
145
+ )
146
+ .all() as { title: string }[];
147
+ const out: Keyterm[] = [];
148
+ for (const { title } of rows) {
149
+ const t = normalize(title);
150
+ if (!t || t.length < 4) continue;
151
+ if (/^(active|archived?|index|key|notable|current)\s/i.test(t)) continue;
152
+ if (!isStrongProperNounPhrase(t)) continue;
153
+ out.push({ term: t, score: 4, source: "title" });
154
+ }
155
+ return out;
156
+ }
157
+
158
+ // Curated path hierarchy maps to company/project names. Each subdirectory
159
+ // under these prefixes corresponds to an entity the user actively engages with.
160
+ // These are HUMAN-CURATED whitelists — every entry was added intentionally —
161
+ // so they outrank frequency-derived signals like acronym counts.
162
+ const PATH_PREFIXES: Array<{ prefix: string; score: number }> = [
163
+ { prefix: "career/opportunities/", score: 14 }, // active interview pipeline
164
+ { prefix: "projects/active/", score: 13 },
165
+ { prefix: "projects/catalog/", score: 9 },
166
+ { prefix: "projects/archive/", score: 5 },
167
+ ];
168
+
169
+ function kebabToTitle(slug: string): string {
170
+ // Promote known acronym-ish 2-3 char tokens to ALL-CAPS.
171
+ const acronymHints = new Set([
172
+ "ai", "ml", "api", "sdk", "cli", "css", "html", "wp", "us", "uk",
173
+ "eu", "io", "js", "ts", "qa", "hr", "vp",
174
+ ]);
175
+ return slug
176
+ .split("-")
177
+ .map((tok) => {
178
+ if (!tok) return tok;
179
+ if (acronymHints.has(tok.toLowerCase())) return tok.toUpperCase();
180
+ return tok[0].toUpperCase() + tok.slice(1);
181
+ })
182
+ .join(" ");
183
+ }
184
+
185
+ function extractPaths(db: Database): Keyterm[] {
186
+ const out: Keyterm[] = [];
187
+ const seen = new Set<string>();
188
+ for (const { prefix, score } of PATH_PREFIXES) {
189
+ const rows = db
190
+ .query("SELECT DISTINCT path FROM documents WHERE path LIKE ?")
191
+ .all(`${prefix}%`) as { path: string }[];
192
+ for (const { path } of rows) {
193
+ const tail = path.slice(prefix.length);
194
+ const segment = tail.split("/")[0];
195
+ if (!segment || segment.startsWith("_")) continue;
196
+ // Skip files at the prefix root (e.g. an _index.md was filtered above)
197
+ if (segment.endsWith(".md") || segment.endsWith(".pdf")) continue;
198
+ const name = kebabToTitle(segment);
199
+ if (!name || name.length < 2) continue;
200
+ const key = name.toLowerCase();
201
+ if (seen.has(key)) continue;
202
+ seen.add(key);
203
+ out.push({ term: name, score, source: "title" });
204
+ }
205
+ }
206
+ return out;
207
+ }
208
+
209
+ function extractLinks(db: Database): Keyterm[] {
210
+ const rows = db.query("SELECT DISTINCT target FROM links").all() as LinkRow[];
211
+ const out: Keyterm[] = [];
212
+ for (const { target } of rows) {
213
+ if (!target) continue;
214
+ if (target.includes("/") || target.startsWith("#")) continue;
215
+ const t = normalize(target);
216
+ if (t.length < 3) continue;
217
+ if (STOPLIST.has(t.toLowerCase())) continue;
218
+ if (GENERIC_TERMS.has(t.toLowerCase())) continue;
219
+ if (!hasProperNounSignal(t)) continue;
220
+ out.push({ term: t, score: 4, source: "link" });
221
+ }
222
+ return out;
223
+ }
224
+
225
+ const BOLD_NAME_RE =
226
+ /\*\*([A-ZÀ-ſ][\p{L}'.-]+(?:\s+(?:de|von|van|del|la|le|der|den|di)\s+|\s+)[A-ZÀ-ſ][\p{L}'.-]+(?:\s+[A-ZÀ-ſ][\p{L}'.-]+)?)\*\*/gu;
227
+
228
+ const ACRONYM_RE = /\b([A-Z]{2,8}(?:-[A-Z0-9]{1,4})?)\b/g;
229
+
230
+ const NETWORK_TYPE = "network";
231
+ // People names from network/ are kept only if they're mentioned this often
232
+ // across the entire brain. Filters out one-off acquaintances, keeps recurring
233
+ // day-job contacts.
234
+ const NETWORK_NAME_MIN_MENTIONS = 3;
235
+
236
+ interface ContentRow {
237
+ title: string;
238
+ content: string;
239
+ type: string;
240
+ }
241
+
242
+ function extractFromContent(db: Database): Keyterm[] {
243
+ const rows = db
244
+ .query(
245
+ "SELECT title, content, type FROM documents WHERE asset_type = 'markdown'"
246
+ )
247
+ .all() as ContentRow[];
248
+
249
+ // Collect bold matches separately by source role
250
+ const networkNameCandidates = new Set<string>();
251
+ const otherBoldCounts = new Map<string, number>();
252
+ const acronymCounts = new Map<string, number>();
253
+
254
+ for (const { content, type } of rows) {
255
+ if (!content) continue;
256
+
257
+ for (const m of content.matchAll(BOLD_NAME_RE)) {
258
+ const name = normalize(m[1]);
259
+ if (!name) continue;
260
+ const tokens = name.split(/\s+/);
261
+ if (tokens.some((t) => STOPLIST.has(t.toLowerCase()))) continue;
262
+ if (type === NETWORK_TYPE) {
263
+ networkNameCandidates.add(name);
264
+ } else {
265
+ otherBoldCounts.set(name, (otherBoldCounts.get(name) || 0) + 1);
266
+ }
267
+ }
268
+
269
+ for (const m of content.matchAll(ACRONYM_RE)) {
270
+ const ac = m[1];
271
+ if (STOPLIST.has(ac) && !ALL_CAPS_KEEP.has(ac)) continue;
272
+ acronymCounts.set(ac, (acronymCounts.get(ac) || 0) + 1);
273
+ }
274
+ }
275
+
276
+ // For each network name candidate, count brain-wide mentions (substring,
277
+ // word-bounded). Names that don't recur enough are dropped.
278
+ const networkNameCounts = new Map<string, number>();
279
+ if (networkNameCandidates.size > 0) {
280
+ const candidates = [...networkNameCandidates];
281
+ for (const { content } of rows) {
282
+ if (!content) continue;
283
+ for (const name of candidates) {
284
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
285
+ const re = new RegExp(`\\b${escaped}\\b`, "g");
286
+ const matches = content.match(re);
287
+ if (matches) {
288
+ networkNameCounts.set(
289
+ name,
290
+ (networkNameCounts.get(name) || 0) + matches.length
291
+ );
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ const out: Keyterm[] = [];
298
+
299
+ for (const [term, count] of networkNameCounts) {
300
+ if (count < NETWORK_NAME_MIN_MENTIONS) continue;
301
+ if (!isStrongProperNounPhrase(term)) continue;
302
+ out.push({ term, score: 9 + Math.log2(1 + count), source: "bold" });
303
+ const tokens = term.split(/\s+/);
304
+ if (tokens.length >= 2) {
305
+ const last = tokens[tokens.length - 1];
306
+ if (
307
+ last.length >= 4 &&
308
+ !STOPLIST.has(last.toLowerCase()) &&
309
+ !GENERIC_TERMS.has(last.toLowerCase()) &&
310
+ looksLikeProperNoun(last)
311
+ ) {
312
+ out.push({
313
+ term: last,
314
+ score: 4 + Math.log2(1 + count),
315
+ source: "bold",
316
+ });
317
+ }
318
+ }
319
+ }
320
+
321
+ for (const [term, count] of otherBoldCounts) {
322
+ if (count < 1) continue;
323
+ if (!isStrongProperNounPhrase(term)) continue;
324
+ out.push({ term, score: 8 + Math.log2(1 + count), source: "bold" });
325
+ }
326
+
327
+ for (const [term, count] of acronymCounts) {
328
+ if (count < 2) continue;
329
+ out.push({ term, score: 4 + Math.log2(1 + count), source: "acronym" });
330
+ }
331
+ return out;
332
+ }
333
+
334
+ function dedupeAndRank(terms: Keyterm[], limit: number): string[] {
335
+ const byTerm = new Map<string, Keyterm>();
336
+ for (const k of terms) {
337
+ const key = k.term.toLowerCase();
338
+ const prev = byTerm.get(key);
339
+ if (!prev || k.score > prev.score) {
340
+ byTerm.set(key, k);
341
+ } else {
342
+ // accumulate score for repeated finds across sources
343
+ prev.score += k.score * 0.25;
344
+ }
345
+ }
346
+ const sorted = [...byTerm.values()].sort((a, b) => b.score - a.score);
347
+ return sorted.slice(0, limit).map((k) => k.term);
348
+ }
349
+
350
+ export function loadOverrides(): PronunciationOverride[] {
351
+ if (!existsSync(OVERRIDES_PATH)) return [];
352
+ try {
353
+ const md = readFileSync(OVERRIDES_PATH, "utf-8");
354
+ const out: PronunciationOverride[] = [];
355
+ for (const line of md.split("\n")) {
356
+ // Format: - Doe → DOH (also accepts "->")
357
+ const m = line.match(/^\s*-\s+(.+?)\s+(?:→|->)\s+(.+?)\s*$/);
358
+ if (!m) continue;
359
+ const match = m[1].trim();
360
+ const replacement = m[2].trim();
361
+ if (match && replacement) out.push({ match, replacement });
362
+ }
363
+ return out;
364
+ } catch (err) {
365
+ console.warn("[voice] Failed to load overrides:", err);
366
+ return [];
367
+ }
368
+ }
369
+
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
+ }
378
+
379
+ const db = new Database(dbPath, { readonly: true });
380
+ 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();
398
+ }
399
+ }
400
+
401
+ export function writeCache(cache: KeytermsCache): void {
402
+ const dir = dirname(CACHE_PATH);
403
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
404
+ writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2), "utf-8");
405
+ }
406
+
407
+ export function readCache(): KeytermsCache | null {
408
+ if (!existsSync(CACHE_PATH)) return null;
409
+ try {
410
+ const cache = JSON.parse(readFileSync(CACHE_PATH, "utf-8")) as KeytermsCache;
411
+ if (cache.version !== CACHE_VERSION) return null;
412
+ return cache;
413
+ } catch {
414
+ return null;
415
+ }
416
+ }
417
+
418
+ export function getKeyterms(forceRebuild = false): KeytermsCache {
419
+ if (!forceRebuild) {
420
+ const cached = readCache();
421
+ if (cached) return cached;
422
+ }
423
+ const fresh = buildKeyterms();
424
+ writeCache(fresh);
425
+ return fresh;
426
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Server side of the STT seam. A SpeechProvider mints the per-session
3
+ * connection material the client's matching AsrClient needs. Built-ins:
4
+ *
5
+ * - `deepgram` — mints a short-lived Deepgram token and the full streaming WS
6
+ * URL (base params + domain keyterms baked in).
7
+ * - `webspeech` — no server session; the browser's SpeechRecognition does the
8
+ * work, so it returns empty url/token.
9
+ *
10
+ * VOICE_PROVIDER selects the provider; default is deepgram when
11
+ * DEEPGRAM_API_KEY is set, else webspeech.
12
+ */
13
+
14
+ import type { SpeechProvider } from "@schlessera/brain-ui-sdk/server";
15
+ import { defineSpeechProvider } from "@schlessera/brain-ui-sdk/server";
16
+ import { mintDeepgramToken } from "./deepgram-token.js";
17
+
18
+ const DEEPGRAM_WS_URL = "wss://api.deepgram.com/v1/listen";
19
+
20
+ // Match the previous client-side caps: Deepgram nova-3 handles ~500 keyterms
21
+ // semantically, but the WS connect URL has a practical length ceiling.
22
+ const MAX_KEYTERMS = 100;
23
+ const MAX_URL_BYTES = 6000;
24
+ const TOKEN_TTL_SECONDS = 60;
25
+
26
+ /** Build the full Deepgram streaming URL with base params + capped keyterms. */
27
+ function buildDeepgramUrl(keyterms: string[]): string {
28
+ const params = new URLSearchParams({
29
+ model: "nova-3",
30
+ language: "en",
31
+ smart_format: "true",
32
+ interim_results: "true",
33
+ utterance_end_ms: "1500",
34
+ vad_events: "true",
35
+ encoding: "opus",
36
+ mip_opt_out: "true",
37
+ });
38
+ let url = `${DEEPGRAM_WS_URL}?${params.toString()}`;
39
+ let added = 0;
40
+ for (const term of keyterms) {
41
+ if (added >= MAX_KEYTERMS) break;
42
+ const candidate = `${url}&keyterm=${encodeURIComponent(term)}`;
43
+ if (candidate.length > MAX_URL_BYTES) break;
44
+ url = candidate;
45
+ added++;
46
+ }
47
+ return url;
48
+ }
49
+
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
+ });
67
+
68
+ export const webspeechSpeechProvider: SpeechProvider = defineSpeechProvider({
69
+ id: "webspeech",
70
+ capabilities: {
71
+ streaming: true,
72
+ interimResults: true,
73
+ keyterms: false,
74
+ endpointing: false,
75
+ },
76
+ async createSession() {
77
+ // No server session — the browser's SpeechRecognition does everything.
78
+ return { url: "", expiresAt: 0 };
79
+ },
80
+ });
81
+
82
+ /**
83
+ * Select the active speech provider from env.
84
+ *
85
+ * webspeech is OPT-IN ONLY (`VOICE_PROVIDER=webspeech`): on Chromium it streams
86
+ * microphone audio to Google, so it must never be a silent fallback when the
87
+ * Deepgram key goes missing. Every other unresolved case throws so the caller
88
+ * 500s loudly instead of quietly degrading to third-party egress.
89
+ */
90
+ export function pickSpeechProvider(): SpeechProvider {
91
+ const configured = process.env.VOICE_PROVIDER?.trim().toLowerCase();
92
+ if (configured === "webspeech") return webspeechSpeechProvider;
93
+ if (configured === "deepgram") {
94
+ if (!process.env.DEEPGRAM_API_KEY) {
95
+ throw new Error("VOICE_PROVIDER=deepgram but DEEPGRAM_API_KEY is not set.");
96
+ }
97
+ return deepgramSpeechProvider;
98
+ }
99
+ if (configured) {
100
+ throw new Error(
101
+ `Unknown VOICE_PROVIDER="${configured}" (expected "deepgram" or "webspeech").`
102
+ );
103
+ }
104
+ // Auto-detect: Deepgram when its key is present; otherwise fail loudly.
105
+ if (process.env.DEEPGRAM_API_KEY) return deepgramSpeechProvider;
106
+ throw new Error(
107
+ 'No speech provider configured. Set DEEPGRAM_API_KEY, or set ' +
108
+ 'VOICE_PROVIDER=webspeech to explicitly opt into the browser speech API ' +
109
+ "(which streams audio to Google on Chromium)."
110
+ );
111
+ }