@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.
- package/dist/agent-loop.d.ts +113 -0
- package/dist/agent-loop.js +128 -0
- package/dist/cairn-widget.js +2 -2
- package/dist/element-ladder.d.ts +71 -0
- package/dist/element-ladder.js +168 -0
- package/dist/index.d.ts +79 -1
- package/dist/index.js +864 -89
- package/dist/key-rotator.d.ts +28 -0
- package/dist/key-rotator.js +57 -3
- package/dist/memory-sqlite.d.ts +86 -0
- package/dist/memory-sqlite.js +230 -0
- package/dist/realtime-cli.js +22 -1
- package/dist/realtime-server.d.ts +83 -2
- package/dist/realtime-server.js +549 -120
- package/dist/server.d.ts +249 -5
- package/dist/server.js +984 -77
- package/dist/skill-store.d.ts +17 -0
- package/dist/skill-store.js +78 -0
- package/dist/tts-stream.d.ts +25 -0
- package/dist/tts-stream.js +32 -0
- package/dist/vad.d.ts +27 -0
- package/dist/vad.js +128 -0
- package/dist/verb-executor.d.ts +32 -11
- package/dist/verb-executor.js +224 -16
- package/dist/webmcp-client.d.ts +14 -1
- package/dist/webmcp-client.js +22 -1
- package/package.json +3 -1
- package/src/agent-loop.ts +222 -0
- package/src/element-ladder.ts +170 -0
- package/src/index.tsx +914 -93
- package/src/key-rotator.ts +57 -2
- package/src/memory-sqlite.ts +283 -0
- package/src/realtime-cli.ts +24 -1
- package/src/realtime-server.ts +655 -122
- package/src/server.ts +1077 -77
- package/src/skill-store.ts +88 -0
- package/src/tts-stream.ts +30 -0
- package/src/vad.ts +153 -0
- package/src/verb-executor.ts +243 -22
- package/src/web-component.ts +82 -17
- package/src/webmcp-client.ts +30 -2
package/dist/element-ladder.js
CHANGED
|
@@ -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[];
|