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