@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
@@ -0,0 +1,88 @@
1
+ // Architecture Pillar 3 (Skill half) — real, persistent storage for
2
+ // self-authored Skills (packages/core/src/skills.ts), scoped by whatever
3
+ // opaque `scopeId` string the caller passes — same discipline as
4
+ // memory-sqlite.ts's own MemoryStore, but for a DIFFERENT axis of scope on
5
+ // purpose: a MemoryStore scopeId is per-user/session (today's usage,
6
+ // unchanged), while a SkillStore scopeId is meant to be per-DEPLOYMENT
7
+ // (the whole app, shared across every user who talks to it — the same
8
+ // scope ui-manifest.json itself already has). Nothing in this file
9
+ // enforces that distinction; it's the caller's own choice of scopeId that
10
+ // makes it true, exactly as MemoryStore's own doc comment already
11
+ // establishes for its scope.
12
+ //
13
+ // A dedicated table (not reusing memory-sqlite's facts table with a
14
+ // JSON-blob value) specifically so `listSkillSummaries` can select just
15
+ // id/name/description/pattern at the SQL level — the real mechanism
16
+ // behind "progressive disclosure stays cheap": a deployment with many
17
+ // learned Skills never pays to load every Skill's full instructions just
18
+ // to list what's available, only the one a caller actually requests via
19
+ // getSkill.
20
+
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import Database from "better-sqlite3";
24
+ import type { Skill, SkillSummary } from "@cairnvibe/core";
25
+
26
+ const SKILLS_TABLE = "cairn_skills";
27
+
28
+ export interface SkillStore {
29
+ /** Upserts by (scopeId, id) — a re-learned Skill for the same real capability replaces the old one, never accumulates duplicates. */
30
+ saveSkill(scopeId: string, skill: Skill): void;
31
+ /** Cheap: id/name/description/pattern only, never the full instructions — see this file's own doc comment. */
32
+ listSkillSummaries(scopeId: string): SkillSummary[];
33
+ /** The one Skill a caller actually matched — full instructions included. Null if this scope has no Skill with that id. */
34
+ getSkill(scopeId: string, id: string): Skill | null;
35
+ }
36
+
37
+ /**
38
+ * @param target Either a file path (opened/created, parent dir made if
39
+ * needed) or an already-open better-sqlite3 `Database` — pass an open
40
+ * connection to share it with your own tables (or memory-sqlite.ts's own
41
+ * store) instead of opening a second file.
42
+ */
43
+ export function createSqliteSkillStore(target: string | Database.Database): SkillStore {
44
+ const db = typeof target === "string" ? openFile(target) : target;
45
+
46
+ db.exec(`
47
+ CREATE TABLE IF NOT EXISTS ${SKILLS_TABLE} (
48
+ scope_id TEXT NOT NULL,
49
+ id TEXT NOT NULL,
50
+ name TEXT NOT NULL,
51
+ description TEXT NOT NULL,
52
+ instructions TEXT NOT NULL,
53
+ pattern TEXT,
54
+ created_at TEXT NOT NULL,
55
+ PRIMARY KEY (scope_id, id)
56
+ )
57
+ `);
58
+
59
+ const upsertSkill = db.prepare(`
60
+ INSERT INTO ${SKILLS_TABLE} (scope_id, id, name, description, instructions, pattern, created_at)
61
+ VALUES (@scopeId, @id, @name, @description, @instructions, @pattern, @createdAt)
62
+ ON CONFLICT(scope_id, id) DO UPDATE SET
63
+ name = excluded.name, description = excluded.description, instructions = excluded.instructions,
64
+ pattern = excluded.pattern, created_at = excluded.created_at
65
+ `);
66
+ const selectSummaries = db.prepare(`SELECT id, name, description, pattern FROM ${SKILLS_TABLE} WHERE scope_id = ?`);
67
+ const selectSkill = db.prepare(`SELECT id, name, description, instructions, pattern, created_at FROM ${SKILLS_TABLE} WHERE scope_id = ? AND id = ?`);
68
+
69
+ return {
70
+ saveSkill(scopeId, skill) {
71
+ upsertSkill.run({ scopeId, id: skill.id, name: skill.name, description: skill.description, instructions: skill.instructions, pattern: skill.pattern ?? null, createdAt: skill.createdAt });
72
+ },
73
+ listSkillSummaries(scopeId) {
74
+ const rows = selectSummaries.all(scopeId) as { id: string; name: string; description: string; pattern: string | null }[];
75
+ return rows.map((r) => ({ id: r.id, name: r.name, description: r.description, pattern: r.pattern ?? undefined }));
76
+ },
77
+ getSkill(scopeId, id) {
78
+ const row = selectSkill.get(scopeId, id) as { id: string; name: string; description: string; instructions: string; pattern: string | null; created_at: string } | undefined;
79
+ if (!row) return null;
80
+ return { id: row.id, name: row.name, description: row.description, instructions: row.instructions, pattern: row.pattern ?? undefined, createdAt: row.created_at };
81
+ },
82
+ };
83
+ }
84
+
85
+ function openFile(filePath: string): Database.Database {
86
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
87
+ return new Database(filePath);
88
+ }
package/src/tts-stream.ts CHANGED
@@ -21,6 +21,36 @@ import { WebSocket } from "ws";
21
21
 
