@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.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Animates the synthetic cursor to `el`'s center and resolves once it has
3
+ * genuinely arrived (plus a brief real hover pause) — callers await this
4
+ * BEFORE performing the real action, so the cursor is seen gliding there
5
+ * first. Deliberately timer-driven (`window.setTimeout`), not
6
+ * `transitionend`/`Element.animate().finished`-driven — this repo's test
7
+ * environment is plain Node, not a real browser (see waitForDomSettle's own
8
+ * doc comment for the same discipline), and a fixed, known duration is what
9
+ * makes this testable with fake timers instead of needing real animation-
10
+ * completion events that a headless/no-DOM environment may never fire.
11
+ *
12
+ * SSR/no-DOM safe — same defensive guard `waitForDomSettle` already uses —
13
+ * so a caller never needs its own environment check before calling this.
14
+ */
15
+ export declare function moveCursorTo(el: HTMLElement): Promise<void>;
16
+ /** Fades the synthetic cursor out — called once the widget itself closes or
17
+ * unmounts, so it doesn't sit visible on screen after the conversation
18
+ * ends. Safe to call even if the cursor was never created. */
19
+ export declare function hideCursor(): void;
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ // A visible, animated synthetic cursor that glides to whatever element the
3
+ // agent is about to act on, and genuinely arrives — before anything actually
4
+ // happens on screen — rather than a click just occurring with no visible
5
+ // lead-up. Real, watchable proof of what the agent resolved, the same way
6
+ // watching a person's own mouse move tells you where they're about to click
7
+ // before it happens; matches this SDK's own "verified, not trusted"
8
+ // discipline in a form a user can literally see, not just read.
9
+ //
10
+ // Purely additive and deliberately decoupled from highlightElement
11
+ // (element-ladder.ts) — that function's own scroll+glow behavior is
12
+ // unchanged and still called by every site that used it before. This module
13
+ // only adds the moving cursor itself; a caller awaits moveCursorTo(el)
14
+ // before firing the real action so the cursor is seen arriving first, never
15
+ // after the fact — see verb-executor.ts's own call sites for the exact
16
+ // sequencing.
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.moveCursorTo = moveCursorTo;
19
+ exports.hideCursor = hideCursor;
20
+ const CURSOR_ID = "cairn-cursor";
21
+ const MOVE_MS = 550;
22
+ const ARRIVE_PAUSE_MS = 160;
23
+ // The CSS side already disables the cursor's transition/animation under
24
+ // prefers-reduced-motion (see #cairn-cursor in the injected <style> block),
25
+ // which makes it jump instead of glide — but without this, the real delay
26
+ // before the action fires would stay the full ~710ms even though there's
27
+ // nothing left to watch. Mirrors the visual change with a real timing one.
28
+ const REDUCED_MOVE_MS = 60;
29
+ const REDUCED_ARRIVE_PAUSE_MS = 40;
30
+ function prefersReducedMotion() {
31
+ return (typeof window !== "undefined" &&
32
+ typeof window.matchMedia === "function" &&
33
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches);
34
+ }
35
+ // Module-scope, not per-call — the whole point is a SINGLE cursor that
36
+ // glides from wherever it last was, the way a real mouse never teleports
37
+ // between two unrelated screen positions.
38
+ let lastX = null;
39
+ let lastY = null;
40
+ function ensureCursorEl() {
41
+ if (typeof document === "undefined" || !document.body)
42
+ return null;
43
+ let el = document.getElementById(CURSOR_ID);
44
+ if (el)
45
+ 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
+ * Animates the synthetic cursor to `el`'s center and resolves once it has
63
+ * genuinely arrived (plus a brief real hover pause) — callers await this
64
+ * BEFORE performing the real action, so the cursor is seen gliding there
65
+ * first. Deliberately timer-driven (`window.setTimeout`), not
66
+ * `transitionend`/`Element.animate().finished`-driven — this repo's test
67
+ * environment is plain Node, not a real browser (see waitForDomSettle's own
68
+ * doc comment for the same discipline), and a fixed, known duration is what
69
+ * makes this testable with fake timers instead of needing real animation-
70
+ * completion events that a headless/no-DOM environment may never fire.
71
+ *
72
+ * SSR/no-DOM safe — same defensive guard `waitForDomSettle` already uses —
73
+ * so a caller never needs its own environment check before calling this.
74
+ */
75
+ function moveCursorTo(el) {
76
+ if (typeof document === "undefined" || typeof window === "undefined" || typeof el.getBoundingClientRect !== "function") {
77
+ return Promise.resolve();
78
+ }
79
+ const cursor = ensureCursorEl();
80
+ if (!cursor)
81
+ return Promise.resolve();
82
+ const rect = el.getBoundingClientRect();
83
+ const x = rect.left + rect.width / 2;
84
+ const y = rect.top + rect.height / 2;
85
+ if (lastX === null || lastY === null) {
86
+ // The very first move of the session starts from the widget's own
87
+ // corner (bottom-right, where the FAB lives) instead of materializing
88
+ // at (0,0) — reads as "coming from Cairn," not appearing from nowhere.
89
+ lastX = window.innerWidth - 40;
90
+ lastY = window.innerHeight - 40;
91
+ cursor.style.transform = `translate(${lastX}px, ${lastY}px)`;
92
+ }
93
+ const reduced = prefersReducedMotion();
94
+ const moveMs = reduced ? REDUCED_MOVE_MS : MOVE_MS;
95
+ const arrivePauseMs = reduced ? REDUCED_ARRIVE_PAUSE_MS : ARRIVE_PAUSE_MS;
96
+ cursor.style.transition = `transform ${moveMs}ms cubic-bezier(.4,0,.2,1), opacity 180ms ease`;
97
+ cursor.style.opacity = "1";
98
+ // Forces a style flush so the browser animates FROM the current position
99
+ // TO the new one instead of jumping straight there — reading a layout
100
+ // property is the standard, harmless way to force this without a real
101
+ // animation API (which, per this function's own doc comment, this
102
+ // deliberately avoids depending on for its completion signal anyway).
103
+ void cursor.offsetHeight;
104
+ cursor.style.transform = `translate(${x}px, ${y}px)`;
105
+ lastX = x;
106
+ lastY = y;
107
+ return new Promise((resolve) => {
108
+ window.setTimeout(() => {
109
+ cursor.classList.add("cairn-cursor-hover");
110
+ window.setTimeout(() => {
111
+ cursor.classList.remove("cairn-cursor-hover");
112
+ resolve();
113
+ }, arrivePauseMs);
114
+ }, moveMs);
115
+ });
116
+ }
117
+ /** Fades the synthetic cursor out — called once the widget itself closes or
118
+ * unmounts, so it doesn't sit visible on screen after the conversation
119
+ * ends. Safe to call even if the cursor was never created. */
120
+ function hideCursor() {
121
+ if (typeof document === "undefined")
122
+ return;
123
+ const el = document.getElementById(CURSOR_ID);
124
+ if (el)
125
+ el.style.opacity = "0";
126
+ }
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ const lucide_react_1 = require("lucide-react");
13
13
  const core_1 = require("@cairnvibe/core");
14
14
  const agent_loop_1 = require("./agent-loop");
15
15
  const context_collector_1 = require("./context-collector");
16
+ const cursor_overlay_1 = require("./cursor-overlay");
16
17
  const element_ladder_1 = require("./element-ladder");
17
18
  const runtime_scan_1 = require("./runtime-scan");
18
19
  const webmcp_client_1 = require("./webmcp-client");
@@ -40,6 +41,13 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
40
41
  // loadPersistedConversation's own doc comment) had showing a moment ago,
41
42
  // so a real reload never again looks like the conversation simply ended.
42
43
  const [open, setOpen] = (0, react_1.useState)(false);
44
+ // Fades the synthetic cursor out once the panel closes (or the widget
45
+ // itself unmounts) instead of leaving it sitting visible on the page.
46
+ (0, react_1.useEffect)(() => {
47
+ if (!open)
48
+ (0, cursor_overlay_1.hideCursor)();
49
+ return () => (0, cursor_overlay_1.hideCursor)();
50
+ }, [open]);
43
51
  // Collapsed by default so the panel only ever shows the current exchange
44
52
  // — the full archived transcript (built up over a long conversation)
45
53
  // stays out of the way behind an explicit toggle instead of always being
@@ -1901,9 +1909,13 @@ const COPILOT_STYLES = `
1901
1909
  0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
1902
1910
  70% { box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
1903
1911
  }
1904
- @keyframes cairn-pulse-indigo {
1905
- 0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); }
1906
- 70% { box-shadow: 0 0 0 10px rgba(99, 102, 241, 0); }
1912
+ @keyframes cairn-pulse-ember {
1913
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(224, 122, 63, 0.4); }
1914
+ 70% { box-shadow: 0 0 0 10px rgba(224, 122, 63, 0); }
1915
+ }
1916
+ @keyframes cairn-cursor-arrive {
1917
+ 0% { box-shadow: 0 0 0 0 rgba(224, 122, 63, 0.55); }
1918
+ 100% { box-shadow: 0 0 0 9px rgba(224, 122, 63, 0); }
1907
1919
  }
1908
1920
  @keyframes cairn-spin {
1909
1921
  from { transform: rotate(0deg); }
@@ -1919,7 +1931,7 @@ const COPILOT_STYLES = `
1919
1931
  }
