@cairnvibe/sdk 0.2.6 → 0.2.8

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
@@ -6,13 +6,32 @@ const jsx_runtime_1 = require("react/jsx-runtime");
6
6
  const react_1 = require("react");
7
7
  const navigation_1 = require("next/navigation");
8
8
  const lucide_react_1 = require("lucide-react");
9
+ const core_1 = require("@cairnvibe/core");
9
10
  const context_collector_1 = require("./context-collector");
10
11
  const element_ladder_1 = require("./element-ladder");
12
+ const runtime_scan_1 = require("./runtime-scan");
13
+ const webmcp_client_1 = require("./webmcp-client");
11
14
  const verb_executor_1 = require("./verb-executor");
12
15
  function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, reportMissesEndpoint, transcribeEndpoint, speakEndpoint, realtimeUrl, persona = "Cairn", }) {
13
16
  const pathname = (0, navigation_1.usePathname)() ?? "/";
17
+ // Mirrors `pathname` for use inside long-lived closures (a realtime
18
+ // session's handlers are all created once, when the connection opens —
19
+ // same staleness reason runTour tracks its own `currentRoute` locally
20
+ // rather than trusting its closure's `pathname` after a mid-tour
21
+ // navigation).
22
+ const pathnameRef = (0, react_1.useRef)(pathname);
23
+ (0, react_1.useEffect)(() => {
24
+ pathnameRef.current = pathname;
25
+ void sendFreshContext(); // no-op if no realtime session is open
26
+ // eslint-disable-next-line react-hooks/exhaustive-deps
27
+ }, [pathname]);
14
28
  const router = (0, navigation_1.useRouter)();
15
29
  const [open, setOpen] = (0, react_1.useState)(false);
30
+ // Collapsed by default so the panel only ever shows the current exchange
31
+ // — the full archived transcript (built up over a long conversation)
32
+ // stays out of the way behind an explicit toggle instead of always being
33
+ // visible inline, which made the panel grow uncomfortably tall.
34
+ const [historyExpanded, setHistoryExpanded] = (0, react_1.useState)(false);
16
35
  const [question, setQuestion] = (0, react_1.useState)("");
17
36
  const [answer, setAnswer] = (0, react_1.useState)(null);
18
37
  const [status, setStatus] = (0, react_1.useState)("idle");
@@ -65,6 +84,19 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
65
84
  // keeps its own history server-side instead, since that connection is
66
85
  // already stateful).
67
86
  const historyRef = (0, react_1.useRef)([]);
87
+ // A background scanner that keeps a live inventory of what's actually
88
+ // clickable on screen right now (runtime-scan.ts) — running continuously
89
+ // via a MutationObserver so there's never a pause to "go look at the
90
+ // page" right when a verb needs to click something. `liveMapRef` freezes
91
+ // one snapshot of it per turn (set alongside every context/question send,
92
+ // below) so a background rescan landing mid-flight can't shift what an id
93
+ // resolves to between when a request went out and its response came back.
94
+ const liveRegistryRef = (0, react_1.useRef)((0, runtime_scan_1.createLiveElementRegistry)());
95
+ const liveMapRef = (0, react_1.useRef)(new Map());
96
+ (0, react_1.useEffect)(() => {
97
+ liveRegistryRef.current.start();
98
+ return () => liveRegistryRef.current.stop();
99
+ }, []);
68
100
  const mediaRecorderRef = (0, react_1.useRef)(null);
69
101
  const audioChunksRef = (0, react_1.useRef)([]);
70
102
  const transcribeInFlightRef = (0, react_1.useRef)(false);
@@ -152,6 +184,35 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
152
184
  rtStateRef.current = next;
153
185
  setStatus(next);
154
186
  }