22
22
  const DEEPGRAM_SPEAK_WS_URL = "wss://api.deepgram.com/v1/speak";
23
23
 
24
+ /**
25
+ * Phase 2 step 1 — the sentence-boundary detector behind true "LLM tokens
26
+ * streamed straight into TTS." `sendText`/`flush` below already support
27
+ * being called many times per turn (queue more, render what's queued so
28
+ * far) — the missing piece was WHEN to flush a growing buffer of
29
+ * streamed LLM text so audio starts before the whole answer finishes
30
+ * generating, without cutting off mid-word or (the real trap) mid-number
31
+ * ("3." from "3.5 dollars").
32
+ *
33
+ * Deliberately simple: a period/!/? followed by REAL whitespace that has
34
+ * already arrived — never a boundary at the current end of the buffer,
35
+ * even if it looks sentence-final ("...3." with nothing after it yet),
36
+ * because more streamed text (".5 dollars") could still be on the way.
37
+ * The caller flushes whatever's left in the buffer once the LLM stream
38
+ * itself ends, regardless of trailing punctuation — real content is
39
+ * never silently dropped, just possibly not maximally chunked. This is
40
+ * a real, accepted simplification, not NLP-grade sentence detection: an
41
+ * abbreviation like "Mr. Smith" flushes one beat early. That costs a
42
+ * little TTS naturalness, never correctness — nothing in the text is
43
+ * lost or reordered.
44
+ */
45
+ export function splitFlushableSentences(buffer: string): { toFlush: string; remainder: string } {
46
+ const re = /[.!?]\s+/g;
47
+ let lastEnd = -1;
48
+ let m: RegExpExecArray | null;
49
+ while ((m = re.exec(buffer)) !== null) lastEnd = re.lastIndex;
50
+ if (lastEnd === -1) return { toFlush: "", remainder: buffer };
51
+ return { toFlush: buffer.slice(0, lastEnd).trimEnd(), remainder: buffer.slice(lastEnd) };
52
+ }
53
+
24
54
  export type SpeakChunkCallback = (audio: Buffer) => void;
25
55
 
