@cairnvibe/sdk 0.2.13 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-loop.d.ts +113 -0
- package/dist/agent-loop.js +128 -0
- package/dist/cairn-widget.js +14 -9
- package/dist/cursor-overlay.d.ts +19 -0
- package/dist/cursor-overlay.js +126 -0
- 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 +886 -96
- 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 +561 -121
- package/dist/server.d.ts +266 -5
- package/dist/server.js +1013 -83
- 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 +315 -39
- 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/cursor-overlay.ts +130 -0
- package/src/element-ladder.ts +170 -0
- package/src/index.tsx +935 -100
- 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 +669 -123
- package/src/server.ts +1119 -83
- 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 +329 -42
- package/src/web-component.ts +97 -24
- package/src/webmcp-client.ts +30 -2
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// The shared skeleton behind both agent-loop drivers — index.tsx's
|
|
2
|
+
// runTypedAgentLoop (HTTP/typed transport) and realtime-server.ts's
|
|
3
|
+
// finalizeTurn (WebSocket/voice relay) independently re-implemented the
|
|
4
|
+
// exact same "ask, check terminal, execute a continuing step for real,
|
|
5
|
+
// fold the result into working history, ask again, up to a hard
|
|
6
|
+
// iteration cap" shape — a real, live duplication risk (any future fix
|
|
7
|
+
// to one had to be remembered and re-applied to the other by hand).
|
|
8
|
+
// This module is the first step of the Phase 3 multi-agent redesign
|
|
9
|
+
// (see DEVELOPMENT.md/the plan file's "Phase 3" entry): extract exactly
|
|
10
|
+
// that shared shape, with ZERO behavior change, so Planner/Critic
|
|
11
|
+
// wiring in later steps has one real place to attach to instead of two.
|
|
12
|
+
//
|
|
13
|
+
// Deliberately does NOT own transport-specific side effects — sending a
|
|
14
|
+
// message to a client, speaking, committing to a connection's real
|
|
15
|
+
// cross-turn memory, barge-in cancellation timing. Those stay in each
|
|
16
|
+
// transport's own getNextStep/onStep/onStepResult/executeStep closures,
|
|
17
|
+
// and in what the caller does with this function's return value, exactly
|
|
18
|
+
// as before this extraction. Plain TypeScript only (no JSX, no Node
|
|
19
|
+
// built-ins) — imported as raw source by index.tsx's browser bundle AND
|
|
20
|
+
// compiled to dist/ for realtime-server.ts's Node build.
|
|
21
|
+
|
|
22
|
+
import { isTerminalVerb, type AgentEvent, type CriticVerdict, type HistoryTurn, type VerbResponse } from "@cairnvibe/core";
|
|
23
|
+
|
|
24
|
+
/** 4 exchanges — matches the cap both original drivers independently used. */
|
|
25
|
+
export const MAX_HISTORY_TURNS = 8;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Architecture Pillar 4 — a cheap, LOCAL signal for "this goal probably
|
|
29
|
+
* needs more than one real step," checked BEFORE the first step even
|
|
30
|
+
* runs, so a caller can start the Planner call in PARALLEL with the
|
|
31
|
+
* first getNextStep instead of only after that first step already came
|
|
32
|
+
* back non-terminal (the "lazy gate" the plan singles out for
|
|
33
|
+
* replacement — realtime-server.ts's own onStep used to build planPromise
|
|
34
|
+
* only once `!terminal && iteration === 0` was already true, one full
|
|
35
|
+
* model round trip later than it needed to be). Deliberately
|
|
36
|
+
* conservative, on purpose: a false negative here just falls back to
|
|
37
|
+
* that same lazy-after-step-1 behavior — unchanged, zero regression —
|
|
38
|
+
* while a false positive costs one Planner call that would have started
|
|
39
|
+
* a moment later anyway, never a wrong answer. Genuine UI-pattern-aware
|
|
40
|
+
* classification (Pillar 2, not built yet) can replace this heuristic
|
|
41
|
+
* later without changing what calls it. Lives here (not server.ts) so
|
|
42
|
+
* BOTH transports can use the exact same check: this file is plain,
|
|
43
|
+
* dependency-free TypeScript imported as raw source by index.tsx's
|
|
44
|
+
* browser bundle AND compiled for realtime-server.ts's Node build — a
|
|
45
|
+
* server-only file (server.ts imports the Anthropic/Groq SDKs) can never
|
|
46
|
+
* be imported from the client widget.
|
|
47
|
+
*/
|
|
48
|
+
const MULTI_STEP_SIGNAL = /\b(then|after that|once (you|it|that|i)|and then|next,|first[,.]? .*\bthen\b)\b/;
|
|
49
|
+
export function looksMultiStep(question: string): boolean {
|
|
50
|
+
return MULTI_STEP_SIGNAL.test(question.toLowerCase());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function summarizeVerbForHistory(verb: VerbResponse): string {
|
|
54
|
+
if ("text" in verb && verb.text) return verb.text;
|
|
55
|
+
switch (verb.verb) {
|
|
56
|
+
case "highlight":
|
|
57
|
+
case "open":
|
|
58
|
+
return `(highlighted ${verb.target})`;
|
|
59
|
+
case "navigate":
|
|
60
|
+
return `(navigated to ${verb.route})`;
|
|
61
|
+
case "do":
|
|
62
|
+
return `(ran ${verb.action}${verb.target ? ` on ${verb.target}` : ""})`;
|
|
63
|
+
case "tour":
|
|
64
|
+
return verb.steps.map((s) => s.text).join(" ");
|
|
65
|
+
case "click":
|
|
66
|
+
return `(clicked ${verb.target})`;
|
|
67
|
+
case "fill":
|
|
68
|
+
return `(typed "${verb.value}" into ${verb.target})`;
|
|
69
|
+
case "read":
|
|
70
|
+
return `(read ${verb.target})`;
|
|
71
|
+
case "call_tool":
|
|
72
|
+
return `(called ${verb.name})`;
|
|
73
|
+
case "drag":
|
|
74
|
+
return `(dragged ${verb.target} to ${verb.to})`;
|
|
75
|
+
case "select":
|
|
76
|
+
return `(selected "${verb.value}" in ${verb.target})`;
|
|
77
|
+
case "key":
|
|
78
|
+
return `(pressed ${verb.key}${verb.target ? ` on ${verb.target}` : ""})`;
|
|
79
|
+
case "batch":
|
|
80
|
+
return `(${verb.actions.length} steps: ${verb.actions.map((a) => a.verb).join(", ")})`;
|
|
81
|
+
default:
|
|
82
|
+
return "(no response)";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AgentLoopStepEvent {
|
|
87
|
+
verb: VerbResponse;
|
|
88
|
+
/** 0-based. */
|
|
89
|
+
iteration: number;
|
|
90
|
+
/** True if isTerminalVerb(verb) says this ends the loop right after
|
|
91
|
+
* this hook returns (TERMINAL_VERBS membership, except a navigate
|
|
92
|
+
* marked continueAfter — see isTerminalVerb's own doc comment). Lets a
|
|
93
|
+
* caller act differently for a continuing vs. final step without
|
|
94
|
+
* re-deriving that check itself. */
|
|
95
|
+
terminal: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface AgentLoopStepResultEvent {
|
|
99
|
+
verb: VerbResponse;
|
|
100
|
+
iteration: number;
|
|
101
|
+
observation: string | null | undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface AgentLoopDeps {
|
|
105
|
+
/** Resolve the next step given the CURRENT working history. Return
|
|
106
|
+
* `null` for a response that failed to parse/validate — the HTTP
|
|
107
|
+
* path's own real case (a raw fetch response might not conform);
|
|
108
|
+
* realtime's in-process resolveVerb never produces this, since it
|
|
109
|
+
* always returns a valid VerbResponse itself. A null return ends the
|
|
110
|
+
* loop immediately with outcome "unparseable". */
|
|
111
|
+
getNextStep(loopHistory: HistoryTurn[], iteration: number): Promise<VerbResponse | null>;
|
|
112
|
+
/** Fires immediately after getNextStep resolves, before the terminal/
|
|
113
|
+
* continuing branch is decided — the real-time side-effect point (send
|
|
114
|
+
* the verb to a client, trigger an ack on the first continuing step,
|
|
115
|
+
* check for a superseding barge-in). Returning true aborts the loop
|
|
116
|
+
* immediately: no further side effects, outcome "aborted". */
|
|
117
|
+
onStep?(event: AgentLoopStepEvent): boolean | Promise<boolean>;
|
|
118
|
+
/** Execute a continuing verb (click/fill/read/call_tool/batch) for
|
|
119
|
+
* real; return its observation text (or null/undefined for "no
|
|
120
|
+
* result", folded into history as "no result" exactly like both
|
|
121
|
+
* original drivers already did). */
|
|
122
|
+
executeStep(verb: VerbResponse, iteration: number): Promise<string | null | undefined>;
|
|
123
|
+
/** Fires after executeStep resolves, before folding the observation
|
|
124
|
+
* into working history — a second real-time abort checkpoint (e.g. a
|
|
125
|
+
* barge-in generation check after awaiting a real tool result, which
|
|
126
|
+
* can itself take a while). Returning true aborts the loop with
|
|
127
|
+
* outcome "aborted", discarding this step's observation. */
|
|
128
|
+
onStepResult?(event: AgentLoopStepResultEvent): boolean | Promise<boolean>;
|
|
129
|
+
/**
|
|
130
|
+
* Phase 3 step 3 — a genuinely separate pass over the step's REAL
|
|
131
|
+
* observation, decoupled from the Executor/model's own self-report
|
|
132
|
+
* (the direct fix for the diagnosed bug: a batch succeeded and the
|
|
133
|
+
* model kept looping instead of recognizing it). Fires after
|
|
134
|
+
* onStepResult/the history fold. Returning a "task_complete" or
|
|
135
|
+
* "give_up" verdict ends the loop right here — even though the
|
|
136
|
+
* model's own verb was never a TERMINAL_VERBS member — instead of
|
|
137
|
+
* asking the model again and hoping it notices. Returning "continue"
|
|
138
|
+
* (including after the caller's own closure has silently handled a
|
|
139
|
+
* "replan" by fetching a fresh Plan — driveAgentLoop itself has no
|
|
140
|
+
* concept of a Plan, only of "keep going or stop") keeps the loop
|
|
141
|
+
* going exactly as if this hook were absent. Returning null/undefined
|
|
142
|
+
* behaves the same as "continue" — a caller can choose not to run the
|
|
143
|
+
* Critic on a particular step without a special no-op verdict shape.
|
|
144
|
+
*/
|
|
145
|
+
runCritic?(event: AgentLoopStepResultEvent): Promise<CriticVerdict | null | undefined>;
|
|
146
|
+
/**
|
|
147
|
+
* Phase 3 step 5 — a pure, fire-and-forget event consumer for a
|
|
148
|
+
* Talker-style narration layer ("Revisable by Design"'s pattern):
|
|
149
|
+
* never awaited, never able to affect control flow. driveAgentLoop
|
|
150
|
+
* itself emits "act" (right after a step's onStep/abort check passes —
|
|
151
|
+
* only for a verb that's actually going to execute, never a discarded
|
|
152
|
+
* one) and "obs" (right after onStepResult's own abort check passes),
|
|
153
|
+
* since it already has that data at exactly those points. A caller's
|
|
154
|
+
* own onStep/runCritic closures can call this SAME callback directly —
|
|
155
|
+
* it's just a plain reference they already have via the deps object
|
|
156
|
+
* they constructed — to emit "thk" (Critic reasoning) or "inj"
|
|
157
|
+
* (injected filler narration, e.g. a Talker ack phrase) events too;
|
|
158
|
+
* driveAgentLoop has no opinion on those.
|
|
159
|
+
*/
|
|
160
|
+
onEvent?(event: AgentEvent): void;
|
|
161
|
+
/** Defaults to 6 — a hard cap, not a target, matching both original drivers. */
|
|
162
|
+
maxIterations?: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type AgentLoopOutcome =
|
|
166
|
+
| { outcome: "terminal"; finalVerb: VerbResponse; workingHistory: HistoryTurn[] }
|
|
167
|
+
/** The Critic independently confirmed the (last) task's doneContract
|
|
168
|
+
* is satisfied — the real fix for the diagnosed bug. The caller
|
|
169
|
+
* synthesizes its own terminal-shaped response (e.g. `{verb: "explain",
|
|
170
|
+
* text: verdict.reasoning}`) from `verdict`, same as it would for a
|
|
171
|
+
* model-produced terminal verb. */
|
|
172
|
+
| { outcome: "critic-complete"; verdict: CriticVerdict; workingHistory: HistoryTurn[] }
|
|
173
|
+
/** The Critic (or the harness's own stall-count fail-safe, inside the
|
|
174
|
+
* caller's runCritic closure) decided continuing wouldn't help. */
|
|
175
|
+
| { outcome: "critic-give-up"; verdict: CriticVerdict; workingHistory: HistoryTurn[] }
|
|
176
|
+
| { outcome: "unparseable"; workingHistory: HistoryTurn[] }
|
|
177
|
+
| { outcome: "gave-up"; workingHistory: HistoryTurn[] }
|
|
178
|
+
| { outcome: "aborted"; workingHistory: HistoryTurn[] };
|
|
179
|
+
|
|
180
|
+
export async function driveAgentLoop(initialHistory: HistoryTurn[], deps: AgentLoopDeps): Promise<AgentLoopOutcome> {
|
|
181
|
+
const maxIterations = deps.maxIterations ?? 6;
|
|
182
|
+
let loopHistory = initialHistory;
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
185
|
+
const verb = await deps.getNextStep(loopHistory, i);
|
|
186
|
+
if (verb === null) return { outcome: "unparseable", workingHistory: loopHistory };
|
|
187
|
+
|
|
188
|
+
const terminal = isTerminalVerb(verb);
|
|
189
|
+
if (deps.onStep) {
|
|
190
|
+
const abort = await deps.onStep({ verb, iteration: i, terminal });
|
|
191
|
+
if (abort) return { outcome: "aborted", workingHistory: loopHistory };
|
|
192
|
+
}
|
|
193
|
+
deps.onEvent?.({ type: "act", verb, at: Date.now() });
|
|
194
|
+
|
|
195
|
+
if (terminal) {
|
|
196
|
+
return { outcome: "terminal", finalVerb: verb, workingHistory: loopHistory };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const observation = await deps.executeStep(verb, i);
|
|
200
|
+
if (deps.onStepResult) {
|
|
201
|
+
const abort = await deps.onStepResult({ verb, iteration: i, observation });
|
|
202
|
+
if (abort) return { outcome: "aborted", workingHistory: loopHistory };
|
|
203
|
+
}
|
|
204
|
+
deps.onEvent?.({ type: "obs", observation: observation ?? "no result", ok: observation !== null && observation !== undefined, at: Date.now() });
|
|
205
|
+
|
|
206
|
+
loopHistory = [
|
|
207
|
+
...loopHistory,
|
|
208
|
+
{ role: "assistant" as const, text: `${summarizeVerbForHistory(verb)}. Result: ${observation ?? "no result"}` },
|
|
209
|
+
].slice(-MAX_HISTORY_TURNS);
|
|
210
|
+
|
|
211
|
+
if (deps.runCritic) {
|
|
212
|
+
const verdict = await deps.runCritic({ verb, iteration: i, observation });
|
|
213
|
+
if (verdict?.verdict === "task_complete") return { outcome: "critic-complete", verdict, workingHistory: loopHistory };
|
|
214
|
+
if (verdict?.verdict === "give_up") return { outcome: "critic-give-up", verdict, workingHistory: loopHistory };
|
|
215
|
+
// "continue", "replan" (already handled inside the caller's own
|
|
216
|
+
// runCritic closure — see this field's own doc comment), or no
|
|
217
|
+
// verdict at all: fall through and keep looping, unchanged.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { outcome: "gave-up", workingHistory: loopHistory };
|
|
222
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// A visible, animated synthetic cursor that glides to whatever element the
|
|
2
|
+
// agent is about to act on, and genuinely arrives — before anything actually
|
|
3
|
+
// happens on screen — rather than a click just occurring with no visible
|
|
4
|
+
// lead-up. Real, watchable proof of what the agent resolved, the same way
|
|
5
|
+
// watching a person's own mouse move tells you where they're about to click
|
|
6
|
+
// before it happens; matches this SDK's own "verified, not trusted"
|
|
7
|
+
// discipline in a form a user can literally see, not just read.
|
|
8
|
+
//
|
|
9
|
+
// Purely additive and deliberately decoupled from highlightElement
|
|
10
|
+
// (element-ladder.ts) — that function's own scroll+glow behavior is
|
|
11
|
+
// unchanged and still called by every site that used it before. This module
|
|
12
|
+
// only adds the moving cursor itself; a caller awaits moveCursorTo(el)
|
|
13
|
+
// before firing the real action so the cursor is seen arriving first, never
|
|
14
|
+
// after the fact — see verb-executor.ts's own call sites for the exact
|
|
15
|
+
// sequencing.
|
|
16
|
+
|
|
17
|
+
const CURSOR_ID = "cairn-cursor";
|
|
18
|
+
const MOVE_MS = 550;
|
|
19
|
+
const ARRIVE_PAUSE_MS = 160;
|
|
20
|
+
// The CSS side already disables the cursor's transition/animation under
|
|
21
|
+
// prefers-reduced-motion (see #cairn-cursor in the injected <style> block),
|
|
22
|
+
// which makes it jump instead of glide — but without this, the real delay
|
|
23
|
+
// before the action fires would stay the full ~710ms even though there's
|
|
24
|
+
// nothing left to watch. Mirrors the visual change with a real timing one.
|
|
25
|
+
const REDUCED_MOVE_MS = 60;
|
|
26
|
+
const REDUCED_ARRIVE_PAUSE_MS = 40;
|
|
27
|
+
|
|
28
|
+
function prefersReducedMotion(): boolean {
|
|
29
|
+
return (
|
|
30
|
+
typeof window !== "undefined" &&
|
|
31
|
+
typeof window.matchMedia === "function" &&
|
|
32
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Module-scope, not per-call — the whole point is a SINGLE cursor that
|
|
37
|
+
// glides from wherever it last was, the way a real mouse never teleports
|
|
38
|
+
// between two unrelated screen positions.
|
|
39
|
+
let lastX: number | null = null;
|
|
40
|
+
let lastY: number | null = null;
|
|
41
|
+
|
|
42
|
+
function ensureCursorEl(): HTMLElement | null {
|
|
43
|
+
if (typeof document === "undefined" || !document.body) return null;
|
|
44
|
+
let el = document.getElementById(CURSOR_ID);
|
|
45
|
+
if (el) return el;
|
|
46
|
+
el = document.createElement("div");
|
|
47
|
+
el.id = CURSOR_ID;
|
|
48
|
+
el.setAttribute("aria-hidden", "true");
|
|
49
|
+
// A simple filled pointer shape — matches the widget's own ember accent,
|
|
50
|
+
// with a thin dark stroke so it reads clearly on light AND dark pages
|
|
51
|
+
// (the host app's own background is never something this SDK controls).
|
|
52
|
+
el.innerHTML =
|
|
53
|
+
'<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">' +
|
|
54
|
+
'<path d="M2 1.5 L2 18.2 L6.3 14.4 L9.1 20.6 L11.7 19.4 L8.9 13.3 L14.6 13.1 Z" fill="#E07A3F" stroke="#1B1815" stroke-width="1.1" stroke-linejoin="round"/>' +
|
|
55
|
+
"</svg>";
|
|
56
|
+
el.style.cssText =
|
|
57
|
+
"position:fixed;left:0;top:0;z-index:2147483001;pointer-events:none;opacity:0;transition:opacity 180ms ease;will-change:transform;filter:drop-shadow(0 3px 6px rgba(0,0,0,0.35));";
|
|
58
|
+
document.body.appendChild(el);
|
|
59
|
+
return el;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Animates the synthetic cursor to `el`'s center and resolves once it has
|
|
64
|
+
* genuinely arrived (plus a brief real hover pause) — callers await this
|
|
65
|
+
* BEFORE performing the real action, so the cursor is seen gliding there
|
|
66
|
+
* first. Deliberately timer-driven (`window.setTimeout`), not
|
|
67
|
+
* `transitionend`/`Element.animate().finished`-driven — this repo's test
|
|
68
|
+
* environment is plain Node, not a real browser (see waitForDomSettle's own
|
|
69
|
+
* doc comment for the same discipline), and a fixed, known duration is what
|
|
70
|
+
* makes this testable with fake timers instead of needing real animation-
|
|
71
|
+
* completion events that a headless/no-DOM environment may never fire.
|
|
72
|
+
*
|
|
73
|
+
* SSR/no-DOM safe — same defensive guard `waitForDomSettle` already uses —
|
|
74
|
+
* so a caller never needs its own environment check before calling this.
|
|
75
|
+
*/
|
|
76
|
+
export function moveCursorTo(el: HTMLElement): Promise<void> {
|
|
77
|
+
if (typeof document === "undefined" || typeof window === "undefined" || typeof el.getBoundingClientRect !== "function") {
|
|
78
|
+
return Promise.resolve();
|
|
79
|
+
}
|
|
80
|
+
const cursor = ensureCursorEl();
|
|
81
|
+
if (!cursor) return Promise.resolve();
|
|
82
|
+
|
|
83
|
+
const rect = el.getBoundingClientRect();
|
|
84
|
+
const x = rect.left + rect.width / 2;
|
|
85
|
+
const y = rect.top + rect.height / 2;
|
|
86
|
+
|
|
87
|
+
if (lastX === null || lastY === null) {
|
|
88
|
+
// The very first move of the session starts from the widget's own
|
|
89
|
+
// corner (bottom-right, where the FAB lives) instead of materializing
|
|
90
|
+
// at (0,0) — reads as "coming from Cairn," not appearing from nowhere.
|
|
91
|
+
lastX = window.innerWidth - 40;
|
|
92
|
+
lastY = window.innerHeight - 40;
|
|
93
|
+
cursor.style.transform = `translate(${lastX}px, ${lastY}px)`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const reduced = prefersReducedMotion();
|
|
97
|
+
const moveMs = reduced ? REDUCED_MOVE_MS : MOVE_MS;
|
|
98
|
+
const arrivePauseMs = reduced ? REDUCED_ARRIVE_PAUSE_MS : ARRIVE_PAUSE_MS;
|
|
99
|
+
|
|
100
|
+
cursor.style.transition = `transform ${moveMs}ms cubic-bezier(.4,0,.2,1), opacity 180ms ease`;
|
|
101
|
+
cursor.style.opacity = "1";
|
|
102
|
+
// Forces a style flush so the browser animates FROM the current position
|
|
103
|
+
// TO the new one instead of jumping straight there — reading a layout
|
|
104
|
+
// property is the standard, harmless way to force this without a real
|
|
105
|
+
// animation API (which, per this function's own doc comment, this
|
|
106
|
+
// deliberately avoids depending on for its completion signal anyway).
|
|
107
|
+
void cursor.offsetHeight;
|
|
108
|
+
cursor.style.transform = `translate(${x}px, ${y}px)`;
|
|
109
|
+
lastX = x;
|
|
110
|
+
lastY = y;
|
|
111
|
+
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
window.setTimeout(() => {
|
|
114
|
+
cursor.classList.add("cairn-cursor-hover");
|
|
115
|
+
window.setTimeout(() => {
|
|
116
|
+
cursor.classList.remove("cairn-cursor-hover");
|
|
117
|
+
resolve();
|
|
118
|
+
}, arrivePauseMs);
|
|
119
|
+
}, moveMs);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Fades the synthetic cursor out — called once the widget itself closes or
|
|
124
|
+
* unmounts, so it doesn't sit visible on screen after the conversation
|
|
125
|
+
* ends. Safe to call even if the cursor was never created. */
|
|
126
|
+
export function hideCursor(): void {
|
|
127
|
+
if (typeof document === "undefined") return;
|
|
128
|
+
const el = document.getElementById(CURSOR_ID);
|
|
129
|
+
if (el) el.style.opacity = "0";
|
|
130
|
+
}
|
package/src/element-ladder.ts
CHANGED
|
@@ -40,6 +40,94 @@ export function findElement(target: string, liveElements?: Map<string, HTMLEleme
|
|
|
40
40
|
return null;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Phase 3 step 4 (see DEVELOPMENT.md/the plan file) — CODA's own point:
|
|
45
|
+
* the Executor gets real local retry latitude for a genuinely MECHANICAL
|
|
46
|
+
* miss (a re-render replaced the DOM node the frozen liveElements snapshot
|
|
47
|
+
* pointed at; an animation/async render hadn't settled yet) before a
|
|
48
|
+
* failure escalates all the way to the Critic/a replan. Deliberately NOT
|
|
49
|
+
* a second LLM call — the Executor stays opinion-free, exactly re-running
|
|
50
|
+
* the SAME real lookup (which, past the liveElements-map check, already
|
|
51
|
+
* queries the LIVE DOM directly — a stale snapshot doesn't matter to that
|
|
52
|
+
* part) after a short real wait. A target that's genuinely not on the
|
|
53
|
+
* page still fails after `attempts`, surfacing as a real miss — this
|
|
54
|
+
* never silently invents success.
|
|
55
|
+
*/
|
|
56
|
+
export async function findElementWithRetry(
|
|
57
|
+
target: string,
|
|
58
|
+
liveElements?: Map<string, HTMLElement>,
|
|
59
|
+
attempts = 2,
|
|
60
|
+
delayMs = 300,
|
|
61
|
+
): Promise<HTMLElement | null> {
|
|
62
|
+
for (let i = 0; i < attempts; i++) {
|
|
63
|
+
const el = findElement(target, liveElements);
|
|
64
|
+
if (el) return el;
|
|
65
|
+
if (i < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Real, live-found bug this closes: a `fill`/`click` step reported itself
|
|
72
|
+
* "done" the instant its DOM event was dispatched — but the app's own
|
|
73
|
+
* reaction to that event (a filtered search-results grid re-rendering, a
|
|
74
|
+
* cart count updating) can be an unbounded-latency async round trip (a
|
|
75
|
+
* Next.js App Router `router.push` re-fetching a server component, for
|
|
76
|
+
* example — not a fixed debounce with a known delay to just sleep past).
|
|
77
|
+
* A `read` step immediately after saw STALE content and the agent
|
|
78
|
+
* confidently reported findings that didn't match what the page actually,
|
|
79
|
+
* eventually, showed — confirmed live: typing "book" into a search box,
|
|
80
|
+
* then reading the still-unfiltered product grid a moment later, and
|
|
81
|
+
* reporting a match ("Novel: The Long Way") the REAL, since-filtered page
|
|
82
|
+
* went on to show zero results for.
|
|
83
|
+
*
|
|
84
|
+
* Waits for real DOM mutations instead of guessing a sleep duration: if
|
|
85
|
+
* nothing starts mutating within `initialWaitMs`, resolves immediately
|
|
86
|
+
* (the action had no async effect at all — no reason to add latency to
|
|
87
|
+
* the common case); once mutations start, waits for `quietMs` of no
|
|
88
|
+
* further mutations before considering the page settled; a hard
|
|
89
|
+
* `timeoutMs` ceiling means a page that never stops mutating (an
|
|
90
|
+
* animation, a polling widget) can't stall the agent loop forever.
|
|
91
|
+
*/
|
|
92
|
+
export function waitForDomSettle(initialWaitMs = 100, quietMs = 200, timeoutMs = 1500): Promise<void> {
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
// Real gap this closes: some callers stub a partial `document` (real
|
|
95
|
+
// tests in this repo do exactly that for other reasons — a fake
|
|
96
|
+
// WebMCP-tool document, for instance) without the rest of the DOM API
|
|
97
|
+
// surface — checking `document` alone isn't enough to guarantee
|
|
98
|
+
// MutationObserver (or document.body) actually exist too.
|
|
99
|
+
if (typeof document === "undefined" || typeof MutationObserver === "undefined" || !document.body) {
|
|
100
|
+
resolve();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
let settled = false;
|
|
104
|
+
let sawMutation = false;
|
|
105
|
+
let quietTimer: ReturnType<typeof setTimeout> | null = null;
|
|
106
|
+
|
|
107
|
+
const finish = () => {
|
|
108
|
+
if (settled) return;
|
|
109
|
+
settled = true;
|
|
110
|
+
observer.disconnect();
|
|
111
|
+
if (quietTimer) clearTimeout(quietTimer);
|
|
112
|
+
clearTimeout(hardCap);
|
|
113
|
+
resolve();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const observer = new MutationObserver(() => {
|
|
117
|
+
sawMutation = true;
|
|
118
|
+
if (quietTimer) clearTimeout(quietTimer);
|
|
119
|
+
quietTimer = setTimeout(finish, quietMs);
|
|
120
|
+
});
|
|
121
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true });
|
|
122
|
+
|
|
123
|
+
const hardCap = setTimeout(finish, timeoutMs);
|
|
124
|
+
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
if (!sawMutation) finish(); // the action had no async effect — nothing to wait for
|
|
127
|
+
}, initialWaitMs);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
43
131
|
export function highlightElement(el: HTMLElement, glowMs = 4000): void {
|
|
44
132
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
45
133
|
el.classList.add("cairn-glow");
|
|
@@ -87,6 +175,88 @@ export function readElement(el: HTMLElement): string {
|
|
|
87
175
|
return trimmed.length > 500 ? `${trimmed.slice(0, 499)}…` : trimmed || "(empty)";
|
|
88
176
|
}
|
|
89
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Chooses a real `<option>` by its visible text — never a raw internal
|
|
180
|
+
* `value` the model could never actually see. Native `<select>` gets the
|
|
181
|
+
* direct path (set `.value` to the matching option's own value, then fire
|
|
182
|
+
* the same input/change pair fillElement uses so React notices). A custom
|
|
183
|
+
* listbox/combobox (role="listbox"/"option" — Radix, Headless UI, etc.)
|
|
184
|
+
* has no real `<option>` to set, so the fallback clicks the matching
|
|
185
|
+
* option-shaped descendant instead, the same "do the real user gesture"
|
|
186
|
+
* principle the do/click cases already follow.
|
|
187
|
+
*/
|
|
188
|
+
export function selectOption(el: HTMLElement, visibleText: string): boolean {
|
|
189
|
+
if (el.tagName === "SELECT") {
|
|
190
|
+
const select = el as HTMLSelectElement;
|
|
191
|
+
const match = Array.from(select.options).find((o) => normalize(o.textContent ?? "") === normalize(visibleText)) ?? Array.from(select.options).find((o) => normalize(o.textContent ?? "").includes(normalize(visibleText)));
|
|
192
|
+
if (!match) return false;
|
|
193
|
+
select.value = match.value;
|
|
194
|
+
select.dispatchEvent(new Event("input", { bubbles: true }));
|
|
195
|
+
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const candidates = el.querySelectorAll<HTMLElement>('[role="option"], option, li, [role="menuitem"]');
|
|
200
|
+
const match = Array.from(candidates).find((c) => normalize(c.textContent ?? "") === normalize(visibleText)) ?? Array.from(candidates).find((c) => normalize(c.textContent ?? "").includes(normalize(visibleText)));
|
|
201
|
+
if (!match) return false;
|
|
202
|
+
match.click();
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A real multi-point pointer-event sequence — pointerdown on `from`'s
|
|
208
|
+
* center, several pointermove steps toward `to`'s center, pointerup on
|
|
209
|
+
* `to` — the same technique a real mouse drag produces, for canvas/kanban/
|
|
210
|
+
* sortable-list libraries (react-dnd, dnd-kit, n8n's own node canvas) that
|
|
211
|
+
* listen for pointer events rather than a single synthetic "drop". Mouse
|
|
212
|
+
* events are fired alongside (same coordinates) for the older libraries
|
|
213
|
+
* that still only listen for those. jsdom's getBoundingClientRect returns
|
|
214
|
+
* all-zero rects with no real layout engine — fine here, since what matters
|
|
215
|
+
* for a test is that the sequence fires with consistent coordinates, not
|
|
216
|
+
* that they reflect real pixels.
|
|
217
|
+
*/
|
|
218
|
+
export function dragElement(from: HTMLElement, to: HTMLElement, steps = 5): void {
|
|
219
|
+
const fromRect = from.getBoundingClientRect();
|
|
220
|
+
const toRect = to.getBoundingClientRect();
|
|
221
|
+
const fromX = fromRect.left + fromRect.width / 2;
|
|
222
|
+
const fromY = fromRect.top + fromRect.height / 2;
|
|
223
|
+
const toX = toRect.left + toRect.width / 2;
|
|
224
|
+
const toY = toRect.top + toRect.height / 2;
|
|
225
|
+
|
|
226
|
+
const fire = (target: HTMLElement, type: string, x: number, y: number) => {
|
|
227
|
+
const opts = { bubbles: true, cancelable: true, clientX: x, clientY: y, view: typeof window !== "undefined" ? window : undefined };
|
|
228
|
+
if (typeof PointerEvent !== "undefined") target.dispatchEvent(new PointerEvent(type.replace("mouse", "pointer"), opts));
|
|
229
|
+
target.dispatchEvent(new MouseEvent(type, opts));
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
fire(from, "mousedown", fromX, fromY);
|
|
233
|
+
for (let i = 1; i <= steps; i++) {
|
|
234
|
+
const x = fromX + ((toX - fromX) * i) / steps;
|
|
235
|
+
const y = fromY + ((toY - fromY) * i) / steps;
|
|
236
|
+
fire(i === steps ? to : from, "mousemove", x, y);
|
|
237
|
+
}
|
|
238
|
+
fire(to, "mouseup", toX, toY);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const KEYS_WITH_PRINTABLE_CHAR = new Set(["Enter", "Tab"]);
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Presses one real key on a target element — focuses it first (a real
|
|
245
|
+
* keypress always lands on whatever's focused; a component that reacts to
|
|
246
|
+
* Escape/Enter/arrows almost always keys off document-level or its own
|
|
247
|
+
* focus-scoped listener, so focus has to be real before the event fires).
|
|
248
|
+
* Fires keydown/keyup (and keypress only for the handful of keys that
|
|
249
|
+
* still expect one — Enter/Tab — matching a real browser's own behavior,
|
|
250
|
+
* which no longer fires keypress for pure navigation keys like arrows).
|
|
251
|
+
*/
|
|
252
|
+
export function pressKey(el: HTMLElement, key: string): void {
|
|
253
|
+
if (typeof el.focus === "function") el.focus();
|
|
254
|
+
const opts = { bubbles: true, cancelable: true, key };
|
|
255
|
+
el.dispatchEvent(new KeyboardEvent("keydown", opts));
|
|
256
|
+
if (KEYS_WITH_PRINTABLE_CHAR.has(key)) el.dispatchEvent(new KeyboardEvent("keypress", opts));
|
|
257
|
+
el.dispatchEvent(new KeyboardEvent("keyup", opts));
|
|
258
|
+
}
|
|
259
|
+
|
|
90
260
|
export interface MissContext {
|
|
91
261
|
attempted: string;
|
|
92
262
|
route: string;
|