@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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/agent/backend.d.ts +30 -0
- package/dist/agent/backend.d.ts.map +1 -0
- package/dist/agent/backend.js +226 -0
- package/dist/agent/backend.js.map +1 -0
- package/dist/app.d.ts +27 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +116 -0
- package/dist/app.js.map +1 -0
- package/dist/brain/client.d.ts +43 -0
- package/dist/brain/client.d.ts.map +1 -0
- package/dist/brain/client.js +154 -0
- package/dist/brain/client.js.map +1 -0
- package/dist/brain/types.d.ts +41 -0
- package/dist/brain/types.d.ts.map +1 -0
- package/dist/brain/types.js +2 -0
- package/dist/brain/types.js.map +1 -0
- package/dist/cron/scheduler.d.ts +9 -0
- package/dist/cron/scheduler.d.ts.map +1 -0
- package/dist/cron/scheduler.js +58 -0
- package/dist/cron/scheduler.js.map +1 -0
- package/dist/db/client.d.ts +10 -0
- package/dist/db/client.d.ts.map +1 -0
- package/dist/db/client.js +73 -0
- package/dist/db/client.js.map +1 -0
- package/dist/files/walker.d.ts +63 -0
- package/dist/files/walker.d.ts.map +1 -0
- package/dist/files/walker.js +416 -0
- package/dist/files/walker.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.d.ts +59 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +246 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/passkeys.d.ts +36 -0
- package/dist/middleware/passkeys.d.ts.map +1 -0
- package/dist/middleware/passkeys.js +427 -0
- package/dist/middleware/passkeys.js.map +1 -0
- package/dist/middleware/tailscale.d.ts +31 -0
- package/dist/middleware/tailscale.d.ts.map +1 -0
- package/dist/middleware/tailscale.js +64 -0
- package/dist/middleware/tailscale.js.map +1 -0
- package/dist/render/template.d.ts +7 -0
- package/dist/render/template.d.ts.map +1 -0
- package/dist/render/template.js +112 -0
- package/dist/render/template.js.map +1 -0
- package/dist/routes/brain.d.ts +155 -0
- package/dist/routes/brain.d.ts.map +1 -0
- package/dist/routes/brain.js +250 -0
- package/dist/routes/brain.js.map +1 -0
- package/dist/routes/files.d.ts +89 -0
- package/dist/routes/files.d.ts.map +1 -0
- package/dist/routes/files.js +91 -0
- package/dist/routes/files.js.map +1 -0
- package/dist/routes/health.d.ts +37 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +27 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/providers.d.ts +33 -0
- package/dist/routes/providers.d.ts.map +1 -0
- package/dist/routes/providers.js +9 -0
- package/dist/routes/providers.js.map +1 -0
- package/dist/routes/render.d.ts +25 -0
- package/dist/routes/render.d.ts.map +1 -0
- package/dist/routes/render.js +47 -0
- package/dist/routes/render.js.map +1 -0
- package/dist/routes/sessions.d.ts +79 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +37 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/voice.d.ts +92 -0
- package/dist/routes/voice.d.ts.map +1 -0
- package/dist/routes/voice.js +64 -0
- package/dist/routes/voice.js.map +1 -0
- package/dist/voice/deepgram-token.d.ts +5 -0
- package/dist/voice/deepgram-token.d.ts.map +1 -0
- package/dist/voice/deepgram-token.js +63 -0
- package/dist/voice/deepgram-token.js.map +1 -0
- package/dist/voice/keyterm-builder.d.ts +18 -0
- package/dist/voice/keyterm-builder.d.ts.map +1 -0
- package/dist/voice/keyterm-builder.js +391 -0
- package/dist/voice/keyterm-builder.js.map +1 -0
- package/dist/voice/speech-providers.d.ts +25 -0
- package/dist/voice/speech-providers.d.ts.map +1 -0
- package/dist/voice/speech-providers.js +104 -0
- package/dist/voice/speech-providers.js.map +1 -0
- package/dist/ws/attachments.d.ts +10 -0
- package/dist/ws/attachments.d.ts.map +1 -0
- package/dist/ws/attachments.js +63 -0
- package/dist/ws/attachments.js.map +1 -0
- package/dist/ws/bridge.d.ts +7 -0
- package/dist/ws/bridge.d.ts.map +1 -0
- package/dist/ws/bridge.js +86 -0
- package/dist/ws/bridge.js.map +1 -0
- package/dist/ws/clients.d.ts +20 -0
- package/dist/ws/clients.d.ts.map +1 -0
- package/dist/ws/clients.js +48 -0
- package/dist/ws/clients.js.map +1 -0
- package/dist/ws/connection.d.ts +8 -0
- package/dist/ws/connection.d.ts.map +1 -0
- package/dist/ws/connection.js +116 -0
- package/dist/ws/connection.js.map +1 -0
- package/dist/ws/dispatch.d.ts +5 -0
- package/dist/ws/dispatch.d.ts.map +1 -0
- package/dist/ws/dispatch.js +139 -0
- package/dist/ws/dispatch.js.map +1 -0
- package/dist/ws/frames.d.ts +17 -0
- package/dist/ws/frames.d.ts.map +1 -0
- package/dist/ws/frames.js +37 -0
- package/dist/ws/frames.js.map +1 -0
- package/dist/ws/handler.d.ts +34 -0
- package/dist/ws/handler.d.ts.map +1 -0
- package/dist/ws/handler.js +33 -0
- package/dist/ws/handler.js.map +1 -0
- package/dist/ws/history.d.ts +17 -0
- package/dist/ws/history.d.ts.map +1 -0
- package/dist/ws/history.js +45 -0
- package/dist/ws/history.js.map +1 -0
- package/dist/ws/host.d.ts +40 -0
- package/dist/ws/host.d.ts.map +1 -0
- package/dist/ws/host.js +49 -0
- package/dist/ws/host.js.map +1 -0
- package/dist/ws/routing.d.ts +12 -0
- package/dist/ws/routing.d.ts.map +1 -0
- package/dist/ws/routing.js +31 -0
- package/dist/ws/routing.js.map +1 -0
- package/dist/ws/run-session.d.ts +17 -0
- package/dist/ws/run-session.d.ts.map +1 -0
- package/dist/ws/run-session.js +160 -0
- package/dist/ws/run-session.js.map +1 -0
- package/dist/ws/session-catalog.d.ts +25 -0
- package/dist/ws/session-catalog.d.ts.map +1 -0
- package/dist/ws/session-catalog.js +45 -0
- package/dist/ws/session-catalog.js.map +1 -0
- package/dist/ws/shrink.d.ts +34 -0
- package/dist/ws/shrink.d.ts.map +1 -0
- package/dist/ws/shrink.js +91 -0
- package/dist/ws/shrink.js.map +1 -0
- package/dist/ws/turns.d.ts +77 -0
- package/dist/ws/turns.d.ts.map +1 -0
- package/dist/ws/turns.js +89 -0
- package/dist/ws/turns.js.map +1 -0
- package/migrations/001_initial.sql +29 -0
- package/migrations/002_session_provider.sql +1 -0
- package/migrations/003_session_backend.sql +5 -0
- package/migrations/004_drop_gemini_backend.sql +20 -0
- package/migrations/005_passkey_credentials.sql +18 -0
- package/package.json +72 -0
- package/src/agent/backend.ts +308 -0
- package/src/app.ts +159 -0
- package/src/brain/client.ts +186 -0
- package/src/brain/types.ts +44 -0
- package/src/cron/scheduler.ts +80 -0
- package/src/db/client.ts +85 -0
- package/src/files/walker.ts +418 -0
- package/src/index.ts +39 -0
- package/src/middleware/auth.ts +321 -0
- package/src/middleware/passkeys.ts +510 -0
- package/src/middleware/tailscale.ts +81 -0
- package/src/render/template.ts +124 -0
- package/src/routes/brain.ts +305 -0
- package/src/routes/files.ts +99 -0
- package/src/routes/health.ts +29 -0
- package/src/routes/providers.ts +9 -0
- package/src/routes/render.ts +68 -0
- package/src/routes/sessions.ts +49 -0
- package/src/routes/voice.ts +80 -0
- package/src/voice/deepgram-token.ts +80 -0
- package/src/voice/keyterm-builder.ts +426 -0
- package/src/voice/speech-providers.ts +111 -0
- package/src/ws/attachments.ts +86 -0
- package/src/ws/bridge.ts +139 -0
- package/src/ws/clients.ts +57 -0
- package/src/ws/connection.ts +132 -0
- package/src/ws/dispatch.ts +162 -0
- package/src/ws/frames.ts +42 -0
- package/src/ws/handler.ts +50 -0
- package/src/ws/history.ts +51 -0
- package/src/ws/host.ts +69 -0
- package/src/ws/routing.ts +43 -0
- package/src/ws/run-session.ts +192 -0
- package/src/ws/session-catalog.ts +97 -0
- package/src/ws/shrink.ts +95 -0
- 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
|
+
}
|