@cairnvibe/sdk 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -8,11 +8,28 @@ const navigation_1 = require("next/navigation");
8
8
  const lucide_react_1 = require("lucide-react");
9
9
  const context_collector_1 = require("./context-collector");
10
10
  const element_ladder_1 = require("./element-ladder");
11
+ const runtime_scan_1 = require("./runtime-scan");
11
12
  const verb_executor_1 = require("./verb-executor");
12
13
  function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, reportMissesEndpoint, transcribeEndpoint, speakEndpoint, realtimeUrl, persona = "Cairn", }) {
13
14
  const pathname = (0, navigation_1.usePathname)() ?? "/";
15
+ // Mirrors `pathname` for use inside long-lived closures (a realtime
16
+ // session's handlers are all created once, when the connection opens —
17
+ // same staleness reason runTour tracks its own `currentRoute` locally
18
+ // rather than trusting its closure's `pathname` after a mid-tour
19
+ // navigation).
20
+ const pathnameRef = (0, react_1.useRef)(pathname);
21
+ (0, react_1.useEffect)(() => {
22
+ pathnameRef.current = pathname;
23
+ sendFreshContext(); // no-op if no realtime session is open
24
+ // eslint-disable-next-line react-hooks/exhaustive-deps
25
+ }, [pathname]);
14
26
  const router = (0, navigation_1.useRouter)();
15
27
  const [open, setOpen] = (0, react_1.useState)(false);
28
+ // Collapsed by default so the panel only ever shows the current exchange
29
+ // — the full archived transcript (built up over a long conversation)
30
+ // stays out of the way behind an explicit toggle instead of always being
31
+ // visible inline, which made the panel grow uncomfortably tall.
32
+ const [historyExpanded, setHistoryExpanded] = (0, react_1.useState)(false);
16
33
  const [question, setQuestion] = (0, react_1.useState)("");
17
34
  const [answer, setAnswer] = (0, react_1.useState)(null);
18
35
  const [status, setStatus] = (0, react_1.useState)("idle");
@@ -65,6 +82,19 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
65
82
  // keeps its own history server-side instead, since that connection is
66
83
  // already stateful).
67
84
  const historyRef = (0, react_1.useRef)([]);
85
+ // A background scanner that keeps a live inventory of what's actually
86
+ // clickable on screen right now (runtime-scan.ts) — running continuously
87
+ // via a MutationObserver so there's never a pause to "go look at the
88
+ // page" right when a verb needs to click something. `liveMapRef` freezes
89
+ // one snapshot of it per turn (set alongside every context/question send,
90
+ // below) so a background rescan landing mid-flight can't shift what an id
91
+ // resolves to between when a request went out and its response came back.
92
+ const liveRegistryRef = (0, react_1.useRef)((0, runtime_scan_1.createLiveElementRegistry)());
93
+ const liveMapRef = (0, react_1.useRef)(new Map());
94
+ (0, react_1.useEffect)(() => {
95
+ liveRegistryRef.current.start();
96
+ return () => liveRegistryRef.current.stop();
97
+ }, []);
68
98
  const mediaRecorderRef = (0, react_1.useRef)(null);
69
99
  const audioChunksRef = (0, react_1.useRef)([]);
70
100
  const transcribeInFlightRef = (0, react_1.useRef)(false);
@@ -152,6 +182,26 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
152
182
  rtStateRef.current = next;
153
183
  setStatus(next);
154
184
  }
