@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,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
+ }
@@ -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, or a
13
- * batch of several) — fed back to the model as its next turn's
14
- * "observation" so it can decide what to do next instead of acting blind.
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) resolves once the real action has actually
29
- * finished (synchronously for click/fill/read, after a real await for
30
- * call_tool) with its real observation, instead of the fire-and-forget
31
- * callback shape every other verb uses. This is what a loop driver awaits
32
- * before deciding whether to call the model again.
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(raw: unknown, route: string, liveElements?: Map<string, HTMLElement>): Promise<ToolStepResult | null> {
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 TERMINAL_VERBS) that the parsed verb is one,
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
- options.onToolStep?.({ verb: "click", target: verb.target, ok: true, observation: "Clicked it." });
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
- options.onToolStep?.({ verb: "fill", target: verb.target, ok: true, observation: `Typed "${verb.value}" into it.` });
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
- steps.push(`${action.verb} ${"target" in action ? action.target : action.name}: ${result.observation}`);
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 = findElement(action.target, options.liveElements);
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 = findElement(action.target, options.liveElements);
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 = findElement(action.target, options.liveElements);
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