187
+ /**
188
+ * Refreshes the server's picture of route/visible/liveElements over an
189
+ * already-open realtime connection. Beyond the initial connect, called
190
+ * on every route change and whenever the mic is about to start listening
191
+ * again — a real, pre-existing gap this closes as a side effect: the
192
+ * server's context previously updated only once, at connection open, so
193
+ * navigating mid-call (via a "navigate" verb, or the user clicking
194
+ * around) left the server answering every later turn as if the user were
195
+ * still on the original page. Reads pathnameRef, not the closure's
196
+ * `pathname`, so it's correct even called from a handler created once at
197
+ * connection-open time.
198
+ */
199
+ async function sendFreshContext() {
200
+ const ws = rtSocketRef.current;
201
+ if (!ws || ws.readyState !== WebSocket.OPEN)
202
+ return;
203
+ const liveScan = liveRegistryRef.current.getSnapshot();
204
+ liveMapRef.current = liveScan.byId;
205
+ const webMcpTools = await (0, webmcp_client_1.discoverWebMcpTools)();
206
+ if (ws.readyState !== WebSocket.OPEN)
207
+ return; // may have closed while awaiting discovery
208
+ ws.send(JSON.stringify({
209
+ type: "context",
210
+ route: pathnameRef.current,
211
+ visible: (0, context_collector_1.collectVisible)(),
212
+ liveElements: liveScan.elements,
213
+ webMcpTools,
214
+ }));
215
+ }
155
216
  function reportMiss(context) {
156
217
  (0, element_ladder_1.logMiss)(context);
157
218
  if (reportMissesEndpoint) {
@@ -174,6 +235,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
174
235
  onDo,
175
236
  onTour: (steps) => void runTour(steps),
176
237
  registeredActions,
238
+ liveElements: liveMapRef.current,
177
239
  });
178
240
  }