185
+ /**
186
+ * Refreshes the server's picture of route/visible/liveElements over an
187
+ * already-open realtime connection. Beyond the initial connect, called
188
+ * on every route change and whenever the mic is about to start listening
189
+ * again — a real, pre-existing gap this closes as a side effect: the
190
+ * server's context previously updated only once, at connection open, so
191
+ * navigating mid-call (via a "navigate" verb, or the user clicking
192
+ * around) left the server answering every later turn as if the user were
193
+ * still on the original page. Reads pathnameRef, not the closure's
194
+ * `pathname`, so it's correct even called from a handler created once at
195
+ * connection-open time.
196
+ */
197
+ function sendFreshContext() {
198
+ const ws = rtSocketRef.current;
199
+ if (!ws || ws.readyState !== WebSocket.OPEN)
200
+ return;
201
+ const liveScan = liveRegistryRef.current.getSnapshot();
202
+ liveMapRef.current = liveScan.byId;
203
+ ws.send(JSON.stringify({ type: "context", route: pathnameRef.current, visible: (0, context_collector_1.collectVisible)(), liveElements: liveScan.elements }));
204
+ }
155
205
  function reportMiss(context) {
156
206
  (0, element_ladder_1.logMiss)(context);
157
207
  if (reportMissesEndpoint) {
@@ -174,6 +224,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
174
224
  onDo,
175
225
  onTour: (steps) => void runTour(steps),
176
226
  registeredActions,
227
+ liveElements: liveMapRef.current,
177
228
  });
178
229
  }
