@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/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)();
@@ -104,7 +106,18 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
104
106
  const rtMicMutedRef = (0, react_1.useRef)(false);
105
107
  const rtSpeakerMutedRef = (0, react_1.useRef)(false);
106
108
  const rtStartingRef = (0, react_1.useRef)(false); // closes the click-to-first-state-update gap so a rapid double-click can't open two sessions
107
- const activeAudioRef = (0, react_1.useRef)(null);
109
+ // Progressive PCM playback for the buffered (non-realtime) speak endpoint
110
+ // — the same gapless AudioBufferSourceNode scheduling the realtime path
111
+ // uses for its audio_chunk messages (see rtPlaybackCtxRef below), just fed
112
+ // by a fetch() ReadableStream instead of WebSocket messages. This exists
113
+ // because res.blob()/res.arrayBuffer() always wait for the whole response
114
+ // body in every browser no matter how the server sent it — streaming the
115
+ // wire alone (speak-server.ts) doesn't help unless playback also starts
116
+ // before the full reply has arrived.
117
+ const typedPlaybackCtxRef = (0, react_1.useRef)(null);
118
+ const typedPlaybackGainRef = (0, react_1.useRef)(null);
119
+ const typedNextPlayTimeRef = (0, react_1.useRef)(0);
120
+ const typedScheduledSourcesRef = (0, react_1.useRef)([]);
108
121
  // Watchdog for the "rt-thinking" state: started on every "final" transcript,
109
122
  // cleared the moment the server responds with anything for that turn
110
123
  // (verb/speaking_start/speaking_end/turn_complete/error). If it ever
@@ -194,13 +207,22 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
194
207
  * `pathname`, so it's correct even called from a handler created once at
195
208
  * connection-open time.
196
209
  */
197
- function sendFreshContext() {
210
+ async function sendFreshContext() {
198
211
  const ws = rtSocketRef.current;
199
212
  if (!ws || ws.readyState !== WebSocket.OPEN)
200
213
  return;
201
214
  const liveScan = liveRegistryRef.current.getSnapshot();
202
215
  liveMapRef.current = liveScan.byId;
203
- ws.send(JSON.stringify({ type: "context", route: pathnameRef.current, visible: (0, context_collector_1.collectVisible)(), liveElements: liveScan.elements }));
216
+ const webMcpTools = await (0, webmcp_client_1.discoverWebMcpTools)();
217
+ if (ws.readyState !== WebSocket.OPEN)
218
+ return; // may have closed while awaiting discovery
219
+ ws.send(JSON.stringify({
220
+ type: "context",
221
+ route: pathnameRef.current,
222
+ visible: (0, context_collector_1.collectVisible)(),
223
+ liveElements: liveScan.elements,
224
+ webMcpTools,
225
+ }));
204
226
  }
205
227
  function reportMiss(context) {
206
228
  (0, element_ladder_1.logMiss)(context);
@@ -346,6 +368,33 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
346
368
  setLastQuestion(q);
347
369
  setQuestion("");
348
370
  try {
371
+ await runTypedAgentLoop(q);
372
+ }
373
+ catch {
374
+ setAnswer("Something went wrong reaching the help service — try again in a moment.");
375
+ }
376
+ finally {
377
+ setStatus("idle");
378
+ }
379
+ }
380
+ const MAX_LOOP_ITERATIONS = 6; // a hard cap, not a target — see runTypedAgentLoop
381
+ /**
382
+ * Drives the agent loop over the stateless HTTP path: ask the server,
383
+ * and if it comes back with a continuing step (click/fill/read/
384
+ * call_tool — TERMINAL_VERBS in @cairnvibe/core says which verbs end a
385
+ * turn), execute that step for real, fold the real result into this
386
+ * turn's own working history, and ask again — repeat until a terminal
387
+ * verb or the iteration cap, instead of the old one-call-one-answer
388
+ * shape. `question` stays the original ask on every call; only
389
+ * `history` grows with each step's real trace, so the model always
390
+ * still knows what it was actually asked. `historyRef` (the
391
+ * conversation's real memory) is only ever committed once, at the end —
392
+ * a turn that hits the cap mid-loop doesn't leave partial noise in it.
393
+ */
394
+ async function runTypedAgentLoop(q) {
395
+ let loopHistory = historyRef.current;
396
+ const webMcpTools = await (0, webmcp_client_1.discoverWebMcpTools)();
397
+ for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
349
398
  const liveScan = liveRegistryRef.current.getSnapshot();
350
399
  liveMapRef.current = liveScan.byId;
351
400
  const res = await fetch(endpoint, {
@@ -355,53 +404,145 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
355
404
  route: pathname,
356
405
  question: q,
357
406
  visible: (0, context_collector_1.collectVisible)(),
358
- history: historyRef.current,
407
+ history: loopHistory,
359
408
  liveElements: liveScan.elements,
409
+ webMcpTools,
360
410
  }),
361
411
  });
