@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/dist/index.js CHANGED
@@ -8,11 +8,28 @@ const navigation_1 = require("next/navigation");
8
8
  const lucide_react_1 = require("lucide-react");
9
9
  const context_collector_1 = require("./context-collector");
10
10
  const element_ladder_1 = require("./element-ladder");
11
+ const runtime_scan_1 = require("./runtime-scan");
11
12
  const verb_executor_1 = require("./verb-executor");
12
13
  function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, reportMissesEndpoint, transcribeEndpoint, speakEndpoint, realtimeUrl, persona = "Cairn", }) {
13
14
  const pathname = (0, navigation_1.usePathname)() ?? "/";
15
+ // Mirrors `pathname` for use inside long-lived closures (a realtime
16
+ // session's handlers are all created once, when the connection opens —
17
+ // same staleness reason runTour tracks its own `currentRoute` locally
18
+ // rather than trusting its closure's `pathname` after a mid-tour
19
+ // navigation).
20
+ const pathnameRef = (0, react_1.useRef)(pathname);
21
+ (0, react_1.useEffect)(() => {
22
+ pathnameRef.current = pathname;
23
+ sendFreshContext(); // no-op if no realtime session is open
24
+ // eslint-disable-next-line react-hooks/exhaustive-deps
25
+ }, [pathname]);
14
26
  const router = (0, navigation_1.useRouter)();
15
27
  const [open, setOpen] = (0, react_1.useState)(false);
28
+ // Collapsed by default so the panel only ever shows the current exchange
29
+ // — the full archived transcript (built up over a long conversation)
30
+ // stays out of the way behind an explicit toggle instead of always being
31
+ // visible inline, which made the panel grow uncomfortably tall.
32
+ const [historyExpanded, setHistoryExpanded] = (0, react_1.useState)(false);
16
33
  const [question, setQuestion] = (0, react_1.useState)("");
17
34
  const [answer, setAnswer] = (0, react_1.useState)(null);
18
35
  const [status, setStatus] = (0, react_1.useState)("idle");
@@ -40,16 +57,19 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
40
57
  const [rtMicMuted, setRtMicMuted] = (0, react_1.useState)(false);
41
58
  const [rtSpeakerMuted, setRtSpeakerMuted] = (0, react_1.useState)(false);
42
59
  // Set while a "tour" verb's steps are being narrated/highlighted one at a
43
- // time — drives the step-progress caption and blocks the input/mic so a
44
- // typed or spoken question can't interrupt mid-walkthrough.
60
+ // time — drives the step-progress caption and blocks the *typed* input so
61
+ // a typed question can't collide with the walkthrough (a voice
62
+ // interruption is handled separately — see touringRef/triggerBargeIn).
45
63
  const [tourStep, setTourStep] = (0, react_1.useState)(null);
46
- const tourGenerationRef = (0, react_1.useRef)(0); // bumped to cancel an in-progress tour (e.g. widget closed) without extra flags
64
+ const tourGenerationRef = (0, react_1.useRef)(0); // bumped to cancel an in-progress tour (e.g. widget closed, or a voice barge-in) without extra flags
47
65
  // Mirrors whether a tour is running, for use inside the mic's
48
66
  // onaudioprocess callback (a stale closure over React state there would
49
67
  // miss a tour that started after the callback was created) — a tour
50
- // reuses "rt-speaking" to hold the mic off too, but must NOT be
51
- // barge-in-able the way a real conversational reply is (see the RMS
52
- // check below): it's a deliberate walkthrough, not a turn to interrupt.
68
+ // reuses "rt-speaking" to hold the mic off between steps, but IS
69
+ // barge-in-able like a real conversational reply (see the RMS check
70
+ // below): interrupting mid-tour cancels the rest of the walkthrough,
71
+ // the way a real person giving a tour stops when you have a question
72
+ // instead of talking over you.
53
73
  const touringRef = (0, react_1.useRef)(false);
54
74
  // Resolver for "this tour step's audio has fully finished playing" when
55
75
  // narrating over an already-open realtime session (see maybeResumeListening
@@ -62,6 +82,19 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
62
82
  // keeps its own history server-side instead, since that connection is
63
83
  // already stateful).
64
84
  const historyRef = (0, react_1.useRef)([]);
85
+ // A background scanner that keeps a live inventory of what's actually
86
+ // clickable on screen right now (runtime-scan.ts) — running continuously
87
+ // via a MutationObserver so there's never a pause to "go look at the
88
+ // page" right when a verb needs to click something. `liveMapRef` freezes
89
+ // one snapshot of it per turn (set alongside every context/question send,
90
+ // below) so a background rescan landing mid-flight can't shift what an id
91
+ // resolves to between when a request went out and its response came back.
92
+ const liveRegistryRef = (0, react_1.useRef)((0, runtime_scan_1.createLiveElementRegistry)());
93
+ const liveMapRef = (0, react_1.useRef)(new Map());
94
+ (0, react_1.useEffect)(() => {
95
+ liveRegistryRef.current.start();
96
+ return () => liveRegistryRef.current.stop();
97
+ }, []);
65
98
  const mediaRecorderRef = (0, react_1.useRef)(null);
66
99
  const audioChunksRef = (0, react_1.useRef)([]);
67
100
  const transcribeInFlightRef = (0, react_1.useRef)(false);
@@ -149,6 +182,26 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
149
182
  rtStateRef.current = next;
150
183
  setStatus(next);
151
184
  }
