@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.
- package/dist/agent-loop.d.ts +113 -0
- package/dist/agent-loop.js +128 -0
- package/dist/cairn-widget.js +2 -2
- package/dist/element-ladder.d.ts +71 -0
- package/dist/element-ladder.js +168 -0
- package/dist/index.d.ts +79 -1
- package/dist/index.js +864 -89
- package/dist/key-rotator.d.ts +28 -0
- package/dist/key-rotator.js +57 -3
- package/dist/memory-sqlite.d.ts +86 -0
- package/dist/memory-sqlite.js +230 -0
- package/dist/realtime-cli.js +22 -1
- package/dist/realtime-server.d.ts +83 -2
- package/dist/realtime-server.js +549 -120
- package/dist/server.d.ts +249 -5
- package/dist/server.js +984 -77
- package/dist/skill-store.d.ts +17 -0
- package/dist/skill-store.js +78 -0
- package/dist/tts-stream.d.ts +25 -0
- package/dist/tts-stream.js +32 -0
- package/dist/vad.d.ts +27 -0
- package/dist/vad.js +128 -0
- package/dist/verb-executor.d.ts +32 -11
- package/dist/verb-executor.js +224 -16
- package/dist/webmcp-client.d.ts +14 -1
- package/dist/webmcp-client.js +22 -1
- package/package.json +3 -1
- package/src/agent-loop.ts +222 -0
- package/src/element-ladder.ts +170 -0
- package/src/index.tsx +914 -93
- package/src/key-rotator.ts +57 -2
- package/src/memory-sqlite.ts +283 -0
- package/src/realtime-cli.ts +24 -1
- package/src/realtime-server.ts +655 -122
- package/src/server.ts +1077 -77
- package/src/skill-store.ts +88 -0
- package/src/tts-stream.ts +30 -0
- package/src/vad.ts +153 -0
- package/src/verb-executor.ts +243 -22
- package/src/web-component.ts +82 -17
- package/src/webmcp-client.ts +30 -2
package/src/web-component.ts
CHANGED
|
@@ -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
|
-
|
|
983
|
-
this
|
|
984
|
-
|
|
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
|
|
1024
|
-
|
|
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;
|
package/src/webmcp-client.ts
CHANGED
|
@@ -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(
|
|
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) };
|