@cairnvibe/sdk 0.2.13 → 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.
Files changed (44) hide show
  1. package/dist/agent-loop.d.ts +113 -0
  2. package/dist/agent-loop.js +128 -0
  3. package/dist/cairn-widget.js +14 -9
  4. package/dist/cursor-overlay.d.ts +19 -0
  5. package/dist/cursor-overlay.js +126 -0
  6. package/dist/element-ladder.d.ts +71 -0
  7. package/dist/element-ladder.js +168 -0
  8. package/dist/index.d.ts +79 -1
  9. package/dist/index.js +886 -96
  10. package/dist/key-rotator.d.ts +28 -0
  11. package/dist/key-rotator.js +57 -3
  12. package/dist/memory-sqlite.d.ts +86 -0
  13. package/dist/memory-sqlite.js +230 -0
  14. package/dist/realtime-cli.js +22 -1
  15. package/dist/realtime-server.d.ts +83 -2
  16. package/dist/realtime-server.js +561 -121
  17. package/dist/server.d.ts +266 -5
  18. package/dist/server.js +1013 -83
  19. package/dist/skill-store.d.ts +17 -0
  20. package/dist/skill-store.js +78 -0
  21. package/dist/tts-stream.d.ts +25 -0
  22. package/dist/tts-stream.js +32 -0
  23. package/dist/vad.d.ts +27 -0
  24. package/dist/vad.js +128 -0
  25. package/dist/verb-executor.d.ts +32 -11
  26. package/dist/verb-executor.js +315 -39
  27. package/dist/webmcp-client.d.ts +14 -1
  28. package/dist/webmcp-client.js +22 -1
  29. package/package.json +3 -1
  30. package/src/agent-loop.ts +222 -0
  31. package/src/cursor-overlay.ts +130 -0
  32. package/src/element-ladder.ts +170 -0
  33. package/src/index.tsx +935 -100
  34. package/src/key-rotator.ts +57 -2
  35. package/src/memory-sqlite.ts +283 -0
  36. package/src/realtime-cli.ts +24 -1
  37. package/src/realtime-server.ts +669 -123
  38. package/src/server.ts +1119 -83
  39. package/src/skill-store.ts +88 -0
  40. package/src/tts-stream.ts +30 -0
  41. package/src/vad.ts +153 -0
  42. package/src/verb-executor.ts +329 -42
  43. package/src/web-component.ts +97 -24
  44. package/src/webmcp-client.ts +30 -2
@@ -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
+ }
@@ -1,4 +1,41 @@
1
1
  export declare function findElement(target: string, liveElements?: Map<string, HTMLElement>): HTMLElement | null;
2
+ /**
3
+ * Phase 3 step 4 (see DEVELOPMENT.md/the plan file) — CODA's own point:
4
+ * the Executor gets real local retry latitude for a genuinely MECHANICAL
5
+ * miss (a re-render replaced the DOM node the frozen liveElements snapshot
6
+ * pointed at; an animation/async render hadn't settled yet) before a
7
+ * failure escalates all the way to the Critic/a replan. Deliberately NOT
8
+ * a second LLM call — the Executor stays opinion-free, exactly re-running
9
+ * the SAME real lookup (which, past the liveElements-map check, already
10
+ * queries the LIVE DOM directly — a stale snapshot doesn't matter to that
11
+ * part) after a short real wait. A target that's genuinely not on the
12
+ * page still fails after `attempts`, surfacing as a real miss — this
13
+ * never silently invents success.
14
+ */
15
+ export declare function findElementWithRetry(target: string, liveElements?: Map<string, HTMLElement>, attempts?: number, delayMs?: number): Promise<HTMLElement | null>;
16
+ /**
17
+ * Real, live-found bug this closes: a `fill`/`click` step reported itself
18
+ * "done" the instant its DOM event was dispatched — but the app's own
19
+ * reaction to that event (a filtered search-results grid re-rendering, a
20
+ * cart count updating) can be an unbounded-latency async round trip (a
21
+ * Next.js App Router `router.push` re-fetching a server component, for
22
+ * example — not a fixed debounce with a known delay to just sleep past).
23
+ * A `read` step immediately after saw STALE content and the agent
24
+ * confidently reported findings that didn't match what the page actually,
25
+ * eventually, showed — confirmed live: typing "book" into a search box,
26
+ * then reading the still-unfiltered product grid a moment later, and
27
+ * reporting a match ("Novel: The Long Way") the REAL, since-filtered page
28
+ * went on to show zero results for.
29
+ *
30
+ * Waits for real DOM mutations instead of guessing a sleep duration: if
31
+ * nothing starts mutating within `initialWaitMs`, resolves immediately
32
+ * (the action had no async effect at all — no reason to add latency to
33
+ * the common case); once mutations start, waits for `quietMs` of no
34
+ * further mutations before considering the page settled; a hard
35
+ * `timeoutMs` ceiling means a page that never stops mutating (an
36
+ * animation, a polling widget) can't stall the agent loop forever.
37
+ */
38
+ export declare function waitForDomSettle(initialWaitMs?: number, quietMs?: number, timeoutMs?: number): Promise<void>;
2
39
  export declare function highlightElement(el: HTMLElement, glowMs?: number): void;
