@cairnvibe/sdk 0.2.5 → 0.2.6

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/src/server.ts CHANGED
@@ -146,7 +146,22 @@ export async function resolveVerb(
146
146
  }
147
147
 
148
148
  if (parsedVerb.data.verb === "do" && !registeredActions.includes(parsedVerb.data.action)) {
149
- return { verb: "explain", text: "That action isn't available here." };
149
+ // Not a manually registered action the auto-discovery fallback: does
150
+ // "target" name a real element on the CURRENT page that the indexer
151
+ // itself found a real, mutating handler call on? If so, attach that
152
+ // call here (never something the model emitted itself — see
153
+ // ApiCallSchema's doc comment) so the client can execute exactly the
154
+ // same request a real click on that element would already make.
155
+ // Anything else — no target, an unknown target, or a real target with
156
+ // no discoverable apiCall (a client-only handler, a dynamic per-row
157
+ // URL — see manifest.ts's parseApiCall) — stays refused, same as before.
158
+ const target = parsedVerb.data.target;
159
+ const pageElements = manifest.pages.find((p) => p.route === input.route)?.elements ?? [];
160
+ const targetElement = target ? pageElements.find((e) => e.id === target) : undefined;
161
+ if (!targetElement?.apiCall) {
162
+ return { verb: "explain", text: "That action isn't available here." };
163
+ }
164
+ return { ...parsedVerb.data, apiCall: targetElement.apiCall };
150
165
  }
151
166
 
152
167
  // tour is allowed at every tier (see TIER_ALLOWED_VERBS) because
@@ -310,9 +325,11 @@ function buildVerbToolSchema(registeredActions: string[]): Record<string, unknow
310
325
  ),
311
326
  route: nullableString("A route from the manifest. Required for navigate. null (or omitted) if not applicable."),
312
327
  action: nullableString(
313
- registeredActions.length
314
- ? `Required for do. Must be exactly one of: ${registeredActions.join(", ")}. null (or omitted) if not applicable.`
315
- : "Required for do. No actions are registered in this deployment never use this verb.",
328
+ "Required for do. A short label for what's being done, e.g. \"archive-invoice\" " +
329
+ (registeredActions.length
330
+ ? `— either one of this deployment's registered actions [${registeredActions.join(", ")}], or, for any other element from currentPageElements whose own description says it performs a real action, any short label describing it.`
331
+ : "for any element from currentPageElements whose own description says it performs a real action — no actions are separately registered in this deployment, but currentPageElements-driven actions still work.") +
332
+ " null (or omitted) if not applicable.",
316
333
  ),
