@cairnvibe/sdk 0.2.13 → 0.4.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.
Files changed (44) hide show
  1. package/dist/agent-loop.d.ts +113 -0
  2. package/dist/agent-loop.js +128 -0
  3. package/dist/cairn-widget.js +14 -9
  4. package/dist/cursor-overlay.d.ts +19 -0
  5. package/dist/cursor-overlay.js +126 -0
  6. package/dist/element-ladder.d.ts +71 -0
  7. package/dist/element-ladder.js +168 -0
  8. package/dist/index.d.ts +79 -1
  9. package/dist/index.js +886 -96
  10. package/dist/key-rotator.d.ts +28 -0
  11. package/dist/key-rotator.js +57 -3
  12. package/dist/memory-sqlite.d.ts +86 -0
  13. package/dist/memory-sqlite.js +230 -0
  14. package/dist/realtime-cli.js +22 -1
  15. package/dist/realtime-server.d.ts +83 -2
  16. package/dist/realtime-server.js +561 -121
  17. package/dist/server.d.ts +266 -5
  18. package/dist/server.js +1013 -83
  19. package/dist/skill-store.d.ts +17 -0
  20. package/dist/skill-store.js +78 -0
  21. package/dist/tts-stream.d.ts +25 -0
  22. package/dist/tts-stream.js +32 -0
  23. package/dist/vad.d.ts +27 -0
  24. package/dist/vad.js +128 -0
  25. package/dist/verb-executor.d.ts +32 -11
  26. package/dist/verb-executor.js +315 -39
  27. package/dist/webmcp-client.d.ts +14 -1
  28. package/dist/webmcp-client.js +22 -1
  29. package/package.json +3 -1
  30. package/src/agent-loop.ts +222 -0
  31. package/src/cursor-overlay.ts +130 -0
  32. package/src/element-ladder.ts +170 -0
  33. package/src/index.tsx +935 -100
  34. package/src/key-rotator.ts +57 -2
  35. package/src/memory-sqlite.ts +283 -0
  36. package/src/realtime-cli.ts +24 -1
  37. package/src/realtime-server.ts +669 -123
  38. package/src/server.ts +1119 -83
  39. package/src/skill-store.ts +88 -0
  40. package/src/tts-stream.ts +30 -0
  41. package/src/vad.ts +153 -0
  42. package/src/verb-executor.ts +329 -42
  43. package/src/web-component.ts +97 -24
  44. package/src/webmcp-client.ts +30 -2
@@ -24,7 +24,9 @@
24
24
  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
+ import { hideCursor } from "./cursor-overlay";
27
28
  import { executeVerbResponse } from "./verb-executor";
29
+ import { createBargeInGate, createVadDetector } from "./vad";
28
30
 
29
31
  type Status = "idle" | "asking" | "recording" | "rt-connecting" | "rt-listening" | "rt-thinking" | "rt-speaking";
30
32
 
@@ -55,9 +57,13 @@ const STYLES = `
55
57
  0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
56
58
  70% { box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
57
59
  }
58
- @keyframes cairn-pulse-indigo {
59
- 0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); }
60
- 70% { box-shadow: 0 0 0 10px rgba(99, 102, 241, 0); }
60
+ @keyframes cairn-pulse-ember {
61
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(224, 122, 63, 0.4); }
62
+ 70% { box-shadow: 0 0 0 10px rgba(224, 122, 63, 0); }
63
+ }
64
+ @keyframes cairn-cursor-arrive {
65
+ 0% { box-shadow: 0 0 0 0 rgba(224, 122, 63, 0.55); }
66
+ 100% { box-shadow: 0 0 0 9px rgba(224, 122, 63, 0); }
61
67
  }
62
68
  @keyframes cairn-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
63
69
  @keyframes cairn-rt-dot {
@@ -74,7 +80,7 @@ const STYLES = `
74
80
  }
