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