@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,7 @@
1
+ export declare class KeyRotator {
2
+ private keys;
3
+ private next;
4
+ constructor(keys: string[]);
5
+ static fromEnvList(value: string | undefined): KeyRotator | null;
6
+ take(): string;
7
+ }
@@ -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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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
+ }
@@ -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;