@cairnvibe/sdk 0.2.5 → 0.2.7

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/src/index.tsx CHANGED
@@ -3,6 +3,8 @@
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import { usePathname, useRouter } from "next/navigation";
5
5
  import {
6
+ ChevronDown,
7
+ ChevronUp,
6
8
  Loader2,
7
9
  Mic,
8
10
  MicOff,
@@ -17,6 +19,7 @@ import {
17
19
  import type { HistoryTurn as HistoryEntry, TourStep } from "@cairnvibe/core";
18
20
  import { collectVisible } from "./context-collector";
19
21
  import { findElement, highlightElement, logMiss, type MissContext } from "./element-ladder";
22
+ import { createLiveElementRegistry } from "./runtime-scan";
20
23
  import { executeVerbResponse } from "./verb-executor";
21
24
 
22
25
  export interface CopilotProps {
@@ -63,8 +66,24 @@ export function Copilot({
63
66
  persona = "Cairn",
64
67
  }: CopilotProps) {
65
68
  const pathname = usePathname() ?? "/";
69
+ // Mirrors `pathname` for use inside long-lived closures (a realtime
70
+ // session's handlers are all created once, when the connection opens —
71
+ // same staleness reason runTour tracks its own `currentRoute` locally
72
+ // rather than trusting its closure's `pathname` after a mid-tour
73
+ // navigation).
74
+ const pathnameRef = useRef(pathname);
75
+ useEffect(() => {
76
+ pathnameRef.current = pathname;
77
+ sendFreshContext(); // no-op if no realtime session is open
78
+ // eslint-disable-next-line react-hooks/exhaustive-deps
79
+ }, [pathname]);
66
80
  const router = useRouter();
67
81
  const [open, setOpen] = useState(false);
82
+ // Collapsed by default so the panel only ever shows the current exchange
83
+ // — the full archived transcript (built up over a long conversation)
84
+ // stays out of the way behind an explicit toggle instead of always being
85
+ // visible inline, which made the panel grow uncomfortably tall.
86
+ const [historyExpanded, setHistoryExpanded] = useState(false);
68
87
  const [question, setQuestion] = useState("");
69
88
  const [answer, setAnswer] = useState<string | null>(null);
70
89
  const [status, setStatus] = useState<Status>("idle");
@@ -92,16 +111,19 @@ export function Copilot({
92
111
  const [rtMicMuted, setRtMicMuted] = useState(false);
93
112
  const [rtSpeakerMuted, setRtSpeakerMuted] = useState(false);
94
113
  // 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.
114
+ // time — drives the step-progress caption and blocks the *typed* input so
115
+ // a typed question can't collide with the walkthrough (a voice
116
+ // interruption is handled separately — see touringRef/triggerBargeIn).
97
117
  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
118
+ const tourGenerationRef = useRef(0); // bumped to cancel an in-progress tour (e.g. widget closed, or a voice barge-in) without extra flags
99
119
  // Mirrors whether a tour is running, for use inside the mic's
100
120
  // onaudioprocess callback (a stale closure over React state there would
101
121
  // 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.
122
+ // reuses "rt-speaking" to hold the mic off between steps, but IS
123
+ // barge-in-able like a real conversational reply (see the RMS check
124
+ // below): interrupting mid-tour cancels the rest of the walkthrough,
125
+ // the way a real person giving a tour stops when you have a question
126
+ // instead of talking over you.
105
127
  const touringRef = useRef(false);
106
128
  // Resolver for "this tour step's audio has fully finished playing" when
107
129
  // narrating over an already-open realtime session (see maybeResumeListening
@@ -115,6 +137,20 @@ export function Copilot({
115
137
  // already stateful).
116
138
  const historyRef = useRef<HistoryEntry[]>([]);
117
139
 
140
+ // A background scanner that keeps a live inventory of what's actually
141
+ // clickable on screen right now (runtime-scan.ts) — running continuously
142
+ // via a MutationObserver so there's never a pause to "go look at the
143
+ // page" right when a verb needs to click something. `liveMapRef` freezes
144
+ // one snapshot of it per turn (set alongside every context/question send,
145
+ // below) so a background rescan landing mid-flight can't shift what an id
146
+ // resolves to between when a request went out and its response came back.
147
+ const liveRegistryRef = useRef(createLiveElementRegistry());
148
+ const liveMapRef = useRef<Map<string, HTMLElement>>(new Map());
149
+ useEffect(() => {
150
+ liveRegistryRef.current.start();
151
+ return () => liveRegistryRef.current.stop();
152
+ }, []);
153
+
118
154
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
119
155
  const audioChunksRef = useRef<Blob[]>([]);
120
156
  const transcribeInFlightRef = useRef(false);
@@ -210,6 +246,28 @@ export function Copilot({
210
246
  setStatus(next);
211
247
  }
212
248
 
249
+ /**
250
+ * Refreshes the server's picture of route/visible/liveElements over an
251
+ * already-open realtime connection. Beyond the initial connect, called
252
+ * on every route change and whenever the mic is about to start listening
253
+ * again — a real, pre-existing gap this closes as a side effect: the
254
+ * server's context previously updated only once, at connection open, so
255
+ * navigating mid-call (via a "navigate" verb, or the user clicking
256
+ * around) left the server answering every later turn as if the user were
257
+ * still on the original page. Reads pathnameRef, not the closure's
258
+ * `pathname`, so it's correct even called from a handler created once at
259
+ * connection-open time.
260
+ */
261
+ function sendFreshContext() {
262
+ const ws = rtSocketRef.current;
263
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
264
+ const liveScan = liveRegistryRef.current.getSnapshot();
265
+ liveMapRef.current = liveScan.byId;
266
+ ws.send(
267
+ JSON.stringify({ type: "context", route: pathnameRef.current, visible: collectVisible(), liveElements: liveScan.elements }),
268
+ );
269
+ }
270
+
213
271
  function reportMiss(context: MissContext) {
214
272
  logMiss(context);
215
273
  if (reportMissesEndpoint) {
@@ -232,6 +290,7 @@ export function Copilot({
232
290
  onDo,
233
291
  onTour: (steps) => void runTour(steps),
234
292
  registeredActions,
293
+ liveElements: liveMapRef.current,
235
294
  });
236
295
  }
237
296
 
@@ -242,12 +301,14 @@ export function Copilot({
242
301
  * actually showing you around instead of one paragraph naming several
243
302
  * buttons at once with nothing highlighted.
244
303
  *
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.
304
+ * During a live realtime session, narration reuses the same streaming
305
+ * Speak connection a normal conversational reply uses (see
306
+ * speakOverRealtime below) instead of a separate buffered REST call
307
+ * otherwise falls back to speakEndpoint. Either way, the mic is held off
308
+ * between steps (mirrors "rt-speaking") so it doesn't pick up the tour's
309
+ * own narration but it's still listening for a real interruption:
310
+ * talking during a step cancels the rest of the tour via triggerBargeIn,
311
+ * the same as interrupting a normal spoken reply.
251
312
  */
252
313
  async function runTour(steps: TourStep[]) {
253
314
  const myGeneration = ++tourGenerationRef.current;
@@ -292,9 +353,23 @@ export function Copilot({
292
353
  }
293
354
 
294
355
  if (step.target) {
295
- const el = findElement(step.target);
296
- if (el) highlightElement(el);
297
- else reportMiss({ attempted: step.target, route: currentRoute });
356
+ // A fresh scan, not the tour's starting liveMapRef snapshot — a
357
+ // step after a mid-tour navigation targets elements on a page
358
+ // that didn't exist when the tour began.
359
+ const liveScan = liveRegistryRef.current.getSnapshot();
360
+ const el = findElement(step.target, liveScan.byId);
361
+ if (el) {
362
+ highlightElement(el);
363
+ if (step.click) {
364
+ el.click();
365
+ // Give whatever the click reveals (a detail view, an expanded
366
+ // row) a moment to actually render before narrating it.
367
+ await new Promise((resolve) => setTimeout(resolve, 400));
368
+ if (tourGenerationRef.current !== myGeneration) return;
369
+ }
370
+ } else {
371
+ reportMiss({ attempted: step.target, route: currentRoute });
372
+ }
298
373
  }
299
374
 
300
375
  if (wasRealtimeListening && rtSocketRef.current?.readyState === WebSocket.OPEN) {
@@ -332,10 +407,18 @@ export function Copilot({
332
407
  setLastQuestion(q);
333
408
  setQuestion("");
334
409
  try {
410
+ const liveScan = liveRegistryRef.current.getSnapshot();
411
+ liveMapRef.current = liveScan.byId;
335
412
  const res = await fetch(endpoint, {
336
413
  method: "POST",
337
414
  headers: { "content-type": "application/json" },
338
- body: JSON.stringify({ route: pathname, question: q, visible: collectVisible(), history: historyRef.current }),
415
+ body: JSON.stringify({
416
+ route: pathname,
417
+ question: q,
418
+ visible: collectVisible(),
419
+ history: historyRef.current,
420
+ liveElements: liveScan.elements,
421
+ }),
339
422
  });
340
423
  const data = await res.json().catch(() => null);
341
424
  handleVerb(data);
@@ -550,12 +633,20 @@ export function Copilot({
550
633
  if (ws.readyState !== WebSocket.OPEN) return;
551
634
  if (rtMicMutedRef.current) return;
552
635
 
553
- // Barge-in: while the agent is speaking a real conversational
554
- // reply (not touring a tour deliberately can't be talked over),
555
- // keep listening to the mic locally even though it isn't being
556
- // sent yet, and cut the agent off the instant the user starts
557
- // talking over it instead of making them wait for it to finish.
558
- if (rtStateRef.current === "rt-speaking" && !touringRef.current) {
636
+ // Barge-in: while the agent is speaking a real conversational reply,
637
+ // still thinking about one, OR mid-tour, keep listening to the mic
638
+ // locally even though it isn't being sent yet, and cut the agent
639
+ // off the instant the user starts talking again instead of making
640
+ // them wait including during a guided tour, which now cancels the
641
+ // rest of the walkthrough on interruption (see triggerBargeIn)
642
+ // instead of being talked-over-proof by design, the way a real
643
+ // person giving a tour stops when you have a question. The
644
+ // "rt-thinking" half matters just as much as "rt-speaking": an LLM
645
+ // turn can easily take a couple of seconds with nothing playing
646
+ // yet, and without this the mic was completely deaf during that
647
+ // whole window — found live as "not listening while speaking... no
648
+ // interrupting system", not just a missed nice-to-have.
649
+ if (rtStateRef.current === "rt-speaking" || rtStateRef.current === "rt-thinking") {
559
650
  const rms = computeRms(e.inputBuffer.getChannelData(0));
560
651
  if (rms > BARGE_IN_RMS_THRESHOLD) triggerBargeIn();
561
652
  return;
@@ -601,6 +692,7 @@ export function Copilot({
601
692
  }
602
693
  setRtStatus("rt-listening");
603
694
  setCaption("");
695
+ sendFreshContext(); // refresh before the user starts talking again, not after
604
696
  }
605
697
 
606
698
  function disarmThinkingWatchdog() {
@@ -642,13 +734,26 @@ export function Copilot({
642
734
  disarmThinkingWatchdog();
643
735
  stopScheduledRtAudio();
644
736
  rtAudioDoneArrivingRef.current = true;
737
+ if (touringRef.current) {
738
+ // Interrupting mid-guide cancels the whole rest of the tour, not
739
+ // just the current step — the way a real person giving a tour
740
+ // stops and answers your question instead of continuing to talk
741
+ // over you. Without resolving the current step's own pending
742
+ // promise here, runTour only notices the cancellation via its own
743
+ // 15s-per-step fallback timeout instead of right away.
744
+ tourGenerationRef.current++;
745
+ touringRef.current = false;
746
+ setTourStep(null);
747
+ rtTourAudioDoneRef.current?.();
748
+ rtTourAudioDoneRef.current = null;
749
+ }
645
750
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "barge_in" }));
646
751
  setRtStatus("rt-listening");
647
752
  setCaption("");
648
753
  }
649
754
 
650
755
  ws.onopen = () => {
651
- ws.send(JSON.stringify({ type: "context", route: pathname, visible: collectVisible() }));
756
+ sendFreshContext();
652
757
  setRtStatus("rt-listening");
653
758
  rtStartingRef.current = false;
654
759
  };
@@ -718,7 +823,17 @@ export function Copilot({
718
823
  // exactly the way a silently-dropped response used to leave it.
719
824
  disarmThinkingWatchdog();
720
825
  setAnswer(msg.message ?? "Something went wrong.");
721
- if (!touringRef.current) {
826
+ if (touringRef.current) {
827
+ // A tour step's own speakStreamed() failed server-side (see
828
+ // realtime-server.ts's "speak" handler). Without resolving this
829
+ // step's pending promise here, runTour's `await
830
+ // speakOverRealtime(step.text)` only recovers via its own 15s
831
+ // fallback timeout — found live as a guide that goes badly
832
+ // quiet for long stretches, one step at a time.
833
+ rtAudioDoneArrivingRef.current = true;
834
+ rtTourAudioDoneRef.current?.();
835
+ rtTourAudioDoneRef.current = null;
836
+ } else {
722
837
  setRtStatus("rt-listening");
723
838
  setCaption("");
724
839
  }
@@ -807,14 +922,26 @@ export function Copilot({
807
922
 
808
923
  {(transcript.length > 0 || userCaption || answer || busy) && (
809
924
  <div className="cairn-stack">
810
- {transcript.map((entry) => (
811
- <div
812
- className={entry.role === "user" ? "cairn-bubble cairn-bubble-user cairn-bubble-past" : "cairn-bubble cairn-bubble-agent cairn-bubble-past"}
813
- key={entry.id}
925
+ {transcript.length > 0 && (
926
+ <button
927
+ type="button"
928
+ className="cairn-history-toggle"
929
+ onClick={() => setHistoryExpanded((v) => !v)}
930
+ aria-expanded={historyExpanded}
814
931
  >
815
- {entry.role === "agent" ? <span className="cairn-bubble-text">{entry.text}</span> : entry.text}
816
- </div>
817
- ))}
932
+ {historyExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
933
+ {historyExpanded ? "Hide earlier" : `${transcript.length} earlier`}
934
+ </button>
935
+ )}
936
+ {historyExpanded &&
937
+ transcript.map((entry) => (
938
+ <div
939
+ className={entry.role === "user" ? "cairn-bubble cairn-bubble-user cairn-bubble-past" : "cairn-bubble cairn-bubble-agent cairn-bubble-past"}
940
+ key={entry.id}
941
+ >
942
+ {entry.role === "agent" ? <span className="cairn-bubble-text">{entry.text}</span> : entry.text}
943
+ </div>
944
+ ))}
818
945
  {userCaption && (
819
946
  <div className="cairn-bubble cairn-bubble-user" key={`u-${userCaption}`}>
820
947
  {userCaption}
@@ -1184,6 +1311,26 @@ const COPILOT_STYLES = `
1184
1311
  text-transform: uppercase;
1185
1312
  color: rgba(11, 13, 18, 0.48);
1186
1313
  }
1314
+ .cairn-history-toggle {
1315
+ align-self: center;
1316
+ display: inline-flex;
1317
+ align-items: center;
1318
+ gap: 3px;
1319
+ border: none;
1320
+ background: none;
1321
+ padding: 2px 8px;
1322
+ font: inherit;
1323
+ font-size: 11px;
1324
+ font-weight: 600;
1325
+ color: rgba(11, 13, 18, 0.4);
1326
+ cursor: pointer;
1327
+ border-radius: 999px;
1328
+ transition: background 0.15s ease, color 0.15s ease;
1329
+ }
1330
+ .cairn-history-toggle:hover {
1331
+ background: rgba(11, 13, 18, 0.05);
1332
+ color: rgba(11, 13, 18, 0.6);
1333
+ }
1187
1334
  .cairn-thinking {
1188
1335
  display: inline-flex;
1189
1336
  gap: 4px;
@@ -27,7 +27,7 @@
27
27
 
28
28
  import http from "node:http";
29
29
  import { WebSocket, WebSocketServer } from "ws";
30
- import type { HistoryTurn, Manifest, VerbResponse } from "@cairnvibe/core";
30
+ import type { HistoryTurn, LiveElement, Manifest, VerbResponse } from "@cairnvibe/core";
31
31
  import { buildSystemPrompt, createVerbLLM, resolveVerb, type CapabilityTier, type CreateCopilotHandlerOptions } from "./server";
32
32
  import { DeepgramSpeakStream } from "./tts-stream";
33
33
 
@@ -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;
@@ -109,7 +109,10 @@ interface ConnectionDeps {
109
109
  const MAX_HISTORY_TURNS = 8; // 4 exchanges — enough for "the first one"/"do that instead" without growing the prompt unbounded over a long call
110
110
 
111
111
  async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promise<void> {
112
- let context = { route: "/", visible: [] as string[] };
112
+ // liveElements refreshes on every "context" resend (the client sends one
113
+ // on route changes and each time it's about to start listening again),
114
+ // so a live scan from several turns ago never lingers into a later one.
115
+ let context: { route: string; visible: string[]; liveElements: LiveElement[] } = { route: "/", visible: [], liveElements: [] };
113
116
  // Unlike the stateless HTTP path (which needs the client to resend
114
117
  // history every request), a realtime connection is already stateful —
115
118
  // one WebSocket per call — so this is accumulated here directly rather
@@ -202,8 +205,13 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
202
205
  });
203
206
  }
204
207
 
208
+ // Accumulates Deepgram "Results" transcript segments across one utterance
209
+ // — see handleDeepgramMessage for why this can't just react to every
210
+ // is_final.
211
+ const turnState = { buffer: "" };
212
+
205
213
  dg.on("message", (data) => {
206
- void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history);
214
+ void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation);
207
215
  });
208
216
 
209
217
  dg.on("error", (err) => {
@@ -221,7 +229,11 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
221
229
  try {
222
230
  const msg = JSON.parse(data.toString());
223
231
  if (msg.type === "context") {
224
- context = { route: String(msg.route ?? "/"), visible: Array.isArray(msg.visible) ? msg.visible : [] };
232
+ context = {
233
+ route: String(msg.route ?? "/"),
234
+ visible: Array.isArray(msg.visible) ? msg.visible : [],
235
+ liveElements: parseLiveElements(msg.liveElements),
236
+ };
225
237
  } else if (msg.type === "end") {
226
238
  client.close();
227
239
  } else if (msg.type === "barge_in") {
@@ -233,7 +245,20 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
233
245
  // client falling back to a separate buffered REST call. No STT/verb
234
246
  // resolution involved; the client already resolved the tour steps
235
247
  // itself and just needs this text spoken.
236
- void speakStreamed(msg.text);
248
+ //
249
+ // Caught explicitly, unlike a normal turn's speakStreamed call (see
250
+ // handleDeepgramMessage) — this one isn't inside that function's own
251
+ // try/catch, and an uncaught rejection here previously vanished
252
+ // silently: the client's speakOverRealtime() promise for this step
253
+ // never resolves except via its own 15s fallback timeout, with
254
+ // nothing telling the user anything went wrong in the meantime —
255
+ // found live as a tour that goes badly quiet for stretches at a
256
+ // time. A real "error" message lets the client's tour-step handler
257
+ // (index.tsx's ws.onmessage) unstick itself immediately instead.
258
+ speakStreamed(msg.text).catch((err) => {
259
+ console.error("[cairn realtime] speakStreamed failed for a tour step:", err);
260
+ safeSend(client, { type: "error", message: "Something went wrong narrating that step." });
261
+ });
237
262
  }
238
263
  } catch {
239
264
  // Ignore malformed control messages — never crash the relay on bad client input.
@@ -250,13 +275,15 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
250
275
  });
251
276
  }
252
277
 
253
- async function handleDeepgramMessage(
278
+ export async function handleDeepgramMessage(
254
279
  raw: string,
255
280
  client: WebSocket,
256
281
  deps: ConnectionDeps,
257
- getContext: () => { route: string; visible: string[] },
282
+ getContext: () => { route: string; visible: string[]; liveElements: LiveElement[] },
258
283
  speakStreamed: (text: string) => Promise<void>,
259
284
  history: HistoryTurn[],
285
+ turnState: { buffer: string },
286
+ getGeneration: () => number,
260
287
  ): Promise<void> {
261
288
  let msg: any;
262
289
  try {
@@ -265,34 +292,86 @@ async function handleDeepgramMessage(
265
292
  return;
266
293
  }
267
294
 
295
+ if (msg.type === "UtteranceEnd") {
296
+ // A second, independent "the user is truly done" signal Deepgram sends
297
+ // after utterance_end_ms of silence — a safety net for the rare case a
298
+ // Results message never carries speech_final:true, so a turn can't get
299
+ // permanently stuck with real transcript sitting in the buffer forever.
300
+ if (turnState.buffer) await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
301
+ return;
302
+ }
303
+
268
304
  if (msg.type !== "Results") return;
269
305
  const transcript: string | undefined = msg.channel?.alternatives?.[0]?.transcript;
270
306
  if (!transcript) return;
271
307
 
272
308
  if (!msg.is_final) {
273
- safeSend(client, { type: "interim", text: transcript });
309
+ safeSend(client, { type: "interim", text: turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript });
274
310
  return;
275
311
  }
276
312
 
313
+ // is_final means this chunk of transcript is stable and won't be
314
+ // revised — it does NOT mean the user is done talking. Deepgram can (and
315
+ // routinely does) finalize several chunks of one continuous utterance in
316
+ // a row with no real pause between them. Only speech_final (endpointing
317
+ // actually detected a pause) means the turn is genuinely over. Found
318
+ // live, not theoretical: treating every is_final as a separate finished
319
+ // question fired two independent LLM+TTS turns for one utterance — the
320
+ // literal cause of both the duplicated transcript entries ("hello" /
321
+ // "hello" with no reply in between) and the agent audibly speaking
322
+ // twice, overlapping.
323
+ turnState.buffer = turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript;
324
+ if (!msg.speech_final) {
325
+ safeSend(client, { type: "interim", text: turnState.buffer });
326
+ return;
327
+ }
328
+
329
+ await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
330
+ }
331
+
332
+ /**
333
+ * Everything from here on (the LLM call, TTS streaming) can fail in ways
334
+ * that have nothing to do with a malformed message — a flaky provider
335
+ * call, a rate limit, a dropped upstream connection. handleDeepgramMessage
336
+ * is invoked fire-and-forget (`void handleDeepgramMessage(...)`), so an
337
+ * uncaught throw here previously vanished into an unhandled rejection: the
338
+ * client had already been told "final" (entering its "thinking" state) and
339
+ * then simply never heard from the server again for this turn — stuck
340
+ * indefinitely with the mic never resuming. Every path out of the try
341
+ * block now sends the client something that ends the turn.
342
+ *
343
+ * myGeneration is captured before the (potentially slow) LLM call and
344
+ * re-checked before the verb/speech actually goes out — a barge-in that
345
+ * happens while this turn is still "thinking" bumps the generation, and
346
+ * without this check the now-stale response would still land on the
347
+ * client after the user had already moved on to a new question.
348
+ */
349
+ async function finalizeTurn(
350
+ turnState: { buffer: string },
351
+ client: WebSocket,
352
+ deps: ConnectionDeps,
353
+ getContext: () => { route: string; visible: string[]; liveElements: LiveElement[] },
354
+ speakStreamed: (text: string) => Promise<void>,
355
+ history: HistoryTurn[],
356
+ getGeneration: () => number,
357
+ ): Promise<void> {
358
+ const transcript = turnState.buffer;
359
+ turnState.buffer = "";
360
+ const myGeneration = getGeneration();
277
361
  safeSend(client, { type: "final", text: transcript });
278
362
 
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
363
  try {
289
- const { route, visible } = getContext();
364
+ const { route, visible, liveElements } = getContext();
290
365
  const verb = await resolveVerb(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
291
366
  route,
292
367
  question: transcript,
293
368
  visible,
369
+ liveElements,
294
370
  history,
295
371
  });
372
+
373
+ if (myGeneration !== getGeneration()) return; // superseded by a barge-in while this turn was resolving
374
+
296
375
  // Sent immediately — before speech synthesis even starts — so
297
376
  // highlight/navigate/do execute in the browser right away instead of
298
377
  // waiting on audio. The agent visibly acts while it's still about to
@@ -312,8 +391,10 @@ async function handleDeepgramMessage(
312
391
  }
313
392
  } catch (err) {
314
393
  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" });
394
+ if (myGeneration === getGeneration()) {
395
+ safeSend(client, { type: "error", message: "Something went wrong answering that — try again." });
396
+ safeSend(client, { type: "turn_complete" });
397
+ }
317
398
  }
318
399
  }
319
400
 
@@ -322,6 +403,28 @@ function safeSend(client: WebSocket, message: ServerMessage): void {
322
403
  client.send(JSON.stringify(message));
323
404
  }
324
405
 
406
+ /** Defensive parse for the client's self-reported live DOM scan — same
407
+ * untrusted-input treatment `visible` already gets on this control-message
408
+ * path (no CopilotRequestSchema here, unlike the HTTP handler), just
409
+ * shaped-checked so a malformed entry can't reach the LLM prompt oddly. */
410
+ function parseLiveElements(raw: unknown): LiveElement[] {
411
+ if (!Array.isArray(raw)) return [];
412
+ const elements: LiveElement[] = [];
413
+ for (const entry of raw) {
414
+ if (
415
+ entry &&
416
+ typeof entry === "object" &&
417
+ typeof (entry as any).id === "string" &&
418
+ typeof (entry as any).role === "string" &&
419
+ typeof (entry as any).label === "string"
420
+ ) {
421
+ elements.push({ id: (entry as any).id, role: (entry as any).role, label: (entry as any).label });
422
+ }
423
+ if (elements.length >= 60) break;
424
+ }
425
+ return elements;
426
+ }
427
+
325
428
  /** A short text form of any verb for the history log — not shown to the
326
429
  * user, just fed back to the model on later turns so it knows what it
327
430
  * already did/said. */