@cairnvibe/sdk 0.2.7 → 0.2.9
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/cairn-widget.js +2 -2
- package/dist/element-ladder.d.ts +15 -0
- package/dist/element-ladder.js +41 -0
- package/dist/index.js +212 -45
- package/dist/realtime-server.d.ts +3 -2
- package/dist/realtime-server.js +171 -33
- package/dist/runtime-scan.js +33 -3
- package/dist/server.d.ts +3 -1
- package/dist/server.js +110 -14
- package/dist/speak-server.d.ts +13 -3
- package/dist/speak-server.js +79 -23
- package/dist/verb-executor.d.ts +24 -0
- package/dist/verb-executor.js +87 -0
- package/dist/webmcp-client.d.ts +13 -0
- package/dist/webmcp-client.js +70 -0
- package/package.json +1 -1
- package/src/element-ladder.ts +41 -0
- package/src/index.tsx +221 -46
- package/src/realtime-server.ts +172 -33
- package/src/runtime-scan.ts +33 -3
- package/src/server.ts +119 -15
- package/src/speak-server.ts +99 -23
- package/src/verb-executor.ts +104 -1
- package/src/webmcp-client.ts +79 -0
package/dist/speak-server.js
CHANGED
|
@@ -1,41 +1,97 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
// Server-side text-to-speech for the Copilot widget's spoken answers (see
|
|
3
|
-
// `speakEndpoint` in index.tsx). The Deepgram key must never reach the
|
|
4
|
-
// client, so this is a plain fetch to Deepgram's /v1/speak REST endpoint —
|
|
5
|
-
// no SDK dependency needed for one request shape.
|
|
6
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
3
|
exports.createSpeakHandler = createSpeakHandler;
|
|
8
|
-
|
|
9
|
-
//
|
|
10
|
-
//
|
|
4
|
+
// Server-side text-to-speech for the Copilot widget's spoken answers (see
|
|
5
|
+
// `speakEndpoint` in index.tsx). The Deepgram key must never reach the
|
|
6
|
+
// client.
|
|
7
|
+
//
|
|
8
|
+
// This used to be one fetch to Deepgram's /v1/speak REST endpoint, buffered
|
|
9
|
+
// into an ArrayBuffer with `await response.arrayBuffer()` before returning
|
|
10
|
+
// anything. That's the exact same bug the realtime path already fixed once
|
|
11
|
+
// (see tts-stream.ts's own comment): nothing plays until Deepgram renders
|
|
12
|
+
// AND the network delivers the *entire* reply, which measured 5-8s for a
|
|
13
|
+
// normal explain answer in real production logs. It also hit Deepgram's
|
|
14
|
+
// REST-only 2000-character cap on longer replies with no handling at all.
|
|
15
|
+
//
|
|
16
|
+
// Fixed the same way the realtime path was: open the streaming Speak
|
|
17
|
+
// WebSocket (tts-stream.ts's DeepgramSpeakStream, the same class the
|
|
18
|
+
// realtime server already uses) and forward audio chunks to the caller as
|
|
19
|
+
// they arrive, via a ReadableStream — not buffered. The route handler
|
|
20
|
+
// forwards that stream straight through as the HTTP response body, and the
|
|
21
|
+
// client (index.tsx) reads it progressively instead of awaiting a full
|
|
22
|
+
// Blob, so playback can start on the first chunk. Splitting the text into
|
|
23
|
+
// sentence-sized `Speak` messages before one `Flush` sidesteps the old
|
|
24
|
+
// 2000-char REST limit entirely (it doesn't apply to the WS protocol) and
|
|
25
|
+
// lets Deepgram start rendering the first sentence sooner.
|
|
26
|
+
const tts_stream_1 = require("./tts-stream");
|
|
11
27
|
const DEEPGRAM_DEFAULT_VOICE = "aura-2-thalia-en";
|
|
12
|
-
|
|
28
|
+
// Matches the realtime path's own playback sample rate (index.tsx's
|
|
29
|
+
// audio_chunk handling defaults to 24000 too) — keeping them identical lets
|
|
30
|
+
// both paths share one raw-PCM16 decode/schedule routine on the client.
|
|
31
|
+
const SAMPLE_RATE = 24000;
|
|
32
|
+
// Not a protocol limit (the WS Speak protocol has none like REST's 2000
|
|
33
|
+
// chars) — just keeps each queued chunk sentence-sized so Deepgram can start
|
|
34
|
+
// rendering the first one quickly instead of parsing one giant message.
|
|
35
|
+
const MAX_CHUNK_CHARS = 300;
|
|
36
|
+
function splitIntoChunks(text, maxChars) {
|
|
37
|
+
const sentences = text.match(/[^.!?]+[.!?]*\s*/g) ?? [text];
|
|
38
|
+
const chunks = [];
|
|
39
|
+
let current = "";
|
|
40
|
+
for (const sentence of sentences) {
|
|
41
|
+
if (current && current.length + sentence.length > maxChars) {
|
|
42
|
+
chunks.push(current);
|
|
43
|
+
current = "";
|
|
44
|
+
}
|
|
45
|
+
current += sentence;
|
|
46
|
+
}
|
|
47
|
+
if (current)
|
|
48
|
+
chunks.push(current);
|
|
49
|
+
return chunks;
|
|
50
|
+
}
|
|
51
|
+
function createSpeakHandler(options, streamFactory = (opts, onAudioChunk, handlers) => new tts_stream_1.DeepgramSpeakStream(opts, onAudioChunk, handlers)) {
|
|
13
52
|
const model = options.model ?? process.env.DEEPGRAM_VOICE ?? DEEPGRAM_DEFAULT_VOICE;
|
|
14
53
|
return async function handleSpeak(text) {
|
|
15
54
|
if (!text || !text.trim()) {
|
|
16
55
|
return { status: 400, body: { error: "no text provided" } };
|
|
17
56
|
}
|
|
18
|
-
let
|
|
57
|
+
let enqueue = null;
|
|
58
|
+
let closeOut = null;
|
|
59
|
+
let failOut = null;
|
|
60
|
+
const stream = new ReadableStream({
|
|
61
|
+
start(controller) {
|
|
62
|
+
enqueue = (chunk) => controller.enqueue(chunk);
|
|
63
|
+
closeOut = () => controller.close();
|
|
64
|
+
failOut = (err) => controller.error(err);
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
// True once connect() below resolves — an onError before that point is
|
|
68
|
+
// already reported through connect()'s own rejection, so it's ignored
|
|
69
|
+
// here to avoid double-handling the same failure.
|
|
70
|
+
let connected = false;
|
|
71
|
+
const speakStream = streamFactory({ apiKey: options.apiKey, model, encoding: "linear16", sampleRate: SAMPLE_RATE }, (chunk) => enqueue?.(new Uint8Array(chunk)), {
|
|
72
|
+
onFlushed: () => {
|
|
73
|
+
closeOut?.();
|
|
74
|
+
speakStream.close();
|
|
75
|
+
},
|
|
76
|
+
onError: (err) => {
|
|
77
|
+
if (!connected)
|
|
78
|
+
return;
|
|
79
|
+
console.error("[cairn] speak stream error:", err);
|
|
80
|
+
failOut?.(err);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
19
83
|
try {
|
|
20
|
-
|
|
21
|
-
method: "POST",
|
|
22
|
-
headers: {
|
|
23
|
-
Authorization: `Token ${options.apiKey}`,
|
|
24
|
-
"content-type": "application/json",
|
|
25
|
-
},
|
|
26
|
-
body: JSON.stringify({ text }),
|
|
27
|
-
});
|
|
84
|
+
await speakStream.connect();
|
|
28
85
|
}
|
|
29
86
|
catch (err) {
|
|
30
87
|
console.error("[cairn] speak request failed:", err);
|
|
31
88
|
return { status: 200, body: { error: "speech service unreachable" } };
|
|
32
89
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return { status: 200, body: { error: "speech synthesis failed" } };
|
|
90
|
+
connected = true;
|
|
91
|
+
for (const chunk of splitIntoChunks(text, MAX_CHUNK_CHARS)) {
|
|
92
|
+
speakStream.sendText(chunk);
|
|
37
93
|
}
|
|
38
|
-
|
|
39
|
-
return { status: 200, body: {
|
|
94
|
+
speakStream.flush();
|
|
95
|
+
return { status: 200, body: { stream, contentType: `audio/L16;rate=${SAMPLE_RATE}` } };
|
|
40
96
|
};
|
|
41
97
|
}
|
package/dist/verb-executor.d.ts
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
import { type TourStep } from "@cairnvibe/core";
|
|
2
2
|
import { type MissContext } from "./element-ladder";
|
|
3
|
+
/** The real result of one agent-loop step (click/fill/read/call_tool) —
|
|
4
|
+
* fed back to the model as its next turn's "observation" so it can decide
|
|
5
|
+
* what to do next instead of acting blind. The loop that drives this lives
|
|
6
|
+
* on the caller's side, not here: index.tsx's runTypedAgentLoop for the
|
|
7
|
+
* HTTP path, realtime-server.ts's finalizeTurn for the realtime one — this
|
|
8
|
+
* module only ever executes one step at a time. */
|
|
9
|
+
export interface ToolStepResult {
|
|
10
|
+
verb: "click" | "fill" | "read" | "call_tool";
|
|
11
|
+
target?: string;
|
|
12
|
+
ok: boolean;
|
|
13
|
+
observation: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Promise wrapper around executeVerbResponse for a continuing verb
|
|
17
|
+
* (click/fill/read/call_tool) — resolves once the real action has actually
|
|
18
|
+
* finished (synchronously for click/fill/read, after a real await for
|
|
19
|
+
* call_tool) with its real observation, instead of the fire-and-forget
|
|
20
|
+
* callback shape every other verb uses. This is what a loop driver awaits
|
|
21
|
+
* before deciding whether to call the model again.
|
|
22
|
+
*/
|
|
23
|
+
export declare function executeToolStep(raw: unknown, route: string, liveElements?: Map<string, HTMLElement>): Promise<ToolStepResult | null>;
|
|
3
24
|
export interface VerbExecutorOptions {
|
|
4
25
|
onExplain: (text: string) => void;
|
|
5
26
|
onNavigate?: (route: string) => void;
|
|
@@ -11,6 +32,9 @@ export interface VerbExecutorOptions {
|
|
|
11
32
|
* owns the UI (progress display) and, for voice, the TTS sequencing.
|
|
12
33
|
*/
|
|
13
34
|
onTour?: (steps: TourStep[]) => void;
|
|
35
|
+
/** A click/fill/read/call_tool step finished — see ToolStepResult. Only
|
|
36
|
+
* called for the agent loop's continuing verbs, never the terminal ones. */
|
|
37
|
+
onToolStep?: (result: ToolStepResult) => void;
|
|
14
38
|
/** Action ids the customer has actually wired up. "do" is rejected for anything else. */
|
|
15
39
|
registeredActions?: string[];
|
|
16
40
|
/**
|
package/dist/verb-executor.js
CHANGED
|
@@ -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);
|
|
@@ -103,6 +133,63 @@ function dispatchVerb(verb, route, options) {
|
|
|
103
133
|
options.onExplain(verb.steps.map((s) => s.text).join(" "));
|
|
104
134
|
}
|
|
105
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
|
+
}
|
|
106
193
|
}
|
|
107
194
|
}
|
|
108
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.
|
|
3
|
+
"version": "0.2.9",
|
|
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" },
|
package/src/element-ladder.ts
CHANGED
|
@@ -46,6 +46,47 @@ export function highlightElement(el: HTMLElement, glowMs = 4000): void {
|
|
|
46
46
|
window.setTimeout(() => el.classList.remove("cairn-glow"), glowMs);
|
|
47
47
|
}
|
|
48
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
|
+
|
|
49
90
|
export interface MissContext {
|
|
50
91
|
attempted: string;
|
|
51
92
|
route: string;
|