179
230
  /**
@@ -236,11 +287,25 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
236
287
  return;
237
288
  }
238
289
  if (step.target) {
239
- const el = (0, element_ladder_1.findElement)(step.target);
240
- if (el)
290
+ // A fresh scan, not the tour's starting liveMapRef snapshot — a
291
+ // step after a mid-tour navigation targets elements on a page
292
+ // that didn't exist when the tour began.
293
+ const liveScan = liveRegistryRef.current.getSnapshot();
294
+ const el = (0, element_ladder_1.findElement)(step.target, liveScan.byId);
295
+ if (el) {
241
296
  (0, element_ladder_1.highlightElement)(el);
242
- else
297
+ if (step.click) {
298
+ el.click();
299
+ // Give whatever the click reveals (a detail view, an expanded
300
+ // row) a moment to actually render before narrating it.
301
+ await new Promise((resolve) => setTimeout(resolve, 400));
302
+ if (tourGenerationRef.current !== myGeneration)
303
+ return;
304
+ }
305
+ }
306
+ else {
243
307
  reportMiss({ attempted: step.target, route: currentRoute });
308
+ }
244
309
  }
245
310
  if (wasRealtimeListening && rtSocketRef.current?.readyState === WebSocket.OPEN) {
246
311
  // Already have a live streaming connection open — reuse it
@@ -281,10 +346,18 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
281
346
  setLastQuestion(q);
282
347
  setQuestion("");
283
348
  try {
349
+ const liveScan = liveRegistryRef.current.getSnapshot();
350
+ liveMapRef.current = liveScan.byId;
284
351
  const res = await fetch(endpoint, {
285
352
  method: "POST",
286
353
  headers: { "content-type": "application/json" },
287
- body: JSON.stringify({ route: pathname, question: q, visible: (0, context_collector_1.collectVisible)(), history: historyRef.current }),
354
+ body: JSON.stringify({
355
+ route: pathname,
356
+ question: q,
357
+ visible: (0, context_collector_1.collectVisible)(),
358
+ history: historyRef.current,
359
+ liveElements: liveScan.elements,
360
+ }),
288
361
  });
289
362
  const data = await res.json().catch(() => null);
290
363
  handleVerb(data);
@@ -568,6 +641,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
568
641
  }
569
642
  setRtStatus("rt-listening");
570
643
  setCaption("");
644
+ sendFreshContext(); // refresh before the user starts talking again, not after
571
645
  }
572
646
  function disarmThinkingWatchdog() {
573
647
  if (rtThinkingWatchdogRef.current) {
@@ -625,7 +699,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
625
699
  setCaption("");
626
700
  }
627
701
  ws.onopen = () => {
628
- ws.send(JSON.stringify({ type: "context", route: pathname, visible: (0, context_collector_1.collectVisible)() }));
702
+ sendFreshContext();
629
703
  setRtStatus("rt-listening");
630
704
  rtStartingRef.current = false;
631
705
  };
@@ -779,7 +853,8 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
779
853
  "rt-thinking": "Thinking…",
780
854
  "rt-speaking": "Speaking…",
781
855
  };
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) => {
856
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("style", { suppressHydrationWarning: true, dangerouslySetInnerHTML: { __html: COPILOT_STYLES } }), (0, jsx_runtime_1.jsx)("button", { className: status === "rt-speaking" ? "cairn-fab cairn-fab-speaking" : "cairn-fab", "aria-label": open ? `Close ${persona} help` : `Open ${persona} help`, onClick: () => setOpen((v) => !v), children: open ? (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 22 }) : (0, jsx_runtime_1.jsx)(CairnMark, {}) }), open && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-panel", role: "dialog", "aria-label": `${persona} help panel`, ref: panelRef, children: [(transcript.length > 0 || userCaption || answer || busy) && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-stack", children: [transcript.length > 0 && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "cairn-history-toggle", onClick: () => setHistoryExpanded((v) => !v), "aria-expanded": historyExpanded, children: [historyExpanded ? (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronUp, { size: 12 }) : (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { size: 12 }), historyExpanded ? "Hide earlier" : `${transcript.length} earlier`] })), historyExpanded &&
857
+ transcript.map((entry) => ((0, jsx_runtime_1.jsx)("div", { className: entry.role === "user" ? "cairn-bubble cairn-bubble-user cairn-bubble-past" : "cairn-bubble cairn-bubble-agent cairn-bubble-past", children: entry.role === "agent" ? (0, jsx_runtime_1.jsx)("span", { className: "cairn-bubble-text", children: entry.text }) : entry.text }, entry.id))), userCaption && ((0, jsx_runtime_1.jsx)("div", { className: "cairn-bubble cairn-bubble-user", children: userCaption }, `u-${userCaption}`)), (answer || busy) && ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-bubble cairn-bubble-agent", children: [tourChip && (0, jsx_runtime_1.jsx)("span", { className: "cairn-chip", children: tourChip }), answer ? ((0, jsx_runtime_1.jsx)("span", { className: "cairn-bubble-text", children: renderCaptionWords(answer) })) : ((0, jsx_runtime_1.jsxs)("span", { className: "cairn-thinking", "aria-label": "Thinking", children: [(0, jsx_runtime_1.jsx)("span", { className: "cairn-thinking-dot" }), (0, jsx_runtime_1.jsx)("span", { className: "cairn-thinking-dot" }), (0, jsx_runtime_1.jsx)("span", { className: "cairn-thinking-dot" })] }))] }, `a-${answer ?? status}`))] })), realtimeActive ? ((0, jsx_runtime_1.jsxs)("div", { className: "cairn-rt-bar", children: [(0, jsx_runtime_1.jsx)("span", { className: `cairn-rt-dot cairn-rt-dot-${status}` }), (0, jsx_runtime_1.jsx)("span", { className: "cairn-rt-label", children: statusLabel[status] }), (0, jsx_runtime_1.jsxs)("div", { className: "cairn-rt-controls", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: status === "rt-speaking" ? "cairn-icon-btn cairn-icon-btn-speaking" : "cairn-icon-btn", "aria-label": rtMicMuted ? "Unmute microphone" : "Mute microphone", onClick: toggleRtMic, children: rtMicMuted ? (0, jsx_runtime_1.jsx)(lucide_react_1.MicOff, { size: 16 }) : (0, jsx_runtime_1.jsx)(lucide_react_1.Mic, { size: 16 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: status === "rt-speaking" ? "cairn-icon-btn cairn-icon-btn-speaking" : "cairn-icon-btn", "aria-label": rtSpeakerMuted ? "Unmute speaker" : "Mute speaker", onClick: toggleRtSpeaker, children: rtSpeakerMuted ? (0, jsx_runtime_1.jsx)(lucide_react_1.VolumeX, { size: 16 }) : (0, jsx_runtime_1.jsx)(lucide_react_1.Volume2, { size: 16 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "cairn-icon-btn cairn-icon-btn-end", "aria-label": "End conversation", onClick: endRealtime, children: (0, jsx_runtime_1.jsx)(lucide_react_1.PhoneOff, { size: 16 }) })] })] })) : ((0, jsx_runtime_1.jsx)("form", { onSubmit: (e) => {
783
858
  e.preventDefault();
784
859
  const trimmed = question.trim();
785
860
  if (trimmed)
@@ -1031,6 +1106,26 @@ const COPILOT_STYLES = `
1031
1106
  text-transform: uppercase;
1032
1107
  color: rgba(11, 13, 18, 0.48);
1033
1108
  }
1109
+ .cairn-history-toggle {
1110
+ align-self: center;
1111
+ display: inline-flex;
1112
+ align-items: center;
1113
+ gap: 3px;
1114
+ border: none;
1115
+ background: none;
1116
+ padding: 2px 8px;
1117
+ font: inherit;
1118
+ font-size: 11px;
1119
+ font-weight: 600;
1120
+ color: rgba(11, 13, 18, 0.4);
1121
+ cursor: pointer;
1122
+ border-radius: 999px;
1123
+ transition: background 0.15s ease, color 0.15s ease;
1124
+ }
1125
+ .cairn-history-toggle:hover {
1126
+ background: rgba(11, 13, 18, 0.05);
1127
+ color: rgba(11, 13, 18, 0.6);
1128
+ }
1034
1129
  .cairn-thinking {
1035
1130
  display: inline-flex;
1036
1131
  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, LiveElement, Manifest } 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,7 @@ 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[];
25
26
  }, speakStreamed: (text: string) => Promise<void>, history: HistoryTurn[], turnState: {
26
27
  buffer: string;
27
28
  }, getGeneration: () => number): Promise<void>;
@@ -74,7 +74,10 @@ function createRealtimeServer(options) {
74
74
  }
75
75
  const MAX_HISTORY_TURNS = 8; // 4 exchanges — enough for "the first one"/"do that instead" without growing the prompt unbounded over a long call
76
76
  async function handleConnection(client, deps) {
77
- let context = { route: "/", visible: [] };
77
+ // liveElements refreshes on every "context" resend (the client sends one
78
+ // on route changes and each time it's about to start listening again),
79
+ // so a live scan from several turns ago never lingers into a later one.
80
+ let context = { route: "/", visible: [], liveElements: [] };
78
81
  // Unlike the stateless HTTP path (which needs the client to resend
79
82
  // history every request), a realtime connection is already stateful —
80
83
  // one WebSocket per call — so this is accumulated here directly rather
@@ -179,7 +182,11 @@ async function handleConnection(client, deps) {
179
182
  try {
180
183
  const msg = JSON.parse(data.toString());
181
184
  if (msg.type === "context") {
182
- context = { route: String(msg.route ?? "/"), visible: Array.isArray(msg.visible) ? msg.visible : [] };
185
+ context = {
186
+ route: String(msg.route ?? "/"),
187
+ visible: Array.isArray(msg.visible) ? msg.visible : [],
188
+ liveElements: parseLiveElements(msg.liveElements),
189
+ };
183
190
  }
184
191
  else if (msg.type === "end") {
185
192
  client.close();
@@ -290,11 +297,12 @@ async function finalizeTurn(turnState, client, deps, getContext, speakStreamed,
290
297
  const myGeneration = getGeneration();
291
298
  safeSend(client, { type: "final", text: transcript });
292
299
  try {
293
- const { route, visible } = getContext();
300
+ const { route, visible, liveElements } = getContext();
294
301
  const verb = await (0, server_1.resolveVerb)(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
295
302
  route,
296
303
  question: transcript,
297
304
  visible,
305
+ liveElements,
298
306
  history,
299
307
  });
300
308
  if (myGeneration !== getGeneration())
@@ -329,6 +337,27 @@ function safeSend(client, message) {
329
337
  return;
330
338
  client.send(JSON.stringify(message));
331
339
  }
340
+ /** Defensive parse for the client's self-reported live DOM scan — same
341
+ * untrusted-input treatment `visible` already gets on this control-message
342
+ * path (no CopilotRequestSchema here, unlike the HTTP handler), just
343
+ * shaped-checked so a malformed entry can't reach the LLM prompt oddly. */
344
+ function parseLiveElements(raw) {
345
+ if (!Array.isArray(raw))
346
+ return [];
347
+ const elements = [];
348
+ for (const entry of raw) {
349
+ if (entry &&
350
+ typeof entry === "object" &&
351
+ typeof entry.id === "string" &&
352
+ typeof entry.role === "string" &&
353
+ typeof entry.label === "string") {
354
+ elements.push({ id: entry.id, role: entry.role, label: entry.label });
355
+ }
356
+ if (elements.length >= 60)
357
+ break;
358
+ }
359
+ return elements;
360
+ }
332
361
  /** A short text form of any verb for the history log — not shown to the
333
362
  * user, just fed back to the model on later turns so it knows what it
334
363
  * already did/said. */