179
241
  /**
@@ -236,11 +298,25 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
236
298
  return;
237
299
  }
238
300
  if (step.target) {
239
- const el = (0, element_ladder_1.findElement)(step.target);
240
- if (el)
301
+ // A fresh scan, not the tour's starting liveMapRef snapshot — a
302
+ // step after a mid-tour navigation targets elements on a page
303
+ // that didn't exist when the tour began.
304
+ const liveScan = liveRegistryRef.current.getSnapshot();
305
+ const el = (0, element_ladder_1.findElement)(step.target, liveScan.byId);
306
+ if (el) {
241
307
  (0, element_ladder_1.highlightElement)(el);
242
- else
308
+ if (step.click) {
309
+ el.click();
310
+ // Give whatever the click reveals (a detail view, an expanded
311
+ // row) a moment to actually render before narrating it.
312
+ await new Promise((resolve) => setTimeout(resolve, 400));
313
+ if (tourGenerationRef.current !== myGeneration)
314
+ return;
315
+ }
316
+ }
317
+ else {
243
318
  reportMiss({ attempted: step.target, route: currentRoute });
319
+ }
244
320
  }
245
321
  if (wasRealtimeListening && rtSocketRef.current?.readyState === WebSocket.OPEN) {
246
322
  // Already have a live streaming connection open — reuse it
@@ -281,22 +357,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
281
357
  setLastQuestion(q);
282
358
  setQuestion("");
283
359
  try {
284
- const res = await fetch(endpoint, {
285
- method: "POST",
286
- headers: { "content-type": "application/json" },
287
- body: JSON.stringify({ route: pathname, question: q, visible: (0, context_collector_1.collectVisible)(), history: historyRef.current }),
288
- });
289
- const data = await res.json().catch(() => null);
290
- handleVerb(data);
291
- // Unlike the realtime relay (one persistent connection, memory lives
292
- // server-side), each of these POSTs is stateless — the widget itself
293
- // is what remembers, and resends it above so the model has context
294
- // for "the first one" / "do that instead" on the next question.
295
- historyRef.current = [
296
- ...historyRef.current,
297
- { role: "user", text: q },
298
- { role: "assistant", text: summarizeVerbForHistory(data) },
299
- ].slice(-MAX_HISTORY_TURNS);
360
+ await runTypedAgentLoop(q);
300
361
  }
301
362
  catch {
302
363
  setAnswer("Something went wrong reaching the help service — try again in a moment.");
@@ -305,6 +366,73 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
305
366
  setStatus("idle");
306
367
  }
307
368
  }
369
+ const MAX_LOOP_ITERATIONS = 6; // a hard cap, not a target — see runTypedAgentLoop
370
+ /**
371
+ * Drives the agent loop over the stateless HTTP path: ask the server,
372
+ * and if it comes back with a continuing step (click/fill/read/
373
+ * call_tool — TERMINAL_VERBS in @cairnvibe/core says which verbs end a
374
+ * turn), execute that step for real, fold the real result into this
375
+ * turn's own working history, and ask again — repeat until a terminal
376
+ * verb or the iteration cap, instead of the old one-call-one-answer
377
+ * shape. `question` stays the original ask on every call; only
378
+ * `history` grows with each step's real trace, so the model always
379
+ * still knows what it was actually asked. `historyRef` (the
380
+ * conversation's real memory) is only ever committed once, at the end —
381
+ * a turn that hits the cap mid-loop doesn't leave partial noise in it.
382
+ */
383
+ async function runTypedAgentLoop(q) {
384
+ let loopHistory = historyRef.current;
385
+ const webMcpTools = await (0, webmcp_client_1.discoverWebMcpTools)();
386
+ for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
387
+ const liveScan = liveRegistryRef.current.getSnapshot();
388
+ liveMapRef.current = liveScan.byId;
389
+ const res = await fetch(endpoint, {
390
+ method: "POST",
391
+ headers: { "content-type": "application/json" },
392
+ body: JSON.stringify({
393
+ route: pathname,
394
+ question: q,
395
+ visible: (0, context_collector_1.collectVisible)(),
396
+ history: loopHistory,
397
+ liveElements: liveScan.elements,
398
+ webMcpTools,
399
+ }),
400
+ });
401
+ const data = await res.json().catch(() => null);
402
+ const parsed = (0, core_1.safeParseVerbResponse)(data);
403
+ if (!parsed || core_1.TERMINAL_VERBS.has(parsed.verb)) {
404
+ handleVerb(data);
405
+ // Unlike the realtime relay (one persistent connection, memory
406
+ // lives server-side), each of these POSTs is stateless — the
407
+ // widget itself is what remembers, and resends it above so the
408
+ // model has context for "the first one" / "do that instead" on
409
+ // the next question.
410
+ historyRef.current = [
411
+ ...loopHistory,
412
+ { role: "user", text: q },
413
+ { role: "assistant", text: summarizeVerbForHistory(data) },
414
+ ].slice(-MAX_HISTORY_TURNS);
415
+ return;
416
+ }
417
+ // A continuing step — show it happening, execute it for real, and
418
+ // go around again with the real result instead of ending the turn.
419
+ setAnswer(summarizeVerbForHistory(data));
420
+ const stepResult = await (0, verb_executor_1.executeToolStep)(data, pathname, liveMapRef.current);
421
+ loopHistory = [
422
+ ...loopHistory,
423
+ {
424
+ role: "assistant",
425
+ text: `${summarizeVerbForHistory(data)}. Result: ${stepResult?.observation ?? "no result"}`,
426
+ },
427
+ ].slice(-MAX_HISTORY_TURNS);
428
+ }
429
+ setAnswer("I wasn't able to finish that — try asking again or breaking it into smaller steps.");
430
+ historyRef.current = [
431
+ ...loopHistory,
432
+ { role: "user", text: q },
433
+ { role: "assistant", text: "(gave up after too many steps)" },
434
+ ].slice(-MAX_HISTORY_TURNS);
435
+ }
308
436
  /**
309
437
  * The one place that starts audio playback for a spoken response — stops
310
438
  * whatever's currently playing first, so two responses (e.g. a rapid
@@ -568,6 +696,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
568
696
  }
569
697
  setRtStatus("rt-listening");
570
698
  setCaption("");
699
+ void sendFreshContext(); // refresh before the user starts talking again, not after
571
700
  }
572
701
  function disarmThinkingWatchdog() {
573
702
  if (rtThinkingWatchdogRef.current) {
@@ -625,7 +754,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
625
754
  setCaption("");
626
755
  }
627
756
  ws.onopen = () => {
628
- ws.send(JSON.stringify({ type: "context", route: pathname, visible: (0, context_collector_1.collectVisible)() }));
757
+ sendFreshContext();
629
758
  setRtStatus("rt-listening");
630
759
  rtStartingRef.current = false;
631
760
  };
@@ -643,6 +772,25 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
643
772
  armThinkingWatchdog();
644
773
  }
645
774
  else if (msg.type === "verb") {
775
+ const parsedStep = (0, core_1.safeParseVerbResponse)(msg.verb);
776
+ if (parsedStep && !core_1.TERMINAL_VERBS.has(parsedStep.verb)) {
777
+ // A continuing agent-loop step (click/fill/read/call_tool) —
778
+ // the turn isn't over: execute it for real and report the
779
+ // result back so the server can decide the next step, instead
780
+ // of treating this like a normal answer (no
781
+ // disarmThinkingWatchdog/handleVerb — those are for when a
782
+ // turn actually ends). Shown visually so a multi-step turn
783
+ // reads as visible progress, not a silent pause; never spoken
784
+ // — the server's loop stays quiet between steps on purpose,
785
+ // to keep it fast.
786
+ setAnswer(summarizeVerbForHistory(msg.verb));
787
+ void (0, verb_executor_1.executeToolStep)(msg.verb, pathnameRef.current, liveMapRef.current).then((result) => {
788
+ if (ws.readyState === WebSocket.OPEN) {
789
+ ws.send(JSON.stringify({ type: "tool_result", observation: result?.observation ?? "no result" }));
790
+ }
791
+ });
792
+ return;
793
+ }
646
794
  disarmThinkingWatchdog();
647
795
  handleVerb(msg.verb);
648
796
  }
@@ -779,7 +927,8 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
779
927
  "rt-thinking": "Thinking…",
780
928
  "rt-speaking": "Speaking…",
781
929
  };
782
- 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) => {
930
+ 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 &&
931
+ 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) => {
783
932
  e.preventDefault();
784
933
  const trimmed = question.trim();
785
934
  if (trimmed)
@@ -809,6 +958,14 @@ function summarizeVerbForHistory(raw) {
809
958
  return `(ran ${String(v.action)}${v.target ? ` on ${String(v.target)}` : ""})`;
810
959
  case "tour":
811
960
  return Array.isArray(v.steps) ? v.steps.map((s) => s.text ?? "").join(" ") : "(tour)";
961
+ case "click":
962
+ return `(clicked ${String(v.target)})`;
963
+ case "fill":
964
+ return `(typed "${String(v.value)}" into ${String(v.target)})`;
965
+ case "read":
966
+ return `(read ${String(v.target)})`;
967
+ case "call_tool":
968
+ return `(called ${String(v.name)})`;
812
969
  default:
813
970
  return "(no response)";
814
971
  }
@@ -1031,6 +1188,26 @@ const COPILOT_STYLES = `
1031
1188
  text-transform: uppercase;
1032
1189
  color: rgba(11, 13, 18, 0.48);
1033
1190
  }
1191
+ .cairn-history-toggle {
1192
+ align-self: center;
1193
+ display: inline-flex;
1194
+ align-items: center;
1195
+ gap: 3px;
1196
+ border: none;
1197
+ background: none;
1198
+ padding: 2px 8px;
1199
+ font: inherit;
1200
+ font-size: 11px;
1201
+ font-weight: 600;
1202
+ color: rgba(11, 13, 18, 0.4);
1203
+ cursor: pointer;
1204
+ border-radius: 999px;
1205
+ transition: background 0.15s ease, color 0.15s ease;
1206
+ }
1207
+ .cairn-history-toggle:hover {
1208
+ background: rgba(11, 13, 18, 0.05);
1209
+ color: rgba(11, 13, 18, 0.6);
1210
+ }
1034
1211
  .cairn-thinking {
1035
1212
  display: inline-flex;
1036
1213
  gap: 4px;
@@ -1,6 +1,6 @@
1
1
  import http from "node:http";
2
2
  import { WebSocket } from "ws";
3
- import type { HistoryTurn, Manifest } from "@cairnvibe/core";
3
+ import { type HistoryTurn, type LiveElement, type Manifest, type WebMcpTool } from "@cairnvibe/core";
4
4
  import { createVerbLLM, type CapabilityTier, type CreateCopilotHandlerOptions } from "./server";
5
5
  export interface CreateRealtimeServerOptions extends CreateCopilotHandlerOptions {
6
6
  manifest: Manifest;
@@ -22,6 +22,8 @@ export interface ConnectionDeps {
22
22
  export declare function handleDeepgramMessage(raw: string, client: WebSocket, deps: ConnectionDeps, getContext: () => {
23
23
  route: string;
24
24
  visible: string[];
25
+ liveElements: LiveElement[];
26
+ webMcpTools: WebMcpTool[];
25
27
  }, speakStreamed: (text: string) => Promise<void>, history: HistoryTurn[], turnState: {
26
28
  buffer: string;
27
- }, getGeneration: () => number): Promise<void>;
29
+ }, getGeneration: () => number, waitForToolResult: () => Promise<string>): Promise<void>;
@@ -33,6 +33,7 @@ exports.createRealtimeServer = createRealtimeServer;
33
33
  exports.handleDeepgramMessage = handleDeepgramMessage;
34
34
  const node_http_1 = __importDefault(require("node:http"));
35
35
  const ws_1 = require("ws");
36
+ const core_1 = require("@cairnvibe/core");
36
37
  const server_1 = require("./server");
37
38
  const tts_stream_1 = require("./tts-stream");
38
39
  const DEEPGRAM_LIVE_URL = "wss://api.deepgram.com/v1/listen";
@@ -73,13 +74,28 @@ function createRealtimeServer(options) {
73
74
  return httpServer;
74
75
  }
75
76
  const MAX_HISTORY_TURNS = 8; // 4 exchanges — enough for "the first one"/"do that instead" without growing the prompt unbounded over a long call
77
+ const MAX_LOOP_ITERATIONS = 6; // a hard cap on one turn's agent-loop steps, not a target — see finalizeTurn
76
78
  async function handleConnection(client, deps) {
77
- let context = { route: "/", visible: [] };
79
+ // liveElements/webMcpTools refresh on every "context" resend (the client
80
+ // sends one on route changes and each time it's about to start listening
81
+ // again), so a live scan from several turns ago never lingers into a
82
+ // later one.
83
+ let context = {
84
+ route: "/",
85
+ visible: [],
86
+ liveElements: [],
87
+ webMcpTools: [],
88
+ };
78
89
  // Unlike the stateless HTTP path (which needs the client to resend
79
90
  // history every request), a realtime connection is already stateful —
80
91
  // one WebSocket per call — so this is accumulated here directly rather
81
92
  // than round-tripped through the client.
82
93
  const history = [];
94
+ // Resolves the agent loop's in-flight waitForToolResult() call once the
95
+ // client reports back what a click/fill/read/call_tool step actually
96
+ // did — same "a mutable pending-callback slot, resolved when the right
97
+ // message arrives" pattern onCurrentTurnFlushed already uses below.
98
+ let pendingToolResultResolve = null;
83
99
  const dgUrl = `${DEEPGRAM_LIVE_URL}?model=${encodeURIComponent(deps.sttModel)}` +
84
100
  `&encoding=linear16&sample_rate=16000&channels=1&interim_results=true&endpointing=300&utterance_end_ms=1000`;
85
101
  const dg = new ws_1.WebSocket(dgUrl, { headers: { Authorization: `Token ${deps.deepgramApiKey}` } });
@@ -156,12 +172,31 @@ async function handleConnection(client, deps) {
156
172
  stream.flush();
157
173
  });
158
174
  }
175
+ /**
176
+ * Pauses the agent loop (finalizeTurn, below) until the client reports
177
+ * back the real result of a click/fill/read/call_tool step it just sent
178
+ * out — the server can't execute a DOM action itself, so every
179
+ * continuing step needs a real round trip to the browser and back. A
180
+ * real timeout, not a hang: a client that never answers (closed tab,
181
+ * dropped connection) can't leave a turn stuck forever.
182
+ */
183
+ function waitForToolResult() {
184
+ return new Promise((resolve) => {
185
+ pendingToolResultResolve = resolve;
186
+ setTimeout(() => {
187
+ if (pendingToolResultResolve === resolve) {
188
+ pendingToolResultResolve = null;
189
+ resolve("(no result — timed out waiting for the browser)");
190
+ }
191
+ }, 15000);
192
+ });
193
+ }
159
194
  // Accumulates Deepgram "Results" transcript segments across one utterance
