@cairnvibe/sdk 0.2.13 → 0.3.0

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.
@@ -25,6 +25,7 @@ import type { HistoryTurn as HistoryEntry, TourStep } from "@cairnvibe/core";
25
25
  import { collectVisible } from "./context-collector";
26
26
  import { findElement, highlightElement, logMiss, type MissContext } from "./element-ladder";
27
27
  import { executeVerbResponse } from "./verb-executor";
28
+ import { createBargeInGate, createVadDetector } from "./vad";
28
29
 
29
30
  type Status = "idle" | "asking" | "recording" | "rt-connecting" | "rt-listening" | "rt-thinking" | "rt-speaking";
30
31
 
@@ -333,6 +334,15 @@ export class CairnWidgetElement extends HTMLElement {
333
334
  private rtMicMuted = false;
334
335
  private rtSpeakerMuted = false;
335
336
  private rtStarting = false; // closes the click-to-first-state-update gap so a rapid double-click can't open two sessions
337
+ // Generation of the most recent "final" this client has processed — see
338
+ // ServerMessage's own doc comment in realtime-server.ts (index.tsx
339
+ // carries the same fix, ported here) for the real, live-found race this
340
+ // closes: a locally-triggered barge-in can start a new turn before an
341
+ // earlier turn's own verb/audio, already in flight when the server
342
+ // processed the barge-in, actually arrives. isStaleRtMessage() drops
343
+ // anything older than this instead of applying it to whatever caption
344
+ // is now current.
345
+ private rtLastFinalGeneration = 0;
336
346
  private rtPlaybackCtx: AudioContext | null = null;
337
347
  private rtPlaybackGain: GainNode | null = null;
338
348
  private rtNextPlayTime = 0;
@@ -363,6 +373,16 @@ export class CairnWidgetElement extends HTMLElement {
363
373
  if (name === "persona" && this.panel) this.panel.setAttribute("aria-label", `${this.persona} help panel`);
364
374
  }
365
375
 
376
+ // Same real gap index.tsx's own unmount-safety-net closes, ported here:
377
+ // without this, removing the element from the DOM (an SPA route change,
378
+ // conditional rendering) while a realtime call is open left the
379
+ // WebSocket, the open mic stream, and both AudioContexts running
380
+ // orphaned — a zombie connection that keeps transcribing and replying
381
+ // in parallel with whatever comes next.
382
+ disconnectedCallback() {
383
+ if (this.rtSocket || this.rtCleanup) this.endRealtime();
384
+ }
385
+
366
386
  // --- attributes -----------------------------------------------------
367
387
  private get endpoint(): string { return this.getAttribute("endpoint") ?? "/api/copilot"; }
368
388
  private get speakEndpoint(): string | null { return this.getAttribute("speak-endpoint"); }
@@ -907,6 +927,13 @@ export class CairnWidgetElement extends HTMLElement {
907
927
  // Real-time voice conversation
908
928
  // ---------------------------------------------------------------------
909
929
 
930
+ /** See rtLastFinalGeneration's own doc comment. A message with no
931
+ * generation field at all is treated as current rather than dropped —
932
+ * additive/backward-compatible against a server predating this fix. */
933
+ private isStaleRtMessage(msg: { generation?: unknown }): boolean {
934
+ return typeof msg.generation === "number" && msg.generation < this.rtLastFinalGeneration;
935
+ }
936
+
910
937
  private async startRealtime() {
911
938
  // rtStarting closes the gap between click and the first status update
912
939
  // landing — without it a rapid double-click could race past the
@@ -946,6 +973,12 @@ export class CairnWidgetElement extends HTMLElement {
946
973
  const processor = audioCtx.createScriptProcessor(4096, 1, 1);
947
974
  const silence = audioCtx.createGain();
948
975
  silence.gain.value = 0;
976
+ const bargeInVad = createVadDetector();
977
+ // See index.tsx's own doc comment on its matching bargeInGate for
978
+ // the real, live-reported bug this closes (a single noise-burst
979
+ // VAD frame permanently cutting the agent off) and the production
980
+ // research (Pipecat/LiveKit/Vapi/Deepgram) it's grounded in.
981
+ const bargeInGate = createBargeInGate();
949
982
 
950
983
  // Only flips back to "listening" (and lets the mic resume sending)
951
984
  // once BOTH the server has said no more audio is coming for this
@@ -963,6 +996,7 @@ export class CairnWidgetElement extends HTMLElement {
963
996
  this.rtTourAudioDoneResolve = null;
964
997
  return;
965
998
  }
999
+ void audioCtx.resume().catch(() => {}); // don't wait up to 2s for the periodic health check if the browser already suspended capture
966
1000
  this.setStatus("rt-listening");
967
1001
  this.setCaption("");
968
1002
  };
@@ -979,9 +1013,21 @@ export class CairnWidgetElement extends HTMLElement {
979
1013
  this.rtThinkingWatchdog = setTimeout(() => {
980
1014
  this.rtThinkingWatchdog = null;
981
1015
  console.warn("[cairn] realtime turn timed out waiting on the server — resuming listening");
982
- this.rtAudioDoneArriving = true;
983
- this.setStatus("rt-listening");
984
- this.setCaption("");
1016
+ // Same real, live-found fix as index.tsx's own watchdog (see its
1017
+ // doc comment) — this used to only reset LOCAL state, never
1018
+ // telling the server anything, so a turn that was merely SLOW
1019
+ // (e.g. retrying a rate-limited call across every configured
1020
+ // key) kept running server-side and its reply arrived late,
1021
+ // landing on whatever the user had moved on to instead of being
1022
+ // recognized as stale. triggerBargeIn() sends the real barge_in
1023
+ // signal, bumping the server's generation so that late reply
1024
+ // gets correctly dropped by isStaleRtMessage when it arrives.
1025
+ triggerBargeIn();
1026
+ // triggerBargeIn() clears the caption but never touched the
1027
+ // answer — without this, a timed-out turn gave the user
1028
+ // literally nothing: no reply, no error, a silent reset that
1029
+ // reads as "it heard me and did nothing."
1030
+ this.setAnswer("That's taking longer than expected — try asking again.");
985
1031
  }, 20000);
986
1032
  };
987
1033
 
@@ -1020,10 +1066,12 @@ export class CairnWidgetElement extends HTMLElement {
1020
1066
  // sent yet, and cut the agent off the instant the user starts
1021
1067
  // talking over it instead of making them wait for it to finish.
1022
1068
  if (this.status === "rt-speaking" && !this.touringActive) {
1023
- const rms = computeRms(e.inputBuffer.getChannelData(0));
1024
- if (rms > BARGE_IN_RMS_THRESHOLD) triggerBargeIn();
1069
+ const frame = bargeInVad.process(e.inputBuffer.getChannelData(0));
1070
+ const frameDurationMs = (e.inputBuffer.length / audioCtx.sampleRate) * 1000;
1071
+ if (bargeInGate.update(frame, frameDurationMs)) triggerBargeIn();
1025
1072
  return;
1026
1073
  }
1074
+ bargeInGate.reset(); // not currently interruptible — don't let stale progress carry into the next speaking phase
1027
1075
 
1028
1076
  if (this.status !== "rt-listening") return; // don't send our own mic while the agent is thinking/speaking
1029
1077
  const pcm = floatTo16BitPCM(downsampleTo16k(e.inputBuffer.getChannelData(0), audioCtx.sampleRate));
@@ -1033,7 +1081,31 @@ export class CairnWidgetElement extends HTMLElement {
1033
1081
  processor.connect(silence);
1034
1082
  silence.connect(audioCtx.destination);
1035
1083
 
1084
+ // See index.tsx's own matching doc comment for the real, live-
1085
+ // reported bug this closes: browsers can silently suspend an
1086
+ // AudioContext with no active output (this capture context has
1087
+ // none by design), after which onaudioprocess just stops firing —
1088
+ // "Listening…" stays on screen while nothing is actually captured.
1089
+ const micHealthCheck = setInterval(() => {
1090
+ if (audioCtx.state !== "running") {
1091
+ void audioCtx.resume().catch(() => {});
1092
+ }
1093
+ const track = stream.getAudioTracks()[0];
1094
+ if (track && (track.readyState === "ended" || track.muted)) {
1095
+ this.setAnswer("The microphone connection was lost — try starting the call again.");
1096
+ this.endRealtime();
1097
+ }
1098
+ }, 2000);
1099
+
1100
+ const handleMicTrackEnded = () => {
1101
+ this.setAnswer("The microphone connection was lost — try starting the call again.");
1102
+ this.endRealtime();
1103
+ };
1104
+ stream.getAudioTracks().forEach((t) => t.addEventListener("ended", handleMicTrackEnded));
1105
+
1036
1106
  this.rtCleanup = () => {
1107
+ clearInterval(micHealthCheck);
1108
+ stream.getAudioTracks().forEach((t) => t.removeEventListener("ended", handleMicTrackEnded));
1037
1109
  processor.disconnect();
1038
1110
  source.disconnect();
1039
1111
  stream.getTracks().forEach((t) => t.stop());
@@ -1056,17 +1128,21 @@ export class CairnWidgetElement extends HTMLElement {
1056
1128
  if (msg.type === "interim") {
1057
1129
  this.setCaption(msg.text);
1058
1130
  } else if (msg.type === "final") {
1131
+ this.rtLastFinalGeneration = typeof msg.generation === "number" ? msg.generation : 0;
1059
1132
  this.setCaption(msg.text);
1060
1133
  this.setStatus("rt-thinking");
1061
1134
  armThinkingWatchdog();
1062
1135
  } else if (msg.type === "verb") {
1136
+ if (this.isStaleRtMessage(msg)) return; // belongs to a turn a later "final" already superseded
1063
1137
  disarmThinkingWatchdog();
1064
1138
  this.handleVerb(msg.verb);
1065
1139
  } else if (msg.type === "speaking_start") {
1140
+ if (this.isStaleRtMessage(msg)) return;
1066
1141
  disarmThinkingWatchdog();
1067
1142
  this.rtAudioDoneArriving = false;
1068
1143
  this.setStatus("rt-speaking");
1069
1144
  } else if (msg.type === "audio_chunk") {
1145
+ if (this.isStaleRtMessage(msg)) return; // the literal "two speakers" case — a chunk from an abandoned turn, already in flight when the barge-in landed
1070
1146
  const ctx = this.rtPlaybackCtx;
1071
1147
  const gain = this.rtPlaybackGain;
1072
1148
  if (!ctx || !gain) return;
@@ -1101,6 +1177,7 @@ export class CairnWidgetElement extends HTMLElement {
1101
1177
  maybeResumeListening();
1102
1178
  };
1103
1179
  } else if (msg.type === "speaking_end" || msg.type === "turn_complete") {
1180
+ if (this.isStaleRtMessage(msg)) return; // a newer turn's own speaking_end/turn_complete will arrive and resume listening correctly on its own
1104
1181
  // turn_complete covers a verb with nothing spoken — no audio_chunk
1105
1182
  // ever arrives for it, so rtScheduledSources is already empty and
1106
1183
  // maybeResumeListening() resumes immediately.
@@ -1202,18 +1279,6 @@ function summarizeVerbForHistory(raw: unknown): string {
1202
1279
  // identical to index.tsx's — same protocol, same math, ported directly)
1203
1280
  // ---------------------------------------------------------------------------
1204
1281
 
1205
- // Heuristic energy gate for barge-in: real speech into a laptop/phone mic
1206
- // typically sits well above this; normal room noise and the mic's own
1207
- // noise floor typically sit below it. Same threshold as the React widget —
1208
- // not independently recalibrated, since it's the same audio pipeline.
1209
- const BARGE_IN_RMS_THRESHOLD = 0.02;
1210
-
1211
- function computeRms(channelData: Float32Array): number {
1212
- let sumSquares = 0;
1213
- for (let i = 0; i < channelData.length; i++) sumSquares += channelData[i] * channelData[i];
1214
- return Math.sqrt(sumSquares / channelData.length);
1215
- }
1216
-
1217
1282
  function downsampleTo16k(input: Float32Array, inputSampleRate: number): Float32Array {
1218
1283
  const targetRate = 16000;
1219
1284
  if (inputSampleRate === targetRate) return input;
@@ -8,12 +8,14 @@
8
8
  // no site has adopted it yet, so this is deliberately a no-op (empty list,
9
9
  // nothing to call) everywhere it isn't present, not a hard dependency.
10
10
 
11
- import type { WebMcpTool } from "@cairnvibe/core";
11
+ import type { WebMcpRiskTier, WebMcpTool } from "@cairnvibe/core";
12
12
 
13
13
  interface ModelContextTool {
14
14
  name: string;
15
15
  description?: string;
16
16
  inputSchema?: Record<string, unknown>;
17
+ /** Architecture Pillar 6 — declared by the page's own tool registration, never invented by Cairn. See WebMcpToolSchema's own doc comment. */
18
+ riskTier?: WebMcpRiskTier;
17
19
  }
18
20
 
19
21
  interface ModelContext {
@@ -44,6 +46,11 @@ export async function discoverWebMcpTools(): Promise<WebMcpTool[]> {
44
46
  name: String(tool.name),
45
47
  description: String(tool.description ?? "").slice(0, MAX_DESCRIPTION_LENGTH),
46
48
  inputSchema: tool.inputSchema,
49
+ // Architecture Pillar 6 — passed through only when the page's own
50
+ // registration declared a real "confirm" tier; anything else
51
+ // (absent, or a value that isn't literally "confirm") stays
52
+ // undefined/"safe" — never invented, never widened by a typo.
53
+ riskTier: tool.riskTier === "confirm" ? "confirm" : undefined,
47
54
  }));
48
55
  } catch {
49
56
  // A page's own registerTool()/getTools() implementation throwing is
@@ -59,8 +66,22 @@ export async function discoverWebMcpTools(): Promise<WebMcpTool[]> {
59
66
  * exact request's own discoverWebMcpTools() call), never invented.
60
67
  * Returns a plain-text observation for the agent loop to reason about
61
68
  * next, the same shape a click/fill/read result already takes.
69
+ *
70
+ * Architecture Pillar 6 (the safety layer) — `confirmTool` is only ever
71
+ * consulted for a tool whose OWN registration declared `riskTier:
72
+ * "confirm"` (never something the model or this call site can widen) — a
73
+ * real-world-effect tool (a payment, a delete, anything hard to undo)
74
+ * that must get a genuine yes from the END USER before it runs, not just
75
+ * the model's own decision to call it. No `confirmTool` provided (a host
76
+ * app that hasn't wired up a confirmation UI) is treated as a decline,
77
+ * never as an implicit yes — the safe default when there's no real way
78
+ * to ask.
62
79
  */
63
- export async function executeWebMcpTool(name: string, args: Record<string, unknown> | undefined): Promise<{ ok: boolean; observation: string }> {
80
+ export async function executeWebMcpTool(
81
+ name: string,
82
+ args: Record<string, unknown> | undefined,
83
+ confirmTool?: (tool: { name: string; description: string }) => Promise<boolean>,
84
+ ): Promise<{ ok: boolean; observation: string }> {
64
85
  const modelContext = getModelContext();
65
86
  if (!modelContext?.getTools || !modelContext.executeTool) {
66
87
  return { ok: false, observation: "This page no longer has that tool available." };
@@ -70,6 +91,13 @@ export async function executeWebMcpTool(name: string, args: Record<string, unkno
70
91
  const tool = Array.isArray(tools) ? tools.find((t) => t.name === name) : undefined;
71
92
  if (!tool) return { ok: false, observation: `No tool named "${name}" is available on this page right now.` };
72
93
 
94
+ if (tool.riskTier === "confirm") {
95
+ const confirmed = confirmTool ? await confirmTool({ name: tool.name, description: tool.description ?? "" }) : false;
96
+ if (!confirmed) {
97
+ return { ok: false, observation: "This action needs the user's real confirmation before it can run, and it wasn't confirmed." };
98
+ }
99
+ }
100
+
73
101
  const result = await modelContext.executeTool(tool, args ?? {});
74
102
  const observation = typeof result === "string" ? result : JSON.stringify(result ?? null);
75
103
  return { ok: true, observation: observation.slice(0, 2000) };