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