@@ -0,0 +1,36 @@
1
+ import type { LiveElement } from "@cairnvibe/core";
2
+ export interface LiveScan {
3
+ elements: LiveElement[];
4
+ byId: Map<string, HTMLElement>;
5
+ }
6
+ /**
7
+ * Scans the live DOM for interactive elements currently in the viewport.
8
+ * Returns both the bounded list to send to the model (`elements`, capped at
9
+ * MAX_ELEMENTS and MAX_LABEL_LENGTH — the actual privacy/payload backstop,
10
+ * mirrored server-side in CopilotRequestSchema) and the real elements it
11
+ * maps to, keyed by the same ids (`byId`) — resolve a verb's target by
12
+ * looking it up here, never by re-deriving a selector from the id string.
13
+ */
14
+ export declare function scanInteractiveElements(root?: ParentNode): LiveScan;
15
+ export interface LiveElementRegistry {
16
+ /** Starts continuous background scanning — call once, typically on mount. */
17
+ start(): void;
18
+ stop(): void;
19
+ /**
20
+ * Freezes the current scan for one request/response round trip. Call
21
+ * this once when a question is sent, and resolve that turn's verb
22
+ * against exactly this snapshot — not a fresh call — so a background
23
+ * rescan that lands mid-flight can't shift what an id resolves to
24
+ * between when the request went out and when the response comes back.
25
+ */
26
+ getSnapshot(): LiveScan;
27
+ }
28
+ /**
29
+ * Keeps a scan continuously fresh in the background via a debounced
30
+ * MutationObserver (plus scroll/resize, since viewport membership changes
31
+ * without any DOM mutation) instead of only scanning at the moment a
32
+ * question is asked — so the agent never has to pause to "go look at the
33
+ * page" right when it needs to click something; a sub-agent gathering
34
+ * context while the main conversation keeps moving.
35
+ */
36
+ export declare function createLiveElementRegistry(): LiveElementRegistry;
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ // A live inventory of what's actually clickable on screen right now — not
3
+ // the build-time manifest, and not limited to elements a developer
4
+ // remembered to tag with data-ai. This is what lets the agent address a
5
+ // dynamically-rendered row (a session card, a list item) it was never told
6
+ // about ahead of time. Deliberately narrow in what counts as "interactive":
7
+ // the same semantic-clickable set element-ladder.ts's own fallback search
8
+ // already uses, plus anything carrying data-ai — a plain `<div onClick>`
9
+ // with no semantic role is invisible to this, same limit the existing
10
+ // ladder already has.
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.scanInteractiveElements = scanInteractiveElements;
13
+ exports.createLiveElementRegistry = createLiveElementRegistry;
14
+ const CANDIDATE_SELECTOR = "[data-ai], button, a, [role='button'], input[type='submit'], input[type='button']";
15
+ const MAX_ELEMENTS = 40;
16
+ const MAX_LABEL_LENGTH = 80;
17
+ const RESCAN_DEBOUNCE_MS = 250;
18
+ function isInViewport(el) {
19
+ const rect = el.getBoundingClientRect();
20
+ return rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth;
21
+ }
22
+ function labelFor(el) {
23
+ const raw = el.getAttribute("aria-label") || el.textContent || "";
24
+ const trimmed = raw.replace(/\s+/g, " ").trim();
25
+ return trimmed.length > MAX_LABEL_LENGTH ? `${trimmed.slice(0, MAX_LABEL_LENGTH - 1)}…` : trimmed;
26
+ }
27
+ function roleFor(el) {
28
+ return el.getAttribute("role") || el.tagName.toLowerCase();
29
+ }
30
+ /**
31
+ * Scans the live DOM for interactive elements currently in the viewport.
32
+ * Returns both the bounded list to send to the model (`elements`, capped at
33
+ * MAX_ELEMENTS and MAX_LABEL_LENGTH — the actual privacy/payload backstop,
34
+ * mirrored server-side in CopilotRequestSchema) and the real elements it
35
+ * maps to, keyed by the same ids (`byId`) — resolve a verb's target by
36
+ * looking it up here, never by re-deriving a selector from the id string.
37
+ */
38
+ function scanInteractiveElements(root = document) {
39
+ const elements = [];
40
+ const byId = new Map();
41
+ let counter = 0;
42
+ if (typeof document === "undefined")
43
+ return { elements, byId };
44
+ const candidates = root.querySelectorAll(CANDIDATE_SELECTOR);
45
+ for (const el of Array.from(candidates)) {
46
+ if (elements.length >= MAX_ELEMENTS)
47
+ break;
48
+ if (!isInViewport(el))
49
+ continue;
50
+ const dataAi = el.getAttribute("data-ai");
51
+ const id = dataAi ?? `live-${counter++}`;
52
+ if (byId.has(id))
53
+ continue; // a data-ai id already covered by an earlier match
54
+ const label = labelFor(el);
55
+ if (!label)
56
+ continue; // nothing to address it by — skip rather than send an empty label
57
+ byId.set(id, el);
58
+ elements.push({ id, role: roleFor(el), label });
59
+ }
60
+ return { elements, byId };
61
+ }
62
+ /**
63
+ * Keeps a scan continuously fresh in the background via a debounced
64
+ * MutationObserver (plus scroll/resize, since viewport membership changes
65
+ * without any DOM mutation) instead of only scanning at the moment a
66
+ * question is asked — so the agent never has to pause to "go look at the
67
+ * page" right when it needs to click something; a sub-agent gathering
68
+ * context while the main conversation keeps moving.
69
+ */
70
+ function createLiveElementRegistry() {
71
+ let current = { elements: [], byId: new Map() };
72
+ let observer = null;
73
+ let debounceTimer = null;
74
+ function rescan() {
75
+ current = scanInteractiveElements();
76
+ }
77
+ function scheduleRescan() {
78
+ if (debounceTimer)
79
+ return;
80
+ debounceTimer = setTimeout(() => {
81
+ debounceTimer = null;
82
+ rescan();
83
+ }, RESCAN_DEBOUNCE_MS);
84
+ }
85
+ function start() {
86
+ if (typeof document === "undefined" || observer)
87
+ return;
88
+ rescan();
89
+ observer = new MutationObserver(scheduleRescan);
90
+ observer.observe(document.body, {
91
+ childList: true,
92
+ subtree: true,
93
+ attributes: true,
94
+ attributeFilter: ["data-ai", "aria-label"],
95
+ });
96
+ window.addEventListener("scroll", scheduleRescan, { passive: true });
97
+ window.addEventListener("resize", scheduleRescan);
98
+ }
99
+ function stop() {
100
+ observer?.disconnect();
101
+ observer = null;
102
+ if (debounceTimer) {
103
+ clearTimeout(debounceTimer);
104
+ debounceTimer = null;
105
+ }
106
+ window.removeEventListener("scroll", scheduleRescan);
107
+ window.removeEventListener("resize", scheduleRescan);
108
+ }
109
+ function getSnapshot() {
110
+ return current;
111
+ }
112
+ return { start, stop, getSnapshot };
113
+ }
package/dist/server.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type HistoryTurn, type Manifest, type VerbResponse } from "@cairnvibe/core";
1
+ import { type HistoryTurn, type LiveElement, type Manifest, type VerbResponse } 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"
@@ -64,6 +64,7 @@ export declare function resolveVerb(llm: VerbLLM, systemPrompt: string, manifest
64
64
  question: string;
65
65
  visible: string[];
66
66
  history?: HistoryTurn[];
67
+ liveElements?: LiveElement[];
67
68
  }): Promise<VerbResponse>;
68
69
  /** Builds the provider-appropriate VerbLLM from the same options createCopilotHandler accepts — reused by the realtime relay. */
69
70
  export declare function createVerbLLM(options?: CreateCopilotHandlerOptions): VerbLLM;