160
195
  // — see handleDeepgramMessage for why this can't just react to every
161
196
  // is_final.
162
197
  const turnState = { buffer: "" };
163
198
  dg.on("message", (data) => {
164
- void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation);
199
+ void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation, waitForToolResult);
165
200
  });
166
201
  dg.on("error", (err) => {
167
202
  console.error("[cairn realtime] Deepgram STT connection error:", err);
@@ -179,7 +214,19 @@ async function handleConnection(client, deps) {
179
214
  try {
180
215
  const msg = JSON.parse(data.toString());
181
216
  if (msg.type === "context") {
182
- context = { route: String(msg.route ?? "/"), visible: Array.isArray(msg.visible) ? msg.visible : [] };
217
+ context = {
218
+ route: String(msg.route ?? "/"),
219
+ visible: Array.isArray(msg.visible) ? msg.visible : [],
220
+ liveElements: parseLiveElements(msg.liveElements),
221
+ webMcpTools: parseWebMcpTools(msg.webMcpTools),
222
+ };
223
+ }
224
+ else if (msg.type === "tool_result" && typeof msg.observation === "string") {
225
+ // The client finished executing a click/fill/read/call_tool step
226
+ // the agent loop sent it — this is what finalizeTurn's
227
+ // waitForToolResult() below is paused on.
228
+ pendingToolResultResolve?.(msg.observation);
229
+ pendingToolResultResolve = null;
183
230
  }
184
231
  else if (msg.type === "end") {
185
232
  client.close();
@@ -224,7 +271,7 @@ async function handleConnection(client, deps) {
224
271
  speakStream?.close();
225
272
  });
226
273
  }
227
- async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history, turnState, getGeneration) {
274
+ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history, turnState, getGeneration, waitForToolResult) {
228
275
  let msg;
229
276
  try {
230
277
  msg = JSON.parse(raw);
@@ -238,7 +285,7 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
238
285
  // Results message never carries speech_final:true, so a turn can't get
239
286
  // permanently stuck with real transcript sitting in the buffer forever.
240
287
  if (turnState.buffer)
241
- await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
288
+ await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult);
242
289
  return;
243
290
  }
244
291
  if (msg.type !== "Results")
@@ -265,7 +312,7 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
265
312
  safeSend(client, { type: "interim", text: turnState.buffer });
266
313
  return;
267
314
  }
268
- await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
315
+ await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult);
269
316
  }
