@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/cairn-widget.js +2 -2
- package/dist/element-ladder.d.ts +1 -1
- package/dist/element-ladder.js +10 -3
- package/dist/index.js +101 -6
- package/dist/realtime-server.d.ts +2 -1
- package/dist/realtime-server.js +32 -3
- package/dist/runtime-scan.d.ts +36 -0
- package/dist/runtime-scan.js +113 -0
- package/dist/server.d.ts +2 -1
- package/dist/server.js +86 -56
- package/dist/verb-executor.d.ts +7 -0
- package/dist/verb-executor.js +37 -17
- package/package.json +1 -1
- package/src/element-ladder.ts +10 -3
- package/src/index.tsx +123 -12
- package/src/realtime-server.ts +36 -6
- package/src/runtime-scan.ts +141 -0
- package/src/server.ts +89 -57
- package/src/verb-executor.ts +42 -16
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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.
|
|
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>;
|
package/dist/realtime-server.js
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
|
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;
|