@cairnvibe/sdk 0.3.0 → 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/cairn-widget.js +14 -9
- package/dist/cursor-overlay.d.ts +19 -0
- package/dist/cursor-overlay.js +126 -0
- package/dist/index.js +22 -7
- package/dist/realtime-server.js +14 -3
- package/dist/server.d.ts +17 -0
- package/dist/server.js +32 -9
- package/dist/verb-executor.js +128 -60
- package/package.json +1 -1
- package/src/cursor-overlay.ts +130 -0
- package/src/index.tsx +21 -7
- package/src/realtime-server.ts +16 -3
- package/src/server.ts +43 -7
- package/src/verb-executor.ts +123 -57
- package/src/web-component.ts +15 -7
package/src/server.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from "@cairnvibe/core";
|
|
35
35
|
import { looksMultiStep, MAX_HISTORY_TURNS, summarizeVerbForHistory } from "./agent-loop";
|
|
36
36
|
import { formatArchivedFacts, formatRememberedFacts, seedHistoryFromMemory, type MemoryStore } from "./memory-sqlite";
|
|
37
|
+
export { KeyRotator } from "./key-rotator";
|
|
37
38
|
import { KeyRotator } from "./key-rotator";
|
|
38
39
|
import type { SkillStore } from "./skill-store";
|
|
39
40
|
|
|
@@ -67,6 +68,22 @@ export interface CreateCopilotHandlerOptions {
|
|
|
67
68
|
/** Single API key. For groq, prefer `apiKeys` to round-robin; falls back to GROQ_API_KEYS env. */
|
|
68
69
|
apiKey?: string;
|
|
69
70
|
apiKeys?: string[];
|
|
71
|
+
/**
|
|
72
|
+
* A pre-built rotator to share across multiple LLM roles (verb, plan,
|
|
73
|
+
* critic) instead of each one building its own from `apiKeys`/`apiKey`/
|
|
74
|
+
* env. Real, live-found gap this closes: createVerbLLM/createPlanLLM/
|
|
75
|
+
* createCriticLLM each called createToolLLM independently, and each one
|
|
76
|
+
* built a BRAND NEW KeyRotator from the same GROQ_API_KEYS list — so a
|
|
77
|
+
* key one of them confirmed dead via a real 401 (KeyRotator.markDead)
|
|
78
|
+
* stayed invisible to the other two, which went on rediscovering the
|
|
79
|
+
* exact same dead key from scratch on every one of their own calls,
|
|
80
|
+
* wasting real round trips and, worse, stacking up wasted attempts
|
|
81
|
+
* against the SAME small number of retries each call is bounded to.
|
|
82
|
+
* Takes precedence over `apiKeys`/`apiKey`/env when provided. See
|
|
83
|
+
* groq-llm.ts in examples/demo-app for the intended usage: build one
|
|
84
|
+
* KeyRotator at module scope, pass it to all three createXLLM calls.
|
|
85
|
+
*/
|
|
86
|
+
keyRotator?: KeyRotator;
|
|
70
87
|
model?: string;
|
|
71
88
|
/** Action ids this deployment actually supports. "do" is refused for anything else. */
|
|
72
89
|
registeredActions?: string[];
|
|
@@ -498,11 +515,12 @@ function createToolLLM(options: CreateCopilotHandlerOptions, toolSchema: Record<
|
|
|
498
515
|
const provider = options.provider ?? "anthropic";
|
|
499
516
|
|
|
500
517
|
if (provider === "groq") {
|
|
501
|
-
const rotator = options.
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
518
|
+
const rotator = options.keyRotator
|
|
519
|
+
?? (options.apiKeys
|
|
520
|
+
? new KeyRotator(options.apiKeys)
|
|
521
|
+
: options.apiKey
|
|
522
|
+
? new KeyRotator([options.apiKey])
|
|
523
|
+
: KeyRotator.fromEnvList(process.env.GROQ_API_KEYS));
|
|
506
524
|
if (!rotator) {
|
|
507
525
|
throw new Error("createToolLLM: provider 'groq' needs apiKey(s), or GROQ_API_KEYS in env");
|
|
508
526
|
}
|
|
@@ -833,7 +851,19 @@ export class GroqVerbLLM implements VerbLLM {
|
|
|
833
851
|
private keys: KeyRotator,
|
|
834
852
|
private model: string,
|
|
835
853
|
private toolSchema: Record<string, unknown>,
|
|
836
|
-
|
|
854
|
+
// maxRetries: 0 — real, live-found latency bug this closes: the Groq
|
|
855
|
+
// SDK's own default (2 automatic retries with exponential backoff) ran
|
|
856
|
+
// UNDERNEATH respond()'s own key-rotation retry loop, so a single 429
|
|
857
|
+
// key attempt could silently eat several real seconds of SDK-internal
|
|
858
|
+
// backoff before respond() ever saw the rejection and moved on to a
|
|
859
|
+
// DIFFERENT key. With several keys in rotation genuinely rate-limited
|
|
860
|
+
// at once (the common case this closes for), that compounded into a
|
|
861
|
+
// real, live-reported multi-second-to-a-minute hang with no visible
|
|
862
|
+
// progress — worse than useless, since respond()'s own retry already
|
|
863
|
+
// tries a different key/quota entirely, which the SDK's blind same-key
|
|
864
|
+
// backoff can never fix. respond() is the sole source of retry policy
|
|
865
|
+
// here now.
|
|
866
|
+
private clientFactory: (apiKey: string) => GroqLikeClient = (apiKey) => new Groq({ apiKey, maxRetries: 0 }),
|
|
837
867
|
private toolName: string = VERB_TOOL_NAME,
|
|
838
868
|
private toolDescription: string = VERB_TOOL_DESCRIPTION,
|
|
839
869
|
) {}
|
|
@@ -973,7 +1003,13 @@ export class GroqStreamingTextLLM implements StreamingTextLLM {
|
|
|
973
1003
|
constructor(
|
|
974
1004
|
private keys: KeyRotator,
|
|
975
1005
|
private model: string,
|
|
976
|
-
|
|
1006
|
+
// maxRetries: 0 — same real latency bug as GroqVerbLLM's own
|
|
1007
|
+
// clientFactory default; see its doc comment for the full reasoning.
|
|
1008
|
+
// respondStreamed below already has its own key-rotation retry loop
|
|
1009
|
+
// (maxAttempts bounded by keys.size), which makes the SDK's blind
|
|
1010
|
+
// same-key backoff redundant AND a source of silent multi-second
|
|
1011
|
+
// delay stacked underneath it.
|
|
1012
|
+
private clientFactory: (apiKey: string) => GroqLikeStreamingClient = (apiKey) => new Groq({ apiKey, maxRetries: 0 }),
|
|
977
1013
|
) {}
|
|
978
1014
|
|
|
979
1015
|
async respondStreamed(systemPrompt: string, userMessage: string, onChunk: (delta: string) => void): Promise<string> {
|
package/src/verb-executor.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { VerbResponseSchema, type ApiCall, type BatchAction, type TourStep, type VerbResponse } from "@cairnvibe/core";
|
|
9
9
|
import { dragElement, findElement, findElementWithRetry, fillElement, highlightElement, logMiss, pressKey, readElement, selectOption, waitForDomSettle, type MissContext } from "./element-ladder";
|
|
10
|
+
import { moveCursorTo } from "./cursor-overlay";
|
|
10
11
|
import { executeWebMcpTool } from "./webmcp-client";
|
|
11
12
|
|
|
12
13
|
/** The real result of one agent-loop step (click/fill/read/call_tool/
|
|
@@ -136,10 +137,15 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
136
137
|
return;
|
|
137
138
|
}
|
|
138
139
|
highlightElement(el);
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
// The cursor visibly arrives before "open"'s real click fires — the
|
|
141
|
+
// whole point is a user watching sees where it's about to click
|
|
142
|
+
// BEFORE the menu/modal/panel actually opens, not simultaneously.
|
|
143
|
+
void moveCursorTo(el).then(() => {
|
|
144
|
+
// "open" means make the thing actually appear (a menu, a modal, a
|
|
145
|
+
// panel) — highlighting alone doesn't do that; a real click does.
|
|
146
|
+
if (verb.verb === "open") el.click();
|
|
147
|
+
if (verb.text) options.onExplain(verb.text);
|
|
148
|
+
});
|
|
143
149
|
return;
|
|
144
150
|
}
|
|
145
151
|
|
|
@@ -198,8 +204,10 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
198
204
|
// on a different page) — never fired in addition to a real
|
|
199
205
|
// click, so the action can't run twice.
|
|
200
206
|
highlightElement(el);
|
|
201
|
-
el.
|
|
202
|
-
|
|
207
|
+
void moveCursorTo(el).then(() => {
|
|
208
|
+
el.click();
|
|
209
|
+
if (verb.text) options.onExplain(verb.text);
|
|
210
|
+
});
|
|
203
211
|
return;
|
|
204
212
|
}
|
|
205
213
|
if (verb.target) (options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
@@ -244,14 +252,16 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
244
252
|
return;
|
|
245
253
|
}
|
|
246
254
|
highlightElement(el);
|
|
247
|
-
el.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
+
void moveCursorTo(el).then(() => {
|
|
256
|
+
el.click();
|
|
257
|
+
// Real, live-found race this closes — see waitForDomSettle's own
|
|
258
|
+
// doc comment: a click can trigger an async re-render (a cart count
|
|
259
|
+
// updating, a filtered list refreshing) that hasn't happened yet the
|
|
260
|
+
// instant .click() returns. A subsequent read step in the same turn
|
|
261
|
+
// needs the SETTLED result, not whatever was on screen a moment ago.
|
|
262
|
+
void waitForDomSettle().then(() => {
|
|
263
|
+
options.onToolStep?.({ verb: "click", target: verb.target, ok: true, observation: "Clicked it." });
|
|
264
|
+
});
|
|
255
265
|
});
|
|
256
266
|
return;
|
|
257
267
|
}
|
|
@@ -259,23 +269,30 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
259
269
|
case "fill": {
|
|
260
270
|
if (verb.text) options.onExplain(verb.text);
|
|
261
271
|
const el = findElement(verb.target, options.liveElements);
|
|
262
|
-
if (!el
|
|
272
|
+
if (!el) {
|
|
263
273
|
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
264
|
-
options.onToolStep?.({
|
|
265
|
-
verb: "fill",
|
|
266
|
-
target: verb.target,
|
|
267
|
-
ok: false,
|
|
268
|
-
observation: el ? "That element isn't a real form field — can't type into it." : "Could not find that element on the page.",
|
|
269
|
-
});
|
|
274
|
+
options.onToolStep?.({ verb: "fill", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
270
275
|
return;
|
|
271
276
|
}
|
|
272
277
|
highlightElement(el);
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
278
|
+
// fillElement itself is both the "is this a real form field" check
|
|
279
|
+
// AND the commit (it sets the value the instant it returns true) —
|
|
280
|
+
// deliberately called from inside this .then(), not the synchronous
|
|
281
|
+
// miss-check above, so the cursor is genuinely seen arriving BEFORE
|
|
282
|
+
// any text appears, not simultaneously with it.
|
|
283
|
+
void moveCursorTo(el).then(() => {
|
|
284
|
+
if (!fillElement(el, verb.value)) {
|
|
285
|
+
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
286
|
+
options.onToolStep?.({ verb: "fill", target: verb.target, ok: false, observation: "That element isn't a real form field — can't type into it." });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// See the click case's own comment — the exact real bug found live:
|
|
290
|
+
// typing into a search box, then reading the still-unfiltered
|
|
291
|
+
// results a moment later and reporting a match the real, since-
|
|
292
|
+
// filtered page never actually showed.
|
|
293
|
+
void waitForDomSettle().then(() => {
|
|
294
|
+
options.onToolStep?.({ verb: "fill", target: verb.target, ok: true, observation: `Typed "${verb.value}" into it.` });
|
|
295
|
+
});
|
|
279
296
|
});
|
|
280
297
|
return;
|
|
281
298
|
}
|
|
@@ -288,7 +305,12 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
288
305
|
options.onToolStep?.({ verb: "read", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
289
306
|
return;
|
|
290
307
|
}
|
|
291
|
-
|
|
308
|
+
// No mutation here, but the cursor still visits what's being read —
|
|
309
|
+
// real visual proof of what the agent is actually looking at, not
|
|
310
|
+
// just a claim in the eventual reported observation.
|
|
311
|
+
void moveCursorTo(el).then(() => {
|
|
312
|
+
options.onToolStep?.({ verb: "read", target: verb.target, ok: true, observation: readElement(el) });
|
|
313
|
+
});
|
|
292
314
|
return;
|
|
293
315
|
}
|
|
294
316
|
|
|
@@ -310,12 +332,19 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
310
332
|
return;
|
|
311
333
|
}
|
|
312
334
|
highlightElement(from);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
335
|
+
void moveCursorTo(from).then(() => {
|
|
336
|
+
dragElement(from, to);
|
|
337
|
+
// The cursor also glides to the drop point — fire-and-forget, not
|
|
338
|
+
// awaited, since it's purely a visual echo of a drag that already
|
|
339
|
+
// happened via real pointer events; nothing downstream depends on
|
|
340
|
+
// it finishing.
|
|
341
|
+
void moveCursorTo(to);
|
|
342
|
+
// Same real re-render race as click/fill — a drop can trigger an
|
|
343
|
+
// async re-render (a canvas connection line, a reordered list) that
|
|
344
|
+
// hasn't settled the instant the pointer sequence finishes.
|
|
345
|
+
void waitForDomSettle().then(() => {
|
|
346
|
+
options.onToolStep?.({ verb: "drag", target: verb.target, ok: true, observation: `Dragged it to ${verb.to}.` });
|
|
347
|
+
});
|
|
319
348
|
});
|
|
320
349
|
return;
|
|
321
350
|
}
|
|
@@ -323,19 +352,24 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
323
352
|
case "select": {
|
|
324
353
|
if (verb.text) options.onExplain(verb.text);
|
|
325
354
|
const el = findElement(verb.target, options.liveElements);
|
|
326
|
-
if (!el
|
|
355
|
+
if (!el) {
|
|
327
356
|
(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
|
-
});
|
|
357
|
+
options.onToolStep?.({ verb: "select", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
334
358
|
return;
|
|
335
359
|
}
|
|
336
360
|
highlightElement(el);
|
|
337
|
-
|
|
338
|
-
|
|
361
|
+
// Same reasoning as fill's own comment — selectOption is both the
|
|
362
|
+
// "does a matching option exist" check and the commit, so it's called
|
|
363
|
+
// from inside this .then(), after the cursor genuinely arrives.
|
|
364
|
+
void moveCursorTo(el).then(() => {
|
|
365
|
+
if (!selectOption(el, verb.value)) {
|
|
366
|
+
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
367
|
+
options.onToolStep?.({ verb: "select", target: verb.target, ok: false, observation: `Could not find an option matching "${verb.value}".` });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
void waitForDomSettle().then(() => {
|
|
371
|
+
options.onToolStep?.({ verb: "select", target: verb.target, ok: true, observation: `Selected "${verb.value}".` });
|
|
372
|
+
});
|
|
339
373
|
});
|
|
340
374
|
return;
|
|
341
375
|
}
|
|
@@ -348,10 +382,21 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
348
382
|
options.onToolStep?.({ verb: "key", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
349
383
|
return;
|
|
350
384
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
385
|
+
const afterMove = () => {
|
|
386
|
+
pressKey(el, verb.key);
|
|
387
|
+
void waitForDomSettle().then(() => {
|
|
388
|
+
options.onToolStep?.({ verb: "key", target: verb.target, ok: true, observation: `Pressed ${verb.key}.` });
|
|
389
|
+
});
|
|
390
|
+
};
|
|
391
|
+
// With no explicit target ("whatever's currently focused"), there's
|
|
392
|
+
// nothing sensible for the cursor to glide to — call straight through
|
|
393
|
+
// instead of routing through a promise callback for no reason, so
|
|
394
|
+
// this path stays exactly as synchronous as it always was.
|
|
395
|
+
if (verb.target) {
|
|
396
|
+
void moveCursorTo(el).then(afterMove);
|
|
397
|
+
} else {
|
|
398
|
+
afterMove();
|
|
399
|
+
}
|
|
355
400
|
return;
|
|
356
401
|
}
|
|
357
402
|
|
|
@@ -368,8 +413,10 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
368
413
|
// exactly the real repositioning this verb exists for; the glow
|
|
369
414
|
// also gives the user a visible cue of where the agent just moved.
|
|
370
415
|
highlightElement(el);
|
|
371
|
-
void
|
|
372
|
-
|
|
416
|
+
void moveCursorTo(el).then(() => {
|
|
417
|
+
void waitForDomSettle().then(() => {
|
|
418
|
+
options.onToolStep?.({ verb: "scroll", target: verb.target, ok: true, observation: "Scrolled it into view." });
|
|
419
|
+
});
|
|
373
420
|
});
|
|
374
421
|
return;
|
|
375
422
|
}
|
|
@@ -382,7 +429,9 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
382
429
|
options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: false, observation: "It never appeared." });
|
|
383
430
|
return;
|
|
384
431
|
}
|
|
385
|
-
|
|
432
|
+
void moveCursorTo(el).then(() => {
|
|
433
|
+
options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: true, observation: "It appeared." });
|
|
434
|
+
});
|
|
386
435
|
});
|
|
387
436
|
return;
|
|
388
437
|
}
|
|
@@ -438,6 +487,7 @@ async function executeOneBatchAction(action: BatchAction, route: string, options
|
|
|
438
487
|
return { ok: false, observation: "Could not find that element on the page." };
|
|
439
488
|
}
|
|
440
489
|
highlightElement(el);
|
|
490
|
+
await moveCursorTo(el);
|
|
441
491
|
el.click();
|
|
442
492
|
// Same real race as the single-step case (see waitForDomSettle's own
|
|
443
493
|
// doc comment) — arguably MORE likely here, since a batch's next
|
|
@@ -447,14 +497,19 @@ async function executeOneBatchAction(action: BatchAction, route: string, options
|
|
|
447
497
|
}
|
|
448
498
|
case "fill": {
|
|
449
499
|
const el = await findElementWithRetry(action.target, options.liveElements);
|
|
450
|
-
if (!el
|
|
500
|
+
if (!el) {
|
|
451
501
|
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
452
|
-
return {
|
|
453
|
-
ok: false,
|
|
454
|
-
observation: el ? "That element isn't a real form field — can't type into it." : "Could not find that element on the page.",
|
|
455
|
-
};
|
|
502
|
+
return { ok: false, observation: "Could not find that element on the page." };
|
|
456
503
|
}
|
|
457
504
|
highlightElement(el);
|
|
505
|
+
// See the single-step fill case's own comment — fillElement is both
|
|
506
|
+
// the field-type check and the commit, called after the cursor
|
|
507
|
+
// genuinely arrives rather than in the synchronous miss-check.
|
|
508
|
+
await moveCursorTo(el);
|
|
509
|
+
if (!fillElement(el, action.value)) {
|
|
510
|
+
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
511
|
+
return { ok: false, observation: "That element isn't a real form field — can't type into it." };
|
|
512
|
+
}
|
|
458
513
|
await waitForDomSettle();
|
|
459
514
|
return { ok: true, observation: `Typed "${action.value}" into it.` };
|
|
460
515
|
}
|
|
@@ -464,6 +519,7 @@ async function executeOneBatchAction(action: BatchAction, route: string, options
|
|
|
464
519
|
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
465
520
|
return { ok: false, observation: "Could not find that element on the page." };
|
|
466
521
|
}
|
|
522
|
+
await moveCursorTo(el);
|
|
467
523
|
return { ok: true, observation: readElement(el) };
|
|
468
524
|
}
|
|
469
525
|
case "call_tool": {
|
|
@@ -478,17 +534,24 @@ async function executeOneBatchAction(action: BatchAction, route: string, options
|
|
|
478
534
|
return { ok: false, observation: from ? "Could not find the drop destination on the page." : "Could not find that element on the page." };
|
|
479
535
|
}
|
|
480
536
|
highlightElement(from);
|
|
537
|
+
await moveCursorTo(from);
|
|
481
538
|
dragElement(from, to);
|
|
539
|
+
void moveCursorTo(to); // visual echo of the drop point — not awaited, purely decorative
|
|
482
540
|
await waitForDomSettle();
|
|
483
541
|
return { ok: true, observation: `Dragged it to ${action.to}.` };
|
|
484
542
|
}
|
|
485
543
|
case "select": {
|
|
486
544
|
const el = await findElementWithRetry(action.target, options.liveElements);
|
|
487
|
-
if (!el
|
|
545
|
+
if (!el) {
|
|
488
546
|
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
489
|
-
return { ok: false, observation:
|
|
547
|
+
return { ok: false, observation: "Could not find that element on the page." };
|
|
490
548
|
}
|
|
491
549
|
highlightElement(el);
|
|
550
|
+
await moveCursorTo(el);
|
|
551
|
+
if (!selectOption(el, action.value)) {
|
|
552
|
+
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
553
|
+
return { ok: false, observation: `Could not find an option matching "${action.value}".` };
|
|
554
|
+
}
|
|
492
555
|
await waitForDomSettle();
|
|
493
556
|
return { ok: true, observation: `Selected "${action.value}".` };
|
|
494
557
|
}
|
|
@@ -498,6 +561,7 @@ async function executeOneBatchAction(action: BatchAction, route: string, options
|
|
|
498
561
|
if (action.target) (options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
499
562
|
return { ok: false, observation: "Could not find that element on the page." };
|
|
500
563
|
}
|
|
564
|
+
if (action.target) await moveCursorTo(el);
|
|
501
565
|
pressKey(el, action.key);
|
|
502
566
|
await waitForDomSettle();
|
|
503
567
|
return { ok: true, observation: `Pressed ${action.key}.` };
|
|
@@ -509,6 +573,7 @@ async function executeOneBatchAction(action: BatchAction, route: string, options
|
|
|
509
573
|
return { ok: false, observation: "Could not find that element on the page." };
|
|
510
574
|
}
|
|
511
575
|
highlightElement(el);
|
|
576
|
+
await moveCursorTo(el);
|
|
512
577
|
await waitForDomSettle();
|
|
513
578
|
return { ok: true, observation: "Scrolled it into view." };
|
|
514
579
|
}
|
|
@@ -518,6 +583,7 @@ async function executeOneBatchAction(action: BatchAction, route: string, options
|
|
|
518
583
|
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
519
584
|
return { ok: false, observation: "It never appeared." };
|
|
520
585
|
}
|
|
586
|
+
await moveCursorTo(el);
|
|
521
587
|
return { ok: true, observation: "It appeared." };
|
|
522
588
|
}
|
|
523
589
|
}
|
package/src/web-component.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import type { HistoryTurn as HistoryEntry, TourStep } from "@cairnvibe/core";
|
|
25
25
|
import { collectVisible } from "./context-collector";
|
|
26
26
|
import { findElement, highlightElement, logMiss, type MissContext } from "./element-ladder";
|
|
27
|
+
import { hideCursor } from "./cursor-overlay";
|
|
27
28
|
import { executeVerbResponse } from "./verb-executor";
|
|
28
29
|
import { createBargeInGate, createVadDetector } from "./vad";
|
|
29
30
|
|
|
@@ -56,9 +57,13 @@ const STYLES = `
|
|
|
56
57
|
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
|
57
58
|
70% { box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
|
|
58
59
|
}
|
|
59
|
-
@keyframes cairn-pulse-
|
|
60
|
-
0%, 100% { box-shadow: 0 0 0 0 rgba(
|
|
61
|
-
70% { box-shadow: 0 0 0 10px rgba(
|
|
60
|
+
@keyframes cairn-pulse-ember {
|
|
61
|
+
0%, 100% { box-shadow: 0 0 0 0 rgba(224, 122, 63, 0.4); }
|
|
62
|
+
70% { box-shadow: 0 0 0 10px rgba(224, 122, 63, 0); }
|
|
63
|
+
}
|
|
64
|
+
@keyframes cairn-cursor-arrive {
|
|
65
|
+
0% { box-shadow: 0 0 0 0 rgba(224, 122, 63, 0.55); }
|
|
66
|
+
100% { box-shadow: 0 0 0 9px rgba(224, 122, 63, 0); }
|
|
62
67
|
}
|
|
63
68
|
@keyframes cairn-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
64
69
|
@keyframes cairn-rt-dot {
|
|
@@ -75,7 +80,7 @@ const STYLES = `
|
|
|
75
80
|
}
|
|
76
81
|
@keyframes cairn-word-sweep {
|
|
77
82
|
0% { opacity: 0.35; text-shadow: none; }
|
|
78
|
-
35% { opacity: 1; color: #
|
|
83
|
+
35% { opacity: 1; color: #E07A3F; text-shadow: 0 0 10px rgba(224, 122, 63, 0.45); }
|
|
79
84
|
100% { opacity: 1; color: inherit; text-shadow: none; }
|
|
80
85
|
}
|
|
81
86
|
@keyframes cairn-thinking-bounce {
|
|
@@ -83,14 +88,15 @@ const STYLES = `
|
|
|
83
88
|
40% { opacity: 0.9; transform: translateY(-3px); }
|
|
84
89
|
}
|
|
85
90
|
.cairn-glow {
|
|
86
|
-
animation: cairn-pulse-
|
|
87
|
-
outline: 2px solid #
|
|
91
|
+
animation: cairn-pulse-ember 1.1s ease-out 2;
|
|
92
|
+
outline: 2px solid #E07A3F;
|
|
88
93
|
outline-offset: 3px;
|
|
89
94
|
border-radius: 8px;
|
|
90
95
|
}
|
|
91
96
|
.cairn-spin { animation: cairn-spin 0.8s linear infinite; }
|
|
97
|
+
.cairn-cursor-hover { animation: cairn-cursor-arrive 0.3s ease-out; }
|
|
92
98
|
@media (prefers-reduced-motion: reduce) {
|
|
93
|
-
.cairn-fab, .cairn-panel, .cairn-bubble, .cairn-word, .cairn-thinking-dot {
|
|
99
|
+
.cairn-fab, .cairn-panel, .cairn-bubble, .cairn-word, .cairn-thinking-dot, #cairn-cursor {
|
|
94
100
|
animation: none !important;
|
|
95
101
|
transition: none !important;
|
|
96
102
|
}
|
|
@@ -381,6 +387,7 @@ export class CairnWidgetElement extends HTMLElement {
|
|
|
381
387
|
// in parallel with whatever comes next.
|
|
382
388
|
disconnectedCallback() {
|
|
383
389
|
if (this.rtSocket || this.rtCleanup) this.endRealtime();
|
|
390
|
+
hideCursor();
|
|
384
391
|
}
|
|
385
392
|
|
|
386
393
|
// --- attributes -----------------------------------------------------
|
|
@@ -561,6 +568,7 @@ export class CairnWidgetElement extends HTMLElement {
|
|
|
561
568
|
this.fab.innerHTML = this.isOpen ? CLOSE_ICON : MARK_ICON;
|
|
562
569
|
this.fab.setAttribute("aria-label", this.isOpen ? `Close ${this.persona} help` : `Open ${this.persona} help`);
|
|
563
570
|
if (this.isOpen && !this.realtimeActive) this.inputEl.focus();
|
|
571
|
+
if (!this.isOpen) hideCursor();
|
|
564
572
|
}
|
|
565
573
|
|
|
566
574
|
// --- rendering ------------------------------------------------------
|