@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/src/index.tsx CHANGED
@@ -16,11 +16,12 @@ import {
16
16
  VolumeX,
17
17
  X,
18
18
  } from "lucide-react";
19
- import type { HistoryTurn as HistoryEntry, TourStep } from "@cairnvibe/core";
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 { executeVerbResponse } from "./verb-executor";
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
- const activeAudioRef = useRef<HTMLAudioElement | null>(null);
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({ type: "context", route: pathnameRef.current, visible: collectVisible(), liveElements: liveScan.elements }),
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: historyRef.current,
467
+ history: loopHistory,
420
468
  liveElements: liveScan.elements,
469
+ webMcpTools,
421
470
  }),
422
471
  });
423
472
  const data = await res.json().catch(() => null);
424
- handleVerb(data);
425
- // Unlike the realtime relay (one persistent connection, memory lives
426
- // server-side), each of these POSTs is stateless — the widget itself
427
- // is what remembers, and resends it above so the model has context
428
- // for "the first one" / "do that instead" on the next question.
429
- historyRef.current = [
430
- ...historyRef.current,
431
- { role: "user", text: q } satisfies HistoryEntry,
432
- { role: "assistant", text: summarizeVerbForHistory(data) } satisfies HistoryEntry,
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
- * The one place that starts audio playback for a spoken response — stops
443
- * whatever's currently playing first, so two responses (e.g. a rapid
444
- * double-click on "start conversation", or two utterances resolved close
445
- * together) can never be heard overlapping. Used by both the typed/mic
446
- * path and the realtime path.
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 playResponseAudio(blob: Blob): Promise<void> {
449
- if (activeAudioRef.current) {
450
- activeAudioRef.current.pause();
451
- activeAudioRef.current.currentTime = 0;
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
- const clear = () => {
458
- URL.revokeObjectURL(url);
459
- if (activeAudioRef.current === audio) activeAudioRef.current = null;
460
- resolve();
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
- audio.onended = clear;
463
- audio.play().catch(clear);
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 playResponseAudio(await res.blob());
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 playResponseAudio(await res.blob());
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
- activeAudioRef.current?.pause();
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
  }