@cairnvibe/sdk 0.2.7 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cairn-widget.js +2 -2
- package/dist/element-ladder.d.ts +15 -0
- package/dist/element-ladder.js +41 -0
- package/dist/index.js +102 -20
- package/dist/realtime-server.d.ts +3 -2
- package/dist/realtime-server.js +132 -34
- package/dist/runtime-scan.js +33 -3
- package/dist/server.d.ts +2 -1
- package/dist/server.js +75 -14
- package/dist/verb-executor.d.ts +24 -0
- package/dist/verb-executor.js +87 -0
- 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 +41 -0
- package/src/index.tsx +106 -20
- package/src/realtime-server.ts +134 -34
- package/src/runtime-scan.ts +33 -3
- package/src/server.ts +85 -15
- package/src/verb-executor.ts +104 -1
- package/src/webmcp-client.ts +79 -0
package/dist/index.js
CHANGED
|
@@ -6,9 +6,11 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
6
6
|
const react_1 = require("react");
|
|
7
7
|
const navigation_1 = require("next/navigation");
|
|
8
8
|
const lucide_react_1 = require("lucide-react");
|
|
9
|
+
const core_1 = require("@cairnvibe/core");
|
|
9
10
|
const context_collector_1 = require("./context-collector");
|
|
10
11
|
const element_ladder_1 = require("./element-ladder");
|
|
11
12
|
const runtime_scan_1 = require("./runtime-scan");
|
|
13
|
+
const webmcp_client_1 = require("./webmcp-client");
|
|
12
14
|
const verb_executor_1 = require("./verb-executor");
|
|
13
15
|
function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, reportMissesEndpoint, transcribeEndpoint, speakEndpoint, realtimeUrl, persona = "Cairn", }) {
|
|
14
16
|
const pathname = (0, navigation_1.usePathname)() ?? "/";
|
|
@@ -20,7 +22,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
20
22
|
const pathnameRef = (0, react_1.useRef)(pathname);
|
|
21
23
|
(0, react_1.useEffect)(() => {
|
|
22
24
|
pathnameRef.current = pathname;
|
|
23
|
-
sendFreshContext(); // no-op if no realtime session is open
|
|
25
|
+
void sendFreshContext(); // no-op if no realtime session is open
|
|
24
26
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
25
27
|
}, [pathname]);
|
|
26
28
|
const router = (0, navigation_1.useRouter)();
|
|
@@ -194,13 +196,22 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
194
196
|
* `pathname`, so it's correct even called from a handler created once at
|
|
195
197
|
* connection-open time.
|
|
196
198
|
*/
|
|
197
|
-
function sendFreshContext() {
|
|
199
|
+
async function sendFreshContext() {
|
|
198
200
|
const ws = rtSocketRef.current;
|
|
199
201
|
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
200
202
|
return;
|
|
201
203
|
const liveScan = liveRegistryRef.current.getSnapshot();
|
|
202
204
|
liveMapRef.current = liveScan.byId;
|
|
203
|
-
|
|
205
|
+
const webMcpTools = await (0, webmcp_client_1.discoverWebMcpTools)();
|
|
206
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
207
|
+
return; // may have closed while awaiting discovery
|
|
208
|
+
ws.send(JSON.stringify({
|
|
209
|
+
type: "context",
|
|
210
|
+
route: pathnameRef.current,
|
|
211
|
+
visible: (0, context_collector_1.collectVisible)(),
|
|
212
|
+
liveElements: liveScan.elements,
|
|
213
|
+
webMcpTools,
|
|
214
|
+
}));
|
|
204
215
|
}
|
|
205
216
|
function reportMiss(context) {
|
|
206
217
|
(0, element_ladder_1.logMiss)(context);
|
|
@@ -346,6 +357,33 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
346
357
|
setLastQuestion(q);
|
|
347
358
|
setQuestion("");
|
|
348
359
|
try {
|
|
360
|
+
await runTypedAgentLoop(q);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
setAnswer("Something went wrong reaching the help service — try again in a moment.");
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
setStatus("idle");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const MAX_LOOP_ITERATIONS = 6; // a hard cap, not a target — see runTypedAgentLoop
|
|
370
|
+
/**
|
|
371
|
+
* Drives the agent loop over the stateless HTTP path: ask the server,
|
|
372
|
+
* and if it comes back with a continuing step (click/fill/read/
|
|
373
|
+
* call_tool — TERMINAL_VERBS in @cairnvibe/core says which verbs end a
|
|
374
|
+
* turn), execute that step for real, fold the real result into this
|
|
375
|
+
* turn's own working history, and ask again — repeat until a terminal
|
|
376
|
+
* verb or the iteration cap, instead of the old one-call-one-answer
|
|
377
|
+
* shape. `question` stays the original ask on every call; only
|
|
378
|
+
* `history` grows with each step's real trace, so the model always
|
|
379
|
+
* still knows what it was actually asked. `historyRef` (the
|
|
380
|
+
* conversation's real memory) is only ever committed once, at the end —
|
|
381
|
+
* a turn that hits the cap mid-loop doesn't leave partial noise in it.
|
|
382
|
+
*/
|
|
383
|
+
async function runTypedAgentLoop(q) {
|
|
384
|
+
let loopHistory = historyRef.current;
|
|
385
|
+
const webMcpTools = await (0, webmcp_client_1.discoverWebMcpTools)();
|
|
386
|
+
for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
|
|
349
387
|
const liveScan = liveRegistryRef.current.getSnapshot();
|
|
350
388
|
liveMapRef.current = liveScan.byId;
|
|
351
389
|
const res = await fetch(endpoint, {
|
|
@@ -355,28 +393,45 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
355
393
|
route: pathname,
|
|
356
394
|
question: q,
|
|
357
395
|
visible: (0, context_collector_1.collectVisible)(),
|
|
358
|
-
history:
|
|
396
|
+
history: loopHistory,
|
|
359
397
|
liveElements: liveScan.elements,
|
|
398
|
+
webMcpTools,
|
|
360
399
|
}),
|
|
361
400
|
});
|
|
362
401
|
const data = await res.json().catch(() => null);
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
402
|
+
const parsed = (0, core_1.safeParseVerbResponse)(data);
|
|
403
|
+
if (!parsed || core_1.TERMINAL_VERBS.has(parsed.verb)) {
|
|
404
|
+
handleVerb(data);
|
|
405
|
+
// Unlike the realtime relay (one persistent connection, memory
|
|
406
|
+
// lives server-side), each of these POSTs is stateless — the
|
|
407
|
+
// widget itself is what remembers, and resends it above so the
|
|
408
|
+
// model has context for "the first one" / "do that instead" on
|
|
409
|
+
// the next question.
|
|
410
|
+
historyRef.current = [
|
|
411
|
+
...loopHistory,
|
|
412
|
+
{ role: "user", text: q },
|
|
413
|
+
{ role: "assistant", text: summarizeVerbForHistory(data) },
|
|
414
|
+
].slice(-MAX_HISTORY_TURNS);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
// A continuing step — show it happening, execute it for real, and
|
|
418
|
+
// go around again with the real result instead of ending the turn.
|
|
419
|
+
setAnswer(summarizeVerbForHistory(data));
|
|
420
|
+
const stepResult = await (0, verb_executor_1.executeToolStep)(data, pathname, liveMapRef.current);
|
|
421
|
+
loopHistory = [
|
|
422
|
+
...loopHistory,
|
|
423
|
+
{
|
|
424
|
+
role: "assistant",
|
|
425
|
+
text: `${summarizeVerbForHistory(data)}. Result: ${stepResult?.observation ?? "no result"}`,
|
|
426
|
+
},
|
|
372
427
|
].slice(-MAX_HISTORY_TURNS);
|
|
373
428
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
429
|
+
setAnswer("I wasn't able to finish that — try asking again or breaking it into smaller steps.");
|
|
430
|
+
historyRef.current = [
|
|
431
|
+
...loopHistory,
|
|
432
|
+
{ role: "user", text: q },
|
|
433
|
+
{ role: "assistant", text: "(gave up after too many steps)" },
|
|
434
|
+
].slice(-MAX_HISTORY_TURNS);
|
|
380
435
|
}
|
|
381
436
|
/**
|
|
382
437
|
* The one place that starts audio playback for a spoken response — stops
|
|
@@ -641,7 +696,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
641
696
|
}
|
|
642
697
|
setRtStatus("rt-listening");
|
|
643
698
|
setCaption("");
|
|
644
|
-
sendFreshContext(); // refresh before the user starts talking again, not after
|
|
699
|
+
void sendFreshContext(); // refresh before the user starts talking again, not after
|
|
645
700
|
}
|
|
646
701
|
function disarmThinkingWatchdog() {
|
|
647
702
|
if (rtThinkingWatchdogRef.current) {
|
|
@@ -717,6 +772,25 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
717
772
|
armThinkingWatchdog();
|
|
718
773
|
}
|
|
719
774
|
else if (msg.type === "verb") {
|
|
775
|
+
const parsedStep = (0, core_1.safeParseVerbResponse)(msg.verb);
|
|
776
|
+
if (parsedStep && !core_1.TERMINAL_VERBS.has(parsedStep.verb)) {
|
|
777
|
+
// A continuing agent-loop step (click/fill/read/call_tool) —
|
|
778
|
+
// the turn isn't over: execute it for real and report the
|
|
779
|
+
// result back so the server can decide the next step, instead
|
|
780
|
+
// of treating this like a normal answer (no
|
|
781
|
+
// disarmThinkingWatchdog/handleVerb — those are for when a
|
|
782
|
+
// turn actually ends). Shown visually so a multi-step turn
|
|
783
|
+
// reads as visible progress, not a silent pause; never spoken
|
|
784
|
+
// — the server's loop stays quiet between steps on purpose,
|
|
785
|
+
// to keep it fast.
|
|
786
|
+
setAnswer(summarizeVerbForHistory(msg.verb));
|
|
787
|
+
void (0, verb_executor_1.executeToolStep)(msg.verb, pathnameRef.current, liveMapRef.current).then((result) => {
|
|
788
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
789
|
+
ws.send(JSON.stringify({ type: "tool_result", observation: result?.observation ?? "no result" }));
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
720
794
|
disarmThinkingWatchdog();
|
|
721
795
|
handleVerb(msg.verb);
|
|
722
796
|
}
|
|
@@ -884,6 +958,14 @@ function summarizeVerbForHistory(raw) {
|
|
|
884
958
|
return `(ran ${String(v.action)}${v.target ? ` on ${String(v.target)}` : ""})`;
|
|
885
959
|
case "tour":
|
|
886
960
|
return Array.isArray(v.steps) ? v.steps.map((s) => s.text ?? "").join(" ") : "(tour)";
|
|
961
|
+
case "click":
|
|
962
|
+
return `(clicked ${String(v.target)})`;
|
|
963
|
+
case "fill":
|
|
964
|
+
return `(typed "${String(v.value)}" into ${String(v.target)})`;
|
|
965
|
+
case "read":
|
|
966
|
+
return `(read ${String(v.target)})`;
|
|
967
|
+
case "call_tool":
|
|
968
|
+
return `(called ${String(v.name)})`;
|
|
887
969
|
default:
|
|
888
970
|
return "(no response)";
|
|
889
971
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { WebSocket } from "ws";
|
|
3
|
-
import type
|
|
3
|
+
import { type HistoryTurn, type LiveElement, type Manifest, type WebMcpTool } from "@cairnvibe/core";
|
|
4
4
|
import { createVerbLLM, type CapabilityTier, type CreateCopilotHandlerOptions } from "./server";
|
|
5
5
|
export interface CreateRealtimeServerOptions extends CreateCopilotHandlerOptions {
|
|
6
6
|
manifest: Manifest;
|
|
@@ -23,6 +23,7 @@ export declare function handleDeepgramMessage(raw: string, client: WebSocket, de
|
|
|
23
23
|
route: string;
|
|
24
24
|
visible: string[];
|
|
25
25
|
liveElements: LiveElement[];
|
|
26
|
+
webMcpTools: WebMcpTool[];
|
|
26
27
|
}, speakStreamed: (text: string) => Promise<void>, history: HistoryTurn[], turnState: {
|
|
27
28
|
buffer: string;
|
|
28
|
-
}, getGeneration: () => number): Promise<void>;
|
|
29
|
+
}, getGeneration: () => number, waitForToolResult: () => Promise<string>): Promise<void>;
|
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,16 +74,28 @@ function createRealtimeServer(options) {
|
|
|
73
74
|
return httpServer;
|
|
74
75
|
}
|
|
75
76
|
const MAX_HISTORY_TURNS = 8; // 4 exchanges — enough for "the first one"/"do that instead" without growing the prompt unbounded over a long call
|
|
77
|
+
const MAX_LOOP_ITERATIONS = 6; // a hard cap on one turn's agent-loop steps, not a target — see finalizeTurn
|
|
76
78
|
async function handleConnection(client, deps) {
|
|
77
|
-
// liveElements
|
|
78
|
-
// on route changes and each time it's about to start listening
|
|
79
|
-
// so a live scan from several turns ago never lingers into a
|
|
80
|
-
|
|
79
|
+
// liveElements/webMcpTools refresh on every "context" resend (the client
|
|
80
|
+
// sends one on route changes and each time it's about to start listening
|
|
81
|
+
// again), so a live scan from several turns ago never lingers into a
|
|
82
|
+
// later one.
|
|
83
|
+
let context = {
|
|
84
|
+
route: "/",
|
|
85
|
+
visible: [],
|
|
86
|
+
liveElements: [],
|
|
87
|
+
webMcpTools: [],
|
|
88
|
+
};
|
|
81
89
|
// Unlike the stateless HTTP path (which needs the client to resend
|
|
82
90
|
// history every request), a realtime connection is already stateful —
|
|
83
91
|
// one WebSocket per call — so this is accumulated here directly rather
|
|
84
92
|
// than round-tripped through the client.
|
|
85
93
|
const history = [];
|
|
94
|
+
// Resolves the agent loop's in-flight waitForToolResult() call once the
|
|
95
|
+
// client reports back what a click/fill/read/call_tool step actually
|
|
96
|
+
// did — same "a mutable pending-callback slot, resolved when the right
|
|
97
|
+
// message arrives" pattern onCurrentTurnFlushed already uses below.
|
|
98
|
+
let pendingToolResultResolve = null;
|
|
86
99
|
const dgUrl = `${DEEPGRAM_LIVE_URL}?model=${encodeURIComponent(deps.sttModel)}` +
|
|
87
100
|
`&encoding=linear16&sample_rate=16000&channels=1&interim_results=true&endpointing=300&utterance_end_ms=1000`;
|
|
88
101
|
const dg = new ws_1.WebSocket(dgUrl, { headers: { Authorization: `Token ${deps.deepgramApiKey}` } });
|
|
@@ -159,12 +172,31 @@ async function handleConnection(client, deps) {
|
|
|
159
172
|
stream.flush();
|
|
160
173
|
});
|
|
161
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Pauses the agent loop (finalizeTurn, below) until the client reports
|
|
177
|
+
* back the real result of a click/fill/read/call_tool step it just sent
|
|
178
|
+
* out — the server can't execute a DOM action itself, so every
|
|
179
|
+
* continuing step needs a real round trip to the browser and back. A
|
|
180
|
+
* real timeout, not a hang: a client that never answers (closed tab,
|
|
181
|
+
* dropped connection) can't leave a turn stuck forever.
|
|
182
|
+
*/
|
|
183
|
+
function waitForToolResult() {
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
pendingToolResultResolve = resolve;
|
|
186
|
+
setTimeout(() => {
|
|
187
|
+
if (pendingToolResultResolve === resolve) {
|
|
188
|
+
pendingToolResultResolve = null;
|
|
189
|
+
resolve("(no result — timed out waiting for the browser)");
|
|
190
|
+
}
|
|
191
|
+
}, 15000);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
162
194
|
// Accumulates Deepgram "Results" transcript segments across one utterance
|
|
163
195
|
// — see handleDeepgramMessage for why this can't just react to every
|
|
164
196
|
// is_final.
|
|
165
197
|
const turnState = { buffer: "" };
|
|
166
198
|
dg.on("message", (data) => {
|
|
167
|
-
void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation);
|
|
199
|
+
void handleDeepgramMessage(data.toString(), client, deps, () => context, speakStreamed, history, turnState, () => generation, waitForToolResult);
|
|
168
200
|
});
|
|
169
201
|
dg.on("error", (err) => {
|
|
170
202
|
console.error("[cairn realtime] Deepgram STT connection error:", err);
|
|
@@ -186,8 +218,16 @@ async function handleConnection(client, deps) {
|
|
|
186
218
|
route: String(msg.route ?? "/"),
|
|
187
219
|
visible: Array.isArray(msg.visible) ? msg.visible : [],
|
|
188
220
|
liveElements: parseLiveElements(msg.liveElements),
|
|
221
|
+
webMcpTools: parseWebMcpTools(msg.webMcpTools),
|
|
189
222
|
};
|
|
190
223
|
}
|
|
224
|
+
else if (msg.type === "tool_result" && typeof msg.observation === "string") {
|
|
225
|
+
// The client finished executing a click/fill/read/call_tool step
|
|
226
|
+
// the agent loop sent it — this is what finalizeTurn's
|
|
227
|
+
// waitForToolResult() below is paused on.
|
|
228
|
+
pendingToolResultResolve?.(msg.observation);
|
|
229
|
+
pendingToolResultResolve = null;
|
|
230
|
+
}
|
|
191
231
|
else if (msg.type === "end") {
|
|
192
232
|
client.close();
|
|
193
233
|
}
|
|
@@ -231,7 +271,7 @@ async function handleConnection(client, deps) {
|
|
|
231
271
|
speakStream?.close();
|
|
232
272
|
});
|
|
233
273
|
}
|
|
234
|
-
async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history, turnState, getGeneration) {
|
|
274
|
+
async function handleDeepgramMessage(raw, client, deps, getContext, speakStreamed, history, turnState, getGeneration, waitForToolResult) {
|
|
235
275
|
let msg;
|
|
236
276
|
try {
|
|
237
277
|
msg = JSON.parse(raw);
|
|
@@ -245,7 +285,7 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
|
|
|
245
285
|
// Results message never carries speech_final:true, so a turn can't get
|
|
246
286
|
// permanently stuck with real transcript sitting in the buffer forever.
|
|
247
287
|
if (turnState.buffer)
|
|
248
|
-
await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
|
|
288
|
+
await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult);
|
|
249
289
|
return;
|
|
250
290
|
}
|
|
251
291
|
if (msg.type !== "Results")
|
|
@@ -272,7 +312,7 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
|
|
|
272
312
|
safeSend(client, { type: "interim", text: turnState.buffer });
|
|
273
313
|
return;
|
|
274
314
|
}
|
|
275
|
-
await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration);
|
|
315
|
+
await finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult);
|
|
276
316
|
}
|
|
277
317
|
/**
|
|
278
318
|
* Everything from here on (the LLM call, TTS streaming) can fail in ways
|
|
@@ -290,39 +330,74 @@ async function handleDeepgramMessage(raw, client, deps, getContext, speakStreame
|
|
|
290
330
|
* happens while this turn is still "thinking" bumps the generation, and
|
|
291
331
|
* without this check the now-stale response would still land on the
|
|
292
332
|
* client after the user had already moved on to a new question.
|
|
333
|
+
*
|
|
334
|
+
* A continuing verb (click/fill/read/call_tool — TERMINAL_VERBS says which
|
|
335
|
+
* ones aren't) doesn't end the turn here: the server can't execute a DOM
|
|
336
|
+
* action itself, so it sends the step to the client, awaits its real
|
|
337
|
+
* result over waitForToolResult(), folds that into a *local* working copy
|
|
338
|
+
* of history, and calls resolveVerb again — repeat up to
|
|
339
|
+
* MAX_LOOP_ITERATIONS. The connection's real `history` only gets the
|
|
340
|
+
* user's real question plus the turn's final answer, committed once at
|
|
341
|
+
* the end — a turn that hits the cap mid-loop doesn't leave partial tool
|
|
342
|
+
* noise in the conversation's real memory, same discipline the HTTP
|
|
343
|
+
* path's runTypedAgentLoop (index.tsx) follows.
|
|
293
344
|
*/
|
|
294
|
-
async function finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration) {
|
|
345
|
+
async function finalizeTurn(turnState, client, deps, getContext, speakStreamed, history, getGeneration, waitForToolResult) {
|
|
295
346
|
const transcript = turnState.buffer;
|
|
296
347
|
turnState.buffer = "";
|
|
297
348
|
const myGeneration = getGeneration();
|
|
298
349
|
safeSend(client, { type: "final", text: transcript });
|
|
350
|
+
let loopHistory = history;
|
|
299
351
|
try {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
352
|
+
for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
|
|
353
|
+
const { route, visible, liveElements, webMcpTools } = getContext();
|
|
354
|
+
const verb = await (0, server_1.resolveVerb)(deps.llm, deps.systemPrompt, deps.manifest, deps.registeredActions, deps.capability, {
|
|
355
|
+
route,
|
|
356
|
+
question: transcript,
|
|
357
|
+
visible,
|
|
358
|
+
liveElements,
|
|
359
|
+
webMcpTools,
|
|
360
|
+
history: loopHistory,
|
|
361
|
+
});
|
|
362
|
+
if (myGeneration !== getGeneration())
|
|
363
|
+
return; // superseded by a barge-in while this turn was resolving
|
|
364
|
+
// Sent immediately — before speech synthesis even starts — so
|
|
365
|
+
// highlight/navigate/do execute in the browser right away instead of
|
|
366
|
+
// waiting on audio. The agent visibly acts while it's still about to
|
|
367
|
+
// speak, not after.
|
|
368
|
+
safeSend(client, { type: "verb", verb });
|
|
369
|
+
if (!core_1.TERMINAL_VERBS.has(verb.verb)) {
|
|
370
|
+
// A continuing step — no speech for it (keeps the loop fast;
|
|
371
|
+
// the client still shows it visually) — wait for its real result
|
|
372
|
+
// and go around again instead of ending the turn.
|
|
373
|
+
const observation = await waitForToolResult();
|
|
374
|
+
if (myGeneration !== getGeneration())
|
|
375
|
+
return;
|
|
376
|
+
loopHistory = [
|
|
377
|
+
...loopHistory,
|
|
378
|
+
{ role: "assistant", text: `${summarizeVerbForHistory(verb)}. Result: ${observation}` },
|
|
379
|
+
].slice(-MAX_HISTORY_TURNS);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
history.push({ role: "user", text: transcript }, { role: "assistant", text: summarizeVerbForHistory(verb) });
|
|
383
|
+
history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
|
|
384
|
+
// A verb with no spoken text (highlight/navigate/do often have none)
|
|
385
|
+
// still needs to unstick the client's "thinking" state and let the mic
|
|
386
|
+
// resume — turn_complete covers that with no audio path involved.
|
|
387
|
+
if ("text" in verb && verb.text) {
|
|
388
|
+
await speakStreamed(verb.text);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
safeSend(client, { type: "turn_complete" });
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
325
394
|
}
|
|
395
|
+
// Iteration cap hit with no terminal verb — degrade honestly instead
|
|
396
|
+
// of leaving the client waiting forever.
|
|
397
|
+
history.push({ role: "user", text: transcript }, { role: "assistant", text: "(gave up after too many steps)" });
|
|
398
|
+
history.splice(0, Math.max(0, history.length - MAX_HISTORY_TURNS));
|
|
399
|
+
safeSend(client, { type: "verb", verb: { verb: "explain", text: "I wasn't able to finish that — try asking again or breaking it into smaller steps." } });
|
|
400
|
+
await speakStreamed("I wasn't able to finish that — try asking again or breaking it into smaller steps.");
|
|
326
401
|
}
|
|
327
402
|
catch (err) {
|
|
328
403
|
console.error("[cairn realtime] failed to resolve/speak this turn:", err);
|
|
@@ -358,6 +433,21 @@ function parseLiveElements(raw) {
|
|
|
358
433
|
}
|
|
359
434
|
return elements;
|
|
360
435
|
}
|
|
436
|
+
/** Same defensive shape-check as parseLiveElements, for the client's
|
|
437
|
+
* self-reported WebMCP tool list. */
|
|
438
|
+
function parseWebMcpTools(raw) {
|
|
439
|
+
if (!Array.isArray(raw))
|
|
440
|
+
return [];
|
|
441
|
+
const tools = [];
|
|
442
|
+
for (const entry of raw) {
|
|
443
|
+
if (entry && typeof entry === "object" && typeof entry.name === "string" && typeof entry.description === "string") {
|
|
444
|
+
tools.push({ name: entry.name, description: entry.description, inputSchema: entry.inputSchema });
|
|
445
|
+
}
|
|
446
|
+
if (tools.length >= 30)
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
return tools;
|
|
450
|
+
}
|
|
361
451
|
/** A short text form of any verb for the history log — not shown to the
|
|
362
452
|
* user, just fed back to the model on later turns so it knows what it
|
|
363
453
|
* already did/said. */
|
|
@@ -374,6 +464,14 @@ function summarizeVerbForHistory(verb) {
|
|
|
374
464
|
return `(ran ${verb.action}${verb.target ? ` on ${verb.target}` : ""})`;
|
|
375
465
|
case "tour":
|
|
376
466
|
return verb.steps.map((s) => s.text).join(" ");
|
|
467
|
+
case "click":
|
|
468
|
+
return `(clicked ${verb.target})`;
|
|
469
|
+
case "fill":
|
|
470
|
+
return `(typed "${verb.value}" into ${verb.target})`;
|
|
471
|
+
case "read":
|
|
472
|
+
return `(read ${verb.target})`;
|
|
473
|
+
case "call_tool":
|
|
474
|
+
return `(called ${verb.name})`;
|
|
377
475
|
default:
|
|
378
476
|
return "(no response)";
|
|
379
477
|
}
|
package/dist/runtime-scan.js
CHANGED
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.scanInteractiveElements = scanInteractiveElements;
|
|
13
13
|
exports.createLiveElementRegistry = createLiveElementRegistry;
|
|
14
|
-
|
|
14
|
+
// Clickable elements, plus real fillable form fields (text/email/number/etc
|
|
15
|
+
// inputs, textarea, select — NOT submit/button inputs, already covered by
|
|
16
|
+
// the plain "button" role below) — the agent loop's fill/read steps need
|
|
17
|
+
// these to be discoverable the same way a click target already is.
|
|
18
|
+
const CANDIDATE_SELECTOR = "[data-ai], button, a, [role='button'], input[type='submit'], input[type='button'], " +
|
|
19
|
+
"input:not([type='submit']):not([type='button']):not([type='hidden']), textarea, select";
|
|
15
20
|
const MAX_ELEMENTS = 40;
|
|
16
21
|
const MAX_LABEL_LENGTH = 80;
|
|
17
22
|
const RESCAN_DEBOUNCE_MS = 250;
|
|
@@ -19,13 +24,38 @@ function isInViewport(el) {
|
|
|
19
24
|
const rect = el.getBoundingClientRect();
|
|
20
25
|
return rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth;
|
|
21
26
|
}
|
|
27
|
+
/** A form field's own text content is always empty — its identity comes
|
|
28
|
+
* from an associated <label>, a placeholder, or its name attribute
|
|
29
|
+
* instead, in that order of how a real user would recognize the field. */
|
|
30
|
+
function formFieldLabel(el) {
|
|
31
|
+
if (el.id) {
|
|
32
|
+
const labelled = el.ownerDocument?.querySelector(`label[for="${cssEscapeId(el.id)}"]`);
|
|
33
|
+
if (labelled?.textContent?.trim())
|
|
34
|
+
return labelled.textContent;
|
|
35
|
+
}
|
|
36
|
+
const wrappingLabel = el.closest("label");
|
|
37
|
+
if (wrappingLabel?.textContent?.trim())
|
|
38
|
+
return wrappingLabel.textContent;
|
|
39
|
+
return el.getAttribute("placeholder") || el.getAttribute("name") || "";
|
|
40
|
+
}
|
|
41
|
+
function cssEscapeId(id) {
|
|
42
|
+
return id.replace(/["\\]/g, "\\$&");
|
|
43
|
+
}
|
|
44
|
+
// tagName, not instanceof — see element-ladder.ts's isFormField for why.
|
|
45
|
+
function isFormField(el) {
|
|
46
|
+
return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
|
|
47
|
+
}
|
|
22
48
|
function labelFor(el) {
|
|
23
|
-
const raw = el.getAttribute("aria-label") || el.textContent || "";
|
|
49
|
+
const raw = el.getAttribute("aria-label") || (isFormField(el) ? formFieldLabel(el) : el.textContent) || "";
|
|
24
50
|
const trimmed = raw.replace(/\s+/g, " ").trim();
|
|
25
51
|
return trimmed.length > MAX_LABEL_LENGTH ? `${trimmed.slice(0, MAX_LABEL_LENGTH - 1)}…` : trimmed;
|
|
26
52
|
}
|
|
27
53
|
function roleFor(el) {
|
|
28
|
-
|
|
54
|
+
if (el.getAttribute("role"))
|
|
55
|
+
return el.getAttribute("role");
|
|
56
|
+
if (isFormField(el))
|
|
57
|
+
return "input";
|
|
58
|
+
return el.tagName.toLowerCase();
|
|
29
59
|
}
|
|
30
60
|
/**
|
|
31
61
|
* Scans the live DOM for interactive elements currently in the viewport.
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type HistoryTurn, type LiveElement, type Manifest, type VerbResponse } from "@cairnvibe/core";
|
|
1
|
+
import { type HistoryTurn, type LiveElement, type Manifest, type VerbResponse, type WebMcpTool } from "@cairnvibe/core";
|
|
2
2
|
import { KeyRotator } from "./key-rotator";
|
|
3
3
|
/**
|
|
4
4
|
* What the agent is allowed to do, independent of which specific "do"
|
|
@@ -65,6 +65,7 @@ export declare function resolveVerb(llm: VerbLLM, systemPrompt: string, manifest
|
|
|
65
65
|
visible: string[];
|
|
66
66
|
history?: HistoryTurn[];
|
|
67
67
|
liveElements?: LiveElement[];
|
|
68
|
+
webMcpTools?: WebMcpTool[];
|
|
68
69
|
}): Promise<VerbResponse>;
|
|
69
70
|
/** Builds the provider-appropriate VerbLLM from the same options createCopilotHandler accepts — reused by the realtime relay. */
|
|
70
71
|
export declare function createVerbLLM(options?: CreateCopilotHandlerOptions): VerbLLM;
|