@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
|
@@ -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
|
+
}
|
package/dist/tts-stream.d.ts
CHANGED
|
@@ -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;
|
package/dist/tts-stream.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/verb-executor.d.ts
CHANGED
|
@@ -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
|
|
4
|
-
* batch of several) — fed back to the model as its next
|
|
5
|
-
* "observation" so it can decide what to do next instead of acting
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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;
|