@cairnvibe/sdk 0.1.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cairn-widget.js +228 -0
  3. package/dist/context-collector.d.ts +1 -0
  4. package/dist/context-collector.js +23 -0
  5. package/dist/dashboard-sqlite.d.ts +8 -0
  6. package/dist/dashboard-sqlite.js +50 -0
  7. package/dist/dashboard.d.ts +39 -0
  8. package/dist/dashboard.js +60 -0
  9. package/dist/element-ladder.d.ts +7 -0
  10. package/dist/element-ladder.js +60 -0
  11. package/dist/index.d.ts +31 -0
  12. package/dist/index.js +1069 -0
  13. package/dist/key-rotator.d.ts +7 -0
  14. package/dist/key-rotator.js +31 -0
  15. package/dist/package.json +1 -0
  16. package/dist/realtime-cli.d.ts +2 -0
  17. package/dist/realtime-cli.js +59 -0
  18. package/dist/realtime-server.d.ts +10 -0
  19. package/dist/realtime-server.js +291 -0
  20. package/dist/server.d.ts +95 -0
  21. package/dist/server.js +298 -0
  22. package/dist/speak-server.d.ts +16 -0
  23. package/dist/speak-server.js +41 -0
  24. package/dist/transcribe-server.d.ts +14 -0
  25. package/dist/transcribe-server.js +47 -0
  26. package/dist/tts-stream.d.ts +33 -0
  27. package/dist/tts-stream.js +124 -0
  28. package/dist/verb-executor.d.ts +17 -0
  29. package/dist/verb-executor.js +67 -0
  30. package/package.json +56 -0
  31. package/src/context-collector.ts +21 -0
  32. package/src/dashboard-sqlite.ts +52 -0
  33. package/src/dashboard.ts +82 -0
  34. package/src/element-ladder.ts +67 -0
  35. package/src/index.tsx +1250 -0
  36. package/src/key-rotator.ts +29 -0
  37. package/src/realtime-cli.ts +62 -0
  38. package/src/realtime-server.ts +342 -0
  39. package/src/server.ts +386 -0
  40. package/src/speak-server.ts +56 -0
  41. package/src/transcribe-server.ts +68 -0
  42. package/src/tts-stream.ts +140 -0
  43. package/src/verb-executor.ts +84 -0
  44. package/src/web-component.ts +1252 -0
