@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,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
|
+
}
|
package/src/verb-executor.ts
CHANGED
|
@@ -6,36 +6,57 @@
|
|
|
6
6
|
// enforces the same schema independently — never trust the client alone.
|
|
7
7
|
|
|
8
8
|
import { VerbResponseSchema, type ApiCall, type BatchAction, type TourStep, type VerbResponse } from "@cairnvibe/core";
|
|
9
|
-
import { findElement, fillElement, highlightElement, logMiss, readElement, type MissContext } from "./element-ladder";
|
|
9
|
+
import { dragElement, findElement, findElementWithRetry, fillElement, highlightElement, logMiss, pressKey, readElement, selectOption, waitForDomSettle, type MissContext } from "./element-ladder";
|
|
10
10
|
import { executeWebMcpTool } from "./webmcp-client";
|
|
11
11
|
|
|
12
|
-
/** The real result of one agent-loop step (click/fill/read/call_tool
|
|
13
|
-
* batch of several) — fed back to the model as its next
|
|
14
|
-
* "observation" so it can decide what to do next instead of acting
|
|
15
|
-
* The loop that drives this lives on the caller's side, not here:
|
|
12
|
+
/** The real result of one agent-loop step (click/fill/read/call_tool/
|
|
13
|
+
* navigate, or a batch of several) — fed back to the model as its next
|
|
14
|
+
* turn's "observation" so it can decide what to do next instead of acting
|
|
15
|
+
* blind. The loop that drives this lives on the caller's side, not here:
|
|
16
16
|
* index.tsx's runTypedAgentLoop for the HTTP path, realtime-server.ts's
|
|
17
17
|
* finalizeTurn for the realtime one — this module only ever executes one
|
|
18
18
|
* step (or one batch of steps) at a time. */
|
|
19
19
|
export interface ToolStepResult {
|
|
20
|
-
verb: "click" | "fill" | "read" | "call_tool" | "batch";
|
|
20
|
+
verb: "click" | "fill" | "read" | "call_tool" | "batch" | "navigate" | "drag" | "select" | "key" | "scroll" | "wait_for";
|
|
21
21
|
target?: string;
|
|
22
22
|
ok: boolean;
|
|
23
23
|
observation: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// wait_for's own real, bounded retry budget — longer than
|
|
27
|
+
// findElementWithRetry's own default (2 attempts, 300ms apart, ~300ms
|
|
28
|
+
// total), since this verb exists specifically for "I know something
|
|
29
|
+
// async should show up" — a toast, a panel appearing after a click — not
|
|
30
|
+
// the incidental transient-miss recovery findElementWithRetry's default
|
|
31
|
+
// already covers for click/fill/batch steps.
|
|
32
|
+
const WAIT_FOR_ATTEMPTS = 6;
|
|
33
|
+
const WAIT_FOR_DELAY_MS = 500;
|
|
34
|
+
|
|
26
35
|
/**
|
|
27
36
|
* Promise wrapper around executeVerbResponse for a continuing verb
|
|
28
|
-
* (click/fill/read/call_tool
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
37
|
+
* (click/fill/read/call_tool, or now a navigate marked `continueAfter` —
|
|
38
|
+
* see isTerminalVerb in @cairnvibe/core) — resolves once the real action
|
|
39
|
+
* has actually finished (synchronously for click/fill/read, after a real
|
|
40
|
+
* await for call_tool/navigate) with its real observation, instead of the
|
|
41
|
+
* fire-and-forget callback shape every other verb uses. This is what a
|
|
42
|
+
* loop driver awaits before deciding whether to call the model again.
|
|
43
|
+
* `onNavigate` is only needed for that new navigate-as-continuing-step
|
|
44
|
+
* case — every existing caller that never passes it keeps working
|
|
45
|
+
* unchanged (a continueAfter navigate with no onNavigate here would just
|
|
46
|
+
* never actually move the page; real callers always pass one, same as
|
|
47
|
+
* handleVerb's own options already do for the terminal case).
|
|
33
48
|
*/
|
|
34
|
-
export function executeToolStep(
|
|
49
|
+
export function executeToolStep(
|
|
50
|
+
raw: unknown,
|
|
51
|
+
route: string,
|
|
52
|
+
liveElements?: Map<string, HTMLElement>,
|
|
53
|
+
onNavigate?: (route: string) => void,
|
|
54
|
+
onConfirmTool?: (tool: { name: string; description: string }) => Promise<boolean>,
|
|
55
|
+
): Promise<ToolStepResult | null> {
|
|
35
56
|
return new Promise((resolve) => {
|
|
36
57
|
// executeVerbResponse only ever reaches onToolStep for a genuinely
|
|
37
58
|
// continuing verb — callers are only expected to call this after
|
|
38
|
-
// already confirming (via
|
|
59
|
+
// already confirming (via isTerminalVerb) that the parsed verb is one,
|
|
39
60
|
// so this should always fire; a real timeout (not an immediate
|
|
40
61
|
// microtask — call_tool's own real network round trip needs the time)
|
|
41
62
|
// is the safety net for the case where it somehow doesn't, so a loop
|
|
@@ -44,6 +65,8 @@ export function executeToolStep(raw: unknown, route: string, liveElements?: Map<
|
|
|
44
65
|
executeVerbResponse(raw, route, {
|
|
45
66
|
onExplain: () => {},
|
|
46
67
|
liveElements,
|
|
68
|
+
onNavigate,
|
|
69
|
+
onConfirmTool,
|
|
47
70
|
onToolStep: (result) => {
|
|
48
71
|
clearTimeout(timer);
|
|
49
72
|
resolve(result);
|
|
@@ -75,6 +98,15 @@ export interface VerbExecutorOptions {
|
|
|
75
98
|
* saw. Absent entirely for a caller that hasn't wired up live scanning.
|
|
76
99
|
*/
|
|
77
100
|
liveElements?: Map<string, HTMLElement>;
|
|
101
|
+
/**
|
|
102
|
+
* Architecture Pillar 6 (the safety layer) — real confirmation for a
|
|
103
|
+
* WebMCP tool whose own registration declared `riskTier: "confirm"`
|
|
104
|
+
* (webmcp-client.ts's own doc comment covers the enforcement point).
|
|
105
|
+
* Absent means every "confirm"-tier tool call is declined by default —
|
|
106
|
+
* the safe fallback for a host app that hasn't wired up a real
|
|
107
|
+
* confirmation UI, never an implicit yes.
|
|
108
|
+
*/
|
|
109
|
+
onConfirmTool?: (tool: { name: string; description: string }) => Promise<boolean>;
|
|
78
110
|
}
|
|
79
111
|
|
|
80
112
|
const FALLBACK_TEXT = "I'm not sure — I couldn't understand that response. Try rephrasing your question.";
|
|
@@ -111,10 +143,34 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
111
143
|
return;
|
|
112
144
|
}
|
|
113
145
|
|
|
114
|
-
case "navigate":
|
|
146
|
+
case "navigate": {
|
|
147
|
+
// Real, live-reported gap this closes: navigate used to ALWAYS end
|
|
148
|
+
// the turn the instant it fired, even for a compound goal like "buy
|
|
149
|
+
// earbuds" that needs navigate, then search, then a real report
|
|
150
|
+
// back — see isTerminalVerb's own doc comment in @cairnvibe/core.
|
|
151
|
+
// `options.onToolStep` is only ever set by executeToolStep's own
|
|
152
|
+
// continuing-step wrapper — handleVerb's options never provide it —
|
|
153
|
+
// so this branch can only run when the caller already confirmed
|
|
154
|
+
// (via isTerminalVerb) that this navigate was genuinely marked
|
|
155
|
+
// continueAfter; the defensive `verb.continueAfter` check here is
|
|
156
|
+
// belt-and-suspenders, not the real gate.
|
|
157
|
+
if (verb.continueAfter && options.onToolStep) {
|
|
158
|
+
if (verb.text) options.onExplain(verb.text);
|
|
159
|
+
options.onNavigate?.(verb.route);
|
|
160
|
+
// A client-side route change is itself an async re-render (a new
|
|
161
|
+
// page's whole DOM mounting) — same real race waitForDomSettle
|
|
162
|
+
// already closes for fill/click, arguably more likely here. The
|
|
163
|
+
// NEXT resolveVerb call needs the settled new page's context, not
|
|
164
|
+
// whatever was on screen the instant router.push was called.
|
|
165
|
+
void waitForDomSettle(300, 200, 2000).then(() => {
|
|
166
|
+
options.onToolStep?.({ verb: "navigate", target: verb.route, ok: true, observation: `Navigated to ${verb.route}.` });
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
115
170
|
options.onNavigate?.(verb.route);
|
|
116
171
|
if (verb.text) options.onExplain(verb.text);
|
|
117
172
|
return;
|
|
173
|
+
}
|
|
118
174
|
|
|
119
175
|
case "do": {
|
|
120
176
|
const allowed = options.registeredActions ?? [];
|
|
@@ -189,7 +245,14 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
189
245
|
}
|
|
190
246
|
highlightElement(el);
|
|
191
247
|
el.click();
|
|
192
|
-
|
|
248
|
+
// Real, live-found race this closes — see waitForDomSettle's own doc
|
|
249
|
+
// comment: a click can trigger an async re-render (a cart count
|
|
250
|
+
// updating, a filtered list refreshing) that hasn't happened yet the
|
|
251
|
+
// instant .click() returns. A subsequent read step in the same turn
|
|
252
|
+
// needs the SETTLED result, not whatever was on screen a moment ago.
|
|
253
|
+
void waitForDomSettle().then(() => {
|
|
254
|
+
options.onToolStep?.({ verb: "click", target: verb.target, ok: true, observation: "Clicked it." });
|
|
255
|
+
});
|
|
193
256
|
return;
|
|
194
257
|
}
|
|
195
258
|
|
|
@@ -207,7 +270,13 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
207
270
|
return;
|
|
208
271
|
}
|
|
209
272
|
highlightElement(el);
|
|
210
|
-
|
|
273
|
+
// See the click case's own comment — the exact real bug found live:
|
|
274
|
+
// typing into a search box, then reading the still-unfiltered
|
|
275
|
+
// results a moment later and reporting a match the real, since-
|
|
276
|
+
// filtered page never actually showed.
|
|
277
|
+
void waitForDomSettle().then(() => {
|
|
278
|
+
options.onToolStep?.({ verb: "fill", target: verb.target, ok: true, observation: `Typed "${verb.value}" into it.` });
|
|
279
|
+
});
|
|
211
280
|
return;
|
|
212
281
|
}
|
|
213
282
|
|
|
@@ -225,12 +294,99 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
225
294
|
|
|
226
295
|
case "call_tool": {
|
|
227
296
|
if (verb.text) options.onExplain(verb.text);
|
|
228
|
-
void executeWebMcpTool(verb.name, verb.args).then((result) => {
|
|
297
|
+
void executeWebMcpTool(verb.name, verb.args, options.onConfirmTool).then((result) => {
|
|
229
298
|
options.onToolStep?.({ verb: "call_tool", target: verb.name, ok: result.ok, observation: result.observation });
|
|
230
299
|
});
|
|
231
300
|
return;
|
|
232
301
|
}
|
|
233
302
|
|
|
303
|
+
case "drag": {
|
|
304
|
+
if (verb.text) options.onExplain(verb.text);
|
|
305
|
+
const from = findElement(verb.target, options.liveElements);
|
|
306
|
+
const to = from ? findElement(verb.to, options.liveElements) : null;
|
|
307
|
+
if (!from || !to) {
|
|
308
|
+
(options.onMiss ?? logMiss)({ attempted: from ? verb.to : verb.target, route });
|
|
309
|
+
options.onToolStep?.({ verb: "drag", target: verb.target, ok: false, observation: from ? "Could not find the drop destination on the page." : "Could not find that element on the page." });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
highlightElement(from);
|
|
313
|
+
dragElement(from, to);
|
|
314
|
+
// Same real re-render race as click/fill — a drop can trigger an
|
|
315
|
+
// async re-render (a canvas connection line, a reordered list) that
|
|
316
|
+
// hasn't settled the instant the pointer sequence finishes.
|
|
317
|
+
void waitForDomSettle().then(() => {
|
|
318
|
+
options.onToolStep?.({ verb: "drag", target: verb.target, ok: true, observation: `Dragged it to ${verb.to}.` });
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
case "select": {
|
|
324
|
+
if (verb.text) options.onExplain(verb.text);
|
|
325
|
+
const el = findElement(verb.target, options.liveElements);
|
|
326
|
+
if (!el || !selectOption(el, verb.value)) {
|
|
327
|
+
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
328
|
+
options.onToolStep?.({
|
|
329
|
+
verb: "select",
|
|
330
|
+
target: verb.target,
|
|
331
|
+
ok: false,
|
|
332
|
+
observation: el ? `Could not find an option matching "${verb.value}".` : "Could not find that element on the page.",
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
highlightElement(el);
|
|
337
|
+
void waitForDomSettle().then(() => {
|
|
338
|
+
options.onToolStep?.({ verb: "select", target: verb.target, ok: true, observation: `Selected "${verb.value}".` });
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
case "key": {
|
|
344
|
+
if (verb.text) options.onExplain(verb.text);
|
|
345
|
+
const el = verb.target ? findElement(verb.target, options.liveElements) : (document.activeElement as HTMLElement | null);
|
|
346
|
+
if (!el) {
|
|
347
|
+
if (verb.target) (options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
348
|
+
options.onToolStep?.({ verb: "key", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
pressKey(el, verb.key);
|
|
352
|
+
void waitForDomSettle().then(() => {
|
|
353
|
+
options.onToolStep?.({ verb: "key", target: verb.target, ok: true, observation: `Pressed ${verb.key}.` });
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
case "scroll": {
|
|
359
|
+
if (verb.text) options.onExplain(verb.text);
|
|
360
|
+
const el = findElement(verb.target, options.liveElements);
|
|
361
|
+
if (!el) {
|
|
362
|
+
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
363
|
+
options.onToolStep?.({ verb: "scroll", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// A real, already-known element (never a coordinate or something
|
|
367
|
+
// not yet discovered) — highlightElement's own scrollIntoView is
|
|
368
|
+
// exactly the real repositioning this verb exists for; the glow
|
|
369
|
+
// also gives the user a visible cue of where the agent just moved.
|
|
370
|
+
highlightElement(el);
|
|
371
|
+
void waitForDomSettle().then(() => {
|
|
372
|
+
options.onToolStep?.({ verb: "scroll", target: verb.target, ok: true, observation: "Scrolled it into view." });
|
|
373
|
+
});
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
case "wait_for": {
|
|
378
|
+
if (verb.text) options.onExplain(verb.text);
|
|
379
|
+
void findElementWithRetry(verb.target, options.liveElements, WAIT_FOR_ATTEMPTS, WAIT_FOR_DELAY_MS).then((el) => {
|
|
380
|
+
if (!el) {
|
|
381
|
+
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
382
|
+
options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: false, observation: "It never appeared." });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: true, observation: "It appeared." });
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
234
390
|
// Several click/fill/read/call_tool steps in one round trip instead of
|
|
235
391
|
// one each — server.ts's resolveVerb already validated every action's
|
|
236
392
|
// target/name against real state before this ever arrived. Runs in
|
|
@@ -257,26 +413,40 @@ async function executeBatchActions(
|
|
|
257
413
|
const steps: string[] = [];
|
|
258
414
|
for (const action of actions) {
|
|
259
415
|
const result = await executeOneBatchAction(action, route, options);
|
|
260
|
-
|
|
416
|
+
const label = ("target" in action && action.target) || ("name" in action && action.name) || "(focused element)";
|
|
417
|
+
steps.push(`${action.verb} ${label}: ${result.observation}`);
|
|
261
418
|
if (!result.ok) return { ok: false, observation: steps.join(" | ") };
|
|
262
419
|
}
|
|
263
420
|
return { ok: true, observation: steps.join(" | ") };
|
|
264
421
|
}
|
|
265
422
|
|
|
423
|
+
// Phase 3 step 4 — real, bounded, LLM-free retry latitude for the
|
|
424
|
+
// Executor's own lookups (CODA's own point: the Executor stays
|
|
425
|
+
// opinion-free; anything requiring judgment escalates to the Critic,
|
|
426
|
+
// which now genuinely exists as of step 3). Scoped to batch specifically,
|
|
427
|
+
// per the plan's own build order — a batch's later steps are the ones
|
|
428
|
+
// most likely to race a DOM update the batch's OWN earlier step just
|
|
429
|
+
// triggered, which is exactly the "stale re-render" case this recovers
|
|
430
|
+
// from; single-step click/fill/read stay unchanged (findElement, no
|
|
431
|
+
// retry) rather than widening scope beyond what was actually planned.
|
|
266
432
|
async function executeOneBatchAction(action: BatchAction, route: string, options: VerbExecutorOptions): Promise<{ ok: boolean; observation: string }> {
|
|
267
433
|
switch (action.verb) {
|
|
268
434
|
case "click": {
|
|
269
|
-
const el =
|
|
435
|
+
const el = await findElementWithRetry(action.target, options.liveElements);
|
|
270
436
|
if (!el) {
|
|
271
437
|
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
272
438
|
return { ok: false, observation: "Could not find that element on the page." };
|
|
273
439
|
}
|
|
274
440
|
highlightElement(el);
|
|
275
441
|
el.click();
|
|
442
|
+
// Same real race as the single-step case (see waitForDomSettle's own
|
|
443
|
+
// doc comment) — arguably MORE likely here, since a batch's next
|
|
444
|
+
// step often deliberately reads what THIS step just changed.
|
|
445
|
+
await waitForDomSettle();
|
|
276
446
|
return { ok: true, observation: "Clicked it." };
|
|
277
447
|
}
|
|
278
448
|
case "fill": {
|
|
279
|
-
const el =
|
|
449
|
+
const el = await findElementWithRetry(action.target, options.liveElements);
|
|
280
450
|
if (!el || !fillElement(el, action.value)) {
|
|
281
451
|
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
282
452
|
return {
|
|
@@ -285,10 +455,11 @@ async function executeOneBatchAction(action: BatchAction, route: string, options
|
|
|
285
455
|
};
|
|
286
456
|
}
|
|
287
457
|
highlightElement(el);
|
|
458
|
+
await waitForDomSettle();
|
|
288
459
|
return { ok: true, observation: `Typed "${action.value}" into it.` };
|
|
289
460
|
}
|
|
290
461
|
case "read": {
|
|
291
|
-
const el =
|
|
462
|
+
const el = await findElementWithRetry(action.target, options.liveElements);
|
|
292
463
|
if (!el) {
|
|
293
464
|
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
294
465
|
return { ok: false, observation: "Could not find that element on the page." };
|
|
@@ -296,9 +467,59 @@ async function executeOneBatchAction(action: BatchAction, route: string, options
|
|
|
296
467
|
return { ok: true, observation: readElement(el) };
|
|
297
468
|
}
|
|
298
469
|
case "call_tool": {
|
|
299
|
-
const result = await executeWebMcpTool(action.name, action.args);
|
|
470
|
+
const result = await executeWebMcpTool(action.name, action.args, options.onConfirmTool);
|
|
300
471
|
return { ok: result.ok, observation: result.observation };
|
|
301
472
|
}
|
|
473
|
+
case "drag": {
|
|
474
|
+
const from = await findElementWithRetry(action.target, options.liveElements);
|
|
475
|
+
const to = from ? await findElementWithRetry(action.to, options.liveElements) : null;
|
|
476
|
+
if (!from || !to) {
|
|
477
|
+
(options.onMiss ?? logMiss)({ attempted: from ? action.to : action.target, route });
|
|
478
|
+
return { ok: false, observation: from ? "Could not find the drop destination on the page." : "Could not find that element on the page." };
|
|
479
|
+
}
|
|
480
|
+
highlightElement(from);
|
|
481
|
+
dragElement(from, to);
|
|
482
|
+
await waitForDomSettle();
|
|
483
|
+
return { ok: true, observation: `Dragged it to ${action.to}.` };
|
|
484
|
+
}
|
|
485
|
+
case "select": {
|
|
486
|
+
const el = await findElementWithRetry(action.target, options.liveElements);
|
|
487
|
+
if (!el || !selectOption(el, action.value)) {
|
|
488
|
+
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
489
|
+
return { ok: false, observation: el ? `Could not find an option matching "${action.value}".` : "Could not find that element on the page." };
|
|
490
|
+
}
|
|
491
|
+
highlightElement(el);
|
|
492
|
+
await waitForDomSettle();
|
|
493
|
+
return { ok: true, observation: `Selected "${action.value}".` };
|
|
494
|
+
}
|
|
495
|
+
case "key": {
|
|
496
|
+
const el = action.target ? await findElementWithRetry(action.target, options.liveElements) : (document.activeElement as HTMLElement | null);
|
|
497
|
+
if (!el) {
|
|
498
|
+
if (action.target) (options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
499
|
+
return { ok: false, observation: "Could not find that element on the page." };
|
|
500
|
+
}
|
|
501
|
+
pressKey(el, action.key);
|
|
502
|
+
await waitForDomSettle();
|
|
503
|
+
return { ok: true, observation: `Pressed ${action.key}.` };
|
|
504
|
+
}
|
|
505
|
+
case "scroll": {
|
|
506
|
+
const el = await findElementWithRetry(action.target, options.liveElements);
|
|
507
|
+
if (!el) {
|
|
508
|
+
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
509
|
+
return { ok: false, observation: "Could not find that element on the page." };
|
|
510
|
+
}
|
|
511
|
+
highlightElement(el);
|
|
512
|
+
await waitForDomSettle();
|
|
513
|
+
return { ok: true, observation: "Scrolled it into view." };
|
|
514
|
+
}
|
|
515
|
+
case "wait_for": {
|
|
516
|
+
const el = await findElementWithRetry(action.target, options.liveElements, WAIT_FOR_ATTEMPTS, WAIT_FOR_DELAY_MS);
|
|
517
|
+
if (!el) {
|
|
518
|
+
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
519
|
+
return { ok: false, observation: "It never appeared." };
|
|
520
|
+
}
|
|
521
|
+
return { ok: true, observation: "It appeared." };
|
|
522
|
+
}
|
|
302
523
|
}
|
|
303
524
|
}
|
|
304
525
|
|