@cairnvibe/sdk 0.2.6 → 0.2.8

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.
@@ -6,9 +6,39 @@
6
6
  // explain — never guess, never wrong-click"). The server (`server.ts`)
7
7
  // enforces the same schema independently — never trust the client alone.
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.executeToolStep = executeToolStep;
9
10
  exports.executeVerbResponse = executeVerbResponse;
10
11
  const core_1 = require("@cairnvibe/core");
11
12
  const element_ladder_1 = require("./element-ladder");
13
+ const webmcp_client_1 = require("./webmcp-client");
14
+ /**
15
+ * Promise wrapper around executeVerbResponse for a continuing verb
16
+ * (click/fill/read/call_tool) — resolves once the real action has actually
17
+ * finished (synchronously for click/fill/read, after a real await for
18
+ * call_tool) with its real observation, instead of the fire-and-forget
19
+ * callback shape every other verb uses. This is what a loop driver awaits
20
+ * before deciding whether to call the model again.
21
+ */
22
+ function executeToolStep(raw, route, liveElements) {
23
+ return new Promise((resolve) => {
24
+ // executeVerbResponse only ever reaches onToolStep for a genuinely
25
+ // continuing verb — callers are only expected to call this after
26
+ // already confirming (via TERMINAL_VERBS) that the parsed verb is one,
27
+ // so this should always fire; a real timeout (not an immediate
28
+ // microtask — call_tool's own real network round trip needs the time)
29
+ // is the safety net for the case where it somehow doesn't, so a loop
30
+ // driver awaiting this can never hang forever.
31
+ const timer = setTimeout(() => resolve(null), 15000);
32
+ executeVerbResponse(raw, route, {
33
+ onExplain: () => { },
34
+ liveElements,
35
+ onToolStep: (result) => {
36
+ clearTimeout(timer);
37
+ resolve(result);
38
+ },
39
+ });
40
+ });
41
+ }
12
42
  const FALLBACK_TEXT = "I'm not sure — I couldn't understand that response. Try rephrasing your question.";