362
412
  const data = await res.json().catch(() => null);
363
- handleVerb(data);
364
- // Unlike the realtime relay (one persistent connection, memory lives
365
- // server-side), each of these POSTs is stateless — the widget itself
366
- // is what remembers, and resends it above so the model has context
367
- // for "the first one" / "do that instead" on the next question.
368
- historyRef.current = [
369
- ...historyRef.current,
370
- { role: "user", text: q },
371
- { role: "assistant", text: summarizeVerbForHistory(data) },
413
+ const parsed = (0, core_1.safeParseVerbResponse)(data);
414
+ if (!parsed || core_1.TERMINAL_VERBS.has(parsed.verb)) {
415
+ handleVerb(data);
416
+ // Unlike the realtime relay (one persistent connection, memory
417
+ // lives server-side), each of these POSTs is stateless the
418
+ // widget itself is what remembers, and resends it above so the
419
+ // model has context for "the first one" / "do that instead" on
420
+ // the next question.
421
+ historyRef.current = [
422
+ ...loopHistory,
423
+ { role: "user", text: q },
424
+ { role: "assistant", text: summarizeVerbForHistory(data) },
425
+ ].slice(-MAX_HISTORY_TURNS);
426
+ return;
427
+ }
428
+ // A continuing step — show it happening, execute it for real, and
429
+ // go around again with the real result instead of ending the turn.
430
+ setAnswer(summarizeVerbForHistory(data));
431
+ const stepResult = await (0, verb_executor_1.executeToolStep)(data, pathname, liveMapRef.current);
432
+ loopHistory = [
433
+ ...loopHistory,
434
+ {
435
+ role: "assistant",
436
+ text: `${summarizeVerbForHistory(data)}. Result: ${stepResult?.observation ?? "no result"}`,
437
+ },
372
438
  ].slice(-MAX_HISTORY_TURNS);
373
439
  }
