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