@cairnvibe/sdk 0.2.13 → 0.3.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.
@@ -12,9 +12,14 @@
12
12
  // 4. FAIL — caller degrades to explain + logs the miss
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.findElement = findElement;
15
+ exports.findElementWithRetry = findElementWithRetry;
16
+ exports.waitForDomSettle = waitForDomSettle;
15
17
  exports.highlightElement = highlightElement;
16
18
  exports.fillElement = fillElement;
17
19
  exports.readElement = readElement;
20
+ exports.selectOption = selectOption;
21
+ exports.dragElement = dragElement;
22
+ exports.pressKey = pressKey;
18
23
  exports.logMiss = logMiss;
19
24
  function findElement(target, liveElements) {
20
25
  const live = liveElements?.get(target);
@@ -43,6 +48,89 @@ function findElement(target, liveElements) {
43
48
  }
44
49
  return null;
45
50
  }
51
+ /**
52
+ * Phase 3 step 4 (see DEVELOPMENT.md/the plan file) — CODA's own point:
53
+ * the Executor gets real local retry latitude for a genuinely MECHANICAL
54
+ * miss (a re-render replaced the DOM node the frozen liveElements snapshot
55
+ * pointed at; an animation/async render hadn't settled yet) before a
56
+ * failure escalates all the way to the Critic/a replan. Deliberately NOT
57
+ * a second LLM call — the Executor stays opinion-free, exactly re-running
58
+ * the SAME real lookup (which, past the liveElements-map check, already
59
+ * queries the LIVE DOM directly — a stale snapshot doesn't matter to that
60
+ * part) after a short real wait. A target that's genuinely not on the
61
+ * page still fails after `attempts`, surfacing as a real miss — this
62
+ * never silently invents success.
63
+ */
64
+ async function findElementWithRetry(target, liveElements, attempts = 2, delayMs = 300) {
65
+ for (let i = 0; i < attempts; i++) {
66
+ const el = findElement(target, liveElements);
67
+ if (el)
68
+ return el;
69
+ if (i < attempts - 1)
70
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
71
+ }
72
+ return null;
73
+ }
74
+ /**
75
+ * Real, live-found bug this closes: a `fill`/`click` step reported itself
76
+ * "done" the instant its DOM event was dispatched — but the app's own
77
+ * reaction to that event (a filtered search-results grid re-rendering, a
78
+ * cart count updating) can be an unbounded-latency async round trip (a
79
+ * Next.js App Router `router.push` re-fetching a server component, for
80
+ * example — not a fixed debounce with a known delay to just sleep past).
81
+ * A `read` step immediately after saw STALE content and the agent
82
+ * confidently reported findings that didn't match what the page actually,
83
+ * eventually, showed — confirmed live: typing "book" into a search box,
84
+ * then reading the still-unfiltered product grid a moment later, and
85
+ * reporting a match ("Novel: The Long Way") the REAL, since-filtered page
86
+ * went on to show zero results for.
87
+ *
88
+ * Waits for real DOM mutations instead of guessing a sleep duration: if
89
+ * nothing starts mutating within `initialWaitMs`, resolves immediately
90
+ * (the action had no async effect at all — no reason to add latency to
91
+ * the common case); once mutations start, waits for `quietMs` of no
92
+ * further mutations before considering the page settled; a hard
93
+ * `timeoutMs` ceiling means a page that never stops mutating (an
94
+ * animation, a polling widget) can't stall the agent loop forever.
95
+ */
96
+ function waitForDomSettle(initialWaitMs = 100, quietMs = 200, timeoutMs = 1500) {
97
+ return new Promise((resolve) => {
98
+ // Real gap this closes: some callers stub a partial `document` (real
99
+ // tests in this repo do exactly that for other reasons — a fake
100
+ // WebMCP-tool document, for instance) without the rest of the DOM API
101
+ // surface — checking `document` alone isn't enough to guarantee
102
+ // MutationObserver (or document.body) actually exist too.
103
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined" || !document.body) {
104
+ resolve();
105
+ return;
106
+ }
107
+ let settled = false;
108
+ let sawMutation = false;
109
+ let quietTimer = null;
110
+ const finish = () => {
111
+ if (settled)
112
+ return;
113
+ settled = true;
114
+ observer.disconnect();
115
+ if (quietTimer)
116
+ clearTimeout(quietTimer);
117
+ clearTimeout(hardCap);
118
+ resolve();
119
+ };
120
+ const observer = new MutationObserver(() => {
121
+ sawMutation = true;
122
+ if (quietTimer)
123
+ clearTimeout(quietTimer);
124
+ quietTimer = setTimeout(finish, quietMs);
125
+ });
126
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true });
127
+ const hardCap = setTimeout(finish, timeoutMs);
128
+ setTimeout(() => {
129
+ if (!sawMutation)
130
+ finish(); // the action had no async effect — nothing to wait for
131
+ }, initialWaitMs);
132
+ });
133
+ }
46
134
  function highlightElement(el, glowMs = 4000) {
47
135
  el.scrollIntoView({ behavior: "smooth", block: "center" });
48
136
  el.classList.add("cairn-glow");
@@ -87,6 +175,86 @@ function readElement(el) {
87
175
  const trimmed = raw.replace(/\s+/g, " ").trim();
88
176
  return trimmed.length > 500 ? `${trimmed.slice(0, 499)}…` : trimmed || "(empty)";
89
177
  }
178
+ /**
179
+ * Chooses a real `<option>` by its visible text — never a raw internal
180
+ * `value` the model could never actually see. Native `<select>` gets the
181
+ * direct path (set `.value` to the matching option's own value, then fire
182
+ * the same input/change pair fillElement uses so React notices). A custom
183
+ * listbox/combobox (role="listbox"/"option" — Radix, Headless UI, etc.)
184
+ * has no real `<option>` to set, so the fallback clicks the matching
185
+ * option-shaped descendant instead, the same "do the real user gesture"
186
+ * principle the do/click cases already follow.
187
+ */
188
+ function selectOption(el, visibleText) {
189
+ if (el.tagName === "SELECT") {
190
+ const select = el;
191
+ const match = Array.from(select.options).find((o) => normalize(o.textContent ?? "") === normalize(visibleText)) ?? Array.from(select.options).find((o) => normalize(o.textContent ?? "").includes(normalize(visibleText)));
192
+ if (!match)
193
+ return false;
194
+ select.value = match.value;
195
+ select.dispatchEvent(new Event("input", { bubbles: true }));
196
+ select.dispatchEvent(new Event("change", { bubbles: true }));
197
+ return true;
198
+ }
199
+ const candidates = el.querySelectorAll('[role="option"], option, li, [role="menuitem"]');
200
+ const match = Array.from(candidates).find((c) => normalize(c.textContent ?? "") === normalize(visibleText)) ?? Array.from(candidates).find((c) => normalize(c.textContent ?? "").includes(normalize(visibleText)));
201
+ if (!match)
202
+ return false;
203
+ match.click();
204
+ return true;
205
+ }
206
+ /**
207
+ * A real multi-point pointer-event sequence — pointerdown on `from`'s
208
+ * center, several pointermove steps toward `to`'s center, pointerup on
209
+ * `to` — the same technique a real mouse drag produces, for canvas/kanban/
210
+ * sortable-list libraries (react-dnd, dnd-kit, n8n's own node canvas) that
211
+ * listen for pointer events rather than a single synthetic "drop". Mouse
212
+ * events are fired alongside (same coordinates) for the older libraries
213
+ * that still only listen for those. jsdom's getBoundingClientRect returns
214
+ * all-zero rects with no real layout engine — fine here, since what matters
215
+ * for a test is that the sequence fires with consistent coordinates, not
216
+ * that they reflect real pixels.
217
+ */
218
+ function dragElement(from, to, steps = 5) {
219
+ const fromRect = from.getBoundingClientRect();
220
+ const toRect = to.getBoundingClientRect();
221
+ const fromX = fromRect.left + fromRect.width / 2;
222
+ const fromY = fromRect.top + fromRect.height / 2;
223
+ const toX = toRect.left + toRect.width / 2;
224
+ const toY = toRect.top + toRect.height / 2;
225
+ const fire = (target, type, x, y) => {
226
+ const opts = { bubbles: true, cancelable: true, clientX: x, clientY: y, view: typeof window !== "undefined" ? window : undefined };
227
+ if (typeof PointerEvent !== "undefined")
228
+ target.dispatchEvent(new PointerEvent(type.replace("mouse", "pointer"), opts));
229
+ target.dispatchEvent(new MouseEvent(type, opts));
230
+ };
231
+ fire(from, "mousedown", fromX, fromY);
232
+ for (let i = 1; i <= steps; i++) {
233
+ const x = fromX + ((toX - fromX) * i) / steps;
234
+ const y = fromY + ((toY - fromY) * i) / steps;
235
+ fire(i === steps ? to : from, "mousemove", x, y);
236
+ }
237
+ fire(to, "mouseup", toX, toY);
238
+ }
239
+ const KEYS_WITH_PRINTABLE_CHAR = new Set(["Enter", "Tab"]);
240
+ /**
241
+ * Presses one real key on a target element — focuses it first (a real
242
+ * keypress always lands on whatever's focused; a component that reacts to
243
+ * Escape/Enter/arrows almost always keys off document-level or its own
244
+ * focus-scoped listener, so focus has to be real before the event fires).
245
+ * Fires keydown/keyup (and keypress only for the handful of keys that
246
+ * still expect one — Enter/Tab — matching a real browser's own behavior,
247
+ * which no longer fires keypress for pure navigation keys like arrows).
248
+ */
249
+ function pressKey(el, key) {
250
+ if (typeof el.focus === "function")
251
+ el.focus();
252
+ const opts = { bubbles: true, cancelable: true, key };
253
+ el.dispatchEvent(new KeyboardEvent("keydown", opts));
254
+ if (KEYS_WITH_PRINTABLE_CHAR.has(key))
255
+ el.dispatchEvent(new KeyboardEvent("keypress", opts));
256
+ el.dispatchEvent(new KeyboardEvent("keyup", opts));
257
+ }
90
258
  const MISS_LOG_KEY = "cairn:misses";
91
259
  const MISS_LOG_LIMIT = 200;
92
260
  function logMiss(context) {
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type HistoryTurn as HistoryEntry } from "@cairnvibe/core";
1
2
  export interface CopilotProps {
2
3
  /** Reserved for a future client-side manifest fetch. Not required — the server handler owns the manifest. */
3
4
  manifest?: string;
@@ -17,6 +18,45 @@ export interface CopilotProps {
17
18
  transcribeEndpoint?: string;
18
19
  /** If set, the widget speaks each explain/highlight answer aloud (Deepgram TTS via `@cairnvibe/sdk/speak-server`). */
19
20
  speakEndpoint?: string;
21
+ /**
22
+ * Phase 5 step 4 — whatever opaque id the CUSTOMER's own app already
23
+ * has for this end user (their own login id, or any other stable
24
+ * string they choose) — this SDK invents no identity of its own, same
25
+ * discipline as the realtime relay's own "context" message scopeId.
26
+ * Sent on every typed-transport request when set; the server only ever
27
+ * seeds/records real cross-session memory when BOTH this and a
28
+ * `memory` store are configured (`createCopilotHandler`'s own
29
+ * `memory` option) — omitting this keeps every request exactly as
30
+ * memory-less as before this existed, regardless of server config.
31
+ */
32
+ scopeId?: string;
33
+ /**
34
+ * Architecture Pillar 4 — if set (alongside `criticEndpoint`), the typed/
35
+ * HTTP loop gets the same real Planner the realtime relay already has:
36
+ * a task breakdown for a compound goal, and a genuinely separate Critic
37
+ * pass over each continuing step's real result (packages/sdk/src/
38
+ * server.ts's `createPlanHandler`/`createCriticHandler`). Omitting
39
+ * either endpoint keeps the typed loop exactly as it was — click/fill/
40
+ * read/call_tool executed and folded into history, ended by a terminal
41
+ * verb or the iteration cap — with zero Planner/Critic overhead, same
42
+ * opt-in discipline as speakEndpoint/transcribeEndpoint.
43
+ */
44
+ planEndpoint?: string;
45
+ /** See `planEndpoint` — both must be set for the typed loop's Planner/Critic wiring to activate. */
46
+ criticEndpoint?: string;
47
+ /**
48
+ * Architecture Pillar 3 (Skill half) — if set (alongside `planEndpoint`/
49
+ * `criticEndpoint`), the typed loop saves whatever real, Critic-
50
+ * verified facts a turn collects (`packages/sdk/src/server.ts`'s
51
+ * `createSkillSaveHandler`) once the turn concludes — the same
52
+ * Formulator mechanism the realtime relay already has. Retrieval (a
53
+ * matching Skill's full instructions surfacing to the Planner) needs no
54
+ * separate client wiring — it's already part of what `planEndpoint`'s
55
+ * own server-side handler does once a `SkillStore` is configured there.
56
+ * Omitting this keeps the typed loop exactly as it was — no Skills are
57
+ * ever saved, zero overhead.
58
+ */
59
+ skillsSaveEndpoint?: string;
20
60
  /**
21
61
  * If set, shows a "start conversation" control that opens a live
22
62
  * WebSocket to a `@cairnvibe/sdk/realtime-server` relay (run via
@@ -28,4 +68,42 @@ export interface CopilotProps {
28
68
  /** Display name for the agent, shown in the widget's header and button labels. Defaults to "Cairn". */
29
69
  persona?: string;
30
70
  }
31
- export declare function Copilot({ endpoint, registeredActions, onDo, reportMissesEndpoint, transcribeEndpoint, speakEndpoint, realtimeUrl, persona, }: CopilotProps): import("react").JSX.Element;
71
+ export declare function Copilot({ endpoint, registeredActions, onDo, reportMissesEndpoint, transcribeEndpoint, speakEndpoint, scopeId, planEndpoint, criticEndpoint, skillsSaveEndpoint, realtimeUrl, persona, }: CopilotProps): import("react").JSX.Element;
72
+ export declare const CONVERSATION_STORAGE_KEY = "cairn:conversation:v1";
73
+ export interface PersistedConversation {
74
+ transcript: {
75
+ id: number;
76
+ role: "user" | "agent";
77
+ text: string;
78
+ }[];
79
+ lastQuestion: string | null;
80
+ answer: string | null;
81
+ open: boolean;
82
+ }
83
+ /**
84
+ * Real, live-reported bug this closes: a host app's own mutation handler
85
+ * calling a real `window.location.reload()` — a common, entirely valid
86
+ * pattern; this SDK's own demo app uses it after several real actions,
87
+ * e.g. moving a kanban card — tears down the ENTIRE React tree, this
88
+ * widget included. Every bit of conversation state (the visible
89
+ * transcript, the current exchange, even whether the panel was open)
90
+ * reset to nothing, making a real, in-progress conversation look like it
91
+ * had simply ended the instant a host page happened to reload — even
92
+ * though nothing about the CONVERSATION itself was actually over.
93
+ * `sessionStorage`, not `localStorage`, is deliberate: it survives
94
+ * exactly a reload/navigation within the same tab — the real scope of
95
+ * "this conversation" — and clears itself once the tab/window actually
96
+ * closes, never lingering into an unrelated later visit the way
97
+ * `localStorage` would.
98
+ */
99
+ export declare function loadPersistedConversation(): PersistedConversation | null;
100
+ export declare function savePersistedConversation(data: PersistedConversation): void;
101
+ /**
102
+ * Rebuilds a real seed for `historyRef` (what gets sent to the model on
103
+ * the NEXT typed turn) from a restored transcript — deliberately derived
104
+ * from the same, already-persisted `transcript` rather than separately
105
+ * persisting `historyRef`'s own shape: one real source of truth for "what
106
+ * was actually said," not two that could quietly drift apart. Capped the
107
+ * same way every other history array in this file already is.
108
+ */
109
+ export declare function reconstructHistoryFromPersisted(persisted: PersistedConversation | null): HistoryEntry[];