@cairnvibe/sdk 0.2.4 → 0.2.6

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,6 +1,7 @@
1
1
  import http from "node:http";
2
- import type { Manifest } from "@cairnvibe/core";
3
- import { type CreateCopilotHandlerOptions } from "./server";
2
+ import { WebSocket } from "ws";
3
+ import type { HistoryTurn, Manifest } from "@cairnvibe/core";
4
+ import { createVerbLLM, type CapabilityTier, type CreateCopilotHandlerOptions } from "./server";
4
5
  export interface CreateRealtimeServerOptions extends CreateCopilotHandlerOptions {
5
6
  manifest: Manifest;
6
7
  deepgramApiKey: string;
@@ -8,3 +9,19 @@ export interface CreateRealtimeServerOptions extends CreateCopilotHandlerOptions
8
9
  ttsVoice?: string;
9
10
  }
10
11
  export declare function createRealtimeServer(options: CreateRealtimeServerOptions): http.Server;
12
+ export interface ConnectionDeps {
13
+ deepgramApiKey: string;
14
+ sttModel: string;
15
+ ttsVoice: string;
16
+ llm: ReturnType<typeof createVerbLLM>;
17
+ systemPrompt: string;
18
+ manifest: Manifest;
19
+ registeredActions: string[];
20
+ capability: CapabilityTier;
21
+ }
22
+ export declare function handleDeepgramMessage(raw: string, client: WebSocket, deps: ConnectionDeps, getContext: () => {
23
+ route: string;
24
+ visible: string[];
25
+ }, speakStreamed: (text: string) => Promise<void>, history: HistoryTurn[], turnState: {
26
+ buffer: string;
27
+ }, getGeneration: () => number): Promise<void>;
@@ -30,6 +30,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
30
30
  };
31
31
  Object.defineProperty(exports, "__esModule", { value: true });
32
32
  exports.createRealtimeServer = createRealtimeServer;
33
+ exports.handleDeepgramMessage = handleDeepgramMessage;
33
34
  const node_http_1 = __importDefault(require("node:http"));
34
35
  const ws_1 = require("ws");
35
36
  const server_1 = require("./server");
@@ -155,8 +156,12 @@ async function handleConnection(client, deps) {
155
156
  stream.flush();
156
157
  });
157
158
  }
159
+ // Accumulates Deepgram "Results" transcript segments across one utterance
160
+ // — see handleDeepgramMessage for why this can't just react to every
161
+ // is_final.
162
+ const turnState = { buffer: "" };
158
163
  dg.on("message", (data) => {
159
- void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history);
164
+ void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation);
160
165
  });
161
166
  dg.on("error", (err) => {
162
167
  console.error("[cairn realtime] Deepgram STT connection error:", err);
@@ -189,7 +194,20 @@ async function handleConnection(client, deps) {
189
194
  // client falling back to a separate buffered REST call. No STT/verb
190
195
  // resolution involved; the client already resolved the tour steps
191
196
  // itself and just needs this text spoken.
192
- void speakStreamed(msg.text);
197
+ //
198
+ // Caught explicitly, unlike a normal turn's speakStreamed call (see
199
+ // handleDeepgramMessage) — this one isn't inside that function's own
200
+ // try/catch, and an uncaught rejection here previously vanished
201
+ // silently: the client's speakOverRealtime() promise for this step
202
+ // never resolves except via its own 15s fallback timeout, with
203
+ // nothing telling the user anything went wrong in the meantime —
204
+ // found live as a tour that goes badly quiet for stretches at a
205
+ // time. A real "error" message lets the client's tour-step handler
206
+ // (index.tsx's ws.onmessage) unstick itself immediately instead.
207
+ speakStreamed(msg.text).catch((err) => {
208
+ console.error("[cairn realtime] speakStreamed failed for a tour step:", err);
209
+ safeSend(client, { type: "error", message: "Something went wrong narrating that step." });
210
+ });
193
211
  }
194
212
  }
195
213
  catch {
@@ -206,7 +224,7 @@ async function handleConnection(client, deps) {
206
224
  speakStream?.close();
207
225
  });
208
226
  }
