@cairnvibe/sdk 0.2.7 → 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,9 +6,11 @@ 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");
11
12
  const runtime_scan_1 = require("./runtime-scan");
13
+ const webmcp_client_1 = require("./webmcp-client");
12
14
  const verb_executor_1 = require("./verb-executor");
13
15
  function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, reportMissesEndpoint, transcribeEndpoint, speakEndpoint, realtimeUrl, persona = "Cairn", }) {
14
16
  const pathname = (0, navigation_1.usePathname)() ?? "/";
@@ -20,7 +22,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
20
22
  const pathnameRef = (0, react_1.useRef)(pathname);
21
23
  (0, react_1.useEffect)(() => {
22
24
  pathnameRef.current = pathname;
23
- sendFreshContext(); // no-op if no realtime session is open
25
+ void sendFreshContext(); // no-op if no realtime session is open
24
26
  // eslint-disable-next-line react-hooks/exhaustive-deps
25
27
  }, [pathname]);
26
28
  const router = (0, navigation_1.useRouter)();
@@ -194,13 +196,22 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
194
196
  * `pathname`, so it's correct even called from a handler created once at
195
197
  * connection-open time.
196
198
  */
197
- function sendFreshContext() {
199
+ async function sendFreshContext() {
198
200
  const ws = rtSocketRef.current;
199
201
  if (!ws || ws.readyState !== WebSocket.OPEN)
200
202
  return;
201
203
  const liveScan = liveRegistryRef.current.getSnapshot();
202
204
  liveMapRef.current = liveScan.byId;
203
- ws.send(JSON.stringify({ type: "context", route: pathnameRef.current, visible: (0, context_collector_1.collectVisible)(), liveElements: liveScan.elements }));
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
+ }));
204
215
  }
205
216
  function reportMiss(context) {
206
217
  (0, element_ladder_1.logMiss)(context);
@@ -346,6 +357,33 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
346
357
  setLastQuestion(q);
347
358
  setQuestion("");
348
359
  try {
360
+ await runTypedAgentLoop(q);
361
+ }
362
+ catch {
363
+ setAnswer("Something went wrong reaching the help service — try again in a moment.");
364
+ }
365
+ finally {
366
+ setStatus("idle");
367
+ }
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++) {
349
387
  const liveScan = liveRegistryRef.current.getSnapshot();
350
388
  liveMapRef.current = liveScan.byId;
351
389
  const res = await fetch(endpoint, {
@@ -355,28 +393,45 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
355
393
  route: pathname,
356
394
  question: q,
357
395
  visible: (0, context_collector_1.collectVisible)(),
358
- history: historyRef.current,
396
+ history: loopHistory,
359
397
  liveElements: liveScan.elements,
398
+ webMcpTools,
360
399
  }),
361
400
  });
362
401
  const data = await res.json().catch(() => null);
363
- handleVerb(data);
364
- // Unlike the realtime relay (one persistent connection, memory lives
365
- // server-side), each of these POSTs is stateless — the widget itself
366
- // is what remembers, and resends it above so the model has context
367
- // for "the first one" / "do that instead" on the next question.
368
- historyRef.current = [
369
- ...historyRef.current,
370
- { role: "user", text: q },
371
- { role: "assistant", text: summarizeVerbForHistory(data) },
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
+ },
372
427
  ].slice(-MAX_HISTORY_TURNS);
373
428
  }
374
- catch {
375
- setAnswer("Something went wrong reaching the help service — try again in a moment.");
376
- }
377
- finally {
378
- setStatus("idle");
379
- }
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);
380
435
  }