374
- catch {
375
- setAnswer("Something went wrong reaching the help service — try again in a moment.");
440
+ setAnswer("I wasn't able to finish that — try asking again or breaking it into smaller steps.");
441
+ historyRef.current = [
442
+ ...loopHistory,
443
+ { role: "user", text: q },
444
+ { role: "assistant", text: "(gave up after too many steps)" },
445
+ ].slice(-MAX_HISTORY_TURNS);
446
+ }
447
+ function ensureTypedPlaybackGraph() {
448
+ if (!typedPlaybackCtxRef.current) {
449
+ const ctx = new AudioContext();
450
+ const gain = ctx.createGain();
451
+ gain.connect(ctx.destination);
452
+ typedPlaybackCtxRef.current = ctx;
453
+ typedPlaybackGainRef.current = gain;
376
454
  }
377
- finally {
378
- setStatus("idle");
455
+ return { ctx: typedPlaybackCtxRef.current, gain: typedPlaybackGainRef.current };
456
+ }
457
+ /** Stops whatever's currently playing on the typed/mic path's playback
458
+ * graph, so two responses (e.g. a rapid double-click, or two answers
459
+ * resolved close together) can never be heard overlapping. */
460
+ function stopTypedPlayback() {
461
+ for (const source of typedScheduledSourcesRef.current) {
462
+ source.onended = null;
463
+ try {
464
+ source.stop();
465
+ }
466
+ catch {
467
+ // may already have finished naturally
468
+ }
379
469
  }
470
+ typedScheduledSourcesRef.current = [];
471
+ typedNextPlayTimeRef.current = typedPlaybackCtxRef.current?.currentTime ?? 0;
472
+ }
473
+ function concatBytes(a, b) {
474
+ const out = new Uint8Array(a.length + b.length);
475
+ out.set(a, 0);
476
+ out.set(b, a.length);
477
+ return out;
380
478
  }
381
479
  /**
382
- * The one place that starts audio playback for a spoken response — stops
383
- * whatever's currently playing first, so two responses (e.g. a rapid
384
- * double-click on "start conversation", or two utterances resolved close
385
- * together) can never be heard overlapping. Used by both the typed/mic
386
- * path and the realtime path.
480
+ * Reads a raw linear16 PCM stream (mono, 24kHz matches speak-server.ts)
481
+ * and schedules it gapless-appended into the Web Audio graph as chunks
482
+ * arrive the same technique the realtime path uses for its audio_chunk
483
+ * messages, just driven by a fetch() reader instead of WebSocket frames.
484
+ * Resolves once every scheduled chunk has actually finished *playing*,
485
+ * not just finished arriving.
387
486
  */
388
- function playResponseAudio(blob) {
389
- if (activeAudioRef.current) {
390
- activeAudioRef.current.pause();
391
- activeAudioRef.current.currentTime = 0;
392
- }
393
- const url = URL.createObjectURL(blob);
394
- const audio = new Audio(url);
395
- activeAudioRef.current = audio;
487
+ function playPcmStream(stream) {
488
+ stopTypedPlayback();
489
+ const { ctx, gain } = ensureTypedPlaybackGraph();
490
+ void ctx.resume().catch(() => { });
396
491
  return new Promise((resolve) => {
397
- const clear = () => {
398
- URL.revokeObjectURL(url);
399
- if (activeAudioRef.current === audio)
400
- activeAudioRef.current = null;
401
- resolve();
492
+ let doneArriving = false;
493
+ let leftover = new Uint8Array(0);
494
+ const maybeResolve = () => {
495
+ if (doneArriving && typedScheduledSourcesRef.current.length === 0)
496
+ resolve();
402
497
  };
403
- audio.onended = clear;
404
- audio.play().catch(clear);
498
+ const scheduleChunk = (bytes) => {
499
+ const sampleCount = Math.floor(bytes.length / 2);
500
+ if (sampleCount === 0)
501
+ return;
502
+ const float32 = new Float32Array(sampleCount);
503
+ const view = new DataView(bytes.buffer, bytes.byteOffset, sampleCount * 2);
504
+ for (let i = 0; i < sampleCount; i++)
505
+ float32[i] = view.getInt16(i * 2, true) / 32768;
506
+ const buffer = ctx.createBuffer(1, sampleCount, 24000);
507
+ buffer.copyToChannel(float32, 0);
508
+ const source = ctx.createBufferSource();
509
+ source.buffer = buffer;
510
+ source.connect(gain);
511
+ const startAt = Math.max(ctx.currentTime, typedNextPlayTimeRef.current);
512
+ source.start(startAt);
513
+ typedNextPlayTimeRef.current = startAt + buffer.duration;
514
+ typedScheduledSourcesRef.current.push(source);
515
+ source.onended = () => {
516
+ typedScheduledSourcesRef.current = typedScheduledSourcesRef.current.filter((s) => s !== source);
517
+ maybeResolve();
518
+ };
519
+ };
520
+ (async () => {
521
+ const reader = stream.getReader();
522
+ try {
523
+ for (;;) {
524
+ const { done, value } = await reader.read();
525
+ if (done)
526
+ break;
527
+ if (!value || value.length === 0)
528
+ continue;
529
+ // PCM16 samples are 2 bytes each — a chunk boundary can split a
530
+ // sample in half, so carry any odd trailing byte into the next
531
+ // read instead of corrupting one sample at every chunk seam.
532
+ const combined = concatBytes(leftover, value);
533
+ const usableLen = combined.length - (combined.length % 2);
534
+ scheduleChunk(combined.subarray(0, usableLen));
535
+ leftover = combined.subarray(usableLen);
536
+ }
537
+ }
538
+ catch {
539
+ // Best-effort — never let a stream read failure hang the caller forever.
540
+ }
541
+ finally {
542
+ doneArriving = true;
543
+ maybeResolve();
544
+ }
545
+ })();
405
546
  });
406
547
  }
407
548
  async function speak(text) {
@@ -413,9 +554,9 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
413
554
  headers: { "content-type": "application/json" },
414
555
  body: JSON.stringify({ text }),
415
556
  });
416
- if (!res.ok)
557
+ if (!res.ok || !res.body)
417
558
  return;
418
- void playResponseAudio(await res.blob());
559
+ void playPcmStream(res.body);
419
560
  }
420
561
  catch {
421
562
  // Best-effort — never let speech playback break the widget.
@@ -433,9 +574,9 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
433
574
  headers: { "content-type": "application/json" },
434
575
  body: JSON.stringify({ text }),
435
576
  });
