@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/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,
@@ -14,10 +16,12 @@ import {
14
16
  VolumeX,
15
17
  X,
16
18
  } from "lucide-react";
17
- import type { HistoryTurn as HistoryEntry, TourStep } from "@cairnvibe/core";
19
+ import { TERMINAL_VERBS, safeParseVerbResponse, type HistoryTurn as HistoryEntry, type TourStep } from "@cairnvibe/core";
18
20
  import { collectVisible } from "./context-collector";
19
21
  import { findElement, highlightElement, logMiss, type MissContext } from "./element-ladder";
20
- import { executeVerbResponse } from "./verb-executor";
22
+ import { createLiveElementRegistry } from "./runtime-scan";
23
+ import { discoverWebMcpTools } from "./webmcp-client";
24
+ import { executeToolStep, executeVerbResponse } from "./verb-executor";
21
25
 
22
26
  export interface CopilotProps {
23
27
  /** Reserved for a future client-side manifest fetch. Not required — the server handler owns the manifest. */
@@ -63,8 +67,24 @@ export function Copilot({
63
67
  persona = "Cairn",
64
68
  }: CopilotProps) {
65
69
  const pathname = usePathname() ?? "/";
70
+ // Mirrors `pathname` for use inside long-lived closures (a realtime
71
+ // session's handlers are all created once, when the connection opens —
72
+ // same staleness reason runTour tracks its own `currentRoute` locally
73
+ // rather than trusting its closure's `pathname` after a mid-tour
74
+ // navigation).
75
+ const pathnameRef = useRef(pathname);
76
+ useEffect(() => {
77
+ pathnameRef.current = pathname;
78
+ void sendFreshContext(); // no-op if no realtime session is open
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, [pathname]);
66
81
  const router = useRouter();
67
82
  const [open, setOpen] = useState(false);
83
+ // Collapsed by default so the panel only ever shows the current exchange
84
+ // — the full archived transcript (built up over a long conversation)
85
+ // stays out of the way behind an explicit toggle instead of always being
86
+ // visible inline, which made the panel grow uncomfortably tall.
87
+ const [historyExpanded, setHistoryExpanded] = useState(false);
68
88
  const [question, setQuestion] = useState("");
69
89
  const [answer, setAnswer] = useState<string | null>(null);
70
90
  const [status, setStatus] = useState<Status>("idle");
@@ -118,6 +138,20 @@ export function Copilot({
118
138
  // already stateful).
119
139
  const historyRef = useRef<HistoryEntry[]>([]);
120
140
 
141
+ // A background scanner that keeps a live inventory of what's actually
142
+ // clickable on screen right now (runtime-scan.ts) — running continuously
143
+ // via a MutationObserver so there's never a pause to "go look at the
144
+ // page" right when a verb needs to click something. `liveMapRef` freezes
145
+ // one snapshot of it per turn (set alongside every context/question send,
146
+ // below) so a background rescan landing mid-flight can't shift what an id
147
+ // resolves to between when a request went out and its response came back.
148
+ const liveRegistryRef = useRef(createLiveElementRegistry());
149
+ const liveMapRef = useRef<Map<string, HTMLElement>>(new Map());
150
+ useEffect(() => {
151
+ liveRegistryRef.current.start();
152
+ return () => liveRegistryRef.current.stop();
153
+ }, []);
154
+
121
155
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
122
156
  const audioChunksRef = useRef<Blob[]>([]);
123
157
  const transcribeInFlightRef = useRef(false);
@@ -213,6 +247,36 @@ export function Copilot({
213
247
  setStatus(next);
214
248
  }
215
249
 
250
+ /**
251
+ * Refreshes the server's picture of route/visible/liveElements over an
252
+ * already-open realtime connection. Beyond the initial connect, called
253
+ * on every route change and whenever the mic is about to start listening
254
+ * again — a real, pre-existing gap this closes as a side effect: the
255
+ * server's context previously updated only once, at connection open, so
256
+ * navigating mid-call (via a "navigate" verb, or the user clicking
257
+ * around) left the server answering every later turn as if the user were
258
+ * still on the original page. Reads pathnameRef, not the closure's
259
+ * `pathname`, so it's correct even called from a handler created once at
260
+ * connection-open time.
261
+ */
262
+ async function sendFreshContext() {
263
+ const ws = rtSocketRef.current;
264
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
265
+ const liveScan = liveRegistryRef.current.getSnapshot();
266
+ liveMapRef.current = liveScan.byId;
267
+ const webMcpTools = await discoverWebMcpTools();
268
+ if (ws.readyState !== WebSocket.OPEN) return; // may have closed while awaiting discovery
269
+ ws.send(
270
+ JSON.stringify({
271
+ type: "context",
272
+ route: pathnameRef.current,
273
+ visible: collectVisible(),
274
+ liveElements: liveScan.elements,
275
+ webMcpTools,
276
+ }),
277
+ );
278
+ }
279
+
216
280
  function reportMiss(context: MissContext) {
217
281
  logMiss(context);
218
282
  if (reportMissesEndpoint) {
@@ -235,6 +299,7 @@ export function Copilot({
235
299
  onDo,
236
300
  onTour: (steps) => void runTour(steps),
237
301
  registeredActions,
302
+ liveElements: liveMapRef.current,
238
303
  });
239
304
  }
240
305
 
@@ -297,9 +362,23 @@ export function Copilot({
297
362
  }
298
363
 
299
364
  if (step.target) {
300
- const el = findElement(step.target);
301
- if (el) highlightElement(el);
302
- else reportMiss({ attempted: step.target, route: currentRoute });
365
+ // A fresh scan, not the tour's starting liveMapRef snapshot — a
366
+ // step after a mid-tour navigation targets elements on a page
367
+ // that didn't exist when the tour began.
368
+ const liveScan = liveRegistryRef.current.getSnapshot();
369
+ const el = findElement(step.target, liveScan.byId);
370
+ if (el) {
371
+ highlightElement(el);
372
+ if (step.click) {
373
+ el.click();
374
+ // Give whatever the click reveals (a detail view, an expanded
375
+ // row) a moment to actually render before narrating it.
376
+ await new Promise((resolve) => setTimeout(resolve, 400));
377
+ if (tourGenerationRef.current !== myGeneration) return;
378
+ }
379
+ } else {
380
+ reportMiss({ attempted: step.target, route: currentRoute });
381
+ }
303
382
  }
304
383
 
305
384
  if (wasRealtimeListening && rtSocketRef.current?.readyState === WebSocket.OPEN) {
@@ -337,27 +416,85 @@ export function Copilot({
337
416
  setLastQuestion(q);
338
417
  setQuestion("");
339
418
  try {
419
+ await runTypedAgentLoop(q);
420
+ } catch {
421
+ setAnswer("Something went wrong reaching the help service — try again in a moment.");
422
+ } finally {
423
+ setStatus("idle");
424
+ }
425
+ }
426
+
427
+ const MAX_LOOP_ITERATIONS = 6; // a hard cap, not a target — see runTypedAgentLoop
428
+
429
+ /**
430
+ * Drives the agent loop over the stateless HTTP path: ask the server,
431
+ * and if it comes back with a continuing step (click/fill/read/
432
+ * call_tool — TERMINAL_VERBS in @cairnvibe/core says which verbs end a
433
+ * turn), execute that step for real, fold the real result into this
434
+ * turn's own working history, and ask again — repeat until a terminal
435
+ * verb or the iteration cap, instead of the old one-call-one-answer
436
+ * shape. `question` stays the original ask on every call; only
437
+ * `history` grows with each step's real trace, so the model always
438
+ * still knows what it was actually asked. `historyRef` (the
439
+ * conversation's real memory) is only ever committed once, at the end —
440
+ * a turn that hits the cap mid-loop doesn't leave partial noise in it.
441
+ */
442
+ async function runTypedAgentLoop(q: string): Promise<void> {
443
+ let loopHistory = historyRef.current;
444
+ const webMcpTools = await discoverWebMcpTools();
445
+
446
+ for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
447
+ const liveScan = liveRegistryRef.current.getSnapshot();
448
+ liveMapRef.current = liveScan.byId;
340
449
  const res = await fetch(endpoint, {
341
450
  method: "POST",
342
451
  headers: { "content-type": "application/json" },
343
- body: JSON.stringify({ route: pathname, question: q, visible: collectVisible(), history: historyRef.current }),
452
+ body: JSON.stringify({
453
+ route: pathname,
454
+ question: q,
455
+ visible: collectVisible(),
456
+ history: loopHistory,
457
+ liveElements: liveScan.elements,
458
+ webMcpTools,
459
+ }),
344
460
  });
345
461
  const data = await res.json().catch(() => null);
346
- handleVerb(data);
347
- // Unlike the realtime relay (one persistent connection, memory lives
348
- // server-side), each of these POSTs is stateless — the widget itself
349
- // is what remembers, and resends it above so the model has context
350
- // for "the first one" / "do that instead" on the next question.
351
- historyRef.current = [
352
- ...historyRef.current,
353
- { role: "user", text: q } satisfies HistoryEntry,
354
- { role: "assistant", text: summarizeVerbForHistory(data) } satisfies HistoryEntry,
462
+ const parsed = safeParseVerbResponse(data);
463
+
464
+ if (!parsed || TERMINAL_VERBS.has(parsed.verb)) {
465
+ handleVerb(data);
466
+ // Unlike the realtime relay (one persistent connection, memory
467
+ // lives server-side), each of these POSTs is stateless — the
468
+ // widget itself is what remembers, and resends it above so the
469
+ // model has context for "the first one" / "do that instead" on
470
+ // the next question.
471
+ historyRef.current = [
472
+ ...loopHistory,
473
+ { role: "user", text: q } satisfies HistoryEntry,
474
+ { role: "assistant", text: summarizeVerbForHistory(data) } satisfies HistoryEntry,
475
+ ].slice(-MAX_HISTORY_TURNS);
476
+ return;
477
+ }
478
+
479
+ // A continuing step — show it happening, execute it for real, and
480
+ // go around again with the real result instead of ending the turn.
481
+ setAnswer(summarizeVerbForHistory(data));
482
+ const stepResult = await executeToolStep(data, pathname, liveMapRef.current);
483
+ loopHistory = [
484
+ ...loopHistory,
485
+ {
486
+ role: "assistant",
487
+ text: `${summarizeVerbForHistory(data)}. Result: ${stepResult?.observation ?? "no result"}`,
488
+ } satisfies HistoryEntry,
355
489
  ].slice(-MAX_HISTORY_TURNS);
356
- } catch {
357
- setAnswer("Something went wrong reaching the help service — try again in a moment.");
358
- } finally {
359
- setStatus("idle");
360
490
  }
491
+
492
+ setAnswer("I wasn't able to finish that — try asking again or breaking it into smaller steps.");
493
+ historyRef.current = [
494
+ ...loopHistory,
495
+ { role: "user", text: q } satisfies HistoryEntry,
496
+ { role: "assistant", text: "(gave up after too many steps)" } satisfies HistoryEntry,
497
+ ].slice(-MAX_HISTORY_TURNS);
361
498
  }
362
499
 
363
500
  /**
@@ -614,6 +751,7 @@ export function Copilot({
614
751
  }
615
752
  setRtStatus("rt-listening");
616
753
  setCaption("");
754
+ void sendFreshContext(); // refresh before the user starts talking again, not after
617
755
  }
618
756
 
619
757
  function disarmThinkingWatchdog() {
@@ -674,7 +812,7 @@ export function Copilot({
674
812
  }
675
813
 
676
814
  ws.onopen = () => {
677
- ws.send(JSON.stringify({ type: "context", route: pathname, visible: collectVisible() }));
815
+ sendFreshContext();
678
816
  setRtStatus("rt-listening");
679
817
  rtStartingRef.current = false;
680
818
  };
@@ -690,6 +828,25 @@ export function Copilot({
690
828
  setRtStatus("rt-thinking");
691
829
  armThinkingWatchdog();
692
830
  } else if (msg.type === "verb") {
831
+ const parsedStep = safeParseVerbResponse(msg.verb);
832
+ if (parsedStep && !TERMINAL_VERBS.has(parsedStep.verb)) {
833
+ // A continuing agent-loop step (click/fill/read/call_tool) —
834
+ // the turn isn't over: execute it for real and report the
835
+ // result back so the server can decide the next step, instead
836
+ // of treating this like a normal answer (no
837
+ // disarmThinkingWatchdog/handleVerb — those are for when a
838
+ // turn actually ends). Shown visually so a multi-step turn
839
+ // reads as visible progress, not a silent pause; never spoken
840
+ // — the server's loop stays quiet between steps on purpose,
841
+ // to keep it fast.
842
+ setAnswer(summarizeVerbForHistory(msg.verb));
843
+ void executeToolStep(msg.verb, pathnameRef.current, liveMapRef.current).then((result) => {
844
+ if (ws.readyState === WebSocket.OPEN) {
845
+ ws.send(JSON.stringify({ type: "tool_result", observation: result?.observation ?? "no result" }));
846
+ }
847
+ });
848
+ return;
849
+ }
693
850
  disarmThinkingWatchdog();
694
851
  handleVerb(msg.verb);
695
852
  } else if (msg.type === "speaking_start") {
@@ -843,14 +1000,26 @@ export function Copilot({
843
1000
 
844
1001
  {(transcript.length > 0 || userCaption || answer || busy) && (
845
1002
  <div className="cairn-stack">
846
- {transcript.map((entry) => (
847
- <div
848
- className={entry.role === "user" ? "cairn-bubble cairn-bubble-user cairn-bubble-past" : "cairn-bubble cairn-bubble-agent cairn-bubble-past"}
849
- key={entry.id}
1003
+ {transcript.length > 0 && (
1004
+ <button
1005
+ type="button"
1006
+ className="cairn-history-toggle"
1007
+ onClick={() => setHistoryExpanded((v) => !v)}
1008
+ aria-expanded={historyExpanded}
850
1009
  >
851
- {entry.role === "agent" ? <span className="cairn-bubble-text">{entry.text}</span> : entry.text}
852
- </div>
853
- ))}
1010
+ {historyExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
1011
+ {historyExpanded ? "Hide earlier" : `${transcript.length} earlier`}
1012
+ </button>
1013
+ )}
1014
+ {historyExpanded &&
1015
+ transcript.map((entry) => (
1016
+ <div
1017
+ className={entry.role === "user" ? "cairn-bubble cairn-bubble-user cairn-bubble-past" : "cairn-bubble cairn-bubble-agent cairn-bubble-past"}
1018
+ key={entry.id}
1019
+ >
1020
+ {entry.role === "agent" ? <span className="cairn-bubble-text">{entry.text}</span> : entry.text}
1021
+ </div>
1022
+ ))}
854
1023
  {userCaption && (
855
1024
  <div className="cairn-bubble cairn-bubble-user" key={`u-${userCaption}`}>
856
1025
  {userCaption}
@@ -981,6 +1150,14 @@ function summarizeVerbForHistory(raw: unknown): string {
981
1150
  return `(ran ${String(v.action)}${v.target ? ` on ${String(v.target)}` : ""})`;
982
1151
  case "tour":
983
1152
  return Array.isArray(v.steps) ? v.steps.map((s: { text?: string }) => s.text ?? "").join(" ") : "(tour)";
1153
+ case "click":
1154
+ return `(clicked ${String(v.target)})`;
1155
+ case "fill":
1156
+ return `(typed "${String(v.value)}" into ${String(v.target)})`;
1157
+ case "read":
1158
+ return `(read ${String(v.target)})`;
1159
+ case "call_tool":
1160
+ return `(called ${String(v.name)})`;
984
1161
  default:
985
1162
  return "(no response)";
986
1163
  }
@@ -1220,6 +1397,26 @@ const COPILOT_STYLES = `
1220
1397
  text-transform: uppercase;
1221
1398
  color: rgba(11, 13, 18, 0.48);
1222
1399
  }
1400
+ .cairn-history-toggle {
1401
+ align-self: center;
1402
+ display: inline-flex;
1403
+ align-items: center;
1404
+ gap: 3px;
1405
+ border: none;
1406
+ background: none;
1407
+ padding: 2px 8px;
1408
+ font: inherit;
1409
+ font-size: 11px;
1410
+ font-weight: 600;
1411
+ color: rgba(11, 13, 18, 0.4);
1412
+ cursor: pointer;
1413
+ border-radius: 999px;
1414
+ transition: background 0.15s ease, color 0.15s ease;
1415
+ }
1416
+ .cairn-history-toggle:hover {
1417
+ background: rgba(11, 13, 18, 0.05);
1418
+ color: rgba(11, 13, 18, 0.6);
1419
+ }
1223
1420
  .cairn-thinking {
1224
1421
  display: inline-flex;
1225
1422
  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 { TERMINAL_VERBS, type HistoryTurn, type LiveElement, type Manifest, type VerbResponse, type WebMcpTool } from "@cairnvibe/core";
31
31
  import { buildSystemPrompt, createVerbLLM, resolveVerb, type CapabilityTier, type CreateCopilotHandlerOptions } from "./server";
32
32
  import { DeepgramSpeakStream } from "./tts-stream";
33
33
 
@@ -107,14 +107,29 @@ export interface ConnectionDeps {
107
107
  }
108
108
 
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
+ const MAX_LOOP_ITERATIONS = 6; // a hard cap on one turn's agent-loop steps, not a target — see finalizeTurn
110
111
 
111
112
  async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promise<void> {
112
- let context = { route: "/", visible: [] as string[] };
113
+ // liveElements/webMcpTools refresh on every "context" resend (the client
114
+ // sends one on route changes and each time it's about to start listening
115
+ // again), so a live scan from several turns ago never lingers into a
116
+ // later one.
117
+ let context: { route: string; visible: string[]; liveElements: LiveElement[]; webMcpTools: WebMcpTool[] } = {
118
+ route: "/",
119
+ visible: [],
120
+ liveElements: [],
121
+ webMcpTools: [],
122
+ };
113
123
  // Unlike the stateless HTTP path (which needs the client to resend
114
124
  // history every request), a realtime connection is already stateful —
115
125
  // one WebSocket per call — so this is accumulated here directly rather
116
126
  // than round-tripped through the client.
117
127
  const history: HistoryTurn[] = [];
128
+ // Resolves the agent loop's in-flight waitForToolResult() call once the
129
+ // client reports back what a click/fill/read/call_tool step actually
130
+ // did — same "a mutable pending-callback slot, resolved when the right
131
+ // message arrives" pattern onCurrentTurnFlushed already uses below.
132
+ let pendingToolResultResolve: ((observation: string) => void) | null = null;
118
133
 
119
134
  const dgUrl =
120
135
  `${DEEPGRAM_LIVE_URL}?model=${encodeURIComponent(deps.sttModel)}` +
@@ -202,13 +217,33 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
202
217
  });
203
218
  }
204
219
 
220
+ /**
221
+ * Pauses the agent loop (finalizeTurn, below) until the client reports
222
+ * back the real result of a click/fill/read/call_tool step it just sent
223
+ * out — the server can't execute a DOM action itself, so every
224
+ * continuing step needs a real round trip to the browser and back. A
225
+ * real timeout, not a hang: a client that never answers (closed tab,
226
+ * dropped connection) can't leave a turn stuck forever.
227
+ */
228
+ function waitForToolResult(): Promise<string> {
229
+ return new Promise((resolve) => {
230
+ pendingToolResultResolve = resolve;
231
+ setTimeout(() => {
232
+ if (pendingToolResultResolve === resolve) {
233
+ pendingToolResultResolve = null;
234
+ resolve("(no result — timed out waiting for the browser)");
235
+ }
236
+ }, 15000);
237
+ });
238
+ }
239
+
205
240
  // Accumulates Deepgram "Results" transcript segments across one utterance
206
241
  // — see handleDeepgramMessage for why this can't just react to every
207
242
  // is_final.
208
243
  const turnState = { buffer: "" };
209
244
 
210
245
  dg.on("message", (data) => {
211
- void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation);
246
+ void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation, waitForToolResult);
212
247
  });
213
248
 
214
249
  dg.on("error", (err) => {
@@ -226,7 +261,18 @@ async function handleConnection(client: WebSocket, deps: ConnectionDeps): Promis
226
261
  try {
227
262
  const msg = JSON.parse(data.toString());
228
263
  if (msg.type === "context") {
229
- context = { route: String(msg.route ?? "/"), visible: Array.isArray(msg.visible) ? msg.visible : [] };
264
+ context = {
265
+ route: String(msg.route ?? "/"),
266
+ visible: Array.isArray(msg.visible) ? msg.visible : [],
267
+ liveElements: parseLiveElements(msg.liveElements),
268
+ webMcpTools: parseWebMcpTools(msg.webMcpTools),
269
+ };
270
+ } else if (msg.type === "tool_result" && typeof msg.observation === "string") {
271
+ // The client finished executing a click/fill/read/call_tool step
272
+ // the agent loop sent it — this is what finalizeTurn's
273
+ // waitForToolResult() below is paused on.
274
+ pendingToolResultResolve?.(msg.observation);
275
+ pendingToolResultResolve = null;
230
276
  } else if (msg.type === "end") {
231
277
  client.close();
232
278
  } else if (msg.type === "barge_in") {
@@ -272,11 +318,12 @@ export async function handleDeepgramMessage(
272
318
  raw: string,
273
319
  client: WebSocket,
274
320
  deps: ConnectionDeps,
275
- getContext: () => { route: string; visible: string[] },
321
+ getContext: () => { route: string; visible: string[]; liveElements: LiveElement[]; webMcpTools: WebMcpTool[] },
276
322
  speakStreamed: (text: string) => Promise<void>,
277
323
  history: HistoryTurn[],
278
324
  turnState: { buffer: string },
279
325
  getGeneration: () => number,
326
+ waitForToolResult: () => Promise<string>,
280
327
  ): Promise<void> {
281
328
  let msg: any;
282
329
  try {
@@ -290,7 +337,7 @@ export async function handleDeepgramMessage(
290
337
  // after utterance_end_ms of silence — a safety net for the rare case a
291
338
  // Results message never carries speech_final:true, so a turn can't get
292
339
  // permanently stuck with real transcript sitting in the buffer forever.
293
- if (turnState.buffer) await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
340
+ if (turnState.buffer) await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult);
294
341
  return;
295
342
  }
296
343
 
@@ -319,7 +366,7 @@ export async function handleDeepgramMessage(
319
366
  return;
320
367
  }
321
368
 
322
- await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
369
+ await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult);
323
370
  }
324
371
 
325
372
  /**
@@ -338,49 +385,88 @@ export async function handleDeepgramMessage(
338
385
  * happens while this turn is still "thinking" bumps the generation, and
339
386
  * without this check the now-stale response would still land on the
340
387
  * client after the user had already moved on to a new question.
388
+ *
389
+ * A continuing verb (click/fill/read/call_tool — TERMINAL_VERBS says which
390
+ * ones aren't) doesn't end the turn here: the server can't execute a DOM
391
+ * action itself, so it sends the step to the client, awaits its real
392
+ * result over waitForToolResult(), folds that into a *local* working copy
393
+ * of history, and calls resolveVerb again — repeat up to
394
+ * MAX_LOOP_ITERATIONS. The connection's real `history` only gets the
395
+ * user's real question plus the turn's final answer, committed once at
396
+ * the end — a turn that hits the cap mid-loop doesn't leave partial tool
397
+ * noise in the conversation's real memory, same discipline the HTTP
398
+ * path's runTypedAgentLoop (index.tsx) follows.
341
399
  */
342
400
  async function finalizeTurn(
343
401
  turnState: { buffer: string },
344
402
  client: WebSocket,
345
403
  deps: ConnectionDeps,
346
- getContext: () => { route: string; visible: string[] },
404
+ getContext: () => { route: string; visible: string[]; liveElements: LiveElement[]; webMcpTools: WebMcpTool[] },
347
405
  speakStreamed: (text: string) => Promise<void>,
348
406
  history: HistoryTurn[],
349
407
  getGeneration: () => number,
408
+ waitForToolResult: () => Promise<string>,
350
409
  ): Promise<void> {
351
410
  const transcript = turnState.buffer;
352
411
  turnState.buffer = "";
353
412
  const myGeneration = getGeneration();
354
413
  safeSend(client, { type: "final", text: transcript });
355
414
 
356
- try {
357
- const { route, visible } = getContext();
358
- const verb = await resolveVerb(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
359
- route,
360
- question: transcript,
361
- visible,
362
- history,
363
- });
415
+ let loopHistory = history;
364
416
 
365
- if (myGeneration !== getGeneration()) return; // superseded by a barge-in while this turn was resolving
417
+ try {
418
+ for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
419
+ const { route, visible, liveElements, webMcpTools } = getContext();
420
+ const verb = await resolveVerb(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
421
+ route,
422
+ question: transcript,
423
+ visible,
424
+ liveElements,
425
+ webMcpTools,
426
+ history: loopHistory,
427
+ });
366
428
 
367
- // Sent immediately before speech synthesis even starts — so
368
- // highlight/navigate/do execute in the browser right away instead of
369
- // waiting on audio. The agent visibly acts while it's still about to
370
- // speak, not after.
371
- safeSend(client, { type: "verb", verb });
429
+ if (myGeneration !== getGeneration()) return; // superseded by a barge-in while this turn was resolving
430
+
431
+ // Sent immediately before speech synthesis even starts so
432
+ // highlight/navigate/do execute in the browser right away instead of
433
+ // waiting on audio. The agent visibly acts while it's still about to
434
+ // speak, not after.
435
+ safeSend(client, { type: "verb", verb });
436
+
437
+ if (!TERMINAL_VERBS.has(verb.verb)) {
438
+ // A continuing step — no speech for it (keeps the loop fast;
439
+ // the client still shows it visually) — wait for its real result
440
+ // and go around again instead of ending the turn.
441
+ const observation = await waitForToolResult();
442
+ if (myGeneration !== getGeneration()) return;
443
+ loopHistory = [
444
+ ...loopHistory,
445
+ { role: "assistant" as const, text: `${summarizeVerbForHistory(verb)}. Result: ${observation}` },
446
+ ].slice(-MAX_HISTORY_TURNS);
447
+ continue;
448
+ }
372
449
 
373
- history.push({ role: "user", text: transcript }, { role: "assistant", text: summarizeVerbForHistory(verb) });
374
- history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
450
+ history.push({ role: "user", text: transcript }, { role: "assistant", text: summarizeVerbForHistory(verb) });
451
+ history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
375
452
 
376
- // A verb with no spoken text (highlight/navigate/do often have none)
377
- // still needs to unstick the client's "thinking" state and let the mic
378
- // resume — turn_complete covers that with no audio path involved.
379
- if ("text" in verb && verb.text) {
380
- await speakStreamed(verb.text);
381
- } else {
382
- safeSend(client, { type: "turn_complete" });
453
+ // A verb with no spoken text (highlight/navigate/do often have none)
454
+ // still needs to unstick the client's "thinking" state and let the mic
455
+ // resume — turn_complete covers that with no audio path involved.
456
+ if ("text" in verb && verb.text) {
457
+ await speakStreamed(verb.text);
458
+ } else {
459
+ safeSend(client, { type: "turn_complete" });
460
+ }
461
+ return;
383
462
  }
463
+
464
+ // Iteration cap hit with no terminal verb — degrade honestly instead
465
+ // of leaving the client waiting forever.
466
+ history.push({ role: "user", text: transcript }, { role: "assistant", text: "(gave up after too many steps)" });
467
+ history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
468
+ safeSend(client, { type: "verb", verb: { verb: "explain", text: "I wasn't able to finish that — try asking again or breaking it into smaller steps." } });
469
+ await speakStreamed("I wasn't able to finish that — try asking again or breaking it into smaller steps.");
384
470
  } catch (err) {
385
471
  console.error("[cairn realtime] failed to resolve/speak this turn:", err);
386
472
  if (myGeneration === getGeneration()) {
@@ -395,6 +481,42 @@ function safeSend(client: WebSocket, message: ServerMessage): void {
395
481
  client.send(JSON.stringify(message));
396
482
  }
397
483
 
484
+ /** Defensive parse for the client's self-reported live DOM scan — same
485
+ * untrusted-input treatment `visible` already gets on this control-message
486
+ * path (no CopilotRequestSchema here, unlike the HTTP handler), just
487
+ * shaped-checked so a malformed entry can't reach the LLM prompt oddly. */
488
+ function parseLiveElements(raw: unknown): LiveElement[] {
489
+ if (!Array.isArray(raw)) return [];
490
+ const elements: LiveElement[] = [];
491
+ for (const entry of raw) {
492
+ if (
493
+ entry &&
494
+ typeof entry === "object" &&
495
+ typeof (entry as any).id === "string" &&
496
+ typeof (entry as any).role === "string" &&
497
+ typeof (entry as any).label === "string"
498
+ ) {
499
+ elements.push({ id: (entry as any).id, role: (entry as any).role, label: (entry as any).label });
500
+ }
501
+ if (elements.length >= 60) break;
502
+ }
503
+ return elements;
504
+ }
505
+
506
+ /** Same defensive shape-check as parseLiveElements, for the client's
507
+ * self-reported WebMCP tool list. */
508
+ function parseWebMcpTools(raw: unknown): WebMcpTool[] {
509
+ if (!Array.isArray(raw)) return [];
510
+ const tools: WebMcpTool[] = [];
511
+ for (const entry of raw) {
512
+ if (entry && typeof entry === "object" && typeof (entry as any).name === "string" && typeof (entry as any).description === "string") {
513
+ tools.push({ name: (entry as any).name, description: (entry as any).description, inputSchema: (entry as any).inputSchema });
514
+ }
515
+ if (tools.length >= 30) break;
516
+ }
517
+ return tools;
518
+ }
519
+
398
520
  /** A short text form of any verb for the history log — not shown to the
399
521
  * user, just fed back to the model on later turns so it knows what it
400
522
  * already did/said. */
@@ -410,6 +532,14 @@ function summarizeVerbForHistory(verb: VerbResponse): string {
410
532
  return `(ran ${verb.action}${verb.target ? ` on ${verb.target}` : ""})`;
411
533
  case "tour":
412
534
  return verb.steps.map((s) => s.text).join(" ");
535
+ case "click":
536
+ return `(clicked ${verb.target})`;
537
+ case "fill":
538
+ return `(typed "${verb.value}" into ${verb.target})`;
539
+ case "read":
540
+ return `(read ${verb.target})`;
541
+ case "call_tool":
542
+ return `(called ${verb.name})`;
413
543
  default:
414
544
  return "(no response)";
415
545
  }