381
436
  /**
382
437
  * The one place that starts audio playback for a spoken response — stops
@@ -641,7 +696,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
641
696
  }
642
697
  setRtStatus("rt-listening");
643
698
  setCaption("");
644
- sendFreshContext(); // refresh before the user starts talking again, not after
699
+ void sendFreshContext(); // refresh before the user starts talking again, not after
645
700
  }
646
701
  function disarmThinkingWatchdog() {
647
702
  if (rtThinkingWatchdogRef.current) {
@@ -717,6 +772,25 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
717
772
  armThinkingWatchdog();
718
773
  }
719
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
+ }
720
794
  disarmThinkingWatchdog();
721
795
  handleVerb(msg.verb);
722
796
  }
@@ -884,6 +958,14 @@ function summarizeVerbForHistory(raw) {
884
958
  return `(ran ${String(v.action)}${v.target ? ` on ${String(v.target)}` : ""})`;
885
959
  case "tour":
886
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)})`;
887
969
  default:
888
970
  return "(no response)";
889
971
  }
@@ -1,6 +1,6 @@
1
1
  import http from "node:http";
2
2
  import { WebSocket } from "ws";
3
- import type { HistoryTurn, LiveElement, 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;
@@ -23,6 +23,7 @@ export declare function handleDeepgramMessage(raw: string, client: WebSocket, de
23
23
  route: string;
24
24
  visible: string[];
25
25
  liveElements: LiveElement[];
26
+ webMcpTools: WebMcpTool[];
26
27
  }, speakStreamed: (text: string) => Promise<void>, history: HistoryTurn[], turnState: {
27
28
  buffer: string;
28
- }, 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,16 +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
- // 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: [] };
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
+ };
81
89
  // Unlike the stateless HTTP path (which needs the client to resend
82
90
  // history every request), a realtime connection is already stateful —
83
91
  // one WebSocket per call — so this is accumulated here directly rather
84
92
  // than round-tripped through the client.
85
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;
86
99
  const dgUrl = `${DEEPGRAM_LIVE_URL}?model=${encodeURIComponent(deps.sttModel)}` +
87
100
  `&encoding=linear16&sample_rate=16000&channels=1&interim_results=true&endpointing=300&utterance_end_ms=1000`;
88
101
  const dg = new ws_1.WebSocket(dgUrl, { headers: { Authorization: `Token ${deps.deepgramApiKey}` } });
@@ -159,12 +172,31 @@ async function handleConnection(client, deps) {
159
172
  stream.flush();
160
173
  });
161
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
+ }
162
194
  // Accumulates Deepgram "Results" transcript segments across one utterance
163
195
  // — see handleDeepgramMessage for why this can't just react to every
164
196
  // is_final.
165
197
  const turnState = { buffer: "" };
166
198
  dg.on("message", (data) => {
167
- void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation);
199
+ void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation, waitForToolResult);
168
200
  });
169
201
  dg.on("error", (err) => {
170
202
  console.error("[cairn realtime] Deepgram STT connection error:", err);
@@ -186,8 +218,16 @@ async function handleConnection(client, deps) {
186
218
  route: String(msg.route ?? "/"),
187
219
  visible: Array.isArray(msg.visible) ? msg.visible : [],
188
220
  liveElements: parseLiveElements(msg.liveElements),
221
+ webMcpTools: parseWebMcpTools(msg.webMcpTools),
189
222
  };
190
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;
230
+ }
191
231
  else if (msg.type === "end") {
192
232
  client.close();
193
233
  }
@@ -231,7 +271,7 @@ async function handleConnection(client, deps) {
231
271
  speakStream?.close();
232
272
  });
233
273
  }
234
- async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history, turnState, getGeneration) {
274
+ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history, turnState, getGeneration, waitForToolResult) {
235
275
  let msg;
236
276
  try {
237
277
  msg = JSON.parse(raw);
@@ -245,7 +285,7 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
245
285
  // Results message never carries speech_final:true, so a turn can't get
246
286
  // permanently stuck with real transcript sitting in the buffer forever.
247
287
  if (turnState.buffer)
248
- await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
288
+ await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult);
249
289
  return;
250
290
  }
251
291
  if (msg.type !== "Results")
@@ -272,7 +312,7 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
272
312
  safeSend(client, { type: "interim", text: turnState.buffer });
273
313
  return;
274
314
  }
275
- await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
315
+ await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult);
276
316
  }
277
317
  /**
278
318
  * Everything from here on (the LLM call, TTS streaming) can fail in ways
@@ -290,39 +330,74 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
290
330
  * happens while this turn is still "thinking" bumps the generation, and
291
331
  * without this check the now-stale response would still land on the
292
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.
293
344
  */
