@cairnvibe/sdk 0.2.7 → 0.2.9

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.
@@ -1,12 +1,36 @@
1
1
  // Server-side text-to-speech for the Copilot widget's spoken answers (see
2
2
  // `speakEndpoint` in index.tsx). The Deepgram key must never reach the
3
- // client, so this is a plain fetch to Deepgram's /v1/speak REST endpoint —
4
- // no SDK dependency needed for one request shape.
3
+ // client.
4
+ //
5
+ // This used to be one fetch to Deepgram's /v1/speak REST endpoint, buffered
6
+ // into an ArrayBuffer with `await response.arrayBuffer()` before returning
7
+ // anything. That's the exact same bug the realtime path already fixed once
8
+ // (see tts-stream.ts's own comment): nothing plays until Deepgram renders
9
+ // AND the network delivers the *entire* reply, which measured 5-8s for a
10
+ // normal explain answer in real production logs. It also hit Deepgram's
11
+ // REST-only 2000-character cap on longer replies with no handling at all.
12
+ //
13
+ // Fixed the same way the realtime path was: open the streaming Speak
14
+ // WebSocket (tts-stream.ts's DeepgramSpeakStream, the same class the
15
+ // realtime server already uses) and forward audio chunks to the caller as
16
+ // they arrive, via a ReadableStream — not buffered. The route handler
17
+ // forwards that stream straight through as the HTTP response body, and the
18
+ // client (index.tsx) reads it progressively instead of awaiting a full
19
+ // Blob, so playback can start on the first chunk. Splitting the text into
20
+ // sentence-sized `Speak` messages before one `Flush` sidesteps the old
21
+ // 2000-char REST limit entirely (it doesn't apply to the WS protocol) and
22
+ // lets Deepgram start rendering the first sentence sooner.
23
+ import { DeepgramSpeakStream, type DeepgramSpeakStreamOptions, type SpeakChunkCallback } from "./tts-stream";
5
24
 
6
- const DEEPGRAM_SPEAK_URL = "https://api.deepgram.com/v1/speak";
7
- // Verified against Deepgram's docs while building this — re-check if this
8
- // starts erroring, voice model names retire over time.
9
25
  const DEEPGRAM_DEFAULT_VOICE = "aura-2-thalia-en";
26
+ // Matches the realtime path's own playback sample rate (index.tsx's
27
+ // audio_chunk handling defaults to 24000 too) — keeping them identical lets
28
+ // both paths share one raw-PCM16 decode/schedule routine on the client.
29
+ const SAMPLE_RATE = 24000;
30
+ // Not a protocol limit (the WS Speak protocol has none like REST's 2000
31
+ // chars) — just keeps each queued chunk sentence-sized so Deepgram can start
32
+ // rendering the first one quickly instead of parsing one giant message.
33
+ const MAX_CHUNK_CHARS = 300;
10
34
 