13
43
  function executeVerbResponse(raw, route, options) {
14
44
  const parsed = core_1.VerbResponseSchema.safeParse(raw);
@@ -25,13 +55,17 @@ function dispatchVerb(verb, route, options) {
25
55
  return;
26
56
  case "highlight":
27
57
  case "open": {
28
- const el = (0, element_ladder_1.findElement)(verb.target);
58
+ const el = (0, element_ladder_1.findElement)(verb.target, options.liveElements);
29
59
  if (!el) {
30
60
  (options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
31
61
  options.onExplain(verb.text ?? "I know what you need, but I can't find it on this page right now.");
32
62
  return;
33
63
  }
34
64
  (0, element_ladder_1.highlightElement)(el);
65
+ // "open" means make the thing actually appear (a menu, a modal, a
66
+ // panel) — highlighting alone doesn't do that; a real click does.
67
+ if (verb.verb === "open")
68
+ el.click();
35
69
  if (verb.text)
36
70
  options.onExplain(verb.text);
37
71
  return;
@@ -50,24 +84,40 @@ function dispatchVerb(verb, route, options) {
50
84
  options.onExplain(verb.text);
51
85
  return;
52
86
  }
53
- if (verb.apiCall) {
54
- // Auto-discovered path: a real, indexer-found handler call on this
55
- // exact target element, attached server-side after looking the
56
- // target up in the manifest never something the model emitted
57
- // itself (see ApiCallSchema's doc comment in @cairnvibe/core).
58
- const el = verb.target ? (0, element_ladder_1.findElement)(verb.target) : null;
59
- if (el)
87
+ // Auto-discovered path: the server already verified `target` names a
88
+ // real element (the static manifest or this exact request's own
89
+ // live-DOM scan) before ever returning this verb — never something
90
+ // the model invented (see resolveVerb in server.ts).
91
+ if (verb.target || verb.apiCall) {
92
+ const el = verb.target ? (0, element_ladder_1.findElement)(verb.target, options.liveElements) : null;
93
+ if (el) {
94
+ // Click-first: the real element's own handler runs in full (any
95
+ // local state update, spinner, or non-network side effect a raw
96
+ // fetch would silently skip), and it's the only way to fire an
97
+ // action that has no fetch/axios call at all — a button that
98
+ // just reveals a form, e.g. — which never gets an `apiCall` in
99
+ // the first place. `apiCall` is only ever the fallback below,
100
+ // for a target that can't be resolved live right now (e.g. it's
101
+ // on a different page) — never fired in addition to a real
102
+ // click, so the action can't run twice.
60
103
  (0, element_ladder_1.highlightElement)(el);
61
- else if (verb.target)
104
+ el.click();
105
+ if (verb.text)
106
+ options.onExplain(verb.text);
107
+ return;
108
+ }
109
+ if (verb.target)
62
110
  (options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
63
- if (verb.text)
64
- options.onExplain(verb.text);
65
- void executeApiCall(verb.apiCall).then((result) => {
66
- if (!result.ok) {
67
- options.onExplain("I tried to do that, but something went wrong — try again in a moment.");
68
- }
69
- });
70
- return;
111
+ if (verb.apiCall) {
112
+ if (verb.text)
113
+ options.onExplain(verb.text);
114
+ void executeApiCall(verb.apiCall).then((result) => {
115
+ if (!result.ok) {
116
+ options.onExplain("I tried to do that, but something went wrong — try again in a moment.");
117
+ }
118
+ });
119
+ return;
120
+ }
71
121
  }
72
122
  options.onExplain(verb.text ?? "That action isn't available here.");
73
123
  return;
@@ -83,6 +133,63 @@ function dispatchVerb(verb, route, options) {
83
133
  options.onExplain(verb.steps.map((s) => s.text).join(" "));
84
134
  }
85
135
  return;
136
+ // The agent loop's steps (server.ts's runAgentLoop) — each executes for
137
+ // real and reports a real observation back via onToolStep, instead of
138
+ // ending the turn the way every verb above does. `target` for these
139
+ // always came from the manifest/currentPageElements/liveElements this
140
+ // exact turn showed the model — never invented, same invariant as do.
141
+ case "click": {
142
+ if (verb.text)
143
+ options.onExplain(verb.text);
144
+ const el = (0, element_ladder_1.findElement)(verb.target, options.liveElements);
145
+ if (!el) {
146
+ (options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
147
+ options.onToolStep?.({ verb: "click", target: verb.target, ok: false, observation: "Could not find that element on the page." });
148
+ return;
149
+ }
150
+ (0, element_ladder_1.highlightElement)(el);
151
+ el.click();
152
+ options.onToolStep?.({ verb: "click", target: verb.target, ok: true, observation: "Clicked it." });
153
+ return;
154
+ }
155
+ case "fill": {
156
+ if (verb.text)
157
+ options.onExplain(verb.text);
158
+ const el = (0, element_ladder_1.findElement)(verb.target, options.liveElements);
159
+ if (!el || !(0, element_ladder_1.fillElement)(el, verb.value)) {
160
+ (options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
161
+ options.onToolStep?.({
162
+ verb: "fill",
163
+ target: verb.target,
164
+ ok: false,
165
+ observation: el ? "That element isn't a real form field — can't type into it." : "Could not find that element on the page.",
166
+ });
167
+ return;
168
+ }
169
+ (0, element_ladder_1.highlightElement)(el);
170
+ options.onToolStep?.({ verb: "fill", target: verb.target, ok: true, observation: `Typed "${verb.value}" into it.` });
171
+ return;
172
+ }
173
+ case "read": {
174
+ if (verb.text)
175
+ options.onExplain(verb.text);
176
+ const el = (0, element_ladder_1.findElement)(verb.target, options.liveElements);
177
+ if (!el) {
178
+ (options.onMiss ?? element_ladder_1.logMiss)({ attempted: verb.target, route });
179
+ options.onToolStep?.({ verb: "read", target: verb.target, ok: false, observation: "Could not find that element on the page." });
180
+ return;
181
+ }
182
+ options.onToolStep?.({ verb: "read", target: verb.target, ok: true, observation: (0, element_ladder_1.readElement)(el) });
183
+ return;
184
+ }
185
+ case "call_tool": {
186
+ if (verb.text)
187
+ options.onExplain(verb.text);
188
+ void (0, webmcp_client_1.executeWebMcpTool)(verb.name, verb.args).then((result) => {
189
+ options.onToolStep?.({ verb: "call_tool", target: verb.name, ok: result.ok, observation: result.observation });
190
+ });
191
+ return;
192
+ }
86
193
  }
87
194
  }
88
195
  /**
@@ -0,0 +1,13 @@
1
+ import type { WebMcpTool } from "@cairnvibe/core";
2
+ export declare function discoverWebMcpTools(): Promise<WebMcpTool[]>;
3
+ /**
4
+ * Calls a real WebMCP tool by name — `name` must be one the model was
5
+ * actually shown this turn (server.ts only ever includes tools from this
6
+ * exact request's own discoverWebMcpTools() call), never invented.
7
+ * Returns a plain-text observation for the agent loop to reason about
8
+ * next, the same shape a click/fill/read result already takes.
9
+ */
10
+ export declare function executeWebMcpTool(name: string, args: Record<string, unknown> | undefined): Promise<{
11
+ ok: boolean;
12
+ observation: string;
13
+ }>;
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ // Discovers and calls real tools a page has registered via WebMCP
3
+ // (https://webmachinelearning.github.io/webmcp/) — an in-progress web
4
+ // standard letting a site expose its own functions as typed, parameterized
5
+ // tools ("document.modelContext.registerTool(...)"), running in the page's
6
+ // own JS with the user's real session. This is the highest-trust action
7
+ // source there is: a real function the app's own developer wrote, with a
8
+ // real return value — not a click simulated from static analysis. Almost
9
+ // no site has adopted it yet, so this is deliberately a no-op (empty list,
10
+ // nothing to call) everywhere it isn't present, not a hard dependency.
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.discoverWebMcpTools = discoverWebMcpTools;
13
+ exports.executeWebMcpTool = executeWebMcpTool;
14
+ function getModelContext() {
15
+ if (typeof document === "undefined")
16
+ return null;
17
+ return document.modelContext ?? null;
18
+ }
19
+ /** Bounded the same way LiveElementSchema/CopilotRequestSchema bound the
20
+ * live DOM scan — a hard cap on both count and description length, so a
21
+ * page that registers an unreasonable number of tools (or one with a huge
22
+ * description) can't blow the request payload or the prompt. */
23
+ const MAX_TOOLS = 30;
24
+ const MAX_DESCRIPTION_LENGTH = 500;
25
+ async function discoverWebMcpTools() {
26
+ const modelContext = getModelContext();
27
+ if (!modelContext?.getTools)
28
+ return [];
29
+ try {
30
+ const tools = await modelContext.getTools();
31
+ if (!Array.isArray(tools))
32
+ return [];
33
+ return tools.slice(0, MAX_TOOLS).map((tool) => ({
34
+ name: String(tool.name),
35
+ description: String(tool.description ?? "").slice(0, MAX_DESCRIPTION_LENGTH),
36
+ inputSchema: tool.inputSchema,
37
+ }));
38
+ }
39
+ catch {
40
+ // A page's own registerTool()/getTools() implementation throwing is
41
+ // that page's bug, not Cairn's — degrade to "no WebMCP tools" rather
42
+ // than breaking the rest of the turn.
43
+ return [];
44
+ }
45
+ }
46
+ /**
47
+ * Calls a real WebMCP tool by name — `name` must be one the model was
48
+ * actually shown this turn (server.ts only ever includes tools from this
49
+ * exact request's own discoverWebMcpTools() call), never invented.
50
+ * Returns a plain-text observation for the agent loop to reason about
51
+ * next, the same shape a click/fill/read result already takes.
52
+ */
53
+ async function executeWebMcpTool(name, args) {
54
+ const modelContext = getModelContext();
55
+ if (!modelContext?.getTools || !modelContext.executeTool) {
56
+ return { ok: false, observation: "This page no longer has that tool available." };
57
+ }
58
+ try {
59
+ const tools = await modelContext.getTools();
60
+ const tool = Array.isArray(tools) ? tools.find((t) => t.name === name) : undefined;
61
+ if (!tool)
62
+ return { ok: false, observation: `No tool named "${name}" is available on this page right now.` };
63
+ const result = await modelContext.executeTool(tool, args ?? {});
64
+ const observation = typeof result === "string" ? result : JSON.stringify(result ?? null);
65
+ return { ok: true, observation: observation.slice(0, 2000) };
66
+ }
67
+ catch (err) {
68
+ return { ok: false, observation: `That tool failed: ${err instanceof Error ? err.message : "unknown error"}` };
69
+ }
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnvibe/sdk",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "In-app AI copilot — <Copilot/> for React/Next.js, <cairn-widget> for any framework — plus the server handlers and realtime voice relay behind them.",
5
5
  "license": "MIT",
6
6
  "publishConfig": { "access": "public" },
@@ -1,12 +1,19 @@
1
- // The 4-step Element Ladder (BUILD_PLAN.md invariant #3): a lookup failure
2
- // must degrade to explain-only, never guess and click the wrong thing.
1
+ // The Element Ladder (BUILD_PLAN.md invariant #3): a lookup failure must
2
+ // degrade to explain-only, never guess and click the wrong thing.
3
3
  //
4
+ // 0. liveElements map — a runtime-scan.ts snapshot, when the caller has
5
+ // one: the id came from real elements the browser itself found this
6
+ // turn (a dynamically-rendered row with no data-ai included), so an
7
+ // exact map lookup is both the fastest and the most trustworthy path.
4
8
  // 1. data-ai="..." — exact, authoritative
5
9
  // 2. aria-label / role — accessible-name fallback
6
10
  // 3. visible text — last resort, exact then substring match
7
11
  // 4. FAIL — caller degrades to explain + logs the miss
8
12
 
9
- export function findElement(target: string): HTMLElement | null {
13
+ export function findElement(target: string, liveElements?: Map<string, HTMLElement>): HTMLElement | null {
14
+ const live = liveElements?.get(target);
15
+ if (live) return live;
16
+
10
17
  if (typeof document === "undefined") return null;
11
18
 
12
19
  const byDataAi = document.querySelector<HTMLElement>(`[data-ai="${cssEscape(target)}"]`);
@@ -39,6 +46,47 @@ export function highlightElement(el: HTMLElement, glowMs = 4000): void {
39
46
  window.setTimeout(() => el.classList.remove("cairn-glow"), glowMs);
40
47
  }
41
48
 
49
+ // tagName, not `instanceof HTMLInputElement` — avoids depending on those
50
+ // classes existing as globals at all (they don't in a plain Node test
51
+ // environment, only a real browser/jsdom), and tagName is exactly what
52
+ // distinguishes a real form field regardless.
53
+ function isFormField(el: HTMLElement): el is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
54
+ return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT";
55
+ }
56
+
57
+ /**
58
+ * Sets a real form field's value AND makes the framework that owns it (React,
59
+ * almost always, in this SDK's own target apps) actually notice — directly
60
+ * assigning `.value` bypasses React's tracked setter, so its own onChange
61
+ * never fires and the app's state silently doesn't update, a well-known
62
+ * React quirk. Going through the *native* prototype's value setter before
63
+ * dispatching a real "input" event is what makes React's synthetic event
64
+ * system pick it up the same way a real keystroke would.
65
+ */
66
+ export function fillElement(el: HTMLElement, value: string): boolean {
67
+ if (!isFormField(el)) return false;
68
+
69
+ const ctorByTag: Record<string, unknown> = typeof window !== "undefined" ? { INPUT: window.HTMLInputElement, TEXTAREA: window.HTMLTextAreaElement, SELECT: window.HTMLSelectElement } : {};
70
+ const ctor = ctorByTag[el.tagName] as { prototype: object } | undefined;
71
+ const nativeSetter = ctor && (Object.getOwnPropertyDescriptor(ctor.prototype, "value")?.set as ((this: HTMLElement, v: string) => void) | undefined);
72
+ if (nativeSetter) nativeSetter.call(el, value);
73
+ else el.value = value;
74
+
75
+ el.dispatchEvent(new Event("input", { bubbles: true }));
76
+ el.dispatchEvent(new Event("change", { bubbles: true }));
77
+ return true;
78
+ }
79
+
80
+ /** The real current value/text of an element — a form field's `.value`,
81
+ * otherwise its trimmed visible text, bounded the same way runtime-scan.ts
82
+ * bounds a live element's label (this is what the agent loop "observes"
83
+ * after a read step, so it needs the same payload/privacy discipline). */
84
+ export function readElement(el: HTMLElement): string {
85
+ const raw = isFormField(el) ? el.value : (el.textContent ?? "");
86
+ const trimmed = raw.replace(/\s+/g, " ").trim();
87
+ return trimmed.length > 500 ? `${trimmed.slice(0, 499)}…` : trimmed || "(empty)";
88
+ }
89
+
42
90
  export interface MissContext {
43
91
  attempted: string;
44
92
  route: string;