185
+ /**
186
+ * Refreshes the server's picture of route/visible/liveElements over an
187
+ * already-open realtime connection. Beyond the initial connect, called
188
+ * on every route change and whenever the mic is about to start listening
189
+ * again — a real, pre-existing gap this closes as a side effect: the
190
+ * server's context previously updated only once, at connection open, so
191
+ * navigating mid-call (via a "navigate" verb, or the user clicking
192
+ * around) left the server answering every later turn as if the user were
193
+ * still on the original page. Reads pathnameRef, not the closure's
194
+ * `pathname`, so it's correct even called from a handler created once at
195
+ * connection-open time.
196
+ */
197
+ function sendFreshContext() {
198
+ const ws = rtSocketRef.current;
199
+ if (!ws || ws.readyState !== WebSocket.OPEN)
200
+ return;
201
+ const liveScan = liveRegistryRef.current.getSnapshot();
202
+ liveMapRef.current = liveScan.byId;
203
+ ws.send(JSON.stringify({ type: "context", route: pathnameRef.current, visible: (0, context_collector_1.collectVisible)(), liveElements: liveScan.elements }));
204
+ }
152
205
  function reportMiss(context) {
153
206
  (0, element_ladder_1.logMiss)(context);
154
207
  if (reportMissesEndpoint) {
@@ -171,6 +224,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
171
224
  onDo,
172
225
  onTour: (steps) => void runTour(steps),
173
226
  registeredActions,
227
+ liveElements: liveMapRef.current,
174
228
  });
175
229
  }
176
230
  /**
@@ -180,12 +234,14 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
180
234
  * actually showing you around instead of one paragraph naming several
181
235
  * buttons at once with nothing highlighted.
182
236
  *
183
- * Always narrates via speakEndpoint (plain request/response TTS), even
184
- * during a live realtime session — a tour is a distinct guided
185
- * walkthrough, not a conversational turn, so it doesn't need the
186
- * streaming relay's turn-taking machinery. If the widget is mid
187
- * realtime call, the mic is held off (mirrors "rt-speaking") for the
188
- * tour's duration so it can't pick up the tour's own narration.
237
+ * During a live realtime session, narration reuses the same streaming
238
+ * Speak connection a normal conversational reply uses (see
239
+ * speakOverRealtime below) instead of a separate buffered REST call
240
+ * otherwise falls back to speakEndpoint. Either way, the mic is held off
241
+ * between steps (mirrors "rt-speaking") so it doesn't pick up the tour's
242
+ * own narration but it's still listening for a real interruption:
243
+ * talking during a step cancels the rest of the tour via triggerBargeIn,
244
+ * the same as interrupting a normal spoken reply.
189
245
  */
190
246
  async function runTour(steps) {
191
247
  const myGeneration = ++tourGenerationRef.current;
@@ -231,11 +287,25 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
231
287
  return;
232
288
  }
