@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/dist/verb-executor.js
CHANGED
|
@@ -10,6 +10,7 @@ exports.executeToolStep = executeToolStep;
|
|
|
10
10
|
exports.executeVerbResponse = executeVerbResponse;
|
|
11
11
|
const core_1 = require("@cairnvibe/core");
|
|
12
12
|
const element_ladder_1 = require("./element-ladder");
|
|
13
|
+
const cursor_overlay_1 = require("./cursor-overlay");
|
|
13
14
|
const webmcp_client_1 = require("./webmcp-client");
|
|
14
15
|
// wait_for's own real, bounded retry budget — longer than
|
|
15
16
|
// findElementWithRetry's own default (2 attempts, 300ms apart, ~300ms
|
|
@@ -78,12 +79,17 @@ function dispatchVerb(verb, route, options) {
|
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
80
81
|
(0, element_ladder_1.highlightElement)(el);
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
// The cursor visibly arrives before "open"'s real click fires — the
|
|
83
|
+
// whole point is a user watching sees where it's about to click
|
|
84
|
+
// BEFORE the menu/modal/panel actually opens, not simultaneously.
|
|
85
|
+
void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
|
|
86
|
+
// "open" means make the thing actually appear (a menu, a modal, a
|
|
87
|
+
// panel) — highlighting alone doesn't do that; a real click does.
|
|
88
|
+
if (verb.verb === "open")
|
|
89
|
+
el.click();
|
|
90
|
+
if (verb.text)
|
|
91
|
+
options.onExplain(verb.text);
|
|
92
|
+
});
|
|
87
93
|
return;
|
|
88
94
|
}
|
|
89
95
|
case "navigate": {
|
|
@@ -142,9 +148,11 @@ function dispatchVerb(verb, route, options) {
|
|
|
142
148
|
// on a different page) — never fired in addition to a real
|
|
143
149
|
// click, so the action can't run twice.
|
|
144
150
|
(0, element_ladder_1.highlightElement)(el);
|
|
145
|
-
el.
|
|
146
|
-
|
|
147
|
-
|
|
151
|
+
void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
|
|
152
|
+
el.click();
|
|
153
|
+
if (verb.text)
|
|
154
|
+
options.onExplain(verb.text);
|
|
155
|
+
});
|
|
148
156
|
return;
|
|
149
157
|
}
|
|
150
158
|
if (verb.target)
|
|
@@ -189,14 +197,16 @@ function dispatchVerb(verb, route, options) {
|
|
|
189
197
|
return;
|
|
190
198
|
}
|
|
191
199
|
(0, element_ladder_1.highlightElement)(el);
|
|
192
|
-
el.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
+
void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
|
|
201
|
+
el.click();
|
|
202
|
+
// Real, live-found race this closes — see waitForDomSettle's own
|
|
203
|
+
// doc comment: a click can trigger an async re-render (a cart count
|
|
204
|
+
// updating, a filtered list refreshing) that hasn't happened yet the
|
|
205
|
+
// instant .click() returns. A subsequent read step in the same turn
|
|
206
|
+
// needs the SETTLED result, not whatever was on screen a moment ago.
|
|
207
|
+
void (0, element_ladder_1.waitForDomSettle)().then(() => {
|
|
208
|
+
options.onToolStep?.({ verb: "click", target: verb.target, ok: true, observation: "Clicked it." });
|
|
209
|
+
});
|
|
200
210
|
});
|
|
201
211
|
return;
|
|
202
212
|
}
|
|
@@ -204,23 +214,30 @@ function dispatchVerb(verb, route, options) {
|
|
|
204
214
|
if (verb.text)
|
|
205
215
|
options.onExplain(verb.text);
|
|
206
216
|
const el = (0, element_ladder_1.findElement)(verb.target, options.liveElements);
|
|
207
|
-
if (!el
|
|
217
|
+
if (!el) {
|
|
208
218
|
(options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
|
|
209
|
-
options.onToolStep?.({
|
|
210
|
-
verb: "fill",
|
|
211
|
-
target: verb.target,
|
|
212
|
-
ok: false,
|
|
213
|
-
observation: el ? "That element isn't a real form field — can't type into it." : "Could not find that element on the page.",
|
|
214
|
-
});
|
|
219
|
+
options.onToolStep?.({ verb: "fill", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
215
220
|
return;
|
|
216
221
|
}
|
|
217
222
|
(0, element_ladder_1.highlightElement)(el);
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
223
|
+
// fillElement itself is both the "is this a real form field" check
|
|
224
|
+
// AND the commit (it sets the value the instant it returns true) —
|
|
225
|
+
// deliberately called from inside this .then(), not the synchronous
|
|
226
|
+
// miss-check above, so the cursor is genuinely seen arriving BEFORE
|
|
227
|
+
// any text appears, not simultaneously with it.
|
|
228
|
+
void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
|
|
229
|
+
if (!(0, element_ladder_1.fillElement)(el, verb.value)) {
|
|
230
|
+
(options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
|
|
231
|
+
options.onToolStep?.({ verb: "fill", target: verb.target, ok: false, observation: "That element isn't a real form field — can't type into it." });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// See the click case's own comment — the exact real bug found live:
|
|
235
|
+
// typing into a search box, then reading the still-unfiltered
|
|
236
|
+
// results a moment later and reporting a match the real, since-
|
|
237
|
+
// filtered page never actually showed.
|
|
238
|
+
void (0, element_ladder_1.waitForDomSettle)().then(() => {
|
|
239
|
+
options.onToolStep?.({ verb: "fill", target: verb.target, ok: true, observation: `Typed "${verb.value}" into it.` });
|
|
240
|
+
});
|
|
224
241
|
});
|
|
225
242
|
return;
|
|
226
243
|
}
|
|
@@ -233,7 +250,12 @@ function dispatchVerb(verb, route, options) {
|
|
|
233
250
|
options.onToolStep?.({ verb: "read", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
234
251
|
return;
|
|
235
252
|
}
|
|
236
|
-
|
|
253
|
+
// No mutation here, but the cursor still visits what's being read —
|
|
254
|
+
// real visual proof of what the agent is actually looking at, not
|
|
255
|
+
// just a claim in the eventual reported observation.
|
|
256
|
+
void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
|
|
257
|
+
options.onToolStep?.({ verb: "read", target: verb.target, ok: true, observation: (0, element_ladder_1.readElement)(el) });
|
|
258
|
+
});
|
|
237
259
|
return;
|
|
238
260
|
}
|
|
239
261
|
case "call_tool": {
|
|
@@ -255,12 +277,19 @@ function dispatchVerb(verb, route, options) {
|
|
|
255
277
|
return;
|
|
256
278
|
}
|
|
257
279
|
(0, element_ladder_1.highlightElement)(from);
|
|
258
|
-
(0,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
280
|
+
void (0, cursor_overlay_1.moveCursorTo)(from).then(() => {
|
|
281
|
+
(0, element_ladder_1.dragElement)(from, to);
|
|
282
|
+
// The cursor also glides to the drop point — fire-and-forget, not
|
|
283
|
+
// awaited, since it's purely a visual echo of a drag that already
|
|
284
|
+
// happened via real pointer events; nothing downstream depends on
|
|
285
|
+
// it finishing.
|
|
286
|
+
void (0, cursor_overlay_1.moveCursorTo)(to);
|
|
287
|
+
// Same real re-render race as click/fill — a drop can trigger an
|
|
288
|
+
// async re-render (a canvas connection line, a reordered list) that
|
|
289
|
+
// hasn't settled the instant the pointer sequence finishes.
|
|
290
|
+
void (0, element_ladder_1.waitForDomSettle)().then(() => {
|
|
291
|
+
options.onToolStep?.({ verb: "drag", target: verb.target, ok: true, observation: `Dragged it to ${verb.to}.` });
|
|
292
|
+
});
|
|
264
293
|
});
|
|
265
294
|
return;
|
|
266
295
|
}
|
|
@@ -268,19 +297,24 @@ function dispatchVerb(verb, route, options) {
|
|
|
268
297
|
if (verb.text)
|
|
269
298
|
options.onExplain(verb.text);
|
|
270
299
|
const el = (0, element_ladder_1.findElement)(verb.target, options.liveElements);
|
|
271
|
-
if (!el
|
|
300
|
+
if (!el) {
|
|
272
301
|
(options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
|
|
273
|
-
options.onToolStep?.({
|
|
274
|
-
verb: "select",
|
|
275
|
-
target: verb.target,
|
|
276
|
-
ok: false,
|
|
277
|
-
observation: el ? `Could not find an option matching "${verb.value}".` : "Could not find that element on the page.",
|
|
278
|
-
});
|
|
302
|
+
options.onToolStep?.({ verb: "select", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
279
303
|
return;
|
|
280
304
|
}
|
|
281
305
|
(0, element_ladder_1.highlightElement)(el);
|
|
282
|
-
|
|
283
|
-
|
|
306
|
+
// Same reasoning as fill's own comment — selectOption is both the
|
|
307
|
+
// "does a matching option exist" check and the commit, so it's called
|
|
308
|
+
// from inside this .then(), after the cursor genuinely arrives.
|
|
309
|
+
void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
|
|
310
|
+
if (!(0, element_ladder_1.selectOption)(el, verb.value)) {
|
|
311
|
+
(options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
|
|
312
|
+
options.onToolStep?.({ verb: "select", target: verb.target, ok: false, observation: `Could not find an option matching "${verb.value}".` });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
void (0, element_ladder_1.waitForDomSettle)().then(() => {
|
|
316
|
+
options.onToolStep?.({ verb: "select", target: verb.target, ok: true, observation: `Selected "${verb.value}".` });
|
|
317
|
+
});
|
|
284
318
|
});
|
|
285
319
|
return;
|
|
286
320
|
}
|
|
@@ -294,10 +328,22 @@ function dispatchVerb(verb, route, options) {
|
|
|
294
328
|
options.onToolStep?.({ verb: "key", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
295
329
|
return;
|
|
296
330
|
}
|
|
297
|
-
(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
331
|
+
const afterMove = () => {
|
|
332
|
+
(0, element_ladder_1.pressKey)(el, verb.key);
|
|
333
|
+
void (0, element_ladder_1.waitForDomSettle)().then(() => {
|
|
334
|
+
options.onToolStep?.({ verb: "key", target: verb.target, ok: true, observation: `Pressed ${verb.key}.` });
|
|
335
|
+
});
|
|
336
|
+
};
|
|
337
|
+
// With no explicit target ("whatever's currently focused"), there's
|
|
338
|
+
// nothing sensible for the cursor to glide to — call straight through
|
|
339
|
+
// instead of routing through a promise callback for no reason, so
|
|
340
|
+
// this path stays exactly as synchronous as it always was.
|
|
341
|
+
if (verb.target) {
|
|
342
|
+
void (0, cursor_overlay_1.moveCursorTo)(el).then(afterMove);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
afterMove();
|
|
346
|
+
}
|
|
301
347
|
return;
|
|
302
348
|
}
|
|
303
349
|
case "scroll": {
|
|
@@ -314,8 +360,10 @@ function dispatchVerb(verb, route, options) {
|
|
|
314
360
|
// exactly the real repositioning this verb exists for; the glow
|
|
315
361
|
// also gives the user a visible cue of where the agent just moved.
|
|
316
362
|
(0, element_ladder_1.highlightElement)(el);
|
|
317
|
-
void (0,
|
|
318
|
-
|
|
363
|
+
void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
|
|
364
|
+
void (0, element_ladder_1.waitForDomSettle)().then(() => {
|
|
365
|
+
options.onToolStep?.({ verb: "scroll", target: verb.target, ok: true, observation: "Scrolled it into view." });
|
|
366
|
+
});
|
|
319
367
|
});
|
|
320
368
|
return;
|
|
321
369
|
}
|
|
@@ -328,7 +376,9 @@ function dispatchVerb(verb, route, options) {
|
|
|
328
376
|
options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: false, observation: "It never appeared." });
|
|
329
377
|
return;
|
|
330
378
|
}
|
|
331
|
-
|
|
379
|
+
void (0, cursor_overlay_1.moveCursorTo)(el).then(() => {
|
|
380
|
+
options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: true, observation: "It appeared." });
|
|
381
|
+
});
|
|
332
382
|
});
|
|
333
383
|
return;
|
|
334
384
|
}
|
|
@@ -379,6 +429,7 @@ async function executeOneBatchAction(action, route, options) {
|
|
|
379
429
|
return { ok: false, observation: "Could not find that element on the page." };
|
|
380
430
|
}
|
|
381
431
|
(0, element_ladder_1.highlightElement)(el);
|
|
432
|
+
await (0, cursor_overlay_1.moveCursorTo)(el);
|
|
382
433
|
el.click();
|
|
383
434
|
// Same real race as the single-step case (see waitForDomSettle's own
|
|
384
435
|
// doc comment) — arguably MORE likely here, since a batch's next
|
|
@@ -388,14 +439,19 @@ async function executeOneBatchAction(action, route, options) {
|
|
|
388
439
|
}
|
|
389
440
|
case "fill": {
|
|
390
441
|
const el = await (0, element_ladder_1.findElementWithRetry)(action.target, options.liveElements);
|
|
391
|
-
if (!el
|
|
442
|
+
if (!el) {
|
|
392
443
|
(options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
|
|
393
|
-
return {
|
|
394
|
-
ok: false,
|
|
395
|
-
observation: el ? "That element isn't a real form field — can't type into it." : "Could not find that element on the page.",
|
|
396
|
-
};
|
|
444
|
+
return { ok: false, observation: "Could not find that element on the page." };
|
|
397
445
|
}
|
|
398
446
|
(0, element_ladder_1.highlightElement)(el);
|
|
447
|
+
// See the single-step fill case's own comment — fillElement is both
|
|
448
|
+
// the field-type check and the commit, called after the cursor
|
|
449
|
+
// genuinely arrives rather than in the synchronous miss-check.
|
|
450
|
+
await (0, cursor_overlay_1.moveCursorTo)(el);
|
|
451
|
+
if (!(0, element_ladder_1.fillElement)(el, action.value)) {
|
|
452
|
+
(options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
|
|
453
|
+
return { ok: false, observation: "That element isn't a real form field — can't type into it." };
|
|
454
|
+
}
|
|
399
455
|
await (0, element_ladder_1.waitForDomSettle)();
|
|
400
456
|
return { ok: true, observation: `Typed "${action.value}" into it.` };
|
|
401
457
|
}
|
|
@@ -405,6 +461,7 @@ async function executeOneBatchAction(action, route, options) {
|
|
|
405
461
|
(options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
|
|
406
462
|
return { ok: false, observation: "Could not find that element on the page." };
|
|
407
463
|
}
|
|
464
|
+
await (0, cursor_overlay_1.moveCursorTo)(el);
|
|
408
465
|
return { ok: true, observation: (0, element_ladder_1.readElement)(el) };
|
|
409
466
|
}
|
|
410
467
|
case "call_tool": {
|
|
@@ -419,17 +476,24 @@ async function executeOneBatchAction(action, route, options) {
|
|
|
419
476
|
return { ok: false, observation: from ? "Could not find the drop destination on the page." : "Could not find that element on the page." };
|
|
420
477
|
}
|
|
421
478
|
(0, element_ladder_1.highlightElement)(from);
|
|
479
|
+
await (0, cursor_overlay_1.moveCursorTo)(from);
|
|
422
480
|
(0, element_ladder_1.dragElement)(from, to);
|
|
481
|
+
void (0, cursor_overlay_1.moveCursorTo)(to); // visual echo of the drop point — not awaited, purely decorative
|
|
423
482
|
await (0, element_ladder_1.waitForDomSettle)();
|
|
424
483
|
return { ok: true, observation: `Dragged it to ${action.to}.` };
|
|
425
484
|
}
|
|
426
485
|
case "select": {
|
|
427
486
|
const el = await (0, element_ladder_1.findElementWithRetry)(action.target, options.liveElements);
|
|
428
|
-
if (!el
|
|
487
|
+
if (!el) {
|
|
429
488
|
(options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
|
|
430
|
-
return { ok: false, observation:
|
|
489
|
+
return { ok: false, observation: "Could not find that element on the page." };
|
|
431
490
|
}
|
|
432
491
|
(0, element_ladder_1.highlightElement)(el);
|
|
492
|
+
await (0, cursor_overlay_1.moveCursorTo)(el);
|
|
493
|
+
if (!(0, element_ladder_1.selectOption)(el, action.value)) {
|
|
494
|
+
(options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
|
|
495
|
+
return { ok: false, observation: `Could not find an option matching "${action.value}".` };
|
|
496
|
+
}
|
|
433
497
|
await (0, element_ladder_1.waitForDomSettle)();
|
|
434
498
|
return { ok: true, observation: `Selected "${action.value}".` };
|
|
435
499
|
}
|
|
@@ -440,6 +504,8 @@ async function executeOneBatchAction(action, route, options) {
|
|
|
440
504
|
(options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
|
|
441
505
|
return { ok: false, observation: "Could not find that element on the page." };
|
|
442
506
|
}
|
|
507
|
+
if (action.target)
|
|
508
|
+
await (0, cursor_overlay_1.moveCursorTo)(el);
|
|
443
509
|
(0, element_ladder_1.pressKey)(el, action.key);
|
|
444
510
|
await (0, element_ladder_1.waitForDomSettle)();
|
|
445
511
|
return { ok: true, observation: `Pressed ${action.key}.` };
|
|
@@ -451,6 +517,7 @@ async function executeOneBatchAction(action, route, options) {
|
|
|
451
517
|
return { ok: false, observation: "Could not find that element on the page." };
|
|
452
518
|
}
|
|
453
519
|
(0, element_ladder_1.highlightElement)(el);
|
|
520
|
+
await (0, cursor_overlay_1.moveCursorTo)(el);
|
|
454
521
|
await (0, element_ladder_1.waitForDomSettle)();
|
|
455
522
|
return { ok: true, observation: "Scrolled it into view." };
|
|
456
523
|
}
|
|
@@ -460,6 +527,7 @@ async function executeOneBatchAction(action, route, options) {
|
|
|
460
527
|
(options.onMiss ?? element_ladder_1.logMiss)({ attempted: action.target, route });
|
|
461
528
|
return { ok: false, observation: "It never appeared." };
|
|
462
529
|
}
|
|
530
|
+
await (0, cursor_overlay_1.moveCursorTo)(el);
|
|
463
531
|
return { ok: true, observation: "It appeared." };
|
|
464
532
|
}
|
|
465
533
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cairnvibe/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "In-app AI copilot — <Copilot/> for React/Next.js, <cairn-widget> for any framework — plus the server handlers and realtime voice relay behind them.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": { "access": "public" },
|
|
@@ -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/index.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { classifyUiPattern, deriveStructureSignals, isTerminalVerb, safeParseVerbResponse, type CriticVerdict, type HistoryTurn as HistoryEntry, type Plan, type ProgressLedger, type Task, type TourStep, type VerbResponse } from "@cairnvibe/core";
|
|
20
20
|
import { driveAgentLoop, looksMultiStep } from "./agent-loop";
|
|
21
21
|
import { collectVisible } from "./context-collector";
|
|
22
|
+
import { hideCursor } from "./cursor-overlay";
|
|
22
23
|
import { findElement, highlightElement, logMiss, type MissContext } from "./element-ladder";
|
|
23
24
|
import { createLiveElementRegistry } from "./runtime-scan";
|
|
24
25
|
import { discoverWebMcpTools } from "./webmcp-client";
|
|
@@ -132,6 +133,12 @@ export function Copilot({
|
|
|
132
133
|
// loadPersistedConversation's own doc comment) had showing a moment ago,
|
|
133
134
|
// so a real reload never again looks like the conversation simply ended.
|
|
134
135
|
const [open, setOpen] = useState(false);
|
|
136
|
+
// Fades the synthetic cursor out once the panel closes (or the widget
|
|
137
|
+
// itself unmounts) instead of leaving it sitting visible on the page.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!open) hideCursor();
|
|
140
|
+
return () => hideCursor();
|
|
141
|
+
}, [open]);
|
|
135
142
|
// Collapsed by default so the panel only ever shows the current exchange
|
|
136
143
|
// — the full archived transcript (built up over a long conversation)
|
|
137
144
|
// stays out of the way behind an explicit toggle instead of always being
|
|
@@ -2160,9 +2167,13 @@ const COPILOT_STYLES = `
|
|
|
2160
2167
|
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
|
2161
2168
|
70% { box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
|
|
2162
2169
|
}
|
|
2163
|
-
@keyframes cairn-pulse-
|
|
2164
|
-
0%, 100% { box-shadow: 0 0 0 0 rgba(
|
|
2165
|
-
70% { box-shadow: 0 0 0 10px rgba(
|
|
2170
|
+
@keyframes cairn-pulse-ember {
|
|
2171
|
+
0%, 100% { box-shadow: 0 0 0 0 rgba(224, 122, 63, 0.4); }
|
|
2172
|
+
70% { box-shadow: 0 0 0 10px rgba(224, 122, 63, 0); }
|
|
2173
|
+
}
|
|
2174
|
+
@keyframes cairn-cursor-arrive {
|
|
2175
|
+
0% { box-shadow: 0 0 0 0 rgba(224, 122, 63, 0.55); }
|
|
2176
|
+
100% { box-shadow: 0 0 0 9px rgba(224, 122, 63, 0); }
|
|
2166
2177
|
}
|
|
2167
2178
|
@keyframes cairn-spin {
|
|
2168
2179
|
from { transform: rotate(0deg); }
|
|
@@ -2178,7 +2189,7 @@ const COPILOT_STYLES = `
|
|
|
2178
2189
|
}
|
|
2179
2190
|
@keyframes cairn-word-sweep {
|
|
2180
2191
|
0% { opacity: 0.35; text-shadow: none; }
|
|
2181
|
-
35% { opacity: 1; color: #
|
|
2192
|
+
35% { opacity: 1; color: #E07A3F; text-shadow: 0 0 10px rgba(224, 122, 63, 0.45); }
|
|
2182
2193
|
100% { opacity: 1; color: inherit; text-shadow: none; }
|
|
2183
2194
|
}
|
|
2184
2195
|
@keyframes cairn-thinking-bounce {
|
|
@@ -2186,16 +2197,19 @@ const COPILOT_STYLES = `
|
|
|
2186
2197
|
40% { opacity: 0.9; transform: translateY(-3px); }
|
|
2187
2198
|
}
|
|
2188
2199
|
.cairn-glow {
|
|
2189
|
-
animation: cairn-pulse-
|
|
2190
|
-
outline: 2px solid #
|
|
2200
|
+
animation: cairn-pulse-ember 1.1s ease-out 2;
|
|
2201
|
+
outline: 2px solid #E07A3F;
|
|
2191
2202
|
outline-offset: 3px;
|
|
2192
2203
|
border-radius: 8px;
|
|
2193
2204
|
}
|
|
2205
|
+
.cairn-cursor-hover {
|
|
2206
|
+
animation: cairn-cursor-arrive 0.3s ease-out;
|
|
2207
|
+
}
|
|
2194
2208
|
.cairn-spin {
|
|
2195
2209
|
animation: cairn-spin 0.8s linear infinite;
|
|
2196
2210
|
}
|
|
2197
2211
|
@media (prefers-reduced-motion: reduce) {
|
|
2198
|
-
.cairn-fab, .cairn-panel, .cairn-bubble, .cairn-word, .cairn-thinking-dot {
|
|
2212
|
+
.cairn-fab, .cairn-panel, .cairn-bubble, .cairn-word, .cairn-thinking-dot, #cairn-cursor {
|
|
2199
2213
|
animation: none !important;
|
|
2200
2214
|
transition: none !important;
|
|
2201
2215
|
}
|
package/src/realtime-server.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
resolveCritic,
|
|
43
43
|
resolvePlan,
|
|
44
44
|
resolveVerb,
|
|
45
|
+
KeyRotator,
|
|
45
46
|
type CapabilityTier,
|
|
46
47
|
type CreateCopilotHandlerOptions,
|
|
47
48
|
} from "./server";
|
|
@@ -175,11 +176,23 @@ export { seedHistoryFromMemory, formatRememberedFacts };
|
|
|
175
176
|
export function createRealtimeServer(options: CreateRealtimeServerOptions): http.Server {
|
|
176
177
|
const registeredActions = options.registeredActions ?? [];
|
|
177
178
|
const capability = options.capability ?? "act";
|
|
178
|
-
|
|
179
|
+
// One shared rotator across all three LLM roles — see
|
|
180
|
+
// CreateCopilotHandlerOptions.keyRotator's own doc comment for the real
|
|
181
|
+
// gap this closes (a key one role confirmed dead used to stay invisible
|
|
182
|
+
// to the other two, which kept rediscovering it fresh on every call).
|
|
183
|
+
// Only built for groq — anthropic's createXLLM calls ignore keyRotator
|
|
184
|
+
// entirely, so building one for it would be dead work. Respects a
|
|
185
|
+
// caller-supplied options.keyRotator (e.g. shared with the typed/HTTP
|
|
186
|
+
// transport in the same process) instead of always building a fresh one.
|
|
187
|
+
const sharedOptions: CreateRealtimeServerOptions =
|
|
188
|
+
options.provider === "groq" && !options.keyRotator
|
|
189
|
+
? { ...options, keyRotator: options.apiKeys ? new KeyRotator(options.apiKeys) : options.apiKey ? new KeyRotator([options.apiKey]) : KeyRotator.fromEnvList(process.env.GROQ_API_KEYS) ?? undefined }
|
|
190
|
+
: options;
|
|
191
|
+
const llm = createVerbLLM(sharedOptions);
|
|
179
192
|
// Phase 3 steps 2-3 — real, separately-configured Planner/Critic LLMs.
|
|
180
193
|
// See finalizeTurn's own doc comment for how they're actually used.
|
|
181
|
-
const planLLM = createPlanLLM(
|
|
182
|
-
const criticLLM = createCriticLLM(
|
|
194
|
+
const planLLM = createPlanLLM(sharedOptions);
|
|
195
|
+
const criticLLM = createCriticLLM(sharedOptions);
|
|
183
196
|
// "text" is optional on highlight/open/navigate/do in the base prompt —
|
|
184
197
|
// fine for the typed/HTTP path, which always has a visible answer area,
|
|
185
198
|
// but silence reads as broken in a live voice conversation (the client
|