@@ -0,0 +1,29 @@
1
+ // Round-robins across a comma-separated list of API keys (e.g. `GROQ_API_KEYS`)
2
+ // so runtime verb calls spread across several free-tier rate limits instead
3
+ // of hammering a single key. Mirrors packages/indexer/src/key-rotator.ts —
4
+ // small enough that duplicating it beats adding a shared package for it.
5
+
6
+ export class KeyRotator {
7
+ private keys: string[];
8
+ private next = 0;
9
+
10
+ constructor(keys: string[]) {
11
+ if (keys.length === 0) throw new Error("KeyRotator: at least one key is required");
12
+ this.keys = keys;
13
+ }
14
+
15
+ static fromEnvList(value: string | undefined): KeyRotator | null {
16
+ if (!value) return null;
17
+ const keys = value
18
+ .split(",")
19
+ .map((k) => k.trim())
20
+ .filter(Boolean);
21
+ return keys.length > 0 ? new KeyRotator(keys) : null;
22
+ }
23
+
24
+ take(): string {
25
+ const key = this.keys[this.next % this.keys.length];
26
+ this.next += 1;
27
+ return key;
28
+ }
29
+ }
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ // `cairn-realtime` — zero-code way to run the realtime voice relay
3
+ // alongside `next dev`, configured entirely through env vars (matching how
4
+ // `cairn build` already works) plus an optional `--port` flag.
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { ManifestSchema } from "@cairnvibe/core";
8
+ import { createRealtimeServer } from "./realtime-server";
9
+ import type { CapabilityTier } from "./server";
10
+
11
+ function parseCapability(raw: string | undefined): CapabilityTier {
12
+ if (raw === "explain" || raw === "guide" || raw === "act") return raw;
13
+ return "act";
14
+ }
15
+
16
+ function parsePortFlag(argv: string[]): number | undefined {
17
+ const idx = argv.indexOf("--port");
18
+ if (idx === -1) return undefined;
19
+ const value = Number(argv[idx + 1]);
20
+ return Number.isFinite(value) ? value : undefined;
21
+ }
22
+
23
+ function main(): void {
24
+ const manifestPath = path.join(process.cwd(), "ui-manifest.json");
25
+ if (!fs.existsSync(manifestPath)) {
26
+ console.error(`cairn-realtime: no ${manifestPath} — run \`cairn build\` first.`);
27
+ process.exit(1);
28
+ }
29
+ const manifest = ManifestSchema.parse(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
30
+
31
+ const deepgramApiKey = process.env.DEEPGRAM_API_KEY;
32
+ if (!deepgramApiKey) {
33
+ console.error("cairn-realtime: DEEPGRAM_API_KEY is not set.");
34
+ process.exit(1);
35
+ }
36
+
37
+ const provider = process.env.CAIRN_RUNTIME_PROVIDER === "anthropic" ? "anthropic" : "groq";
38
+ if (provider === "anthropic" && !process.env.ANTHROPIC_API_KEY) {
39
+ console.error("cairn-realtime: ANTHROPIC_API_KEY is not set (CAIRN_RUNTIME_PROVIDER=anthropic).");
40
+ process.exit(1);
41
+ }
42
+ if (provider === "groq" && !process.env.GROQ_API_KEYS) {
43
+ console.error("cairn-realtime: GROQ_API_KEYS is not set (comma-separated).");
44
+ process.exit(1);
45
+ }
46
+
47
+ const registeredActions = (process.env.CAIRN_REGISTERED_ACTIONS ?? "")
48
+ .split(",")
49
+ .map((a) => a.trim())
50
+ .filter(Boolean);
51
+
52
+ const port = parsePortFlag(process.argv.slice(2)) ?? Number(process.env.CAIRN_REALTIME_PORT ?? 3010);
53
+ const capability = parseCapability(process.env.CAIRN_CAPABILITY);
54
+ const persona = process.env.CAIRN_PERSONA || undefined;
55
+
56
+ const server = createRealtimeServer({ manifest, provider, deepgramApiKey, registeredActions, capability, persona });
57
+ server.listen(port, () => {
58
+ console.error(`cairn-realtime: listening on ws://localhost:${port} (provider: ${provider})`);
59
+ });
60
+ }
61
+
62
+ main();
@@ -0,0 +1,342 @@
1
+ // A real-time voice relay: browser <-> this server <-> Deepgram live STT,
2
+ // resolving a verb (via the same core the HTTP handler uses) on every
3
+ // finalized utterance, then streaming synthesized speech back as it's
4
+ // rendered — not after it's fully rendered.
5
+ //
6
+ // Runs as its OWN process (via realtime-cli.ts / `cairn-realtime`), separate
7
+ // from the consumer's Next.js server — a plain WebSocket relay, nothing
8
+ // Next-specific about it. The Deepgram API key never leaves this process.
9
+ //
10
+ // Verified live before building this: a Node script opened
11
+ // wss://api.deepgram.com/v1/listen with a plain (non-scoped) API key,
12
+ // streamed real 16kHz PCM audio, and got back interim + final transcripts.
13
+ // True client-to-Deepgram streaming needs a short-lived scoped key this
14
+ // account can't mint (no keys:write scope) — this relay sidesteps that
15
+ // entirely by keeping the real key server-side, which is the more secure
16
+ // shape anyway.
17
+ //
18
+ // TTS is Deepgram's streaming Speak WebSocket (see tts-stream.ts), not the
19
+ // one-shot REST /v1/speak call this used to be — that REST call forced
20
+ // waiting for an entire MP3 to render AND download before playing a single
21
+ // byte, which was the actual cause of "the agent takes 5-10s to speak." One
22
+ // connection is opened per client and reused for every turn in the session
23
+ // (a fresh handshake per turn costs a real, measurable chunk of that latency
24
+ // on its own). Pattern verified against a real, working implementation of
25
+ // exactly this shape (a prior voice-agent project of the author's, VOXERA —
26
+ // its lib/deepgram/tts-stream.ts and server.ts) before building this.
27
+
28
+ import http from "node:http";
29
+ import { WebSocket, WebSocketServer } from "ws";
30
+ import type { HistoryTurn, Manifest, VerbResponse } from "@cairnvibe/core";
31
+ import { buildSystemPrompt, createVerbLLM, resolveVerb, type CapabilityTier, type CreateCopilotHandlerOptions } from "./server";
32
+ import { DeepgramSpeakStream } from "./tts-stream";
33
+
34
+ const DEEPGRAM_LIVE_URL = "wss://api.deepgram.com/v1/listen";
35
+ const DEFAULT_STT_MODEL = "nova-2";
36
+ const DEFAULT_TTS_VOICE = "aura-2-thalia-en";
37
+ // Not constrained by any telephony 8kHz requirement — this is just "what
38
+ // quality does Deepgram render at" for browser playback, and the Web Audio
39
+ // API resamples an AudioBuffer at any declared rate transparently.
40
+ const TTS_SAMPLE_RATE = 24000;
41
+
42
+ export interface CreateRealtimeServerOptions extends CreateCopilotHandlerOptions {
43
+ manifest: Manifest;
44
+ deepgramApiKey: string;
45
+ sttModel?: string;
46
+ ttsVoice?: string;
47
+ }
48
+
49
+ type ServerMessage =
50
+ | { type: "interim"; text: string }
51
+ | { type: "final"; text: string }
52
+ | { type: "verb"; verb: VerbResponse }
53
+ | { type: "speaking_start" }
54
+ /** One chunk of raw linear16 PCM audio, base64-encoded, as it's rendered — never the whole clip at once. */
55
+ | { type: "audio_chunk"; audio: string; sampleRate: number }
56
+ /** No more audio chunks are coming for this turn. The client may still be mid-playback of what it already has. */
57
+ | { type: "speaking_end" }
58
+ | { type: "turn_complete" }
59
+ | { type: "error"; message: string };
60
+
61
+ export function createRealtimeServer(options: CreateRealtimeServerOptions): http.Server {
62
+ const registeredActions = options.registeredActions ?? [];
63
+ const capability = options.capability ?? "act";
64
+ const llm = createVerbLLM(options);
65
+ // "text" is optional on highlight/open/navigate/do in the base prompt —
66
+ // fine for the typed/HTTP path, which always has a visible answer area,
67
+ // but silence reads as broken in a live voice conversation (the client
68
+ // still recovers correctly either way, via turn_complete below). The
69
+ // instruction asks for a confirmation grounded in what was actually
70
+ // done, not filler — generic phrasing here is what made replies feel
71
+ // "unrelated" to the question that was just asked.
72
+ const systemPrompt =
73
+ buildSystemPrompt(options.manifest, registeredActions, options.persona) +
74
+ `\n\nYou are in a live voice conversation right now — the user is speaking out loud and may not be looking at the screen. For highlight/open/navigate/do, include a short spoken "text" that names the specific thing you're pointing at or the specific place you're sending them (e.g. "Highlighting the New Invoice button" or "Taking you to Invoices"), not a generic filler phrase — so they hear a confirmation that's actually about their question.`;
75
+ const sttModel = options.sttModel ?? process.env.DEEPGRAM_MODEL ?? DEFAULT_STT_MODEL;
76
+ const ttsVoice = options.ttsVoice ?? process.env.DEEPGRAM_VOICE ?? DEFAULT_TTS_VOICE;
77
+ const deepgramApiKey = options.deepgramApiKey;
78
+
79
+ const httpServer = http.createServer((_req, res) => {
80
+ res.writeHead(200, { "content-type": "text/plain" });
81
+ res.end("cairn realtime relay\n");
82
+ });
83
+ const wss = new WebSocketServer({ server: httpServer });
84
+
85
+ wss.on("connection", (client) => {
86
+ handleConnection(client, { deepgramApiKey, sttModel, ttsVoice, llm, systemPrompt, registeredActions, capability }).catch(
87
+ (err) => {
88
+ console.error("[cairn realtime] connection error:", err);
89
+ safeSend(client, { type: "error", message: "internal error" });
90
+ client.close();
91
+ },
92
+ );
93
+ });
94
+
95
+ return httpServer;
96
+ }
97
+
98
+ interface ConnectionDeps {
99
+ deepgramApiKey: string;
100
+ sttModel: string;
101
+ ttsVoice: string;
102
+ llm: ReturnType<typeof createVerbLLM>;
103
+ systemPrompt: string;
104
+ registeredActions: string[];
105
+ capability: CapabilityTier;
106
+ }
107
+
108
+ const MAX_HISTORY_TURNS = 8; // 4 exchanges — enough for "the first one"/"do that instead" without growing the prompt unbounded over a long call
109
+
110
+ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promise<void> {
111
+ let context = { route: "/", visible: [] as string[] };
112
+ // Unlike the stateless HTTP path (which needs the client to resend
113
+ // history every request), a realtime connection is already stateful —
114
+ // one WebSocket per call — so this is accumulated here directly rather
115
+ // than round-tripped through the client.
116
+ const history: HistoryTurn[] = [];
117
+
118
+ const dgUrl =
119
+ `${DEEPGRAM_LIVE_URL}?model=${encodeURIComponent(deps.sttModel)}` +
120
+ `&encoding=linear16&sample_rate=16000&channels=1&interim_results=true&endpointing=300&utterance_end_ms=1000`;
121
+ const dg = new WebSocket(dgUrl, { headers: { Authorization: `Token ${deps.deepgramApiKey}` } });
122
+
123
+ let dgOpen = false;
124
+ const pendingAudio: Buffer[] = [];
125
+
126
+ dg.on("open", () => {
127
+ dgOpen = true;
128
+ for (const chunk of pendingAudio.splice(0)) dg.send(chunk);
129
+ });
130
+
131
+ // ONE Speak connection reused for every turn in this session — a fresh
132
+ // handshake per turn is a real, measurable chunk of the latency this
133
+ // rewrite exists to remove. Recreated on demand if it ever drops.
134
+ let speakStream: DeepgramSpeakStream | null = null;
135
+ let speakStreamReady: Promise<void> | null = null;
136
+ // Resolves the in-flight speakStreamed() call for the current turn once
137
+ // Deepgram confirms (via "Flushed") that every chunk for this turn's
138
+ // Flush has actually been sent — the real "no more audio coming" signal,
139
+ // not a fixed timeout. Bound once at stream creation (Deepgram fires one
140
+ // Flushed per Flush call), rebound per-turn as each new call starts.
141
+ let onCurrentTurnFlushed: (() => void) | null = null;
142
+ // Bumped on barge-in — any audio_chunk/speaking_end belonging to an
143
+ // earlier generation is dropped instead of sent, so a chunk that was
144
+ // already in flight over the network when the user interrupted can't
145
+ // sneak back in and resume playback after the client already moved on.
146
+ let generation = 0;
147
+
148
+ function ensureSpeakStream(): { stream: DeepgramSpeakStream; ready: Promise<void> } {
149
+ if (!speakStream) {
150
+ const stream = new DeepgramSpeakStream(
151
+ { apiKey: deps.deepgramApiKey, model: deps.ttsVoice, encoding: "linear16", sampleRate: TTS_SAMPLE_RATE },
152
+ () => {}, // rebound per-turn via setAudioHandler before each use
153
+ { onFlushed: () => onCurrentTurnFlushed?.() },
154
+ );
155
+ speakStream = stream;
156
+ speakStreamReady = stream.connect().catch((err) => {
157
+ console.error("[cairn realtime] Speak stream connect failed:", err);
158
+ speakStream = null;
159
+ speakStreamReady = null;
160
+ });
161
+ }
162
+ return { stream: speakStream, ready: speakStreamReady! };
163
+ }
164
+
165
+ // Discards whatever the current turn is still synthesizing/sending, and
166
+ // unsticks a pending speakStreamed() call if one is in flight — Deepgram's
167
+ // "Clear" isn't guaranteed to itself trigger a "Flushed" confirmation, so
168
+ // without this the interrupted call's promise would hang forever.
169
+ function triggerServerBargeIn(): void {
170
+ generation++;
171
+ speakStream?.clear();
172
+ onCurrentTurnFlushed?.();
173
+ }
174
+
175
+ async function speakStreamed(text: string): Promise<void> {
176
+ const myGeneration = generation;
177
+ const { stream, ready } = ensureSpeakStream();
178
+
179
+ stream.setAudioHandler((chunk) => {
180
+ if (myGeneration !== generation) return; // stale — dropped by barge-in
181
+ safeSend(client, { type: "audio_chunk", audio: chunk.toString("base64"), sampleRate: TTS_SAMPLE_RATE });
182
+ });
183
+
184
+ await ready;
185
+ if (!speakStream || myGeneration !== generation) {
186
+ // Reconnect failed, or barge-in happened before the stream connected —
187
+ // either way, only degrade to turn_complete if this is still current.
188
+ if (myGeneration === generation) safeSend(client, { type: "turn_complete" });
189
+ return;
190
+ }
191
+
192
+ await new Promise<void>((resolve) => {
193
+ onCurrentTurnFlushed = () => {
194
+ onCurrentTurnFlushed = null;
195
+ if (myGeneration === generation) safeSend(client, { type: "speaking_end" });
196
+ resolve();
197
+ };
198
+ safeSend(client, { type: "speaking_start" });
199
+ stream.sendText(text);
200
+ stream.flush();
201
+ });
202
+ }
203
+
204
+ dg.on("message", (data) => {
205
+ void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history);
206
+ });
207
+
208
+ dg.on("error", (err) => {
209
+ console.error("[cairn realtime] Deepgram STT connection error:", err);
210
+ safeSend(client, { type: "error", message: "speech recognition unavailable" });
211
+ });
212
+
213
+ client.on("message", (data, isBinary) => {
214
+ if (isBinary) {
215
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer);
216
+ if (dgOpen) dg.send(buf);
217
+ else pendingAudio.push(buf);
218
+ return;
219
+ }
220
+ try {
221
+ const msg = JSON.parse(data.toString());
222
+ if (msg.type === "context") {
223
+ context = { route: String(msg.route ?? "/"), visible: Array.isArray(msg.visible) ? msg.visible : [] };
224
+ } else if (msg.type === "end") {
225
+ client.close();
226
+ } else if (msg.type === "barge_in") {
227
+ triggerServerBargeIn();
228
+ } else if (msg.type === "speak" && typeof msg.text === "string" && msg.text.trim()) {
229
+ // A "tour" step being narrated while a realtime session is already
230
+ // open — reuses the exact same streaming Speak connection and
231
+ // audio_chunk protocol as a conversational reply, instead of the
232
+ // client falling back to a separate buffered REST call. No STT/verb
233
+ // resolution involved; the client already resolved the tour steps
234
+ // itself and just needs this text spoken.
235
+ void speakStreamed(msg.text);
236
+ }
237
+ } catch {
238
+ // Ignore malformed control messages — never crash the relay on bad client input.
239
+ }
240
+ });
241
+
242
+ client.on("close", () => {
243
+ try {
244
+ dg.close();
245
+ } catch {
246
+ // already closed
247
+ }
248
+ speakStream?.close();
249
+ });
250
+ }
251
+
252
+ async function handleDeepgramMessage(
253
+ raw: string,
254
+ client: WebSocket,
255
+ deps: ConnectionDeps,
256
+ getContext: () => { route: string; visible: string[] },
257
+ speakStreamed: (text: string) => Promise<void>,
258
+ history: HistoryTurn[],
259
+ ): Promise<void> {
260
+ let msg: any;
261
+ try {
262
+ msg = JSON.parse(raw);
263
+ } catch {
264
+ return;
265
+ }
266
+
267
+ if (msg.type !== "Results") return;
268
+ const transcript: string | undefined = msg.channel?.alternatives?.[0]?.transcript;
269
+ if (!transcript) return;
270
+
271
+ if (!msg.is_final) {
272
+ safeSend(client, { type: "interim", text: transcript });
273
+ return;
274
+ }
275
+
276
+ safeSend(client, { type: "final", text: transcript });
277
+
278
+ // Everything from here on (the LLM call, TTS streaming) can fail in ways
279
+ // that have nothing to do with a malformed message — a flaky provider
280
+ // call, a rate limit, a dropped upstream connection. This whole function
281
+ // is invoked fire-and-forget (`void handleDeepgramMessage(...)`), so an
282
+ // uncaught throw here previously vanished into an unhandled rejection:
283
+ // the client had already been told "final" (entering its "thinking"
284
+ // state) and then simply never heard from the server again for this
285
+ // turn — stuck indefinitely with the mic never resuming. Every path out
286
+ // of this try block now sends the client something that ends the turn.
287
+ try {
288
+ const { route, visible } = getContext();
289
+ const verb = await resolveVerb(deps.llm, deps.systemPrompt, deps.registeredActions, deps.capability, {
290
+ route,
291
+ question: transcript,
292
+ visible,
293
+ history,
294
+ });
295
+ // Sent immediately — before speech synthesis even starts — so
296
+ // highlight/navigate/do execute in the browser right away instead of
297
+ // waiting on audio. The agent visibly acts while it's still about to
298
+ // speak, not after.
299
+ safeSend(client, { type: "verb", verb });
300
+
301
+ history.push({ role: "user", text: transcript }, { role: "assistant", text: summarizeVerbForHistory(verb) });
302
+ history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
303
+
304
+ // A verb with no spoken text (highlight/navigate/do often have none)
305
+ // still needs to unstick the client's "thinking" state and let the mic
306
+ // resume — turn_complete covers that with no audio path involved.
307
+ if ("text" in verb && verb.text) {
308
+ await speakStreamed(verb.text);
309
+ } else {
310
+ safeSend(client, { type: "turn_complete" });
311
+ }
312
+ } catch (err) {
313
+ console.error("[cairn realtime] failed to resolve/speak this turn:", err);
314
+ safeSend(client, { type: "error", message: "Something went wrong answering that — try again." });
315
+ safeSend(client, { type: "turn_complete" });
316
+ }
317
+ }
318
+
319
+ function safeSend(client: WebSocket, message: ServerMessage): void {
320
+ if (client.readyState !== WebSocket.OPEN) return;
321
+ client.send(JSON.stringify(message));
322
+ }
323
+
324
+ /** A short text form of any verb for the history log — not shown to the
325
+ * user, just fed back to the model on later turns so it knows what it
326
+ * already did/said. */
327
+ function summarizeVerbForHistory(verb: VerbResponse): string {
328
+ if ("text" in verb && verb.text) return verb.text;
329
+ switch (verb.verb) {
330
+ case "highlight":
331
+ case "open":
332
+ return `(highlighted ${verb.target})`;
333
+ case "navigate":
334
+ return `(navigated to ${verb.route})`;
335
+ case "do":
336
+ return `(ran ${verb.action}${verb.target ? ` on ${verb.target}` : ""})`;
337
+ case "tour":
338
+ return verb.steps.map((s) => s.text).join(" ");
339
+ default:
340
+ return "(no response)";
341
+ }
342
+ }