@cairnvibe/sdk 0.2.7 → 0.2.9
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 +212 -45
- package/dist/realtime-server.d.ts +3 -2
- package/dist/realtime-server.js +171 -33
- package/dist/runtime-scan.js +33 -3
- package/dist/server.d.ts +3 -1
- package/dist/server.js +110 -14
- package/dist/speak-server.d.ts +13 -3
- package/dist/speak-server.js +79 -23
- 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 +221 -46
- package/src/realtime-server.ts +172 -33
- package/src/runtime-scan.ts +33 -3
- package/src/server.ts +119 -15
- package/src/speak-server.ts +99 -23
- package/src/verb-executor.ts +104 -1
- package/src/webmcp-client.ts +79 -0
package/src/index.tsx
CHANGED
|
@@ -16,11 +16,12 @@ import {
|
|
|
16
16
|
VolumeX,
|
|
17
17
|
X,
|
|
18
18
|
} from "lucide-react";
|
|
19
|
-
import type
|
|
19
|
+
import { TERMINAL_VERBS, safeParseVerbResponse, type HistoryTurn as HistoryEntry, type TourStep } from "@cairnvibe/core";
|
|
20
20
|
import { collectVisible } from "./context-collector";
|
|
21
21
|
import { findElement, highlightElement, logMiss, type MissContext } from "./element-ladder";
|
|
22
22
|
import { createLiveElementRegistry } from "./runtime-scan";
|
|
23
|
-
import {
|
|
23
|
+
import { discoverWebMcpTools } from "./webmcp-client";
|
|
24
|
+
import { executeToolStep, executeVerbResponse } from "./verb-executor";
|
|
24
25
|
|
|
25
26
|
export interface CopilotProps {
|
|
26
27
|
/** Reserved for a future client-side manifest fetch. Not required — the server handler owns the manifest. */
|
|
@@ -74,7 +75,7 @@ export function Copilot({
|
|
|
74
75
|
const pathnameRef = useRef(pathname);
|
|
75
76
|
useEffect(() => {
|
|
76
77
|
pathnameRef.current = pathname;
|
|
77
|
-
sendFreshContext(); // no-op if no realtime session is open
|
|
78
|
+
void sendFreshContext(); // no-op if no realtime session is open
|
|
78
79
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
79
80
|
}, [pathname]);
|
|
80
81
|
const router = useRouter();
|
|
@@ -160,7 +161,18 @@ export function Copilot({
|
|
|
160
161
|
const rtMicMutedRef = useRef(false);
|
|
161
162
|
const rtSpeakerMutedRef = useRef(false);
|
|
162
163
|
const rtStartingRef = useRef(false); // closes the click-to-first-state-update gap so a rapid double-click can't open two sessions
|
|
163
|
-
|
|
164
|
+
// Progressive PCM playback for the buffered (non-realtime) speak endpoint
|
|
165
|
+
// — the same gapless AudioBufferSourceNode scheduling the realtime path
|
|
166
|
+
// uses for its audio_chunk messages (see rtPlaybackCtxRef below), just fed
|
|
167
|
+
// by a fetch() ReadableStream instead of WebSocket messages. This exists
|
|
168
|
+
// because res.blob()/res.arrayBuffer() always wait for the whole response
|
|
169
|
+
// body in every browser no matter how the server sent it — streaming the
|
|
170
|
+
// wire alone (speak-server.ts) doesn't help unless playback also starts
|
|
171
|
+
// before the full reply has arrived.
|
|
172
|
+
const typedPlaybackCtxRef = useRef<AudioContext | null>(null);
|
|
173
|
+
const typedPlaybackGainRef = useRef<GainNode | null>(null);
|
|
174
|
+
const typedNextPlayTimeRef = useRef(0);
|
|
175
|
+
const typedScheduledSourcesRef = useRef<AudioBufferSourceNode[]>([]);
|
|
164
176
|
// Watchdog for the "rt-thinking" state: started on every "final" transcript,
|
|
165
177
|
// cleared the moment the server responds with anything for that turn
|
|
166
178
|
// (verb/speaking_start/speaking_end/turn_complete/error). If it ever
|
|
@@ -258,13 +270,21 @@ export function Copilot({
|
|
|
258
270
|
* `pathname`, so it's correct even called from a handler created once at
|
|
259
271
|
* connection-open time.
|
|
260
272
|
*/
|
|
261
|
-
function sendFreshContext() {
|
|
273
|
+
async function sendFreshContext() {
|
|
262
274
|
const ws = rtSocketRef.current;
|
|
263
275
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
264
276
|
const liveScan = liveRegistryRef.current.getSnapshot();
|
|
265
277
|
liveMapRef.current = liveScan.byId;
|
|
278
|
+
const webMcpTools = await discoverWebMcpTools();
|
|
279
|
+
if (ws.readyState !== WebSocket.OPEN) return; // may have closed while awaiting discovery
|
|
266
280
|
ws.send(
|
|
267
|
-
JSON.stringify({
|
|
281
|
+
JSON.stringify({
|
|
282
|
+
type: "context",
|
|
283
|
+
route: pathnameRef.current,
|
|
284
|
+
visible: collectVisible(),
|
|
285
|
+
liveElements: liveScan.elements,
|
|
286
|
+
webMcpTools,
|
|
287
|
+
}),
|
|
268
288
|
);
|
|
269
289
|
}
|
|
270
290
|
|
|
@@ -407,6 +427,34 @@ export function Copilot({
|
|
|
407
427
|
setLastQuestion(q);
|
|
408
428
|
setQuestion("");
|
|
409
429
|
try {
|
|
430
|
+
await runTypedAgentLoop(q);
|
|
431
|
+
} catch {
|
|
432
|
+
setAnswer("Something went wrong reaching the help service — try again in a moment.");
|
|
433
|
+
} finally {
|
|
434
|
+
setStatus("idle");
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const MAX_LOOP_ITERATIONS = 6; // a hard cap, not a target — see runTypedAgentLoop
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Drives the agent loop over the stateless HTTP path: ask the server,
|
|
442
|
+
* and if it comes back with a continuing step (click/fill/read/
|
|
443
|
+
* call_tool — TERMINAL_VERBS in @cairnvibe/core says which verbs end a
|
|
444
|
+
* turn), execute that step for real, fold the real result into this
|
|
445
|
+
* turn's own working history, and ask again — repeat until a terminal
|
|
446
|
+
* verb or the iteration cap, instead of the old one-call-one-answer
|
|
447
|
+
* shape. `question` stays the original ask on every call; only
|
|
448
|
+
* `history` grows with each step's real trace, so the model always
|
|
449
|
+
* still knows what it was actually asked. `historyRef` (the
|
|
450
|
+
* conversation's real memory) is only ever committed once, at the end —
|
|
451
|
+
* a turn that hits the cap mid-loop doesn't leave partial noise in it.
|
|
452
|
+
*/
|
|
453
|
+
async function runTypedAgentLoop(q: string): Promise<void> {
|
|
454
|
+
let loopHistory = historyRef.current;
|
|
455
|
+
const webMcpTools = await discoverWebMcpTools();
|
|
456
|
+
|
|
457
|
+
for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
|
|
410
458
|
const liveScan = liveRegistryRef.current.getSnapshot();
|
|
411
459
|
liveMapRef.current = liveScan.byId;
|
|
412
460
|
const res = await fetch(endpoint, {
|
|
@@ -416,51 +464,152 @@ export function Copilot({
|
|
|
416
464
|
route: pathname,
|
|
417
465
|
question: q,
|
|
418
466
|
visible: collectVisible(),
|
|
419
|
-
history:
|
|
467
|
+
history: loopHistory,
|
|
420
468
|
liveElements: liveScan.elements,
|
|
469
|
+
webMcpTools,
|
|
421
470
|
}),
|
|
422
471
|
});
|
|
423
472
|
const data = await res.json().catch(() => null);
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
473
|
+
const parsed = safeParseVerbResponse(data);
|
|
474
|
+
|
|
475
|
+
if (!parsed || TERMINAL_VERBS.has(parsed.verb)) {
|
|
476
|
+
handleVerb(data);
|
|
477
|
+
// Unlike the realtime relay (one persistent connection, memory
|
|
478
|
+
// lives server-side), each of these POSTs is stateless — the
|
|
479
|
+
// widget itself is what remembers, and resends it above so the
|
|
480
|
+
// model has context for "the first one" / "do that instead" on
|
|
481
|
+
// the next question.
|
|
482
|
+
historyRef.current = [
|
|
483
|
+
...loopHistory,
|
|
484
|
+
{ role: "user", text: q } satisfies HistoryEntry,
|
|
485
|
+
{ role: "assistant", text: summarizeVerbForHistory(data) } satisfies HistoryEntry,
|
|
486
|
+
].slice(-MAX_HISTORY_TURNS);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// A continuing step — show it happening, execute it for real, and
|
|
491
|
+
// go around again with the real result instead of ending the turn.
|
|
492
|
+
setAnswer(summarizeVerbForHistory(data));
|
|
493
|
+
const stepResult = await executeToolStep(data, pathname, liveMapRef.current);
|
|
494
|
+
loopHistory = [
|
|
495
|
+
...loopHistory,
|
|
496
|
+
{
|
|
497
|
+
role: "assistant",
|
|
498
|
+
text: `${summarizeVerbForHistory(data)}. Result: ${stepResult?.observation ?? "no result"}`,
|
|
499
|
+
} satisfies HistoryEntry,
|
|
433
500
|
].slice(-MAX_HISTORY_TURNS);
|
|
434
|
-
} catch {
|
|
435
|
-
setAnswer("Something went wrong reaching the help service — try again in a moment.");
|
|
436
|
-
} finally {
|
|
437
|
-
setStatus("idle");
|
|
438
501
|
}
|
|
502
|
+
|
|
503
|
+
setAnswer("I wasn't able to finish that — try asking again or breaking it into smaller steps.");
|
|
504
|
+
historyRef.current = [
|
|
505
|
+
...loopHistory,
|
|
506
|
+
{ role: "user", text: q } satisfies HistoryEntry,
|
|
507
|
+
{ role: "assistant", text: "(gave up after too many steps)" } satisfies HistoryEntry,
|
|
508
|
+
].slice(-MAX_HISTORY_TURNS);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function ensureTypedPlaybackGraph(): { ctx: AudioContext; gain: GainNode } {
|
|
512
|
+
if (!typedPlaybackCtxRef.current) {
|
|
513
|
+
const ctx = new AudioContext();
|
|
514
|
+
const gain = ctx.createGain();
|
|
515
|
+
gain.connect(ctx.destination);
|
|
516
|
+
typedPlaybackCtxRef.current = ctx;
|
|
517
|
+
typedPlaybackGainRef.current = gain;
|
|
518
|
+
}
|
|
519
|
+
return { ctx: typedPlaybackCtxRef.current, gain: typedPlaybackGainRef.current! };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Stops whatever's currently playing on the typed/mic path's playback
|
|
523
|
+
* graph, so two responses (e.g. a rapid double-click, or two answers
|
|
524
|
+
* resolved close together) can never be heard overlapping. */
|
|
525
|
+
function stopTypedPlayback() {
|
|
526
|
+
for (const source of typedScheduledSourcesRef.current) {
|
|
527
|
+
source.onended = null;
|
|
528
|
+
try {
|
|
529
|
+
source.stop();
|
|
530
|
+
} catch {
|
|
531
|
+
// may already have finished naturally
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
typedScheduledSourcesRef.current = [];
|
|
535
|
+
typedNextPlayTimeRef.current = typedPlaybackCtxRef.current?.currentTime ?? 0;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
539
|
+
const out = new Uint8Array(a.length + b.length);
|
|
540
|
+
out.set(a, 0);
|
|
541
|
+
out.set(b, a.length);
|
|
542
|
+
return out;
|
|
439
543
|
}
|
|
440
544
|
|
|
441
545
|
/**
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
546
|
+
* Reads a raw linear16 PCM stream (mono, 24kHz — matches speak-server.ts)
|
|
547
|
+
* and schedules it gapless-appended into the Web Audio graph as chunks
|
|
548
|
+
* arrive — the same technique the realtime path uses for its audio_chunk
|
|
549
|
+
* messages, just driven by a fetch() reader instead of WebSocket frames.
|
|
550
|
+
* Resolves once every scheduled chunk has actually finished *playing*,
|
|
551
|
+
* not just finished arriving.
|
|
447
552
|
*/
|
|
448
|
-
function
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const url = URL.createObjectURL(blob);
|
|
454
|
-
const audio = new Audio(url);
|
|
455
|
-
activeAudioRef.current = audio;
|
|
553
|
+
function playPcmStream(stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
554
|
+
stopTypedPlayback();
|
|
555
|
+
const { ctx, gain } = ensureTypedPlaybackGraph();
|
|
556
|
+
void ctx.resume().catch(() => {});
|
|
557
|
+
|
|
456
558
|
return new Promise((resolve) => {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
559
|
+
let doneArriving = false;
|
|
560
|
+
let leftover: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
|
561
|
+
|
|
562
|
+
const maybeResolve = () => {
|
|
563
|
+
if (doneArriving && typedScheduledSourcesRef.current.length === 0) resolve();
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const scheduleChunk = (bytes: Uint8Array) => {
|
|
567
|
+
const sampleCount = Math.floor(bytes.length / 2);
|
|
568
|
+
if (sampleCount === 0) return;
|
|
569
|
+
const float32 = new Float32Array(sampleCount);
|
|
570
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, sampleCount * 2);
|
|
571
|
+
for (let i = 0; i < sampleCount; i++) float32[i] = view.getInt16(i * 2, true) / 32768;
|
|
572
|
+
|
|
573
|
+
const buffer = ctx.createBuffer(1, sampleCount, 24000);
|
|
574
|
+
buffer.copyToChannel(float32, 0);
|
|
575
|
+
|
|
576
|
+
const source = ctx.createBufferSource();
|
|
577
|
+
source.buffer = buffer;
|
|
578
|
+
source.connect(gain);
|
|
579
|
+
|
|
580
|
+
const startAt = Math.max(ctx.currentTime, typedNextPlayTimeRef.current);
|
|
581
|
+
source.start(startAt);
|
|
582
|
+
typedNextPlayTimeRef.current = startAt + buffer.duration;
|
|
583
|
+
|
|
584
|
+
typedScheduledSourcesRef.current.push(source);
|
|
585
|
+
source.onended = () => {
|
|
586
|
+
typedScheduledSourcesRef.current = typedScheduledSourcesRef.current.filter((s) => s !== source);
|
|
587
|
+
maybeResolve();
|
|
588
|
+
};
|
|
461
589
|
};
|
|
462
|
-
|
|
463
|
-
|
|
590
|
+
|
|
591
|
+
(async () => {
|
|
592
|
+
const reader = stream.getReader();
|
|
593
|
+
try {
|
|
594
|
+
for (;;) {
|
|
595
|
+
const { done, value } = await reader.read();
|
|
596
|
+
if (done) break;
|
|
597
|
+
if (!value || value.length === 0) continue;
|
|
598
|
+
// PCM16 samples are 2 bytes each — a chunk boundary can split a
|
|
599
|
+
// sample in half, so carry any odd trailing byte into the next
|
|
600
|
+
// read instead of corrupting one sample at every chunk seam.
|
|
601
|
+
const combined = concatBytes(leftover, value);
|
|
602
|
+
const usableLen = combined.length - (combined.length % 2);
|
|
603
|
+
scheduleChunk(combined.subarray(0, usableLen));
|
|
604
|
+
leftover = combined.subarray(usableLen);
|
|
605
|
+
}
|
|
606
|
+
} catch {
|
|
607
|
+
// Best-effort — never let a stream read failure hang the caller forever.
|
|
608
|
+
} finally {
|
|
609
|
+
doneArriving = true;
|
|
610
|
+
maybeResolve();
|
|
611
|
+
}
|
|
612
|
+
})();
|
|
464
613
|
});
|
|
465
614
|
}
|
|
466
615
|
|
|
@@ -472,8 +621,8 @@ export function Copilot({
|
|
|
472
621
|
headers: { "content-type": "application/json" },
|
|
473
622
|
body: JSON.stringify({ text }),
|
|
474
623
|
});
|
|
475
|
-
if (!res.ok) return;
|
|
476
|
-
void
|
|
624
|
+
if (!res.ok || !res.body) return;
|
|
625
|
+
void playPcmStream(res.body);
|
|
477
626
|
} catch {
|
|
478
627
|
// Best-effort — never let speech playback break the widget.
|
|
479
628
|
}
|
|
@@ -490,8 +639,8 @@ export function Copilot({
|
|
|
490
639
|
headers: { "content-type": "application/json" },
|
|
491
640
|
body: JSON.stringify({ text }),
|
|
492
641
|
});
|
|
493
|
-
if (!res.ok) return;
|
|
494
|
-
await
|
|
642
|
+
if (!res.ok || !res.body) return;
|
|
643
|
+
await playPcmStream(res.body);
|
|
495
644
|
} catch {
|
|
496
645
|
// Best-effort — never let a synthesis failure hang the tour forever.
|
|
497
646
|
}
|
|
@@ -692,7 +841,7 @@ export function Copilot({
|
|
|
692
841
|
}
|
|
693
842
|
setRtStatus("rt-listening");
|
|
694
843
|
setCaption("");
|
|
695
|
-
sendFreshContext(); // refresh before the user starts talking again, not after
|
|
844
|
+
void sendFreshContext(); // refresh before the user starts talking again, not after
|
|
696
845
|
}
|
|
697
846
|
|
|
698
847
|
function disarmThinkingWatchdog() {
|
|
@@ -769,6 +918,25 @@ export function Copilot({
|
|
|
769
918
|
setRtStatus("rt-thinking");
|
|
770
919
|
armThinkingWatchdog();
|
|
771
920
|
} else if (msg.type === "verb") {
|
|
921
|
+
const parsedStep = safeParseVerbResponse(msg.verb);
|
|
922
|
+
if (parsedStep && !TERMINAL_VERBS.has(parsedStep.verb)) {
|
|
923
|
+
// A continuing agent-loop step (click/fill/read/call_tool) —
|
|
924
|
+
// the turn isn't over: execute it for real and report the
|
|
925
|
+
// result back so the server can decide the next step, instead
|
|
926
|
+
// of treating this like a normal answer (no
|
|
927
|
+
// disarmThinkingWatchdog/handleVerb — those are for when a
|
|
928
|
+
// turn actually ends). Shown visually so a multi-step turn
|
|
929
|
+
// reads as visible progress, not a silent pause; never spoken
|
|
930
|
+
// — the server's loop stays quiet between steps on purpose,
|
|
931
|
+
// to keep it fast.
|
|
932
|
+
setAnswer(summarizeVerbForHistory(msg.verb));
|
|
933
|
+
void executeToolStep(msg.verb, pathnameRef.current, liveMapRef.current).then((result) => {
|
|
934
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
935
|
+
ws.send(JSON.stringify({ type: "tool_result", observation: result?.observation ?? "no result" }));
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
772
940
|
disarmThinkingWatchdog();
|
|
773
941
|
handleVerb(msg.verb);
|
|
774
942
|
} else if (msg.type === "speaking_start") {
|
|
@@ -860,8 +1028,7 @@ export function Copilot({
|
|
|
860
1028
|
rtThinkingWatchdogRef.current = null;
|
|
861
1029
|
}
|
|
862
1030
|
rtStartingRef.current = false;
|
|
863
|
-
|
|
864
|
-
activeAudioRef.current = null;
|
|
1031
|
+
stopTypedPlayback();
|
|
865
1032
|
rtSocketRef.current?.close();
|
|
866
1033
|
rtSocketRef.current = null;
|
|
867
1034
|
rtCleanupRef.current?.();
|
|
@@ -1072,6 +1239,14 @@ function summarizeVerbForHistory(raw: unknown): string {
|
|
|
1072
1239
|
return `(ran ${String(v.action)}${v.target ? ` on ${String(v.target)}` : ""})`;
|
|
1073
1240
|
case "tour":
|
|
1074
1241
|
return Array.isArray(v.steps) ? v.steps.map((s: { text?: string }) => s.text ?? "").join(" ") : "(tour)";
|
|
1242
|
+
case "click":
|
|
1243
|
+
return `(clicked ${String(v.target)})`;
|
|
1244
|
+
case "fill":
|
|
1245
|
+
return `(typed "${String(v.value)}" into ${String(v.target)})`;
|
|
1246
|
+
case "read":
|
|
1247
|
+
return `(read ${String(v.target)})`;
|
|
1248
|
+
case "call_tool":
|
|
1249
|
+
return `(called ${String(v.name)})`;
|
|
1075
1250
|
default:
|
|
1076
1251
|
return "(no response)";
|
|
1077
1252
|
}
|