233
289
  if (step.target) {
234
- const el = (0, element_ladder_1.findElement)(step.target);
235
- if (el)
290
+ // A fresh scan, not the tour's starting liveMapRef snapshot — a
291
+ // step after a mid-tour navigation targets elements on a page
292
+ // that didn't exist when the tour began.
293
+ const liveScan = liveRegistryRef.current.getSnapshot();
294
+ const el = (0, element_ladder_1.findElement)(step.target, liveScan.byId);
295
+ if (el) {
236
296
  (0, element_ladder_1.highlightElement)(el);
237
- else
297
+ if (step.click) {
298
+ el.click();
299
+ // Give whatever the click reveals (a detail view, an expanded
300
+ // row) a moment to actually render before narrating it.
301
+ await new Promise((resolve) => setTimeout(resolve, 400));
302
+ if (tourGenerationRef.current !== myGeneration)
303
+ return;
304
+ }
305
+ }
306
+ else {
238
307
  reportMiss({ attempted: step.target, route: currentRoute });
308
+ }
239
309
  }
240
310
  if (wasRealtimeListening && rtSocketRef.current?.readyState === WebSocket.OPEN) {
241
311
  // Already have a live streaming connection open — reuse it
@@ -276,10 +346,18 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
276
346
  setLastQuestion(q);
277
347
  setQuestion("");
278
348
  try {
349
+ const liveScan = liveRegistryRef.current.getSnapshot();
350
+ liveMapRef.current = liveScan.byId;
279
351
  const res = await fetch(endpoint, {
280
352
  method: "POST",
281
353
  headers: { "content-type": "application/json" },
282
- body: JSON.stringify({ route: pathname, question: q, visible: (0, context_collector_1.collectVisible)(), history: historyRef.current }),
354
+ body: JSON.stringify({
355
+ route: pathname,
356
+ question: q,
357
+ visible: (0, context_collector_1.collectVisible)(),
358
+ history: historyRef.current,
359
+ liveElements: liveScan.elements,
360
+ }),
283
361
  });
284
362
  const data = await res.json().catch(() => null);
285
363
  handleVerb(data);
@@ -503,12 +581,20 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
503
581
  return;
504
582
  if (rtMicMutedRef.current)
505
583
  return;
506
- // Barge-in: while the agent is speaking a real conversational
507
- // reply (not touring a tour deliberately can't be talked over),
508
- // keep listening to the mic locally even though it isn't being
509
- // sent yet, and cut the agent off the instant the user starts
510
- // talking over it instead of making them wait for it to finish.
511
- if (rtStateRef.current === "rt-speaking" && !touringRef.current) {
584
+ // Barge-in: while the agent is speaking a real conversational reply,
585
+ // still thinking about one, OR mid-tour, keep listening to the mic
586
+ // locally even though it isn't being sent yet, and cut the agent
587
+ // off the instant the user starts talking again instead of making
588
+ // them wait including during a guided tour, which now cancels the
589
+ // rest of the walkthrough on interruption (see triggerBargeIn)
590
+ // instead of being talked-over-proof by design, the way a real
591
+ // person giving a tour stops when you have a question. The
592
+ // "rt-thinking" half matters just as much as "rt-speaking": an LLM
593
+ // turn can easily take a couple of seconds with nothing playing
594
+ // yet, and without this the mic was completely deaf during that
595
+ // whole window — found live as "not listening while speaking... no
596
+ // interrupting system", not just a missed nice-to-have.
597
+ if (rtStateRef.current === "rt-speaking" || rtStateRef.current === "rt-thinking") {
512
598
  const rms = computeRms(e.inputBuffer.getChannelData(0));
513
599
  if (rms > BARGE_IN_RMS_THRESHOLD)
514
600
  triggerBargeIn();
@@ -555,6 +641,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
555
641
  }
556
642
  setRtStatus("rt-listening");
557
643
  setCaption("");
644
+ sendFreshContext(); // refresh before the user starts talking again, not after
558
645
  }
559
646
  function disarmThinkingWatchdog() {
560
647
  if (rtThinkingWatchdogRef.current) {
@@ -593,13 +680,26 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
593
680
  disarmThinkingWatchdog();
594
681
  stopScheduledRtAudio();
595
682
  rtAudioDoneArrivingRef.current = true;
683
+ if (touringRef.current) {
684
+ // Interrupting mid-guide cancels the whole rest of the tour, not
685
+ // just the current step — the way a real person giving a tour
686
+ // stops and answers your question instead of continuing to talk
687
+ // over you. Without resolving the current step's own pending
688
+ // promise here, runTour only notices the cancellation via its own
689
+ // 15s-per-step fallback timeout instead of right away.
690
+ tourGenerationRef.current++;
691
+ touringRef.current = false;
692
+ setTourStep(null);
693
+ rtTourAudioDoneRef.current?.();
694
+ rtTourAudioDoneRef.current = null;
695
+ }
596
696
  if (ws.readyState === WebSocket.OPEN)
597
697
  ws.send(JSON.stringify({ type: "barge_in" }));
598
698
  setRtStatus("rt-listening");
599
699
  setCaption("");
600
700
  }
601
701
  ws.onopen = () => {
602
- ws.send(JSON.stringify({ type: "context", route: pathname, visible: (0, context_collector_1.collectVisible)() }));
702
+ sendFreshContext();
603
703
  setRtStatus("rt-listening");
604
704
  rtStartingRef.current = false;
605
705
  };
@@ -672,7 +772,18 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
672
772
  // exactly the way a silently-dropped response used to leave it.
673
773
  disarmThinkingWatchdog();
674
774
  setAnswer(msg.message ?? "Something went wrong.");
675
- if (!touringRef.current) {
775
+ if (touringRef.current) {
776
+ // A tour step's own speakStreamed() failed server-side (see
777
+ // realtime-server.ts's "speak" handler). Without resolving this
778
+ // step's pending promise here, runTour's `await
779
+ // speakOverRealtime(step.text)` only recovers via its own 15s
780
+ // fallback timeout — found live as a guide that goes badly
781
+ // quiet for long stretches, one step at a time.
782
+ rtAudioDoneArrivingRef.current = true;
783
+ rtTourAudioDoneRef.current?.();
784
+ rtTourAudioDoneRef.current = null;
785
+ }
786
+ else {
676
787
  setRtStatus("rt-listening");
677
788
  setCaption("");
678
789
  }
@@ -742,7 +853,8 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
742
853
  "rt-thinking": "Thinking…",
743
854
  "rt-speaking": "Speaking…",
744
855
  };
745
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("style", { suppressHydrationWarning: true, dangerouslySetInnerHTML: { __html: COPILOT_STYLES } }), (0, jsx_runtime_1.jsx)("button", { className: status === "rt-speaking" ? "cairn-fab cairn-fab-speaking" : "cairn-fab", "aria-label": open ? `Close ${persona} help` : `Open ${persona} help`, onClick: () => setOpen((v) => !v), children: open ? (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 22 }) : (0, jsx_runtime_1.jsx)(CairnMark, {}) }), open && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-panel", role: "dialog", "aria-label": `${persona} help panel`, ref: panelRef, children: [(transcript.length > 0 || userCaption || answer || busy) && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-stack", children: [transcript.map((entry) => ((0, jsx_runtime_1.jsx)("div", { className: entry.role === "user" ? "cairn-bubble cairn-bubble-user cairn-bubble-past" : "cairn-bubble cairn-bubble-agent cairn-bubble-past", children: entry.role === "agent" ? (0, jsx_runtime_1.jsx)("span", { className: "cairn-bubble-text", children: entry.text }) : entry.text }, entry.id))), userCaption && ((0, jsx_runtime_1.jsx)("div", { className: "cairn-bubble cairn-bubble-user", children: userCaption }, `u-${userCaption}`)), (answer || busy) && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-bubble cairn-bubble-agent", children: [tourChip && (0, jsx_runtime_1.jsx)("span", { className: "cairn-chip", children: tourChip }), answer ? ((0, jsx_runtime_1.jsx)("span", { className: "cairn-bubble-text", children: renderCaptionWords(answer) })) : ((0, jsx_runtime_1.jsxs)("span", { className: "cairn-thinking", "aria-label": "Thinking", children: [(0, jsx_runtime_1.jsx)("span", { className: "cairn-thinking-dot" }), (0, jsx_runtime_1.jsx)("span", { className: "cairn-thinking-dot" }), (0, jsx_runtime_1.jsx)("span", { className: "cairn-thinking-dot" })] }))] }, `a-${answer ?? status}`))] })), realtimeActive ? ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-rt-bar", children: [(0, jsx_runtime_1.jsx)("span", { className: `cairn-rt-dot cairn-rt-dot-${status}` }), (0, jsx_runtime_1.jsx)("span", { className: "cairn-rt-label", children: statusLabel[status] }), (0, jsx_runtime_1.jsxs)("div", { className: "cairn-rt-controls", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: status === "rt-speaking" ? "cairn-icon-btn cairn-icon-btn-speaking" : "cairn-icon-btn", "aria-label": rtMicMuted ? "Unmute microphone" : "Mute microphone", onClick: toggleRtMic, children: rtMicMuted ? (0, jsx_runtime_1.jsx)(lucide_react_1.MicOff, { size: 16 }) : (0, jsx_runtime_1.jsx)(lucide_react_1.Mic, { size: 16 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: status === "rt-speaking" ? "cairn-icon-btn cairn-icon-btn-speaking" : "cairn-icon-btn", "aria-label": rtSpeakerMuted ? "Unmute speaker" : "Mute speaker", onClick: toggleRtSpeaker, children: rtSpeakerMuted ? (0, jsx_runtime_1.jsx)(lucide_react_1.VolumeX, { size: 16 }) : (0, jsx_runtime_1.jsx)(lucide_react_1.Volume2, { size: 16 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "cairn-icon-btn cairn-icon-btn-end", "aria-label": "End conversation", onClick: endRealtime, children: (0, jsx_runtime_1.jsx)(lucide_react_1.PhoneOff, { size: 16 }) })] })] })) : ((0, jsx_runtime_1.jsx)("form", { onSubmit: (e) => {
856
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("style", { suppressHydrationWarning: true, dangerouslySetInnerHTML: { __html: COPILOT_STYLES } }), (0, jsx_runtime_1.jsx)("button", { className: status === "rt-speaking" ? "cairn-fab cairn-fab-speaking" : "cairn-fab", "aria-label": open ? `Close ${persona} help` : `Open ${persona} help`, onClick: () => setOpen((v) => !v), children: open ? (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 22 }) : (0, jsx_runtime_1.jsx)(CairnMark, {}) }), open && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-panel", role: "dialog", "aria-label": `${persona} help panel`, ref: panelRef, children: [(transcript.length > 0 || userCaption || answer || busy) && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-stack", children: [transcript.length > 0 && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "cairn-history-toggle", onClick: () => setHistoryExpanded((v) => !v), "aria-expanded": historyExpanded, children: [historyExpanded ? (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronUp, { size: 12 }) : (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { size: 12 }), historyExpanded ? "Hide earlier" : `${transcript.length} earlier`] })), historyExpanded &&
857
+ transcript.map((entry) => ((0, jsx_runtime_1.jsx)("div", { className: entry.role === "user" ? "cairn-bubble cairn-bubble-user cairn-bubble-past" : "cairn-bubble cairn-bubble-agent cairn-bubble-past", children: entry.role === "agent" ? (0, jsx_runtime_1.jsx)("span", { className: "cairn-bubble-text", children: entry.text }) : entry.text }, entry.id))), userCaption && ((0, jsx_runtime_1.jsx)("div", { className: "cairn-bubble cairn-bubble-user", children: userCaption }, `u-${userCaption}`)), (answer || busy) && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-bubble cairn-bubble-agent", children: [tourChip && (0, jsx_runtime_1.jsx)("span", { className: "cairn-chip", children: tourChip }), answer ? ((0, jsx_runtime_1.jsx)("span", { className: "cairn-bubble-text", children: renderCaptionWords(answer) })) : ((0, jsx_runtime_1.jsxs)("span", { className: "cairn-thinking", "aria-label": "Thinking", children: [(0, jsx_runtime_1.jsx)("span", { className: "cairn-thinking-dot" }), (0, jsx_runtime_1.jsx)("span", { className: "cairn-thinking-dot" }), (0, jsx_runtime_1.jsx)("span", { className: "cairn-thinking-dot" })] }))] }, `a-${answer ?? status}`))] })), realtimeActive ? ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-rt-bar", children: [(0, jsx_runtime_1.jsx)("span", { className: `cairn-rt-dot cairn-rt-dot-${status}` }), (0, jsx_runtime_1.jsx)("span", { className: "cairn-rt-label", children: statusLabel[status] }), (0, jsx_runtime_1.jsxs)("div", { className: "cairn-rt-controls", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: status === "rt-speaking" ? "cairn-icon-btn cairn-icon-btn-speaking" : "cairn-icon-btn", "aria-label": rtMicMuted ? "Unmute microphone" : "Mute microphone", onClick: toggleRtMic, children: rtMicMuted ? (0, jsx_runtime_1.jsx)(lucide_react_1.MicOff, { size: 16 }) : (0, jsx_runtime_1.jsx)(lucide_react_1.Mic, { size: 16 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: status === "rt-speaking" ? "cairn-icon-btn cairn-icon-btn-speaking" : "cairn-icon-btn", "aria-label": rtSpeakerMuted ? "Unmute speaker" : "Mute speaker", onClick: toggleRtSpeaker, children: rtSpeakerMuted ? (0, jsx_runtime_1.jsx)(lucide_react_1.VolumeX, { size: 16 }) : (0, jsx_runtime_1.jsx)(lucide_react_1.Volume2, { size: 16 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "cairn-icon-btn cairn-icon-btn-end", "aria-label": "End conversation", onClick: endRealtime, children: (0, jsx_runtime_1.jsx)(lucide_react_1.PhoneOff, { size: 16 }) })] })] })) : ((0, jsx_runtime_1.jsx)("form", { onSubmit: (e) => {
746
858
  e.preventDefault();
747
859
  const trimmed = question.trim();
748
860
  if (trimmed)
@@ -994,6 +1106,26 @@ const COPILOT_STYLES = `
994
1106
  text-transform: uppercase;
995
1107
  color: rgba(11, 13, 18, 0.48);
996
1108
  }
1109
+ .cairn-history-toggle {
1110
+ align-self: center;
1111
+ display: inline-flex;
1112
+ align-items: center;
1113
+ gap: 3px;
1114
+ border: none;
1115
+ background: none;
1116
+ padding: 2px 8px;
1117
+ font: inherit;
1118
+ font-size: 11px;
1119
+ font-weight: 600;
1120
+ color: rgba(11, 13, 18, 0.4);
1121
+ cursor: pointer;
1122
+ border-radius: 999px;
1123
+ transition: background 0.15s ease, color 0.15s ease;
1124
+ }
1125
+ .cairn-history-toggle:hover {
1126
+ background: rgba(11, 13, 18, 0.05);
1127
+ color: rgba(11, 13, 18, 0.6);
1128
+ }
997
1129
  .cairn-thinking {
998
1130
  display: inline-flex;
999
1131
  gap: 4px;
@@ -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, LiveElement, 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,20 @@ 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
+ liveElements: LiveElement[];
26
+ }, speakStreamed: (text: string) => Promise<void>, history: HistoryTurn[], turnState: {
27
+ buffer: string;
28
+ }, 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");
@@ -73,7 +74,10 @@ function createRealtimeServer(options) {
73
74
  }
74
75
  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
76
  async function handleConnection(client, deps) {
76
- let context = { route: "/", visible: [] };
77
+ // liveElements refreshes on every "context" resend (the client sends one
78
+ // on route changes and each time it's about to start listening again),
79
+ // so a live scan from several turns ago never lingers into a later one.
80
+ let context = { route: "/", visible: [], liveElements: [] };
77
81
  // Unlike the stateless HTTP path (which needs the client to resend
78
82
  // history every request), a realtime connection is already stateful —
79
83
  // one WebSocket per call — so this is accumulated here directly rather
@@ -155,8 +159,12 @@ async function handleConnection(client, deps) {
155
159
  stream.flush();
156
160
  });
157
161
  }
162
+ // Accumulates Deepgram "Results" transcript segments across one utterance
163
+ // — see handleDeepgramMessage for why this can't just react to every
164
+ // is_final.
165
+ const turnState = { buffer: "" };
158
166
  dg.on("message", (data) => {
159
- void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history);
167
+ void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation);
160
168
  });
161
169
  dg.on("error", (err) => {
162
170
  console.error("[cairn realtime] Deepgram STT connection error:", err);
@@ -174,7 +182,11 @@ async function handleConnection(client, deps) {
174
182
  try {
175
183
  const msg = JSON.parse(data.toString());
176
184
  if (msg.type === "context") {
177
- context = { route: String(msg.route ?? "/"), visible: Array.isArray(msg.visible) ? msg.visible : [] };
185
+ context = {
186
+ route: String(msg.route ?? "/"),
187
+ visible: Array.isArray(msg.visible) ? msg.visible : [],
188
+ liveElements: parseLiveElements(msg.liveElements),
189
+ };
178
190
  }
179
191
  else if (msg.type === "end") {
180
192
  client.close();
@@ -189,7 +201,20 @@ async function handleConnection(client, deps) {
189
201
  // client falling back to a separate buffered REST call. No STT/verb
190
202
  // resolution involved; the client already resolved the tour steps
191
203
  // itself and just needs this text spoken.
192
- void speakStreamed(msg.text);
204
+ //
205
+ // Caught explicitly, unlike a normal turn's speakStreamed call (see
206
+ // handleDeepgramMessage) — this one isn't inside that function's own
207
+ // try/catch, and an uncaught rejection here previously vanished
208
+ // silently: the client's speakOverRealtime() promise for this step
209
+ // never resolves except via its own 15s fallback timeout, with
210
+ // nothing telling the user anything went wrong in the meantime —
211
+ // found live as a tour that goes badly quiet for stretches at a
212
+ // time. A real "error" message lets the client's tour-step handler
213
+ // (index.tsx's ws.onmessage) unstick itself immediately instead.
214
+ speakStreamed(msg.text).catch((err) => {
215
+ console.error("[cairn realtime] speakStreamed failed for a tour step:", err);
216
+ safeSend(client, { type: "error", message: "Something went wrong narrating that step." });
217
+ });
193
218
  }
194
219
  }
195
220
  catch {
@@ -206,7 +231,7 @@ async function handleConnection(client, deps) {
206
231
  speakStream?.close();
207
232
  });
208
233
  }
209
- async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history) {
234
+ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history, turnState, getGeneration) {
210
235
  let msg;
211
236
  try {
212
237
  msg = JSON.parse(raw);
@@ -214,33 +239,74 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
214
239
  catch {
215
240
  return;
216
241
  }
242
+ if (msg.type === "UtteranceEnd") {
243
+ // A second, independent "the user is truly done" signal Deepgram sends
244
+ // after utterance_end_ms of silence — a safety net for the rare case a
245
+ // Results message never carries speech_final:true, so a turn can't get
246
+ // permanently stuck with real transcript sitting in the buffer forever.
247
+ if (turnState.buffer)
248
+ await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
249
+ return;
250
+ }
217
251
  if (msg.type !== "Results")
218
252
  return;
219
253
  const transcript = msg.channel?.alternatives?.[0]?.transcript;
220
254
  if (!transcript)
221
255
  return;
222
256
  if (!msg.is_final) {
223
- safeSend(client, { type: "interim", text: transcript });
257
+ safeSend(client, { type: "interim", text: turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript });
224
258
  return;
225
259
  }
260
+ // is_final means this chunk of transcript is stable and won't be
261
+ // revised — it does NOT mean the user is done talking. Deepgram can (and
262
+ // routinely does) finalize several chunks of one continuous utterance in
263
+ // a row with no real pause between them. Only speech_final (endpointing
264
+ // actually detected a pause) means the turn is genuinely over. Found
265
+ // live, not theoretical: treating every is_final as a separate finished
266
+ // question fired two independent LLM+TTS turns for one utterance — the
267
+ // literal cause of both the duplicated transcript entries ("hello" /
268
+ // "hello" with no reply in between) and the agent audibly speaking
269
+ // twice, overlapping.
270
+ turnState.buffer = turnState.buffer ? `${turnState.buffer} ${transcript}` : transcript;
271
+ if (!msg.speech_final) {
272
+ safeSend(client, { type: "interim", text: turnState.buffer });
273
+ return;
274
+ }
275
+ await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
276
+ }
277
+ /**
278
+ * Everything from here on (the LLM call, TTS streaming) can fail in ways
279
+ * that have nothing to do with a malformed message — a flaky provider
280
+ * call, a rate limit, a dropped upstream connection. handleDeepgramMessage
281
+ * is invoked fire-and-forget (`void handleDeepgramMessage(...)`), so an
282
+ * uncaught throw here previously vanished into an unhandled rejection: the
283
+ * client had already been told "final" (entering its "thinking" state) and
284
+ * then simply never heard from the server again for this turn — stuck
285
+ * indefinitely with the mic never resuming. Every path out of the try
286
+ * block now sends the client something that ends the turn.
287
+ *
288
+ * myGeneration is captured before the (potentially slow) LLM call and
289
+ * re-checked before the verb/speech actually goes out — a barge-in that
290
+ * happens while this turn is still "thinking" bumps the generation, and
291
+ * without this check the now-stale response would still land on the
292
+ * client after the user had already moved on to a new question.
293
+ */
294
+ async function finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration) {
295
+ const transcript = turnState.buffer;
296
+ turnState.buffer = "";
297
+ const myGeneration = getGeneration();
226
298
  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
299
  try {
237
- const { route, visible } = getContext();
300
+ const { route, visible, liveElements } = getContext();
238
301
  const verb = await (0, server_1.resolveVerb)(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
239
302
  route,
240
303
  question: transcript,
241
304
  visible,
305
+ liveElements,
242
306
  history,
243
307
  });
308
+ if (myGeneration !== getGeneration())
309
+ return; // superseded by a barge-in while this turn was resolving
244
310
  // Sent immediately — before speech synthesis even starts — so
245
311
  // highlight/navigate/do execute in the browser right away instead of
246
312
  // waiting on audio. The agent visibly acts while it's still about to
@@ -260,8 +326,10 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
260
326
  }
261
327
  catch (err) {
262
328
  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" });
329
+ if (myGeneration === getGeneration()) {
330
+ safeSend(client, { type: "error", message: "Something went wrong answering that — try again." });
331
+ safeSend(client, { type: "turn_complete" });
332
+ }
265
333
  }
266
334
  }
267
335
  function safeSend(client, message) {
@@ -269,6 +337,27 @@ function safeSend(client, message) {
269
337
  return;
270
338
  client.send(JSON.stringify(message));
271
339
  }
340
+ /** Defensive parse for the client's self-reported live DOM scan — same
341
+ * untrusted-input treatment `visible` already gets on this control-message
342
+ * path (no CopilotRequestSchema here, unlike the HTTP handler), just
343
+ * shaped-checked so a malformed entry can't reach the LLM prompt oddly. */
344
+ function parseLiveElements(raw) {
345
+ if (!Array.isArray(raw))
346
+ return [];
347
+ const elements = [];
348
+ for (const entry of raw) {
349
+ if (entry &&
350
+ typeof entry === "object" &&
351
+ typeof entry.id === "string" &&
352
+ typeof entry.role === "string" &&
353
+ typeof entry.label === "string") {
354
+ elements.push({ id: entry.id, role: entry.role, label: entry.label });
355
+ }
356
+ if (elements.length >= 60)
357
+ break;
358
+ }
359
+ return elements;
360
+ }
272
361
  /** A short text form of any verb for the history log — not shown to the
273
362
  * user, just fed back to the model on later turns so it knows what it
274
363
  * already did/said. */
@@ -0,0 +1,36 @@
1
+ import type { LiveElement } from "@cairnvibe/core";
2
+ export interface LiveScan {
3
+ elements: LiveElement[];
4
+ byId: Map<string, HTMLElement>;
5
+ }
6
+ /**
7
+ * Scans the live DOM for interactive elements currently in the viewport.
8
+ * Returns both the bounded list to send to the model (`elements`, capped at
9
+ * MAX_ELEMENTS and MAX_LABEL_LENGTH — the actual privacy/payload backstop,
10
+ * mirrored server-side in CopilotRequestSchema) and the real elements it
11
+ * maps to, keyed by the same ids (`byId`) — resolve a verb's target by
12
+ * looking it up here, never by re-deriving a selector from the id string.
13
+ */
14
+ export declare function scanInteractiveElements(root?: ParentNode): LiveScan;
15
+ export interface LiveElementRegistry {
16
+ /** Starts continuous background scanning — call once, typically on mount. */
17
+ start(): void;
18
+ stop(): void;
19
+ /**
20
+ * Freezes the current scan for one request/response round trip. Call
21
+ * this once when a question is sent, and resolve that turn's verb
22
+ * against exactly this snapshot — not a fresh call — so a background
23
+ * rescan that lands mid-flight can't shift what an id resolves to
24
+ * between when the request went out and when the response comes back.
25
+ */
26
+ getSnapshot(): LiveScan;
27
+ }
28
+ /**
29
+ * Keeps a scan continuously fresh in the background via a debounced
30
+ * MutationObserver (plus scroll/resize, since viewport membership changes
31
+ * without any DOM mutation) instead of only scanning at the moment a
32
+ * question is asked — so the agent never has to pause to "go look at the
33
+ * page" right when it needs to click something; a sub-agent gathering
34
+ * context while the main conversation keeps moving.
35
+ */
36
+ export declare function createLiveElementRegistry(): LiveElementRegistry;