26
56
  export interface DeepgramSpeakStreamOptions {
package/src/vad.ts ADDED
@@ -0,0 +1,153 @@
1
+ // A lightweight, dependency-free voice-activity heuristic for client-side
2
+ // barge-in detection — replaces a bare RMS-amplitude threshold with a
3
+ // two-feature (energy + zero-crossing rate) gate plus an adaptive ambient
4
+ // noise floor, entirely in plain JS math over the same Float32Array PCM
5
+ // samples the ScriptProcessorNode already delivers. No model, no WASM, no
6
+ // added bundle weight — the real alternative to a neural VAD (e.g. Silero)
7
+ // this session's own research flagged as carrying a genuine ~1-2MB/
8
+ // 20-30x bundle-size cost for a widget meant to drop into a third-party
9
+ // page (see DEVELOPMENT.md, Phase 2 step 2's Pending section) — this
10
+ // avoids that tradeoff entirely rather than deciding it.
11
+ //
12
+ // Energy alone (the prior BARGE_IN_RMS_THRESHOLD design) fires on ANY
13
+ // loud sound — a cough, a door slam, background music, a raised-volume
14
+ // TV. Zero-crossing rate distinguishes broadly speech-plausible content
15
+ // (voiced pitch + its harmonics, unvoiced fricatives) from the two
16
+ // extremes most likely to false-trigger a bare energy gate: a low-
17
+ // frequency hum/rumble (near-zero ZCR) and broadband hiss/static-like
18
+ // noise (ZCR near its ceiling). The ZCR band below is deliberately wide
19
+ // — it only rejects those two extremes, not a precise speech classifier
20
+ // — and, like the original RMS threshold, is not calibrated against real
21
+ // hardware in this environment (no live mic here); a reasonable starting
22
+ // point, not a tuned production value.
23
+ //
24
+ // The adaptive noise floor is the other real improvement over a flat
25
+ // threshold: instead of one fixed absolute RMS cutoff, the energy gate
26
+ // tracks a slow-moving estimate of the room's own ambient level (updated
27
+ // only on frames NOT classified as speech) and requires real speech to
28
+ // clear a multiple of it — a noisy room raises the bar automatically
29
+ // instead of the old constant threshold false-triggering on ambient
30
+ // noise all the time.
31
+
32
+ const ABSOLUTE_MIN_RMS = 0.02; // same floor as the original flat threshold — never MORE sensitive than before in a quiet room
33
+ const NOISE_FLOOR_MULTIPLIER = 3; // real speech must clear 3x the ambient floor, not just the absolute minimum
34
+ const NOISE_FLOOR_EMA_ALPHA = 0.05; // slow-moving — ~1.7s time constant at a 4096-sample/48kHz frame, so a rising voice isn't mistaken for a rising ambient floor
35
+ const MIN_ZCR = 0.003; // rejects near-DC hum/rumble; well below any real voiced-speech fundamental's crossing rate
36
+ const MAX_ZCR = 0.4; // rejects hiss/static-like broadband noise (pure white noise sits near 0.5)
37
+
38
+ export interface VadFrameResult {
39
+ isSpeech: boolean;
40
+ rms: number;
41
+ zcr: number;
42
+ noiseFloor: number;
43
+ }
44
+
45
+ export interface VadDetector {
46
+ process(samples: Float32Array): VadFrameResult;
47
+ reset(): void;
48
+ }
49
+
50
+ export function computeRms(samples: Float32Array): number {
51
+ if (samples.length === 0) return 0;
52
+ let sumSquares = 0;
53
+ for (let i = 0; i < samples.length; i++) sumSquares += samples[i] * samples[i];
54
+ return Math.sqrt(sumSquares / samples.length);
55
+ }
56
+
57
+ // Fraction of adjacent-sample sign changes, in [0, 1] — a coarse
58
+ // frequency-content proxy that needs no FFT: near 0 for a slow/DC-ish
59
+ // signal, near 1 for a signal that changes sign every sample (Nyquist-
60
+ // rate content, the discrete analogue of white noise/hiss).
61
+ export function computeZcr(samples: Float32Array): number {
62
+ if (samples.length < 2) return 0;
63
+ let crossings = 0;
64
+ for (let i = 1; i < samples.length; i++) {
65
+ if (samples[i] >= 0 !== samples[i - 1] >= 0) crossings++;
66
+ }
67
+ return crossings / (samples.length - 1);
68
+ }
69
+
70
+ export function createVadDetector(): VadDetector {
71
+ let noiseFloor = 0;
72
+
73
+ return {
74
+ process(samples: Float32Array): VadFrameResult {
75
+ const rms = computeRms(samples);
76
+ const zcr = computeZcr(samples);
77
+
78
+ const energyThreshold = Math.max(ABSOLUTE_MIN_RMS, noiseFloor * NOISE_FLOOR_MULTIPLIER);
79
+ const isSpeech = rms > energyThreshold && zcr >= MIN_ZCR && zcr <= MAX_ZCR;
80
+
81
+ if (!isSpeech) noiseFloor = noiseFloor * (1 - NOISE_FLOOR_EMA_ALPHA) + rms * NOISE_FLOOR_EMA_ALPHA;
82
+
83
+ return { isSpeech, rms, zcr, noiseFloor };
84
+ },
85
+ reset() {
86
+ noiseFloor = 0;
87
+ },
88
+ };
89
+ }
90
+
91
+ // A live-reported bug traced this session (see DEVELOPMENT.md) to a
92
+ // server-side "confirm-or-reverse" barge-in design that raced Deepgram's
93
+ // own transcript against a fixed grace window — real, deliberate
94
+ // interruptions routinely lost that race and got treated as false
95
+ // positives. Removing that system fixed the false "resumes from the
96
+ // top," but left barge-in firing on a SINGLE ~85-100ms VAD frame
97
+ // (createVadDetector's own frame-classification granularity at the
98
+ // 4096-sample ScriptProcessorNode buffer size in use) — a single cough
99
+ // or door-slam frame that happens to pass the energy+ZCR gate still cuts
100
+ // the agent off, permanently now, with no recovery at all.
101
+ //
102
+ // Real research into how production voice-agent platforms actually solve
103
+ // this (Pipecat, LiveKit Agents, Vapi, Deepgram's own Voice Agent API —
104
+ // see DEVELOPMENT.md for the full comparison) converges on the same
105
+ // answer, independent of any STT-transcript race: gate the LOCAL VAD
106
+ // trigger on SUSTAINED speech across a minimum duration, not a single
107
+ // frame. Pipecat's own documented production spec cites a 250ms minimum
108
+ // duration; Vapi's stopSpeakingPlan defaults its VAD-duration threshold
109
+ // (voiceSeconds) to 0.2s specifically to "balance responsiveness and
110
+ // avoid false triggers." This is the same idea, entirely client-side —
111
+ // unlike the removed server-side design, it never waits on a network
112
+ // round trip or Deepgram's own transcript timing, so it can't reintroduce
113
+ // that exact race. A brief, real noise burst (a cough, a single loud
114
+ // clack) essentially never sustains cleanly across multiple consecutive
115
+ // frames at these energy/ZCR bands; genuine speech does.
116
+ const BARGE_IN_MIN_SPEECH_MS = 200;
117
+
118
+ export interface BargeInGate {
119
+ /** Feed one frame's VAD classification plus that frame's real duration
120
+ * (samples.length / sampleRate * 1000 — NOT assumed, since sample rate
121
+ * varies by device/browser). Returns true the instant accumulated
122
+ * consecutive speech crosses the minimum-duration threshold — fires
123
+ * exactly once per sustained speech onset. Any non-speech frame resets
124
+ * the accumulator immediately, so a genuine interruption still cuts in
125
+ * well under half a second, while an isolated noise burst (which
126
+ * essentially never sustains across consecutive frames) never fires at
127
+ * all. */
128
+ update(frame: VadFrameResult, frameDurationMs: number): boolean;
129
+ reset(): void;
130
+ }
131
+
132
+ export function createBargeInGate(minSpeechMs: number = BARGE_IN_MIN_SPEECH_MS): BargeInGate {
133
+ let accumulatedMs = 0;
134
+ let fired = false;
135
+
136
+ return {
137
+ update(frame, frameDurationMs) {
138
+ if (!frame.isSpeech) {
139
+ accumulatedMs = 0;
140
+ fired = false;
141
+ return false;
142
+ }
143
+ accumulatedMs += frameDurationMs;
144
+ if (fired || accumulatedMs < minSpeechMs) return false;
145
+ fired = true;
146
+ return true;
147
+ },
148
+ reset() {
149
+ accumulatedMs = 0;
150
+ fired = false;
151
+ },
152
+ };
153
+ }