209
- async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history) {
227
+ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history, turnState, getGeneration) {
210
228
  let msg;
211
229
  try {
212
230
  msg = JSON.parse(raw);
@@ -214,25 +232,63 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
214
232
  catch {
215
233
  return;
216
234
  }
235
+ if (msg.type === "UtteranceEnd") {
236
+ // A second, independent "the user is truly done" signal Deepgram sends
237
+ // after utterance_end_ms of silence — a safety net for the rare case a
238
+ // Results message never carries speech_final:true, so a turn can't get
239
+ // permanently stuck with real transcript sitting in the buffer forever.
240
+ if (turnState.buffer)
241
+ await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
242
+ return;
243
+ }
217
244
  if (msg.type !== "Results")
218
245
  return;
219
246
  const transcript = msg.channel?.alternatives?.[0]?.transcript;
220
247
  if (!transcript)
221
248
  return;
222
249
  if (!msg.is_final) {
223
- safeSend(client, { type: "interim", text: transcript });
250
+ safeSend(client, { type: "interim", text: turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript });
251
+ return;
252
+ }
253
+ // is_final means this chunk of transcript is stable and won't be
254
+ // revised — it does NOT mean the user is done talking. Deepgram can (and
255
+ // routinely does) finalize several chunks of one continuous utterance in
256
+ // a row with no real pause between them. Only speech_final (endpointing
257
+ // actually detected a pause) means the turn is genuinely over. Found
258
+ // live, not theoretical: treating every is_final as a separate finished
259
+ // question fired two independent LLM+TTS turns for one utterance — the
260
+ // literal cause of both the duplicated transcript entries ("hello" /
261
+ // "hello" with no reply in between) and the agent audibly speaking
262
+ // twice, overlapping.
263
+ turnState.buffer = turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript;
264
+ if (!msg.speech_final) {
265
+ safeSend(client, { type: "interim", text: turnState.buffer });
224
266
  return;
225
267
  }
268
+ await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
269
+ }
270
+ /**
271
+ * Everything from here on (the LLM call, TTS streaming) can fail in ways
272
+ * that have nothing to do with a malformed message — a flaky provider
273
+ * call, a rate limit, a dropped upstream connection. handleDeepgramMessage
274
+ * is invoked fire-and-forget (`void handleDeepgramMessage(...)`), so an
275
+ * uncaught throw here previously vanished into an unhandled rejection: the
276
+ * client had already been told "final" (entering its "thinking" state) and
277
+ * then simply never heard from the server again for this turn — stuck
278
+ * indefinitely with the mic never resuming. Every path out of the try
279
+ * block now sends the client something that ends the turn.
280
+ *
281
+ * myGeneration is captured before the (potentially slow) LLM call and
282
+ * re-checked before the verb/speech actually goes out — a barge-in that
283
+ * happens while this turn is still "thinking" bumps the generation, and
284
+ * without this check the now-stale response would still land on the
285
+ * client after the user had already moved on to a new question.
286
+ */
287
+ async function finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration) {
288
+ const transcript = turnState.buffer;
289
+ turnState.buffer = "";
290
+ const myGeneration = getGeneration();
226
291
  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
292
  try {
237
293
  const { route, visible } = getContext();
238
294
  const verb = await (0, server_1.resolveVerb)(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
@@ -241,6 +297,8 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
241
297
  visible,
242
298
  history,
243
299
  });
300
+ if (myGeneration !== getGeneration())
301
+ return; // superseded by a barge-in while this turn was resolving
244
302
  // Sent immediately — before speech synthesis even starts — so
245
303
  // highlight/navigate/do execute in the browser right away instead of
246
304
  // waiting on audio. The agent visibly acts while it's still about to
@@ -260,8 +318,10 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
260
318
  }
261
319
  catch (err) {
262
320
  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" });
321
+ if (myGeneration === getGeneration()) {
322
+ safeSend(client, { type: "error", message: "Something went wrong answering that — try again." });
323
+ safeSend(client, { type: "turn_complete" });
324
+ }
265
325
  }