270
317
  /**
271
318
  * Everything from here on (the LLM call, TTS streaming) can fail in ways
@@ -283,38 +330,74 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
283
330
  * happens while this turn is still "thinking" bumps the generation, and
284
331
  * without this check the now-stale response would still land on the
285
332
  * client after the user had already moved on to a new question.
333
+ *
334
+ * A continuing verb (click/fill/read/call_tool — TERMINAL_VERBS says which
335
+ * ones aren't) doesn't end the turn here: the server can't execute a DOM
336
+ * action itself, so it sends the step to the client, awaits its real
337
+ * result over waitForToolResult(), folds that into a *local* working copy
338
+ * of history, and calls resolveVerb again — repeat up to
339
+ * MAX_LOOP_ITERATIONS. The connection's real `history` only gets the
340
+ * user's real question plus the turn's final answer, committed once at
341
+ * the end — a turn that hits the cap mid-loop doesn't leave partial tool
342
+ * noise in the conversation's real memory, same discipline the HTTP
343
+ * path's runTypedAgentLoop (index.tsx) follows.
286
344
  */
287
- async function finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration) {
345
+ async function finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult) {
288
346
  const transcript = turnState.buffer;
289
347
  turnState.buffer = "";
290
348
  const myGeneration = getGeneration();
291
349
  safeSend(client, { type: "final", text: transcript });
350
+ let loopHistory = history;
292
351
  try {
293
- const { route, visible } = getContext();
294
- const verb = await (0, server_1.resolveVerb)(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
295
- route,
296
- question: transcript,
297
- visible,
298
- history,
299
- });
300
- if (myGeneration !== getGeneration())
301
- return; // superseded by a barge-in while this turn was resolving
302
- // Sent immediately — before speech synthesis even starts — so
303
- // highlight/navigate/do execute in the browser right away instead of
304
- // waiting on audio. The agent visibly acts while it's still about to
305
- // speak, not after.
306
- safeSend(client, { type: "verb", verb });
307
- history.push({ role: "user", text: transcript }, { role: "assistant", text: summarizeVerbForHistory(verb) });
308
- history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
309
- // A verb with no spoken text (highlight/navigate/do often have none)
310
- // still needs to unstick the client's "thinking" state and let the mic
311
- // resumeturn_complete covers that with no audio path involved.
312
- if ("text" in verb && verb.text) {
313
- await speakStreamed(verb.text);
314
- }
315
- else {
316
- safeSend(client, { type: "turn_complete" });
352
+ for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
353
+ const { route, visible, liveElements, webMcpTools } = getContext();
354
+ const verb = await (0, server_1.resolveVerb)(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
355
+ route,
356
+ question: transcript,
357
+ visible,
358
+ liveElements,
359
+ webMcpTools,
360
+ history: loopHistory,
361
+ });
362
+ if (myGeneration !== getGeneration())
363
+ return; // superseded by a barge-in while this turn was resolving
364
+ // Sent immediately — before speech synthesis even starts — so
365
+ // highlight/navigate/do execute in the browser right away instead of
366
+ // waiting on audio. The agent visibly acts while it's still about to
367
+ // speak, not after.
368
+ safeSend(client, { type: "verb", verb });
369
+ if (!core_1.TERMINAL_VERBS.has(verb.verb)) {
370
+ // A continuing step no speech for it (keeps the loop fast;
371
+ // the client still shows it visually) — wait for its real result
372
+ // and go around again instead of ending the turn.
373
+ const observation = await waitForToolResult();
374
+ if (myGeneration !== getGeneration())
375
+ return;
376
+ loopHistory = [
377
+ ...loopHistory,
378
+ { role: "assistant", text: `${summarizeVerbForHistory(verb)}. Result: ${observation}` },
379
+ ].slice(-MAX_HISTORY_TURNS);
380
+ continue;
381
+ }
382
+ history.push({ role: "user", text: transcript }, { role: "assistant", text: summarizeVerbForHistory(verb) });
383
+ history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
384
+ // A verb with no spoken text (highlight/navigate/do often have none)
385
+ // still needs to unstick the client's "thinking" state and let the mic
386
+ // resume — turn_complete covers that with no audio path involved.
387
+ if ("text" in verb && verb.text) {
388
+ await speakStreamed(verb.text);
389
+ }
390
+ else {
391
+ safeSend(client, { type: "turn_complete" });
392
+ }
393
+ return;
317
394
  }