11
35
  export interface CreateSpeakHandlerOptions {
12
36
  apiKey: string;
@@ -15,13 +39,42 @@ export interface CreateSpeakHandlerOptions {
15
39
 
16
40
  export interface SpeakResult {
17
41
  status: number;
18
- /** `audio` is raw MP3 bytes on success. */
19
- body: { audio: ArrayBuffer; contentType: string } | { error: string };
42
+ /** `stream` yields raw linear16 PCM chunks (mono, 24kHz) as Deepgram
43
+ * renders them forward it directly, unbuffered; do not await it into a
44
+ * Blob/ArrayBuffer or the whole point of streaming is lost. */
45
+ body: { stream: ReadableStream<Uint8Array>; contentType: string } | { error: string };
20
46
  }
21
47
 
22
48
  export type SpeakHandler = (text: string) => Promise<SpeakResult>;
23
49
 
24
- export function createSpeakHandler(options: CreateSpeakHandlerOptions): SpeakHandler {
50
+ /** Test-only seam: lets tests inject a fake stream instead of opening a real
51
+ * Deepgram WebSocket. Not part of CreateSpeakHandlerOptions on purpose — real
52
+ * call sites (the scaffolded route templates) never pass this. */
53
+ export type SpeakStreamFactory = (
54
+ opts: DeepgramSpeakStreamOptions,
55
+ onAudioChunk: SpeakChunkCallback,
56
+ handlers?: { onFlushed?: (sequenceId: number) => void; onError?: (err: Error) => void },
57
+ ) => DeepgramSpeakStream;
58
+
59
+ function splitIntoChunks(text: string, maxChars: number): string[] {
60
+ const sentences = text.match(/[^.!?]+[.!?]*\s*/g) ?? [text];
61
+ const chunks: string[] = [];
62
+ let current = "";
63
+ for (const sentence of sentences) {
64
+ if (current && current.length + sentence.length > maxChars) {
65
+ chunks.push(current);
66
+ current = "";
67
+ }
68
+ current += sentence;
69
+ }
70
+ if (current) chunks.push(current);
71
+ return chunks;
72
+ }
73
+
74
+ export function createSpeakHandler(
75
+ options: CreateSpeakHandlerOptions,
76
+ streamFactory: SpeakStreamFactory = (opts, onAudioChunk, handlers) => new DeepgramSpeakStream(opts, onAudioChunk, handlers),
77
+ ): SpeakHandler {
25
78
  const model = options.model ?? process.env.DEEPGRAM_VOICE ?? DEEPGRAM_DEFAULT_VOICE;
26
79
 
27
80
  return async function handleSpeak(text: string) {
@@ -29,28 +82,51 @@ export function createSpeakHandler(options: CreateSpeakHandlerOptions): SpeakHan
29
82
  return { status: 400, body: { error: "no text provided" } };
30
83
  }
31
84
 
32
- let response: Response;
33
- try {
34
- response = await fetch(`${DEEPGRAM_SPEAK_URL}?model=${encodeURIComponent(model)}`, {
35
- method: "POST",
36
- headers: {
37
- Authorization: `Token ${options.apiKey}`,
38
- "content-type": "application/json",
85
+ let enqueue: ((chunk: Uint8Array) => void) | null = null;
86
+ let closeOut: (() => void) | null = null;
87
+ let failOut: ((err: Error) => void) | null = null;
88
+ const stream = new ReadableStream<Uint8Array>({
89
+ start(controller) {
90
+ enqueue = (chunk) => controller.enqueue(chunk);
91
+ closeOut = () => controller.close();
92
+ failOut = (err) => controller.error(err);
93
+ },
94
+ });
95
+
96
+ // True once connect() below resolves — an onError before that point is
97
+ // already reported through connect()'s own rejection, so it's ignored
98
+ // here to avoid double-handling the same failure.
99
+ let connected = false;
100
+
101
+ const speakStream = streamFactory(
102
+ { apiKey: options.apiKey, model, encoding: "linear16", sampleRate: SAMPLE_RATE },
103
+ (chunk) => enqueue?.(new Uint8Array(chunk)),
104
+ {
105
+ onFlushed: () => {
106
+ closeOut?.();
107
+ speakStream.close();
108
+ },
109
+ onError: (err) => {
110
+ if (!connected) return;
111
+ console.error("[cairn] speak stream error:", err);
112
+ failOut?.(err);
39
113
  },
40
- body: JSON.stringify({ text }),
41
- });
114
+ },
115
+ );
116
+
117
+ try {
118
+ await speakStream.connect();
42
119
  } catch (err) {
43
120
  console.error("[cairn] speak request failed:", err);
44
121
  return { status: 200, body: { error: "speech service unreachable" } };
45
122
  }
123
+ connected = true;
46
124
 
47
- if (!response.ok) {
48
- const detail = await response.text().catch(() => "");
49
- console.error("[cairn] Deepgram speak returned an error:", response.status, detail);
50
- return { status: 200, body: { error: "speech synthesis failed" } };
125
+ for (const chunk of splitIntoChunks(text, MAX_CHUNK_CHARS)) {
126
+ speakStream.sendText(chunk);
51
127
  }
128
+ speakStream.flush();
52
129
 
53
- const audio = await response.arrayBuffer();
54
- return { status: 200, body: { audio, contentType: response.headers.get("content-type") ?? "audio/mpeg" } };
130
+ return { status: 200, body: { stream, contentType: `audio/L16;rate=${SAMPLE_RATE}` } };
55
131
  };
56
132
  }
@@ -6,7 +6,50 @@
6
6
  // enforces the same schema independently — never trust the client alone.
7
7
 
8
8
  import { VerbResponseSchema, type ApiCall, type TourStep, type VerbResponse } from "@cairnvibe/core";
9
- import { findElement, highlightElement, logMiss, type MissContext } from "./element-ladder";
9
+ import { findElement, fillElement, highlightElement, logMiss, readElement, type MissContext } from "./element-ladder";
10
+ import { executeWebMcpTool } from "./webmcp-client";
11
+
12
+ /** The real result of one agent-loop step (click/fill/read/call_tool) —
13
+ * fed back to the model as its next turn's "observation" so it can decide
14
+ * what to do next instead of acting blind. The loop that drives this lives
15
+ * on the caller's side, not here: index.tsx's runTypedAgentLoop for the
16
+ * HTTP path, realtime-server.ts's finalizeTurn for the realtime one — this
17
+ * module only ever executes one step at a time. */
18
+ export interface ToolStepResult {
19
+ verb: "click" | "fill" | "read" | "call_tool";
20
+ target?: string;
21
+ ok: boolean;
22
+ observation: string;
23
+ }
24
+
25
+ /**
26
+ * Promise wrapper around executeVerbResponse for a continuing verb
27
+ * (click/fill/read/call_tool) — resolves once the real action has actually
28
+ * finished (synchronously for click/fill/read, after a real await for
29
+ * call_tool) with its real observation, instead of the fire-and-forget
30
+ * callback shape every other verb uses. This is what a loop driver awaits
31
+ * before deciding whether to call the model again.
32
+ */
33
+ export function executeToolStep(raw: unknown, route: string, liveElements?: Map<string, HTMLElement>): Promise<ToolStepResult | null> {
34
+ return new Promise((resolve) => {
35
+ // executeVerbResponse only ever reaches onToolStep for a genuinely
36
+ // continuing verb — callers are only expected to call this after
37
+ // already confirming (via TERMINAL_VERBS) that the parsed verb is one,
38
+ // so this should always fire; a real timeout (not an immediate
39
+ // microtask — call_tool's own real network round trip needs the time)
40
+ // is the safety net for the case where it somehow doesn't, so a loop
41
+ // driver awaiting this can never hang forever.
42
+ const timer = setTimeout(() => resolve(null), 15000);
43
+ executeVerbResponse(raw, route, {
44
+ onExplain: () => {},
45
+ liveElements,
46
+ onToolStep: (result) => {
47
+ clearTimeout(timer);
48
+ resolve(result);
49
+ },
50
+ });
51
+ });
52
+ }
10
53
 
11
54
  export interface VerbExecutorOptions {
12
55
  onExplain: (text: string) => void;
@@ -19,6 +62,9 @@ export interface VerbExecutorOptions {
19
62
  * owns the UI (progress display) and, for voice, the TTS sequencing.
20
63
  */
21
64
  onTour?: (steps: TourStep[]) => void;
65
+ /** A click/fill/read/call_tool step finished — see ToolStepResult. Only
66
+ * called for the agent loop's continuing verbs, never the terminal ones. */
67
+ onToolStep?: (result: ToolStepResult) => void;
22
68
  /** Action ids the customer has actually wired up. "do" is rejected for anything else. */
23
69
  registeredActions?: string[];
24
70
  /**
@@ -126,6 +172,63 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
126
172
  options.onExplain(verb.steps.map((s) => s.text).join(" "));
127
173
  }
128
174
  return;
175
+
176
+ // The agent loop's steps (server.ts's runAgentLoop) — each executes for
177
+ // real and reports a real observation back via onToolStep, instead of
178
+ // ending the turn the way every verb above does. `target` for these
179
+ // always came from the manifest/currentPageElements/liveElements this
180
+ // exact turn showed the model — never invented, same invariant as do.
181
+ case "click": {
182
+ if (verb.text) options.onExplain(verb.text);
183
+ const el = findElement(verb.target, options.liveElements);
184
+ if (!el) {
185
+ (options.onMiss ?? logMiss)({ attempted: verb.target, route });
186
+ options.onToolStep?.({ verb: "click", target: verb.target, ok: false, observation: "Could not find that element on the page." });
187
+ return;
188
+ }
189
+ highlightElement(el);
190
+ el.click();
191
+ options.onToolStep?.({ verb: "click", target: verb.target, ok: true, observation: "Clicked it." });
192
+ return;
193
+ }
194
+
195
+ case "fill": {
196
+ if (verb.text) options.onExplain(verb.text);
197
+ const el = findElement(verb.target, options.liveElements);
198
+ if (!el || !fillElement(el, verb.value)) {
199
+ (options.onMiss ?? logMiss)({ attempted: verb.target, route });
200
+ options.onToolStep?.({
201
+ verb: "fill",
202
+ target: verb.target,
203
+ ok: false,
204
+ observation: el ? "That element isn't a real form field — can't type into it." : "Could not find that element on the page.",
205
+ });
206
+ return;
207
+ }
208
+ highlightElement(el);
209
+ options.onToolStep?.({ verb: "fill", target: verb.target, ok: true, observation: `Typed "${verb.value}" into it.` });
210
+ return;
211
+ }
212
+
213
+ case "read": {
214
+ if (verb.text) options.onExplain(verb.text);
215
+ const el = findElement(verb.target, options.liveElements);
216
+ if (!el) {
217
+ (options.onMiss ?? logMiss)({ attempted: verb.target, route });
218
+ options.onToolStep?.({ verb: "read", target: verb.target, ok: false, observation: "Could not find that element on the page." });
219
+ return;
220
+ }
221
+ options.onToolStep?.({ verb: "read", target: verb.target, ok: true, observation: readElement(el) });
222
+ return;
223
+ }
224
+
225
+ case "call_tool": {
226
+ if (verb.text) options.onExplain(verb.text);
227
+ void executeWebMcpTool(verb.name, verb.args).then((result) => {
228
+ options.onToolStep?.({ verb: "call_tool", target: verb.name, ok: result.ok, observation: result.observation });
229
+ });
230
+ return;
231
+ }
129
232
  }
130
233
  }
131
234
 
@@ -0,0 +1,79 @@
1
+ // Discovers and calls real tools a page has registered via WebMCP
2
+ // (https://webmachinelearning.github.io/webmcp/) — an in-progress web
3
+ // standard letting a site expose its own functions as typed, parameterized
4
+ // tools ("document.modelContext.registerTool(...)"), running in the page's
5
+ // own JS with the user's real session. This is the highest-trust action
6
+ // source there is: a real function the app's own developer wrote, with a
7
+ // real return value — not a click simulated from static analysis. Almost
8
+ // no site has adopted it yet, so this is deliberately a no-op (empty list,
9
+ // nothing to call) everywhere it isn't present, not a hard dependency.
10
+
11
+ import type { WebMcpTool } from "@cairnvibe/core";
12
+
13
+ interface ModelContextTool {
14
+ name: string;
15
+ description?: string;
16
+ inputSchema?: Record<string, unknown>;
17
+ }
18
+
19
+ interface ModelContext {
20
+ getTools?: () => Promise<ModelContextTool[]> | ModelContextTool[];
21
+ executeTool?: (tool: ModelContextTool, args: unknown) => Promise<unknown>;
22
+ }
23
+
24
+ function getModelContext(): ModelContext | null {
25
+ if (typeof document === "undefined") return null;
26
+ return (document as unknown as { modelContext?: ModelContext }).modelContext ?? null;
27
+ }
28
+
29
+ /** Bounded the same way LiveElementSchema/CopilotRequestSchema bound the
30
+ * live DOM scan — a hard cap on both count and description length, so a
31
+ * page that registers an unreasonable number of tools (or one with a huge
32
+ * description) can't blow the request payload or the prompt. */
33
+ const MAX_TOOLS = 30;
34
+ const MAX_DESCRIPTION_LENGTH = 500;
35
+
36
+ export async function discoverWebMcpTools(): Promise<WebMcpTool[]> {
37
+ const modelContext = getModelContext();
38
+ if (!modelContext?.getTools) return [];
39
+
40
+ try {
41
+ const tools = await modelContext.getTools();
42
+ if (!Array.isArray(tools)) return [];
43
+ return tools.slice(0, MAX_TOOLS).map((tool) => ({
44
+ name: String(tool.name),
45
+ description: String(tool.description ?? "").slice(0, MAX_DESCRIPTION_LENGTH),
46
+ inputSchema: tool.inputSchema,
47
+ }));
48
+ } catch {
49
+ // A page's own registerTool()/getTools() implementation throwing is
50
+ // that page's bug, not Cairn's — degrade to "no WebMCP tools" rather
51
+ // than breaking the rest of the turn.
52
+ return [];
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Calls a real WebMCP tool by name — `name` must be one the model was
58
+ * actually shown this turn (server.ts only ever includes tools from this
59
+ * exact request's own discoverWebMcpTools() call), never invented.
60
+ * Returns a plain-text observation for the agent loop to reason about
61
+ * next, the same shape a click/fill/read result already takes.
62
+ */
63
+ export async function executeWebMcpTool(name: string, args: Record<string, unknown> | undefined): Promise<{ ok: boolean; observation: string }> {
64
+ const modelContext = getModelContext();
65
+ if (!modelContext?.getTools || !modelContext.executeTool) {
66
+ return { ok: false, observation: "This page no longer has that tool available." };
67
+ }
68
+ try {
69
+ const tools = await modelContext.getTools();
70
+ const tool = Array.isArray(tools) ? tools.find((t) => t.name === name) : undefined;
71
+ if (!tool) return { ok: false, observation: `No tool named "${name}" is available on this page right now.` };
72
+
73
+ const result = await modelContext.executeTool(tool, args ?? {});
74
+ const observation = typeof result === "string" ? result : JSON.stringify(result ?? null);
75
+ return { ok: true, observation: observation.slice(0, 2000) };
76
+ } catch (err) {
77
+ return { ok: false, observation: `That tool failed: ${err instanceof Error ? err.message : "unknown error"}` };
78
+ }
79
+ }