1920
1932
  @keyframes cairn-word-sweep {
1921
1933
  0% { opacity: 0.35; text-shadow: none; }
1922
- 35% { opacity: 1; color: #4f46e5; text-shadow: 0 0 10px rgba(99, 102, 241, 0.45); }
1934
+ 35% { opacity: 1; color: #E07A3F; text-shadow: 0 0 10px rgba(224, 122, 63, 0.45); }
1923
1935
  100% { opacity: 1; color: inherit; text-shadow: none; }
1924
1936
  }
1925
1937
  @keyframes cairn-thinking-bounce {
@@ -1927,16 +1939,19 @@ const COPILOT_STYLES = `
1927
1939
  40% { opacity: 0.9; transform: translateY(-3px); }
1928
1940
  }
1929
1941
  .cairn-glow {
1930
- animation: cairn-pulse-indigo 1.1s ease-out 2;
1931
- outline: 2px solid #6366f1;
1942
+ animation: cairn-pulse-ember 1.1s ease-out 2;
1943
+ outline: 2px solid #E07A3F;
1932
1944
  outline-offset: 3px;
1933
1945
  border-radius: 8px;
1934
1946
  }
1947
+ .cairn-cursor-hover {
1948
+ animation: cairn-cursor-arrive 0.3s ease-out;
1949
+ }
1935
1950
  .cairn-spin {
1936
1951
  animation: cairn-spin 0.8s linear infinite;
1937
1952
  }
1938
1953
  @media (prefers-reduced-motion: reduce) {
1939
- .cairn-fab, .cairn-panel, .cairn-bubble, .cairn-word, .cairn-thinking-dot {
1954
+ .cairn-fab, .cairn-panel, .cairn-bubble, .cairn-word, .cairn-thinking-dot, #cairn-cursor {
1940
1955
  animation: none !important;
1941
1956
  transition: none !important;
1942
1957
  }
@@ -105,11 +105,22 @@ async function handleRememberFactTool(memory, scopeId, args) {
105
105
  function createRealtimeServer(options) {
106
106
  const registeredActions = options.registeredActions ?? [];
107
107
  const capability = options.capability ?? "act";
108
- const llm = (0, server_1.createVerbLLM)(options);
108
+ // One shared rotator across all three LLM roles — see
109
+ // CreateCopilotHandlerOptions.keyRotator's own doc comment for the real
110
+ // gap this closes (a key one role confirmed dead used to stay invisible
111
+ // to the other two, which kept rediscovering it fresh on every call).
112
+ // Only built for groq — anthropic's createXLLM calls ignore keyRotator
113
+ // entirely, so building one for it would be dead work. Respects a
114
+ // caller-supplied options.keyRotator (e.g. shared with the typed/HTTP
115
+ // transport in the same process) instead of always building a fresh one.
116
+ const sharedOptions = options.provider === "groq" && !options.keyRotator
117
+ ? { ...options, keyRotator: options.apiKeys ? new server_1.KeyRotator(options.apiKeys) : options.apiKey ? new server_1.KeyRotator([options.apiKey]) : server_1.KeyRotator.fromEnvList(process.env.GROQ_API_KEYS) ?? undefined }
118
+ : options;
119
+ const llm = (0, server_1.createVerbLLM)(sharedOptions);
109
120
  // Phase 3 steps 2-3 — real, separately-configured Planner/Critic LLMs.
110
121
  // See finalizeTurn's own doc comment for how they're actually used.
111
- const planLLM = (0, server_1.createPlanLLM)(options);
112
- const criticLLM = (0, server_1.createCriticLLM)(options);
122
+ const planLLM = (0, server_1.createPlanLLM)(sharedOptions);
123
+ const criticLLM = (0, server_1.createCriticLLM)(sharedOptions);
113
124
  // "text" is optional on highlight/open/navigate/do in the base prompt —
114
125
  // fine for the typed/HTTP path, which always has a visible answer area,
115
126
  // but silence reads as broken in a live voice conversation (the client
package/dist/server.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type CriticVerdict, type HistoryTurn, type LiveElement, type Manifest, type Plan, type Skill, type SkillSummary, type Task, type UiPatternId, type VerbResponse, type WebMcpTool } from "@cairnvibe/core";
2
2
  import { type MemoryStore } from "./memory-sqlite";
3
+ export { KeyRotator } from "./key-rotator";
3
4
  import { KeyRotator } from "./key-rotator";
4
5
  import type { SkillStore } from "./skill-store";
5
6
  /**
@@ -16,6 +17,22 @@ export interface CreateCopilotHandlerOptions {
16
17
  /** Single API key. For groq, prefer `apiKeys` to round-robin; falls back to GROQ_API_KEYS env. */
17
18
  apiKey?: string;
18
19
  apiKeys?: string[];
20
+ /**
21
+ * A pre-built rotator to share across multiple LLM roles (verb, plan,
22
+ * critic) instead of each one building its own from `apiKeys`/`apiKey`/
23
+ * env. Real, live-found gap this closes: createVerbLLM/createPlanLLM/
24
+ * createCriticLLM each called createToolLLM independently, and each one
25
+ * built a BRAND NEW KeyRotator from the same GROQ_API_KEYS list — so a
26
+ * key one of them confirmed dead via a real 401 (KeyRotator.markDead)
27
+ * stayed invisible to the other two, which went on rediscovering the
28
+ * exact same dead key from scratch on every one of their own calls,
29
+ * wasting real round trips and, worse, stacking up wasted attempts
30
+ * against the SAME small number of retries each call is bounded to.
31
+ * Takes precedence over `apiKeys`/`apiKey`/env when provided. See
32
+ * groq-llm.ts in examples/demo-app for the intended usage: build one
33
+ * KeyRotator at module scope, pass it to all three createXLLM calls.
34
+ */
35
+ keyRotator?: KeyRotator;
19
36
  model?: string;
20
37
  /** Action ids this deployment actually supports. "do" is refused for anything else. */
21
38
  registeredActions?: string[];
package/dist/server.js CHANGED
@@ -7,7 +7,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
7
7
  return (mod && mod.__esModule) ? mod : { "default": mod };
8
8
  };
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.GroqStreamingTextLLM = exports.GroqVerbLLM = exports.AnthropicStreamingTextLLM = exports.AnthropicVerbLLM = void 0;
10
+ exports.GroqStreamingTextLLM = exports.GroqVerbLLM = exports.AnthropicStreamingTextLLM = exports.AnthropicVerbLLM = exports.KeyRotator = void 0;
11
11
  exports.createCopilotHandler = createCopilotHandler;
12
12
  exports.createCopilotHandlerWithLLM = createCopilotHandlerWithLLM;
13
13
  exports.resolveVerb = resolveVerb;
@@ -34,7 +34,9 @@ const zod_1 = require("zod");
34
34
  const core_1 = require("@cairnvibe/core");
35
35
  const agent_loop_1 = require("./agent-loop");
36
36
  const memory_sqlite_1 = require("./memory-sqlite");
37
- const key_rotator_1 = require("./key-rotator");
37
+ var key_rotator_1 = require("./key-rotator");
38
+ Object.defineProperty(exports, "KeyRotator", { enumerable: true, get: function () { return key_rotator_1.KeyRotator; } });
39
+ const key_rotator_2 = require("./key-rotator");
38
40
  const VERB_TOOL_NAME = "respond_with_verb";
39
41
  const PLAN_TOOL_NAME = "create_plan";
40
42
  const PLAN_TOOL_DESCRIPTION = "Submit an ordered task plan for achieving the user's real end goal.";
@@ -372,11 +374,12 @@ function createCriticLLM(options = {}) {
372
374
  function createToolLLM(options, toolSchema, toolName, toolDescription) {
373
375
  const provider = options.provider ?? "anthropic";
374
376
  if (provider === "groq") {
375
- const rotator = options.apiKeys
376
- ? new key_rotator_1.KeyRotator(options.apiKeys)
377
- : options.apiKey
378
- ? new key_rotator_1.KeyRotator([options.apiKey])
379
- : key_rotator_1.KeyRotator.fromEnvList(process.env.GROQ_API_KEYS);
377
+ const rotator = options.keyRotator
378
+ ?? (options.apiKeys
379
+ ? new key_rotator_2.KeyRotator(options.apiKeys)
380
+ : options.apiKey
381
+ ? new key_rotator_2.KeyRotator([options.apiKey])
382
+ : key_rotator_2.KeyRotator.fromEnvList(process.env.GROQ_API_KEYS));
380
383
  if (!rotator) {
381
384
  throw new Error("createToolLLM: provider 'groq' needs apiKey(s), or GROQ_API_KEYS in env");
382
385
  }
@@ -650,7 +653,20 @@ class GroqVerbLLM {
650
653
  clientFactory;
651
654
  toolName;
652
655
  toolDescription;
653
- constructor(keys, model, toolSchema, clientFactory = (apiKey) => new groq_sdk_1.default({ apiKey }), toolName = VERB_TOOL_NAME, toolDescription = VERB_TOOL_DESCRIPTION) {
656
+ constructor(keys, model, toolSchema,
657
+ // maxRetries: 0 — real, live-found latency bug this closes: the Groq
658
+ // SDK's own default (2 automatic retries with exponential backoff) ran
659
+ // UNDERNEATH respond()'s own key-rotation retry loop, so a single 429
660
+ // key attempt could silently eat several real seconds of SDK-internal
661
+ // backoff before respond() ever saw the rejection and moved on to a
662
+ // DIFFERENT key. With several keys in rotation genuinely rate-limited
663
+ // at once (the common case this closes for), that compounded into a
664
+ // real, live-reported multi-second-to-a-minute hang with no visible
665
+ // progress — worse than useless, since respond()'s own retry already
666
+ // tries a different key/quota entirely, which the SDK's blind same-key
667
+ // backoff can never fix. respond() is the sole source of retry policy
668
+ // here now.
669
+ clientFactory = (apiKey) => new groq_sdk_1.default({ apiKey, maxRetries: 0 }), toolName = VERB_TOOL_NAME, toolDescription = VERB_TOOL_DESCRIPTION) {
654
670
  this.keys = keys;
655
671
  this.model = model;
656
672
  this.toolSchema = toolSchema;
@@ -784,7 +800,14 @@ class GroqStreamingTextLLM {
784
800
  keys;
785
801
  model;
786
802
  clientFactory;
787
- constructor(keys, model, clientFactory = (apiKey) => new groq_sdk_1.default({ apiKey })) {
803
+ constructor(keys, model,
804
+ // maxRetries: 0 — same real latency bug as GroqVerbLLM's own
805
+ // clientFactory default; see its doc comment for the full reasoning.
806
+ // respondStreamed below already has its own key-rotation retry loop
807
+ // (maxAttempts bounded by keys.size), which makes the SDK's blind
808
+ // same-key backoff redundant AND a source of silent multi-second
809
+ // delay stacked underneath it.
810
+ clientFactory = (apiKey) => new groq_sdk_1.default({ apiKey, maxRetries: 0 })) {
788
811
  this.keys = keys;
789
812
  this.model = model;
790
813
  this.clientFactory = clientFactory;