436
- if (!res.ok)
577
+ if (!res.ok || !res.body)
437
578
  return;
438
- await playResponseAudio(await res.blob());
579
+ await playPcmStream(res.body);
439
580
  }
440
581
  catch {
441
582
  // Best-effort — never let a synthesis failure hang the tour forever.
@@ -641,7 +782,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
641
782
  }
642
783
  setRtStatus("rt-listening");
643
784
  setCaption("");
644
- sendFreshContext(); // refresh before the user starts talking again, not after
785
+ void sendFreshContext(); // refresh before the user starts talking again, not after
645
786
  }
646
787
  function disarmThinkingWatchdog() {
647
788
  if (rtThinkingWatchdogRef.current) {
@@ -717,6 +858,25 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
717
858
  armThinkingWatchdog();
718
859
  }
719
860
  else if (msg.type === "verb") {
861
+ const parsedStep = (0, core_1.safeParseVerbResponse)(msg.verb);
862
+ if (parsedStep && !core_1.TERMINAL_VERBS.has(parsedStep.verb)) {
863
+ // A continuing agent-loop step (click/fill/read/call_tool) —
864
+ // the turn isn't over: execute it for real and report the
865
+ // result back so the server can decide the next step, instead
866
+ // of treating this like a normal answer (no
867
+ // disarmThinkingWatchdog/handleVerb — those are for when a
868
+ // turn actually ends). Shown visually so a multi-step turn
869
+ // reads as visible progress, not a silent pause; never spoken
870
+ // — the server's loop stays quiet between steps on purpose,
871
+ // to keep it fast.
872
+ setAnswer(summarizeVerbForHistory(msg.verb));
873
+ void (0, verb_executor_1.executeToolStep)(msg.verb, pathnameRef.current, liveMapRef.current).then((result) => {
874
+ if (ws.readyState === WebSocket.OPEN) {
875
+ ws.send(JSON.stringify({ type: "tool_result", observation: result?.observation ?? "no result" }));
876
+ }
877
+ });
878
+ return;
879
+ }
720
880
  disarmThinkingWatchdog();
721
881
  handleVerb(msg.verb);
722
882
  }
@@ -810,8 +970,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
810
970
  rtThinkingWatchdogRef.current = null;
811
971
  }
812
972
  rtStartingRef.current = false;
813
- activeAudioRef.current?.pause();
814
- activeAudioRef.current = null;
973
+ stopTypedPlayback();
815
974
  rtSocketRef.current?.close();
816
975
  rtSocketRef.current = null;
817
976
  rtCleanupRef.current?.();
@@ -884,6 +1043,14 @@ function summarizeVerbForHistory(raw) {
884
1043
  return `(ran ${String(v.action)}${v.target ? ` on ${String(v.target)}` : ""})`;
885
1044
  case "tour":
886
1045
  return Array.isArray(v.steps) ? v.steps.map((s) => s.text ?? "").join(" ") : "(tour)";
1046
+ case "click":
1047
+ return `(clicked ${String(v.target)})`;
1048
+ case "fill":
1049
+ return `(typed "${String(v.value)}" into ${String(v.target)})`;
1050
+ case "read":
1051
+ return `(read ${String(v.target)})`;
1052
+ case "call_tool":
1053
+ return `(called ${String(v.name)})`;
887
1054
  default:
888
1055
  return "(no response)";
889
1056
  }
@@ -1,6 +1,6 @@
1
1
  import http from "node:http";
2
2
  import { WebSocket } from "ws";
3
- import type { HistoryTurn, LiveElement, Manifest } from "@cairnvibe/core";
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>;