@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/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.apiKeys
502
- ? new KeyRotator(options.apiKeys)
503
- : options.apiKey
504
- ? new KeyRotator([options.apiKey])
505
- : KeyRotator.fromEnvList(process.env.GROQ_API_KEYS);
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
- private clientFactory: (apiKey: string) => GroqLikeClient = (apiKey) => new Groq({ apiKey }),
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
- private clientFactory: (apiKey: string) => GroqLikeStreamingClient = (apiKey) => new Groq({ apiKey }),
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> {
@@ -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
- // "open" means make the thing actually appear (a menu, a modal, a
140
- // panel) highlighting alone doesn't do that; a real click does.
141
- if (verb.verb === "open") el.click();
142
- if (verb.text) options.onExplain(verb.text);
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.click();
202
- if (verb.text) options.onExplain(verb.text);
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.click();
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
+ 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 || !fillElement(el, verb.value)) {
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
- // 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.` });
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
- options.onToolStep?.({ verb: "read", target: verb.target, ok: true, observation: readElement(el) });
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
- 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}.` });
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 || !selectOption(el, verb.value)) {
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
- void waitForDomSettle().then(() => {
338
- options.onToolStep?.({ verb: "select", target: verb.target, ok: true, observation: `Selected "${verb.value}".` });
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
- pressKey(el, verb.key);
352
- void waitForDomSettle().then(() => {
353
- options.onToolStep?.({ verb: "key", target: verb.target, ok: true, observation: `Pressed ${verb.key}.` });
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 waitForDomSettle().then(() => {
372
- options.onToolStep?.({ verb: "scroll", target: verb.target, ok: true, observation: "Scrolled it into view." });
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
- options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: true, observation: "It appeared." });
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 || !fillElement(el, action.value)) {
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 || !selectOption(el, action.value)) {
545
+ if (!el) {
488
546
  (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." };
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
  }
@@ -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-indigo {
60
- 0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); }
61
- 70% { box-shadow: 0 0 0 10px rgba(99, 102, 241, 0); }
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: #4f46e5; text-shadow: 0 0 10px rgba(99, 102, 241, 0.45); }
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-indigo 1.1s ease-out 2;
87
- outline: 2px solid #6366f1;
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 ------------------------------------------------------