3
40
  /**
4
41
  * Sets a real form field's value AND makes the framework that owns it (React,
@@ -15,6 +52,40 @@ export declare function fillElement(el: HTMLElement, value: string): boolean;
15
52
  * bounds a live element's label (this is what the agent loop "observes"
16
53
  * after a read step, so it needs the same payload/privacy discipline). */
17
54
  export declare function readElement(el: HTMLElement): string;
55
+ /**
56
+ * Chooses a real `<option>` by its visible text — never a raw internal
57
+ * `value` the model could never actually see. Native `<select>` gets the
58
+ * direct path (set `.value` to the matching option's own value, then fire
59
+ * the same input/change pair fillElement uses so React notices). A custom
60
+ * listbox/combobox (role="listbox"/"option" — Radix, Headless UI, etc.)
61
+ * has no real `<option>` to set, so the fallback clicks the matching
62
+ * option-shaped descendant instead, the same "do the real user gesture"
63
+ * principle the do/click cases already follow.
64
+ */
65
+ export declare function selectOption(el: HTMLElement, visibleText: string): boolean;
66
+ /**
67
+ * A real multi-point pointer-event sequence — pointerdown on `from`'s
68
+ * center, several pointermove steps toward `to`'s center, pointerup on
69
+ * `to` — the same technique a real mouse drag produces, for canvas/kanban/
70
+ * sortable-list libraries (react-dnd, dnd-kit, n8n's own node canvas) that
71
+ * listen for pointer events rather than a single synthetic "drop". Mouse
72
+ * events are fired alongside (same coordinates) for the older libraries
73
+ * that still only listen for those. jsdom's getBoundingClientRect returns
74
+ * all-zero rects with no real layout engine — fine here, since what matters
75
+ * for a test is that the sequence fires with consistent coordinates, not
76
+ * that they reflect real pixels.
77
+ */
78
+ export declare function dragElement(from: HTMLElement, to: HTMLElement, steps?: number): void;
79
+ /**
80
+ * Presses one real key on a target element — focuses it first (a real
81
+ * keypress always lands on whatever's focused; a component that reacts to
82
+ * Escape/Enter/arrows almost always keys off document-level or its own
83
+ * focus-scoped listener, so focus has to be real before the event fires).
84
+ * Fires keydown/keyup (and keypress only for the handful of keys that
85
+ * still expect one — Enter/Tab — matching a real browser's own behavior,
86
+ * which no longer fires keypress for pure navigation keys like arrows).
87
+ */
88
+ export declare function pressKey(el: HTMLElement, key: string): void;
18
89
  export interface MissContext {
19
90
  attempted: string;
20
91
  route: string;
@@ -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[];