317
334
  steps: {
318
335
  type: "array",
@@ -383,15 +400,23 @@ Always call ${VERB_TOOL_NAME} exactly once with one of these verbs:
383
400
  spans more than one page (e.g. "how do I get from here to Settings and
384
401
  turn on X"), a step may also carry a "route" to move there first — most
385
402
  steps should NOT set this; only the step where the page actually changes.
386
- - do: ONLY for an action id from this exact list: [${registeredActions.join(", ") || "none registered never use do"}].
387
- If the action applies to one specific thing among several (e.g. one row in
388
- a table), name it in "target". The manifest only describes each element
389
- once per page, even if it's rendered many times with different data — so
390
- for a per-instance target, use the matching id from the request's
391
- "visible" list instead, which reflects the real elements on the page right
392
- now (e.g. manifest has one generic "archive" button, but "visible" might
393
- list "archive-inv-2" for the specific row the user means).
394
- If the user asks for anything not on that list, use "explain" and say you can't do that from here.
403
+ - do: trigger a real action. Two ways this is allowedanything else, refuse:
404
+ 1. One of this deployment's registered action ids: [${registeredActions.join(", ") || "none registered"}].
405
+ Put that exact id in "action".
406
+ 2. Any element in "currentPageElements" whose own description says it
407
+ performs a real action (e.g. "Archives this invoice", "Starts a phone
408
+ call", "Submits the form") put that element's id in "target" and a
409
+ short label describing what it does in "action". This only works for
410
+ an element on the CURRENT page (it must be in currentPageElements) and
411
+ only for an action that doesn't depend on which specific row/instance
412
+ — a generic page-level button, not "archive row 3 of this table". If
413
+ the user means one specific item among several repeated ones, that's
414
+ not currently supported through this path — use "explain" and say so,
415
+ don't guess at a specific instance.
416
+ If neither applies — the action isn't registered and isn't a real element
417
+ on this page, or it needs picking a specific instance — use "explain" and
418
+ say you can't do that from here. Never invent a target or action id that
419
+ isn't in currentPageElements or the registered list above.
395
420
 
396
421
  Every "text" field (in explain, or per-step in tour, or the optional text on
397
422
  any other verb) is read aloud AND shown on screen, so it must sound like a
@@ -5,7 +5,7 @@
5
5
  // explain — never guess, never wrong-click"). The server (`server.ts`)
6
6
  // enforces the same schema independently — never trust the client alone.
7
7
 
8
- import { VerbResponseSchema, type TourStep, type VerbResponse } from "@cairnvibe/core";
8
+ import { VerbResponseSchema, type ApiCall, type TourStep, type VerbResponse } from "@cairnvibe/core";
9
9
  import { findElement, highlightElement, logMiss, type MissContext } from "./element-ladder";
10
10
 
11
11
  export interface VerbExecutorOptions {
@@ -61,12 +61,32 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
61
61
 
62
62
  case "do": {
63
63
  const allowed = options.registeredActions ?? [];
64
- if (!allowed.includes(verb.action)) {
65
- options.onExplain("That action isn't available here.");
64
+ if (allowed.includes(verb.action)) {
65
+ // Explicit, developer-owned path — unchanged.
66
+ options.onDo?.(verb.action, verb.target);
67
+ if (verb.text) options.onExplain(verb.text);
66
68
  return;
67
69
  }
68
- options.onDo?.(verb.action, verb.target);
69
- if (verb.text) options.onExplain(verb.text);
70
+
71
+ if (verb.apiCall) {
72
+ // Auto-discovered path: a real, indexer-found handler call on this
73
+ // exact target element, attached server-side after looking the
74
+ // target up in the manifest — never something the model emitted
75
+ // itself (see ApiCallSchema's doc comment in @cairnvibe/core).
76
+ const el = verb.target ? findElement(verb.target) : null;
77
+ if (el) highlightElement(el);
78
+ else if (verb.target) (options.onMiss ?? logMiss)({ attempted: verb.target, route });
79
+
80
+ if (verb.text) options.onExplain(verb.text);
81
+ void executeApiCall(verb.apiCall).then((result) => {
82
+ if (!result.ok) {
83
+ options.onExplain("I tried to do that, but something went wrong — try again in a moment.");
84
+ }
85
+ });
86
+ return;
87
+ }
88
+
89
+ options.onExplain(verb.text ?? "That action isn't available here.");
70
90
  return;
71
91
  }
72
92
 
@@ -82,3 +102,23 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
82
102
  return;
83
103
  }
84
104
  }
105
+
106
+ /**
107
+ * Fires exactly the same request a real click on the target element would
108
+ * already make — same-origin only (apiCall.url is always relative, never a
109
+ * different host), and `credentials: "same-origin"` so the browser attaches
110
+ * the user's own real session cookies, the same way a manual click would.
111
+ * No body is sent: l1-scan.ts's static capture only ever traces method+url,
112
+ * never a request body (which usually depends on runtime state a build-time
113
+ * scan can't see) — fine for the common trigger-style action (an id already
114
+ * baked into the URL, no other payload needed), a real gap for one that
115
+ * requires one.
116
+ */
117
+ async function executeApiCall(apiCall: ApiCall): Promise<{ ok: boolean; status?: number }> {
118
+ try {
119
+ const res = await fetch(apiCall.url, { method: apiCall.method, credentials: "same-origin" });
120
+ return { ok: res.ok, status: res.status };
121
+ } catch {
122
+ return { ok: false };
123
+ }
124
+ }