294
- async function finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration) {
345
+ async function finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult) {
295
346
  const transcript = turnState.buffer;
296
347
  turnState.buffer = "";
297
348
  const myGeneration = getGeneration();
298
349
  safeSend(client, { type: "final", text: transcript });
350
+ let loopHistory = history;
299
351
  try {
300
- const { route, visible, liveElements } = getContext();
301
- const verb = await (0, server_1.resolveVerb)(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
302
- route,
303
- question: transcript,
304
- visible,
305
- liveElements,
306
- history,
307
- });
308
- if (myGeneration !== getGeneration())
309
- return; // superseded by a barge-in while this turn was resolving
310
- // Sent immediately — before speech synthesis even starts — so
311
- // highlight/navigate/do execute in the browser right away instead of
312
- // waiting on audio. The agent visibly acts while it's still about to
313
- // speak, not after.
314
- safeSend(client, { type: "verb", verb });
315
- history.push({ role: "user", text: transcript }, { role: "assistant", text: summarizeVerbForHistory(verb) });
316
- history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
317
- // A verb with no spoken text (highlight/navigate/do often have none)
318
- // still needs to unstick the client's "thinking" state and let the mic
319
- // resume turn_complete covers that with no audio path involved.
320
- if ("text" in verb && verb.text) {
321
- await speakStreamed(verb.text);
322
- }
323
- else {
324
- 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;
325
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.");
326
401
  }
327
402
  catch (err) {
328
403
  console.error("[cairn realtime] failed to resolve/speak this turn:", err);
@@ -358,6 +433,21 @@ function parseLiveElements(raw) {
358
433
  }
359
434
  return elements;
360
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
+ }
361
451
  /** A short text form of any verb for the history log — not shown to the
362
452
  * user, just fed back to the model on later turns so it knows what it
363
453
  * already did/said. */
@@ -374,6 +464,14 @@ function summarizeVerbForHistory(verb) {
374
464
  return `(ran ${verb.action}${verb.target ? ` on ${verb.target}` : ""})`;
375
465
  case "tour":
376
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})`;
377
475
  default:
378
476
  return "(no response)";
379
477
  }
@@ -11,7 +11,12 @@
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.scanInteractiveElements = scanInteractiveElements;
13
13
  exports.createLiveElementRegistry = createLiveElementRegistry;
14
- const CANDIDATE_SELECTOR = "[data-ai], button, a, [role='button'], input[type='submit'], input[type='button']";
14
+ // Clickable elements, plus real fillable form fields (text/email/number/etc
15
+ // inputs, textarea, select — NOT submit/button inputs, already covered by
16
+ // the plain "button" role below) — the agent loop's fill/read steps need
17
+ // these to be discoverable the same way a click target already is.
18
+ const CANDIDATE_SELECTOR = "[data-ai], button, a, [role='button'], input[type='submit'], input[type='button'], " +
19
+ "input:not([type='submit']):not([type='button']):not([type='hidden']), textarea, select";
15
20
  const MAX_ELEMENTS = 40;
16
21
  const MAX_LABEL_LENGTH = 80;
17
22
  const RESCAN_DEBOUNCE_MS = 250;
@@ -19,13 +24,38 @@ function isInViewport(el) {
19
24
  const rect = el.getBoundingClientRect();
20
25
  return rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth;
21
26
  }
27
+ /** A form field's own text content is always empty — its identity comes
28
+ * from an associated <label>, a placeholder, or its name attribute
29
+ * instead, in that order of how a real user would recognize the field. */
30
+ function formFieldLabel(el) {
31
+ if (el.id) {
32
+ const labelled = el.ownerDocument?.querySelector(`label[for="${cssEscapeId(el.id)}"]`);
33
+ if (labelled?.textContent?.trim())
34
+ return labelled.textContent;
35
+ }
36
+ const wrappingLabel = el.closest("label");
37
+ if (wrappingLabel?.textContent?.trim())
38
+ return wrappingLabel.textContent;
39
+ return el.getAttribute("placeholder") || el.getAttribute("name") || "";
40
+ }
41
+ function cssEscapeId(id) {
42
+ return id.replace(/["\\]/g, "\\$&");
43
+ }
44
+ // tagName, not instanceof — see element-ladder.ts's isFormField for why.
45
+ function isFormField(el) {
46
+ return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
47
+ }
22
48
  function labelFor(el) {
23
- const raw = el.getAttribute("aria-label") || el.textContent || "";
49
+ const raw = el.getAttribute("aria-label") || (isFormField(el) ? formFieldLabel(el) : el.textContent) || "";
24
50
  const trimmed = raw.replace(/\s+/g, " ").trim();
25
51
  return trimmed.length > MAX_LABEL_LENGTH ? `${trimmed.slice(0, MAX_LABEL_LENGTH - 1)}…` : trimmed;
26
52
  }
27
53
  function roleFor(el) {
28
- return el.getAttribute("role") || el.tagName.toLowerCase();
54
+ if (el.getAttribute("role"))
55
+ return el.getAttribute("role");
56
+ if (isFormField(el))
57
+ return "input";
58
+ return el.tagName.toLowerCase();
29
59
  }
30
60
  /**
31
61
  * Scans the live DOM for interactive elements currently in the viewport.
package/dist/server.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type HistoryTurn, type LiveElement, type Manifest, type VerbResponse } from "@cairnvibe/core";
1
+ import { type HistoryTurn, type LiveElement, type Manifest, type VerbResponse, type WebMcpTool } from "@cairnvibe/core";
2
2
  import { KeyRotator } from "./key-rotator";
3
3
  /**
4
4
  * What the agent is allowed to do, independent of which specific "do"
@@ -65,6 +65,7 @@ export declare function resolveVerb(llm: VerbLLM, systemPrompt: string, manifest
65
65
  visible: string[];
66
66
  history?: HistoryTurn[];
67
67
  liveElements?: LiveElement[];
68
+ webMcpTools?: WebMcpTool[];
68
69
  }): Promise<VerbResponse>;
69
70
  /** Builds the provider-appropriate VerbLLM from the same options createCopilotHandler accepts — reused by the realtime relay. */
70
71
  export declare function createVerbLLM(options?: CreateCopilotHandlerOptions): VerbLLM;