395
+ // Iteration cap hit with no terminal verb — degrade honestly instead
396
+ // of leaving the client waiting forever.
397
+ history.push({ role: "user", text: transcript }, { role: "assistant", text: "(gave up after too many steps)" });
398
+ history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
399
+ safeSend(client, { type: "verb", verb: { verb: "explain", text: "I wasn't able to finish that — try asking again or breaking it into smaller steps." } });
400
+ await speakStreamed("I wasn't able to finish that — try asking again or breaking it into smaller steps.");
318
401
  }
319
402
  catch (err) {
320
403
  console.error("[cairn realtime] failed to resolve/speak this turn:", err);
@@ -329,6 +412,42 @@ function safeSend(client, message) {
329
412
  return;
330
413
  client.send(JSON.stringify(message));
331
414
  }
415
+ /** Defensive parse for the client's self-reported live DOM scan — same
416
+ * untrusted-input treatment `visible` already gets on this control-message
417
+ * path (no CopilotRequestSchema here, unlike the HTTP handler), just
418
+ * shaped-checked so a malformed entry can't reach the LLM prompt oddly. */
419
+ function parseLiveElements(raw) {
420
+ if (!Array.isArray(raw))
421
+ return [];
422
+ const elements = [];
423
+ for (const entry of raw) {
424
+ if (entry &&
425
+ typeof entry === "object" &&
426
+ typeof entry.id === "string" &&
427
+ typeof entry.role === "string" &&
428
+ typeof entry.label === "string") {
429
+ elements.push({ id: entry.id, role: entry.role, label: entry.label });
430
+ }
431
+ if (elements.length >= 60)
432
+ break;
433
+ }
434
+ return elements;
435
+ }
436
+ /** Same defensive shape-check as parseLiveElements, for the client's
437
+ * self-reported WebMCP tool list. */
438
+ function parseWebMcpTools(raw) {
439
+ if (!Array.isArray(raw))
440
+ return [];
441
+ const tools = [];
442
+ for (const entry of raw) {
443
+ if (entry && typeof entry === "object" && typeof entry.name === "string" && typeof entry.description === "string") {
444
+ tools.push({ name: entry.name, description: entry.description, inputSchema: entry.inputSchema });
445
+ }
446
+ if (tools.length >= 30)
447
+ break;
448
+ }
449
+ return tools;
450
+ }
332
451
  /** A short text form of any verb for the history log — not shown to the
333
452
  * user, just fed back to the model on later turns so it knows what it
334
453
  * already did/said. */
@@ -345,6 +464,14 @@ function summarizeVerbForHistory(verb) {
345
464
  return `(ran ${verb.action}${verb.target ? ` on ${verb.target}` : ""})`;
346
465
  case "tour":
347
466
  return verb.steps.map((s) => s.text).join(" ");
467
+ case "click":
468
+ return `(clicked ${verb.target})`;
469
+ case "fill":
470
+ return `(typed "${verb.value}" into ${verb.target})`;
471
+ case "read":
472
+ return `(read ${verb.target})`;
473
+ case "call_tool":
474
+ return `(called ${verb.name})`;
348
475
  default:
349
476
  return "(no response)";
350
477
  }