75
81
  @keyframes cairn-word-sweep {
76
82
  0% { opacity: 0.35; text-shadow: none; }
77
- 35% { opacity: 1; color: #4f46e5; text-shadow: 0 0 10px rgba(99, 102, 241, 0.45); }
83
+ 35% { opacity: 1; color: #E07A3F; text-shadow: 0 0 10px rgba(224, 122, 63, 0.45); }
78
84
  100% { opacity: 1; color: inherit; text-shadow: none; }
79
85
  }
80
86
  @keyframes cairn-thinking-bounce {
@@ -82,14 +88,15 @@ const STYLES = `
82
88
  40% { opacity: 0.9; transform: translateY(-3px); }
83
89
  }
84
90
  .cairn-glow {
85
- animation: cairn-pulse-indigo 1.1s ease-out 2;
86
- outline: 2px solid #6366f1;
91
+ animation: cairn-pulse-ember 1.1s ease-out 2;
92
+ outline: 2px solid #E07A3F;
87
93
  outline-offset: 3px;
88
94
  border-radius: 8px;
89
95
  }
90
96
  .cairn-spin { animation: cairn-spin 0.8s linear infinite; }
97
+ .cairn-cursor-hover { animation: cairn-cursor-arrive 0.3s ease-out; }
91
98
  @media (prefers-reduced-motion: reduce) {
92
- .cairn-fab, .cairn-panel, .cairn-bubble, .cairn-word, .cairn-thinking-dot {
99
+ .cairn-fab, .cairn-panel, .cairn-bubble, .cairn-word, .cairn-thinking-dot, #cairn-cursor {
93
100
  animation: none !important;
94
101
  transition: none !important;
95
102
  }
@@ -333,6 +340,15 @@ export class CairnWidgetElement extends HTMLElement {
333
340
  private rtMicMuted = false;
334
341
  private rtSpeakerMuted = false;
335
342
  private rtStarting = false; // closes the click-to-first-state-update gap so a rapid double-click can't open two sessions
343
+ // Generation of the most recent "final" this client has processed — see
344
+ // ServerMessage's own doc comment in realtime-server.ts (index.tsx
345
+ // carries the same fix, ported here) for the real, live-found race this
346
+ // closes: a locally-triggered barge-in can start a new turn before an
347
+ // earlier turn's own verb/audio, already in flight when the server
348
+ // processed the barge-in, actually arrives. isStaleRtMessage() drops
349
+ // anything older than this instead of applying it to whatever caption
350
+ // is now current.
351
+ private rtLastFinalGeneration = 0;
336
352
  private rtPlaybackCtx: AudioContext | null = null;
337
353
  private rtPlaybackGain: GainNode | null = null;
338
354
  private rtNextPlayTime = 0;
@@ -363,6 +379,17 @@ export class CairnWidgetElement extends HTMLElement {
363
379
  if (name === "persona" && this.panel) this.panel.setAttribute("aria-label", `${this.persona} help panel`);
364
380
  }
365
381
 
382
+ // Same real gap index.tsx's own unmount-safety-net closes, ported here:
383
+ // without this, removing the element from the DOM (an SPA route change,
384
+ // conditional rendering) while a realtime call is open left the
385
+ // WebSocket, the open mic stream, and both AudioContexts running
386
+ // orphaned — a zombie connection that keeps transcribing and replying
387
+ // in parallel with whatever comes next.
388
+ disconnectedCallback() {
389
+ if (this.rtSocket || this.rtCleanup) this.endRealtime();
390
+ hideCursor();
391
+ }
392
+
366
393
  // --- attributes -----------------------------------------------------
367
394
  private get endpoint(): string { return this.getAttribute("endpoint") ?? "/api/copilot"; }
368
395
  private get speakEndpoint(): string | null { return this.getAttribute("speak-endpoint"); }
@@ -541,6 +568,7 @@ export class CairnWidgetElement extends HTMLElement {
541
568
  this.fab.innerHTML = this.isOpen ? CLOSE_ICON : MARK_ICON;
542
569
  this.fab.setAttribute("aria-label", this.isOpen ? `Close ${this.persona} help` : `Open ${this.persona} help`);
543
570
  if (this.isOpen && !this.realtimeActive) this.inputEl.focus();
571
+ if (!this.isOpen) hideCursor();
544
572
  }
545
573
 
546
574
  // --- rendering ------------------------------------------------------
@@ -907,6 +935,13 @@ export class CairnWidgetElement extends HTMLElement {
907
935
  // Real-time voice conversation
908
936
  // ---------------------------------------------------------------------
909
937
 
938
+ /** See rtLastFinalGeneration's own doc comment. A message with no
939
+ * generation field at all is treated as current rather than dropped —
940
+ * additive/backward-compatible against a server predating this fix. */
941
+ private isStaleRtMessage(msg: { generation?: unknown }): boolean {
942
+ return typeof msg.generation === "number" && msg.generation < this.rtLastFinalGeneration;
943
+ }
944
+
910
945
  private async startRealtime() {
911
946
  // rtStarting closes the gap between click and the first status update
912
947
  // landing — without it a rapid double-click could race past the
@@ -946,6 +981,12 @@ export class CairnWidgetElement extends HTMLElement {
946
981
  const processor = audioCtx.createScriptProcessor(4096, 1, 1);
947
982
  const silence = audioCtx.createGain();
948
983
  silence.gain.value = 0;
984
+ const bargeInVad = createVadDetector();
985
+ // See index.tsx's own doc comment on its matching bargeInGate for
986
+ // the real, live-reported bug this closes (a single noise-burst
987
+ // VAD frame permanently cutting the agent off) and the production
988
+ // research (Pipecat/LiveKit/Vapi/Deepgram) it's grounded in.
989
+ const bargeInGate = createBargeInGate();
949
990
 
950
991
  // Only flips back to "listening" (and lets the mic resume sending)
951
992
  // once BOTH the server has said no more audio is coming for this
@@ -963,6 +1004,7 @@ export class CairnWidgetElement extends HTMLElement {
963
1004
  this.rtTourAudioDoneResolve = null;
964
1005
  return;
965
1006
  }
1007
+ void audioCtx.resume().catch(() => {}); // don't wait up to 2s for the periodic health check if the browser already suspended capture
966
1008
  this.setStatus("rt-listening");
967
1009
  this.setCaption("");
968
1010
  };
@@ -979,9 +1021,21 @@ export class CairnWidgetElement extends HTMLElement {
979
1021
  this.rtThinkingWatchdog = setTimeout(() => {
980
1022
  this.rtThinkingWatchdog = null;
981
1023
  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("");
1024
+ // Same real, live-found fix as index.tsx's own watchdog (see its
1025
+ // doc comment) — this used to only reset LOCAL state, never
1026
+ // telling the server anything, so a turn that was merely SLOW
1027
+ // (e.g. retrying a rate-limited call across every configured
1028
+ // key) kept running server-side and its reply arrived late,
1029
+ // landing on whatever the user had moved on to instead of being
1030
+ // recognized as stale. triggerBargeIn() sends the real barge_in
1031
+ // signal, bumping the server's generation so that late reply
1032
+ // gets correctly dropped by isStaleRtMessage when it arrives.
1033
+ triggerBargeIn();
1034
+ // triggerBargeIn() clears the caption but never touched the
1035
+ // answer — without this, a timed-out turn gave the user
1036
+ // literally nothing: no reply, no error, a silent reset that
1037
+ // reads as "it heard me and did nothing."
1038
+ this.setAnswer("That's taking longer than expected — try asking again.");
985
1039
  }, 20000);
986
1040
  };
987
1041
 
@@ -1020,10 +1074,12 @@ export class CairnWidgetElement extends HTMLElement {
1020
1074
  // sent yet, and cut the agent off the instant the user starts
1021
1075
  // talking over it instead of making them wait for it to finish.
1022
1076
  if (this.status === "rt-speaking" && !this.touringActive) {
1023
- const rms = computeRms(e.inputBuffer.getChannelData(0));
1024
- if (rms > BARGE_IN_RMS_THRESHOLD) triggerBargeIn();
1077
+ const frame = bargeInVad.process(e.inputBuffer.getChannelData(0));
1078
+ const frameDurationMs = (e.inputBuffer.length / audioCtx.sampleRate) * 1000;
1079
+ if (bargeInGate.update(frame, frameDurationMs)) triggerBargeIn();
1025
1080
  return;
1026
1081
  }
1082
+ bargeInGate.reset(); // not currently interruptible — don't let stale progress carry into the next speaking phase
1027
1083
 
1028
1084
  if (this.status !== "rt-listening") return; // don't send our own mic while the agent is thinking/speaking
1029
1085
  const pcm = floatTo16BitPCM(downsampleTo16k(e.inputBuffer.getChannelData(0), audioCtx.sampleRate));
@@ -1033,7 +1089,31 @@ export class CairnWidgetElement extends HTMLElement {
1033
1089
  processor.connect(silence);
1034
1090
  silence.connect(audioCtx.destination);
1035
1091
 
1092
+ // See index.tsx's own matching doc comment for the real, live-
1093
+ // reported bug this closes: browsers can silently suspend an
1094
+ // AudioContext with no active output (this capture context has
1095
+ // none by design), after which onaudioprocess just stops firing —
1096
+ // "Listening…" stays on screen while nothing is actually captured.
1097
+ const micHealthCheck = setInterval(() => {
1098
+ if (audioCtx.state !== "running") {
1099
+ void audioCtx.resume().catch(() => {});
1100
+ }
1101
+ const track = stream.getAudioTracks()[0];
1102
+ if (track && (track.readyState === "ended" || track.muted)) {
1103
+ this.setAnswer("The microphone connection was lost — try starting the call again.");
1104
+ this.endRealtime();
1105
+ }
1106
+ }, 2000);
1107
+
1108
+ const handleMicTrackEnded = () => {
1109
+ this.setAnswer("The microphone connection was lost — try starting the call again.");
1110
+ this.endRealtime();
1111
+ };
1112
+ stream.getAudioTracks().forEach((t) => t.addEventListener("ended", handleMicTrackEnded));
1113
+
1036
1114
  this.rtCleanup = () => {
1115
+ clearInterval(micHealthCheck);
1116
+ stream.getAudioTracks().forEach((t) => t.removeEventListener("ended", handleMicTrackEnded));
1037
1117
  processor.disconnect();
1038
1118
  source.disconnect();
1039
1119
  stream.getTracks().forEach((t) => t.stop());
@@ -1056,17 +1136,21 @@ export class CairnWidgetElement extends HTMLElement {
1056
1136
  if (msg.type === "interim") {
1057
1137
  this.setCaption(msg.text);
1058
1138
  } else if (msg.type === "final") {
1139
+ this.rtLastFinalGeneration = typeof msg.generation === "number" ? msg.generation : 0;
1059
1140
  this.setCaption(msg.text);
1060
1141
  this.setStatus("rt-thinking");
1061
1142
  armThinkingWatchdog();
1062
1143
  } else if (msg.type === "verb") {
1144
+ if (this.isStaleRtMessage(msg)) return; // belongs to a turn a later "final" already superseded
1063
1145
  disarmThinkingWatchdog();
1064
1146
  this.handleVerb(msg.verb);
1065
1147
  } else if (msg.type === "speaking_start") {
1148
+ if (this.isStaleRtMessage(msg)) return;
1066
1149
  disarmThinkingWatchdog();
1067
1150
  this.rtAudioDoneArriving = false;
1068
1151
  this.setStatus("rt-speaking");
1069
1152
  } else if (msg.type === "audio_chunk") {
1153
+ 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
1154
  const ctx = this.rtPlaybackCtx;
1071
1155
  const gain = this.rtPlaybackGain;
1072
1156
  if (!ctx || !gain) return;
@@ -1101,6 +1185,7 @@ export class CairnWidgetElement extends HTMLElement {
1101
1185
  maybeResumeListening();
1102
1186
  };
1103
1187
  } else if (msg.type === "speaking_end" || msg.type === "turn_complete") {
1188
+ if (this.isStaleRtMessage(msg)) return; // a newer turn's own speaking_end/turn_complete will arrive and resume listening correctly on its own
1104
1189
  // turn_complete covers a verb with nothing spoken — no audio_chunk
1105
1190
  // ever arrives for it, so rtScheduledSources is already empty and
1106
1191
  // maybeResumeListening() resumes immediately.
@@ -1202,18 +1287,6 @@ function summarizeVerbForHistory(raw: unknown): string {
1202
1287
  // identical to index.tsx's — same protocol, same math, ported directly)
1203
1288
  // ---------------------------------------------------------------------------
1204
1289
 
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
1290
  function downsampleTo16k(input: Float32Array, inputSampleRate: number): Float32Array {
1218
1291
  const targetRate = 16000;
1219
1292
  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) };