266
326
  }
267
327
  function safeSend(client, message) {
package/dist/server.js CHANGED
@@ -81,7 +81,22 @@ async function resolveVerb(llm, systemPrompt, manifest, registeredActions, capab
81
81
  return { verb: "explain", text: "I can only explain and point things out here — I can't do that." };
82
82
  }
83
83
  if (parsedVerb.data.verb === "do" && !registeredActions.includes(parsedVerb.data.action)) {
84
- return { verb: "explain", text: "That action isn't available here." };
84
+ // Not a manually registered action the auto-discovery fallback: does
85
+ // "target" name a real element on the CURRENT page that the indexer
86
+ // itself found a real, mutating handler call on? If so, attach that
87
+ // call here (never something the model emitted itself — see
88
+ // ApiCallSchema's doc comment) so the client can execute exactly the
89
+ // same request a real click on that element would already make.
90
+ // Anything else — no target, an unknown target, or a real target with
91
+ // no discoverable apiCall (a client-only handler, a dynamic per-row
92
+ // URL — see manifest.ts's parseApiCall) — stays refused, same as before.
93
+ const target = parsedVerb.data.target;
94
+ const pageElements = manifest.pages.find((p) => p.route === input.route)?.elements ?? [];
95
+ const targetElement = target ? pageElements.find((e) => e.id === target) : undefined;
96
+ if (!targetElement?.apiCall) {
97
+ return { verb: "explain", text: "That action isn't available here." };
98
+ }
99
+ return { ...parsedVerb.data, apiCall: targetElement.apiCall };
85
100
  }
86
101
  // tour is allowed at every tier (see TIER_ALLOWED_VERBS) because
87
102
  // highlighting-only steps never move the user — but a step carrying a
@@ -223,9 +238,11 @@ function buildVerbToolSchema(registeredActions) {
223
238
  text: { type: "string", description: "Shown to the user. Required for explain." },
224
239
  target: nullableString("Manifest element id. Required for highlight/open. For do, the id of what the action applies to, if it needs one. null (or omitted) if not applicable."),
225
240
  route: nullableString("A route from the manifest. Required for navigate. null (or omitted) if not applicable."),
226
- action: nullableString(registeredActions.length
227
- ? `Required for do. Must be exactly one of: ${registeredActions.join(", ")}. null (or omitted) if not applicable.`
228
- : "Required for do. No actions are registered in this deployment never use this verb."),
241
+ action: nullableString("Required for do. A short label for what's being done, e.g. \"archive-invoice\" " +
242
+ (registeredActions.length
243
+ ? `— either one of this deployment's registered actions [${registeredActions.join(", ")}], or, for any other element from currentPageElements whose own description says it performs a real action, any short label describing it.`
244
+ : "for any element from currentPageElements whose own description says it performs a real action — no actions are separately registered in this deployment, but currentPageElements-driven actions still work.") +
245
+ " null (or omitted) if not applicable."),
229
246
  steps: {
230
247
  type: "array",
231
248
  description: "Required for tour, 2-6 items. Each step is spoken/shown in order while highlighting its target (if any) — use this instead of explain when the answer genuinely covers several distinct elements, so the user sees what's being talked about instead of reading a wall of text.",
@@ -288,15 +305,23 @@ Always call ${VERB_TOOL_NAME} exactly once with one of these verbs:
288
305
  spans more than one page (e.g. "how do I get from here to Settings and
289
306
  turn on X"), a step may also carry a "route" to move there first — most
290
307
  steps should NOT set this; only the step where the page actually changes.
291
- - do: ONLY for an action id from this exact list: [${registeredActions.join(", ") || "none registered never use do"}].
292
- If the action applies to one specific thing among several (e.g. one row in
293
- a table), name it in "target". The manifest only describes each element
294
- once per page, even if it's rendered many times with different data — so
295
- for a per-instance target, use the matching id from the request's
296
- "visible" list instead, which reflects the real elements on the page right
297
- now (e.g. manifest has one generic "archive" button, but "visible" might
298
- list "archive-inv-2" for the specific row the user means).
299
- If the user asks for anything not on that list, use "explain" and say you can't do that from here.
308
+ - do: trigger a real action. Two ways this is allowedanything else, refuse:
309
+ 1. One of this deployment's registered action ids: [${registeredActions.join(", ") || "none registered"}].
310
+ Put that exact id in "action".
311
+ 2. Any element in "currentPageElements" whose own description says it
312
+ performs a real action (e.g. "Archives this invoice", "Starts a phone
313
+ call", "Submits the form") put that element's id in "target" and a
314
+ short label describing what it does in "action". This only works for
315
+ an element on the CURRENT page (it must be in currentPageElements) and
316
+ only for an action that doesn't depend on which specific row/instance
317
+ — a generic page-level button, not "archive row 3 of this table". If
318
+ the user means one specific item among several repeated ones, that's
319
+ not currently supported through this path — use "explain" and say so,
320
+ don't guess at a specific instance.
321
+ If neither applies — the action isn't registered and isn't a real element
322
+ on this page, or it needs picking a specific instance — use "explain" and
323
+ say you can't do that from here. Never invent a target or action id that
324
+ isn't in currentPageElements or the registered list above.
300
325
 
301
326
  Every "text" field (in explain, or per-step in tour, or the optional text on
302
327
  any other verb) is read aloud AND shown on screen, so it must sound like a
@@ -43,13 +43,33 @@ function dispatchVerb(verb, route, options) {
43
43
  return;
44
44
  case "do": {
45
45
  const allowed = options.registeredActions ?? [];
46
- if (!allowed.includes(verb.action)) {
47
- options.onExplain("That action isn't available here.");
46
+ if (allowed.includes(verb.action)) {
47
+ // Explicit, developer-owned path — unchanged.
48
+ options.onDo?.(verb.action, verb.target);
49
+ if (verb.text)
50
+ options.onExplain(verb.text);
48
51
  return;
49
52
  }
50
- options.onDo?.(verb.action, verb.target);
51
- if (verb.text)
52
- options.onExplain(verb.text);
53
+ if (verb.apiCall) {
54
+ // Auto-discovered path: a real, indexer-found handler call on this
55
+ // exact target element, attached server-side after looking the
56
+ // target up in the manifest — never something the model emitted
57
+ // itself (see ApiCallSchema's doc comment in @cairnvibe/core).
58
+ const el = verb.target ? (0, element_ladder_1.findElement)(verb.target) : null;
59
+ if (el)
60
+ (0, element_ladder_1.highlightElement)(el);
61
+ else if (verb.target)
62
+ (options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
63
+ if (verb.text)
64
+ options.onExplain(verb.text);
65
+ void executeApiCall(verb.apiCall).then((result) => {
66
+ if (!result.ok) {
67
+ options.onExplain("I tried to do that, but something went wrong — try again in a moment.");
68
+ }
69
+ });
70
+ return;
71
+ }
72
+ options.onExplain(verb.text ?? "That action isn't available here.");
53
73
  return;
54
74
  }
55
75
  case "tour":
@@ -65,3 +85,23 @@ function dispatchVerb(verb, route, options) {
65
85
  return;
66
86
  }
67
87
  }
88
+ /**
89
+ * Fires exactly the same request a real click on the target element would
90
+ * already make — same-origin only (apiCall.url is always relative, never a
91
+ * different host), and `credentials: "same-origin"` so the browser attaches
92
+ * the user's own real session cookies, the same way a manual click would.
93
+ * No body is sent: l1-scan.ts's static capture only ever traces method+url,
94
+ * never a request body (which usually depends on runtime state a build-time
95
+ * scan can't see) — fine for the common trigger-style action (an id already
96
+ * baked into the URL, no other payload needed), a real gap for one that
97
+ * requires one.
98
+ */
99
+ async function executeApiCall(apiCall) {
100
+ try {
101
+ const res = await fetch(apiCall.url, { method: apiCall.method, credentials: "same-origin" });
102
+ return { ok: res.ok, status: res.status };
103
+ }
104
+ catch {
105
+ return { ok: false };
106
+ }
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnvibe/sdk",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "In-app AI copilot — <Copilot/> for React/Next.js, <cairn-widget> for any framework — plus the server handlers and realtime voice relay behind them.",
5
5
  "license": "MIT",
6
6
  "publishConfig": { "access": "public" },
package/src/index.tsx CHANGED
@@ -92,16 +92,19 @@ export function Copilot({
92
92
  const [rtMicMuted, setRtMicMuted] = useState(false);
93
93
  const [rtSpeakerMuted, setRtSpeakerMuted] = useState(false);
94
94
  // Set while a "tour" verb's steps are being narrated/highlighted one at a
95
- // time — drives the step-progress caption and blocks the input/mic so a
96
- // typed or spoken question can't interrupt mid-walkthrough.
95
+ // time — drives the step-progress caption and blocks the *typed* input so
96
+ // a typed question can't collide with the walkthrough (a voice
97
+ // interruption is handled separately — see touringRef/triggerBargeIn).
97
98
  const [tourStep, setTourStep] = useState<{ index: number; total: number } | null>(null);
98
- const tourGenerationRef = useRef(0); // bumped to cancel an in-progress tour (e.g. widget closed) without extra flags
99
+ const tourGenerationRef = useRef(0); // bumped to cancel an in-progress tour (e.g. widget closed, or a voice barge-in) without extra flags
99
100
  // Mirrors whether a tour is running, for use inside the mic's
100
101
  // onaudioprocess callback (a stale closure over React state there would
101
102
  // miss a tour that started after the callback was created) — a tour
102
- // reuses "rt-speaking" to hold the mic off too, but must NOT be
103
- // barge-in-able the way a real conversational reply is (see the RMS
104
- // check below): it's a deliberate walkthrough, not a turn to interrupt.
103
+ // reuses "rt-speaking" to hold the mic off between steps, but IS
104
+ // barge-in-able like a real conversational reply (see the RMS check
105
+ // below): interrupting mid-tour cancels the rest of the walkthrough,
106
+ // the way a real person giving a tour stops when you have a question
107
+ // instead of talking over you.
105
108
  const touringRef = useRef(false);
106
109
  // Resolver for "this tour step's audio has fully finished playing" when
107
110
  // narrating over an already-open realtime session (see maybeResumeListening
@@ -242,19 +245,26 @@ export function Copilot({
242
245
  * actually showing you around instead of one paragraph naming several
243
246
  * buttons at once with nothing highlighted.
244
247
  *
245
- * Always narrates via speakEndpoint (plain request/response TTS), even
246
- * during a live realtime session — a tour is a distinct guided
247
- * walkthrough, not a conversational turn, so it doesn't need the
248
- * streaming relay's turn-taking machinery. If the widget is mid
249
- * realtime call, the mic is held off (mirrors "rt-speaking") for the
250
- * tour's duration so it can't pick up the tour's own narration.
248
+ * During a live realtime session, narration reuses the same streaming
249
+ * Speak connection a normal conversational reply uses (see
250
+ * speakOverRealtime below) instead of a separate buffered REST call
251
+ * otherwise falls back to speakEndpoint. Either way, the mic is held off
252
+ * between steps (mirrors "rt-speaking") so it doesn't pick up the tour's
253
+ * own narration but it's still listening for a real interruption:
254
+ * talking during a step cancels the rest of the tour via triggerBargeIn,
255
+ * the same as interrupting a normal spoken reply.
251
256
  */
252
257
  async function runTour(steps: TourStep[]) {
253
258
  const myGeneration = ++tourGenerationRef.current;
254
259
  const wasRealtimeListening = realtimeActive;
255
260
  touringRef.current = true;
256
261
  if (wasRealtimeListening) setRtStatus("rt-speaking");
257
- archiveCurrentExchange();
262
+ // No archiveCurrentExchange() here: whatever triggered this tour (a typed
263
+ // ask() or a realtime "final") already archived the exchange *before*
264
+ // this one — by the time a tour's "verb" message arrives, the triggering
265
+ // question is the CURRENT turn, still live in userCaption for the whole
266
+ // tour. Archiving it again here would just duplicate it (verified live —
267
+ // this used to show the triggering question twice).
258
268
  setAnswer(null);
259
269
  // Tracked locally rather than reading the component's `pathname` —
260
270
  // that's only current as of this render, and a step below can navigate
@@ -545,12 +555,20 @@ export function Copilot({
545
555
  if (ws.readyState !== WebSocket.OPEN) return;
546
556
  if (rtMicMutedRef.current) return;
547
557
 
548
- // Barge-in: while the agent is speaking a real conversational
549
- // reply (not touring a tour deliberately can't be talked over),
550
- // keep listening to the mic locally even though it isn't being
551
- // sent yet, and cut the agent off the instant the user starts
552
- // talking over it instead of making them wait for it to finish.
553
- if (rtStateRef.current === "rt-speaking" && !touringRef.current) {
558
+ // Barge-in: while the agent is speaking a real conversational reply,
559
+ // still thinking about one, OR mid-tour, keep listening to the mic
560
+ // locally even though it isn't being sent yet, and cut the agent
561
+ // off the instant the user starts talking again instead of making
562
+ // them wait including during a guided tour, which now cancels the
563
+ // rest of the walkthrough on interruption (see triggerBargeIn)
564
+ // instead of being talked-over-proof by design, the way a real
565
+ // person giving a tour stops when you have a question. The
566
+ // "rt-thinking" half matters just as much as "rt-speaking": an LLM
567
+ // turn can easily take a couple of seconds with nothing playing
568
+ // yet, and without this the mic was completely deaf during that
569
+ // whole window — found live as "not listening while speaking... no
570
+ // interrupting system", not just a missed nice-to-have.
571
+ if (rtStateRef.current === "rt-speaking" || rtStateRef.current === "rt-thinking") {
554
572
  const rms = computeRms(e.inputBuffer.getChannelData(0));
555
573
  if (rms > BARGE_IN_RMS_THRESHOLD) triggerBargeIn();
556
574
  return;
@@ -637,6 +655,19 @@ export function Copilot({
637
655
  disarmThinkingWatchdog();
638
656
  stopScheduledRtAudio();
639
657
  rtAudioDoneArrivingRef.current = true;
658
+ if (touringRef.current) {
659
+ // Interrupting mid-guide cancels the whole rest of the tour, not
660
+ // just the current step — the way a real person giving a tour
661
+ // stops and answers your question instead of continuing to talk
662
+ // over you. Without resolving the current step's own pending
663
+ // promise here, runTour only notices the cancellation via its own
664
+ // 15s-per-step fallback timeout instead of right away.
665
+ tourGenerationRef.current++;
666
+ touringRef.current = false;
667
+ setTourStep(null);
668
+ rtTourAudioDoneRef.current?.();
669
+ rtTourAudioDoneRef.current = null;
670
+ }
640
671
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "barge_in" }));
641
672
  setRtStatus("rt-listening");
642
673
  setCaption("");
@@ -713,7 +744,17 @@ export function Copilot({
713
744
  // exactly the way a silently-dropped response used to leave it.
714
745
  disarmThinkingWatchdog();
715
746
  setAnswer(msg.message ?? "Something went wrong.");
716
- if (!touringRef.current) {
747
+ if (touringRef.current) {
748
+ // A tour step's own speakStreamed() failed server-side (see
749
+ // realtime-server.ts's "speak" handler). Without resolving this
750
+ // step's pending promise here, runTour's `await
751
+ // speakOverRealtime(step.text)` only recovers via its own 15s
752
+ // fallback timeout — found live as a guide that goes badly
753
+ // quiet for long stretches, one step at a time.
754
+ rtAudioDoneArrivingRef.current = true;
755
+ rtTourAudioDoneRef.current?.();
756
+ rtTourAudioDoneRef.current = null;
757
+ } else {
717
758
  setRtStatus("rt-listening");
718
759
  setCaption("");
719
760
  }
@@ -95,7 +95,7 @@ export function createRealtimeServer(options: CreateRealtimeServerOptions): http
95
95
  return httpServer;
96
96
  }
97
97
 
98
- interface ConnectionDeps {
98
+ export interface ConnectionDeps {
99
99
  deepgramApiKey: string;
100
100
  sttModel: string;
101
101
  ttsVoice: string;
@@ -202,8 +202,13 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
202
202
  });
203
203
  }
204
204
 
205
+ // Accumulates Deepgram "Results" transcript segments across one utterance
206
+ // — see handleDeepgramMessage for why this can't just react to every
207
+ // is_final.
208
+ const turnState = { buffer: "" };
209
+
205
210
  dg.on("message", (data) => {
206
- void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history);
211
+ void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation);
207
212
  });
208
213
 
209
214
  dg.on("error", (err) => {
@@ -233,7 +238,20 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
233
238
  // client falling back to a separate buffered REST call. No STT/verb
234
239
  // resolution involved; the client already resolved the tour steps
235
240
  // itself and just needs this text spoken.
236
- void speakStreamed(msg.text);
241
+ //
242
+ // Caught explicitly, unlike a normal turn's speakStreamed call (see
243
+ // handleDeepgramMessage) — this one isn't inside that function's own
244
+ // try/catch, and an uncaught rejection here previously vanished
245
+ // silently: the client's speakOverRealtime() promise for this step
246
+ // never resolves except via its own 15s fallback timeout, with
247
+ // nothing telling the user anything went wrong in the meantime —
248
+ // found live as a tour that goes badly quiet for stretches at a
249
+ // time. A real "error" message lets the client's tour-step handler
250
+ // (index.tsx's ws.onmessage) unstick itself immediately instead.
251
+ speakStreamed(msg.text).catch((err) => {
252
+ console.error("[cairn realtime] speakStreamed failed for a tour step:", err);
253
+ safeSend(client, { type: "error", message: "Something went wrong narrating that step." });
254
+ });
237
255
  }
238
256
  } catch {
239
257
  // Ignore malformed control messages — never crash the relay on bad client input.
@@ -250,13 +268,15 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
250
268
  });
251
269
  }
252
270
 
253
- async function handleDeepgramMessage(
271
+ export async function handleDeepgramMessage(
254
272
  raw: string,
255
273
  client: WebSocket,
256
274
  deps: ConnectionDeps,
257
275
  getContext: () => { route: string; visible: string[] },
258
276
  speakStreamed: (text: string) => Promise<void>,
259
277
  history: HistoryTurn[],
278
+ turnState: { buffer: string },
279
+ getGeneration: () => number,
260
280
  ): Promise<void> {
261
281
  let msg: any;
262
282
  try {
@@ -265,26 +285,74 @@ async function handleDeepgramMessage(
265
285
  return;
266
286
  }
267
287
 
288
+ if (msg.type === "UtteranceEnd") {
289
+ // A second, independent "the user is truly done" signal Deepgram sends
290
+ // after utterance_end_ms of silence — a safety net for the rare case a
291
+ // Results message never carries speech_final:true, so a turn can't get
292
+ // permanently stuck with real transcript sitting in the buffer forever.
293
+ if (turnState.buffer) await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
294
+ return;
295
+ }
296
+
268
297
  if (msg.type !== "Results") return;
269
298
  const transcript: string | undefined = msg.channel?.alternatives?.[0]?.transcript;
270
299
  if (!transcript) return;
271
300
 
272
301
  if (!msg.is_final) {
273
- safeSend(client, { type: "interim", text: transcript });
302
+ safeSend(client, { type: "interim", text: turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript });
303
+ return;
304
+ }
305
+
306
+ // is_final means this chunk of transcript is stable and won't be
307
+ // revised — it does NOT mean the user is done talking. Deepgram can (and
308
+ // routinely does) finalize several chunks of one continuous utterance in
309
+ // a row with no real pause between them. Only speech_final (endpointing
310
+ // actually detected a pause) means the turn is genuinely over. Found
311
+ // live, not theoretical: treating every is_final as a separate finished
312
+ // question fired two independent LLM+TTS turns for one utterance — the
313
+ // literal cause of both the duplicated transcript entries ("hello" /
314
+ // "hello" with no reply in between) and the agent audibly speaking
315
+ // twice, overlapping.
316
+ turnState.buffer = turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript;
317
+ if (!msg.speech_final) {
318
+ safeSend(client, { type: "interim", text: turnState.buffer });
274
319
  return;
275
320
  }
276
321
 
322
+ await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
323
+ }
324
+
325
+ /**
326
+ * Everything from here on (the LLM call, TTS streaming) can fail in ways
327
+ * that have nothing to do with a malformed message — a flaky provider
328
+ * call, a rate limit, a dropped upstream connection. handleDeepgramMessage
329
+ * is invoked fire-and-forget (`void handleDeepgramMessage(...)`), so an
330
+ * uncaught throw here previously vanished into an unhandled rejection: the
331
+ * client had already been told "final" (entering its "thinking" state) and
332
+ * then simply never heard from the server again for this turn — stuck
333
+ * indefinitely with the mic never resuming. Every path out of the try
334
+ * block now sends the client something that ends the turn.
335
+ *
336
+ * myGeneration is captured before the (potentially slow) LLM call and
337
+ * re-checked before the verb/speech actually goes out — a barge-in that
338
+ * happens while this turn is still "thinking" bumps the generation, and
339
+ * without this check the now-stale response would still land on the
340
+ * client after the user had already moved on to a new question.
341
+ */
342
+ async function finalizeTurn(
343
+ turnState: { buffer: string },
344
+ client: WebSocket,
345
+ deps: ConnectionDeps,
346
+ getContext: () => { route: string; visible: string[] },
347
+ speakStreamed: (text: string) => Promise<void>,
348
+ history: HistoryTurn[],
349
+ getGeneration: () => number,
350
+ ): Promise<void> {
351
+ const transcript = turnState.buffer;
352
+ turnState.buffer = "";
353
+ const myGeneration = getGeneration();
277
354
  safeSend(client, { type: "final", text: transcript });
278
355
 
279
- // Everything from here on (the LLM call, TTS streaming) can fail in ways
280
- // that have nothing to do with a malformed message — a flaky provider
281
- // call, a rate limit, a dropped upstream connection. This whole function
282
- // is invoked fire-and-forget (`void handleDeepgramMessage(...)`), so an
283
- // uncaught throw here previously vanished into an unhandled rejection:
284
- // the client had already been told "final" (entering its "thinking"
285
- // state) and then simply never heard from the server again for this
286
- // turn — stuck indefinitely with the mic never resuming. Every path out
287
- // of this try block now sends the client something that ends the turn.
288
356
  try {
289
357
  const { route, visible } = getContext();
290
358
  const verb = await resolveVerb(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
@@ -293,6 +361,9 @@ async function handleDeepgramMessage(
293
361
  visible,
294
362
  history,
295
363
  });
364
+
365
+ if (myGeneration !== getGeneration()) return; // superseded by a barge-in while this turn was resolving
366
+
296
367
  // Sent immediately — before speech synthesis even starts — so
297
368
  // highlight/navigate/do execute in the browser right away instead of
298
369
  // waiting on audio. The agent visibly acts while it's still about to
@@ -312,8 +383,10 @@ async function handleDeepgramMessage(
312
383
  }
313
384
  } catch (err) {
314
385
  console.error("[cairn realtime] failed to resolve/speak this turn:", err);
315
- safeSend(client, { type: "error", message: "Something went wrong answering that — try again." });
316
- safeSend(client, { type: "turn_complete" });
386
+ if (myGeneration === getGeneration()) {
387
+ safeSend(client, { type: "error", message: "Something went wrong answering that — try again." });
388
+ safeSend(client, { type: "turn_complete" });
389
+ }
317
390
  }
318
391
  }
319
392