surf-cli 2.19.0 → 2.20.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/README.md +169 -3
- package/dist/content/index.js +4 -4
- package/dist/content/index.js.map +1 -1
- package/dist/options/options.html +1 -18
- package/dist/service-worker/index.js +44 -15
- package/dist/service-worker/index.js.map +1 -1
- package/native/cli.cjs +109 -6
- package/native/do-executor.cjs +35 -8
- package/native/doctor.cjs +200 -40
- package/native/host-helpers.cjs +95 -10
- package/native/host.cjs +24 -2
- package/native/native-host-launch-probe.cjs +69 -0
- package/native/private-state.cjs +25 -1
- package/native/semantic-cli.cjs +764 -0
- package/native/semantic-core.cjs +369 -0
- package/native/semantic-credentials.cjs +207 -0
- package/native/semantic-provider.cjs +61 -0
- package/native/semantic-workflow-executor.cjs +65 -0
- package/native/semantic-workflow-state.cjs +271 -0
- package/native/semantic-workflow.cjs +398 -0
- package/native/tool-scope.cjs +1 -0
- package/native/workflow-definition.cjs +125 -1
- package/native/workflow-runtime.cjs +71 -5
- package/package.json +8 -4
- package/scripts/install-native-host.cjs +103 -60
- package/scripts/uninstall-native-host.cjs +40 -38
- package/scripts/windows-interop.cjs +89 -0
- package/skills/surf/SKILL.md +78 -1
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
const { performance } = require("node:perf_hooks");
|
|
3
|
+
const { openClientTransport } = require("./client-transport.cjs");
|
|
4
|
+
const { SEMANTIC_POLICY, SemanticError, chooseAction, filter, find, verify } = require("./semantic-core.cjs");
|
|
5
|
+
const { createJevEvaluator } = require("./semantic-provider.cjs");
|
|
6
|
+
const {
|
|
7
|
+
clearStoredTypeSafeCredential,
|
|
8
|
+
credentialStatus,
|
|
9
|
+
resolveTypeSafeCredential,
|
|
10
|
+
setTypeSafeCredentialFromInput,
|
|
11
|
+
} = require("./semantic-credentials.cjs");
|
|
12
|
+
|
|
13
|
+
const FIELD_ROLES = new Set(["textbox", "searchbox", "combobox", "spinbutton"]);
|
|
14
|
+
const CLICK_ROLES = new Set(["button", "link", "checkbox", "radio"]);
|
|
15
|
+
const POST_WRITE_SETTLE_WAITS_MS = Object.freeze([500, 1_000, 2_000, 4_000, 2_000]);
|
|
16
|
+
const THRESHOLD_KEYS = Object.freeze({
|
|
17
|
+
find: "find",
|
|
18
|
+
filter: "filter",
|
|
19
|
+
"verify-positive": "verifyPositive",
|
|
20
|
+
"verify-negative": "verifyNegative",
|
|
21
|
+
"prerequisite-supported": "prerequisiteSupported",
|
|
22
|
+
"prerequisite-blocked": "prerequisiteBlocked",
|
|
23
|
+
write: "write",
|
|
24
|
+
"exact-ref-write": "exactRefWrite",
|
|
25
|
+
});
|
|
26
|
+
const COMMAND_THRESHOLD_KEYS = Object.freeze({
|
|
27
|
+
"semantic.find": new Set(["find"]),
|
|
28
|
+
"semantic.filter": new Set(["filter"]),
|
|
29
|
+
"semantic.verify": new Set(["verify-positive", "verify-negative"]),
|
|
30
|
+
"semantic.act": new Set(["find", "write", "exact-ref-write", "verify-positive", "verify-negative", "prerequisite-supported", "prerequisite-blocked"]),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const SEMANTIC_HELP = `Usage:
|
|
34
|
+
surf semantic.find <goal> [--threshold find=<0-1>] [--session <name> | --tab-id <id>] [--json]
|
|
35
|
+
surf semantic.verify <outcome> [--threshold verify-positive=<0-1>] [--threshold verify-negative=<0-1>] [--session <name> | --tab-id <id>] [--json]
|
|
36
|
+
surf semantic.filter <goal> [--top <1-12>] [--threshold filter=<0-1>] [--session <name> | --tab-id <id>] [--json]
|
|
37
|
+
surf semantic.act <goal> [--max-steps <1-8>] [--allow-write] [--allow-ref <ref>...] [--input <name=value>...] [--threshold <name=value>...] [--session <name> | --tab-id <id>] [--json]
|
|
38
|
+
surf semantic auth set|status|clear
|
|
39
|
+
|
|
40
|
+
Semantic commands send a bounded, value-free page observation to TypeSafe. semantic.act allows only same-origin navigation, fixed scroll/wait actions, and (with --allow-write) clicks/fills. --allow-write authorizes mutation-capable clicks, including submit/purchase/delete/send/publish; repeatable --allow-ref narrows this authority. Repeatable --threshold overrides applicable confidence thresholds for this run only; defaults remain safer and write authority is unchanged. Names: find, filter, verify-positive, verify-negative, prerequisite-supported, prerequisite-blocked, write, exact-ref-write.`;
|
|
41
|
+
|
|
42
|
+
function normalizeSemanticArgs(argv) {
|
|
43
|
+
if (argv[0] !== "semantic") return argv;
|
|
44
|
+
if (!argv[1] || argv[1].startsWith("-")) return argv;
|
|
45
|
+
if (argv[1] === "auth" && argv[2]) return [`semantic.auth.${argv[2]}`, ...argv.slice(3)];
|
|
46
|
+
return [`semantic.${argv[1]}`, ...argv.slice(2)];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseSemanticArgs(argv) {
|
|
50
|
+
const args = normalizeSemanticArgs(argv);
|
|
51
|
+
const command = args[0];
|
|
52
|
+
if (!command?.startsWith("semantic")) return null;
|
|
53
|
+
if (command === "semantic" || args.includes("--help") || args.includes("-h")) return { command: "help", json: args.includes("--json") };
|
|
54
|
+
if (["semantic.auth.set", "semantic.auth.status", "semantic.auth.clear"].includes(command)) {
|
|
55
|
+
if (args.slice(1).some((arg) => arg !== "--json")) throw new Error("semantic auth commands accept only --json, not secrets or browser targeting flags");
|
|
56
|
+
return { command, json: args.includes("--json") };
|
|
57
|
+
}
|
|
58
|
+
if (!["semantic.find", "semantic.verify", "semantic.filter", "semantic.act"].includes(command)) throw new Error(`unknown semantic command: ${command}`);
|
|
59
|
+
const result = { command, json: false, allowWrite: false, allowRefs: [], inputs: {}, thresholds: {}, maxSteps: SEMANTIC_POLICY.limits.defaultSteps };
|
|
60
|
+
const positionals = [];
|
|
61
|
+
for (let i = 1; i < args.length; i++) {
|
|
62
|
+
const arg = args[i];
|
|
63
|
+
if (arg === "--json") result.json = true;
|
|
64
|
+
else if (arg === "--no-wait") result.noWait = true;
|
|
65
|
+
else if (arg === "--allow-write") result.allowWrite = true;
|
|
66
|
+
else if (["--session", "--tab-id", "--top", "--max-steps", "--allow-ref", "--input", "--threshold"].includes(arg)) {
|
|
67
|
+
const value = args[++i];
|
|
68
|
+
if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
|
69
|
+
if (arg === "--session") result.session = value;
|
|
70
|
+
if (arg === "--tab-id") result.tabId = positiveInteger(value, arg);
|
|
71
|
+
if (arg === "--top") result.top = positiveInteger(value, arg);
|
|
72
|
+
if (arg === "--max-steps") result.maxSteps = positiveInteger(value, arg);
|
|
73
|
+
if (arg === "--allow-ref") result.allowRefs.push(value);
|
|
74
|
+
if (arg === "--threshold") {
|
|
75
|
+
const separator = value.indexOf("=");
|
|
76
|
+
const name = separator > 0 ? value.slice(0, separator) : "";
|
|
77
|
+
const thresholdValue = separator > 0 ? value.slice(separator + 1) : "";
|
|
78
|
+
const key = THRESHOLD_KEYS[name];
|
|
79
|
+
if (!key) throw new Error(`unknown semantic threshold: ${name || value}`);
|
|
80
|
+
if (!/^(?:0(?:\.\d+)?|1(?:\.0+)?)$/.test(thresholdValue)) throw new Error(`--threshold ${name} must be a decimal between 0 and 1`);
|
|
81
|
+
if (Object.hasOwn(result.thresholds, key)) throw new Error(`duplicate --threshold: ${name}`);
|
|
82
|
+
result.thresholds[key] = Number(thresholdValue);
|
|
83
|
+
}
|
|
84
|
+
if (arg === "--input") {
|
|
85
|
+
const separator = value.indexOf("=");
|
|
86
|
+
const name = separator > 0 ? value.slice(0, separator) : "";
|
|
87
|
+
const inputValue = separator > 0 ? value.slice(separator + 1) : "";
|
|
88
|
+
if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(name)) throw new Error("--input must use a bounded name=value slot");
|
|
89
|
+
if (Object.hasOwn(result.inputs, name)) throw new Error(`duplicate --input slot: ${name}`);
|
|
90
|
+
if (Buffer.byteLength(inputValue, "utf8") > SEMANTIC_POLICY.limits.inputValueBytes) throw new Error(`--input ${name} exceeds ${SEMANTIC_POLICY.limits.inputValueBytes} bytes`);
|
|
91
|
+
result.inputs[name] = inputValue;
|
|
92
|
+
}
|
|
93
|
+
} else if (arg.startsWith("--")) throw new Error(`unknown semantic option: ${arg}`);
|
|
94
|
+
else positionals.push(arg);
|
|
95
|
+
}
|
|
96
|
+
if (positionals.length !== 1 || !positionals[0].trim()) throw new Error(`${command} requires exactly one goal or outcome`);
|
|
97
|
+
if (result.session && result.tabId) throw new Error("use either --session or --tab-id, not both");
|
|
98
|
+
if (result.maxSteps > SEMANTIC_POLICY.limits.maxSteps) throw new Error(`--max-steps must not exceed ${SEMANTIC_POLICY.limits.maxSteps}`);
|
|
99
|
+
if (Object.keys(result.inputs).length > SEMANTIC_POLICY.limits.inputSlots) throw new Error(`--input supports at most ${SEMANTIC_POLICY.limits.inputSlots} slots`);
|
|
100
|
+
if (result.allowRefs.length && !result.allowWrite) throw new Error("--allow-ref requires --allow-write");
|
|
101
|
+
for (const name of Object.keys(THRESHOLD_KEYS)) {
|
|
102
|
+
if (Object.hasOwn(result.thresholds, THRESHOLD_KEYS[name]) && !COMMAND_THRESHOLD_KEYS[command].has(name)) {
|
|
103
|
+
throw new Error(`--threshold ${name} does not apply to ${command}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
result.goal = positionals[0].trim();
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function positiveInteger(value, flag) {
|
|
111
|
+
const parsed = Number(value);
|
|
112
|
+
if (!Number.isInteger(parsed) || parsed < 1) throw new Error(`${flag} must be a positive integer`);
|
|
113
|
+
return parsed;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function interactiveCandidateState(state) {
|
|
117
|
+
const projected = {};
|
|
118
|
+
if (state?.checked === true || state?.checked === false || state?.checked === "mixed") projected.checked = state.checked;
|
|
119
|
+
if (state?.selected === true || state?.selected === false) projected.selected = state.selected;
|
|
120
|
+
return Object.keys(projected).length ? projected : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function providerState(observation) {
|
|
124
|
+
let origin;
|
|
125
|
+
try { origin = new URL(observation.identity.fullUrl).origin; } catch { throw new Error("semantic observation has an invalid page URL"); }
|
|
126
|
+
return {
|
|
127
|
+
origin,
|
|
128
|
+
title: observation.page.title,
|
|
129
|
+
readyState: observation.page.readyState,
|
|
130
|
+
modals: observation.page.modals,
|
|
131
|
+
candidates: observation.candidates.map(({ ref, role, name, type, nearbyText, state }) => {
|
|
132
|
+
const interactiveState = interactiveCandidateState(state);
|
|
133
|
+
return { id: ref, role, name, type, text: nearbyText, ...(interactiveState ? { state: interactiveState } : {}) };
|
|
134
|
+
}),
|
|
135
|
+
chunks: observation.chunks.map(({ id, text, refs = [] }) => ({ id, text, refs })),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function semanticProjectionHash(state) {
|
|
140
|
+
return crypto.createHash("sha256").update(JSON.stringify(state)).digest("hex");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function observedCandidateStateTransition(observation, before) {
|
|
144
|
+
if (!before) return false;
|
|
145
|
+
if (observation.identity.fullUrl !== before.fullUrl || observation.identity.documentToken !== before.documentToken) return false;
|
|
146
|
+
const candidate = observation.candidates.find((item) =>
|
|
147
|
+
item.ref === before.ref && item.role === before.role && item.name === before.name && item.type === before.type);
|
|
148
|
+
const after = interactiveCandidateState(candidate?.state);
|
|
149
|
+
if (!after) return false;
|
|
150
|
+
return (before.state.checked !== undefined && after.checked !== undefined && before.state.checked !== after.checked) ||
|
|
151
|
+
(before.state.selected !== undefined && after.selected !== undefined && before.state.selected !== after.selected);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function canonicalSameOriginDestination(candidate, fullUrl) {
|
|
155
|
+
if (!candidate.href || candidate.download === true || candidate.role !== "link" || candidate.type !== "a") return null;
|
|
156
|
+
try {
|
|
157
|
+
const url = new URL(candidate.href, fullUrl);
|
|
158
|
+
const page = new URL(fullUrl);
|
|
159
|
+
if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.origin !== page.origin) return null;
|
|
160
|
+
return url.href;
|
|
161
|
+
} catch { return null; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function stableLogicalId(identity, prefix = "target") {
|
|
165
|
+
return `${prefix}:${crypto.createHash("sha256").update(identity).digest("hex").slice(0, 56)}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function concreteCandidateOrder(left, right) {
|
|
169
|
+
const leftText = left.representation === "text" ? 1 : 0;
|
|
170
|
+
const rightText = right.representation === "text" ? 1 : 0;
|
|
171
|
+
const leftNamed = typeof left.name === "string" && left.name.trim() ? 1 : 0;
|
|
172
|
+
const rightNamed = typeof right.name === "string" && right.name.trim() ? 1 : 0;
|
|
173
|
+
return rightText - leftText || rightNamed - leftNamed || left.index - right.index;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizedSemanticPart(value) {
|
|
177
|
+
return typeof value === "string" ? value.normalize("NFKC").replace(/\s+/g, " ").trim().toLocaleLowerCase() : "";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildLogicalCandidates(observation, candidates = providerState(observation).candidates) {
|
|
181
|
+
const groups = [];
|
|
182
|
+
const navigationGroups = new Map();
|
|
183
|
+
for (const [index, observed] of observation.candidates.entries()) {
|
|
184
|
+
const candidate = candidates[index];
|
|
185
|
+
const destination = canonicalSameOriginDestination(observed, observation.identity.fullUrl);
|
|
186
|
+
const normalizedName = normalizedSemanticPart(candidate.name);
|
|
187
|
+
const normalizedContext = normalizedSemanticPart(candidate.text);
|
|
188
|
+
const hasDistinctContext = normalizedContext && normalizedContext !== normalizedName;
|
|
189
|
+
if (!destination || !normalizedName || !hasDistinctContext) {
|
|
190
|
+
const logicalIdentity = stableLogicalId(`control:${candidate.id}:${candidate.role || ""}:${candidate.type || ""}:${candidate.name || ""}:${candidate.text || ""}`);
|
|
191
|
+
groups.push({
|
|
192
|
+
...candidate,
|
|
193
|
+
logicalIdentity,
|
|
194
|
+
concreteCandidates: [{ ...candidate, representation: observed.representation, index }],
|
|
195
|
+
});
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const semanticIdentity = [
|
|
199
|
+
normalizedSemanticPart(candidate.role),
|
|
200
|
+
normalizedSemanticPart(candidate.type),
|
|
201
|
+
normalizedContext,
|
|
202
|
+
].join("\u001f");
|
|
203
|
+
const groupingIdentity = `${destination}\u001e${semanticIdentity}`;
|
|
204
|
+
let group = navigationGroups.get(groupingIdentity);
|
|
205
|
+
if (!group) {
|
|
206
|
+
group = {
|
|
207
|
+
id: stableLogicalId(`navigation:${groupingIdentity}`),
|
|
208
|
+
logicalIdentity: stableLogicalId(`navigation:${groupingIdentity}`),
|
|
209
|
+
role: "link",
|
|
210
|
+
name: "",
|
|
211
|
+
text: "",
|
|
212
|
+
concreteCandidates: [],
|
|
213
|
+
};
|
|
214
|
+
navigationGroups.set(groupingIdentity, group);
|
|
215
|
+
groups.push(group);
|
|
216
|
+
}
|
|
217
|
+
group.concreteCandidates.push({ ...candidate, representation: observed.representation, index });
|
|
218
|
+
}
|
|
219
|
+
for (const group of groups) {
|
|
220
|
+
group.concreteCandidates.sort(concreteCandidateOrder);
|
|
221
|
+
const representative = group.concreteCandidates[0];
|
|
222
|
+
if (!group.name) group.name = representative.name;
|
|
223
|
+
if (!group.text) group.text = representative.text;
|
|
224
|
+
}
|
|
225
|
+
return groups;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function unwrapResponse(response) {
|
|
229
|
+
if (response?.error) {
|
|
230
|
+
const error = new Error(response.error.message || response.error.content?.[0]?.text || "browser request failed");
|
|
231
|
+
error.code = response.error.code || response.error.details?.code;
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
const text = response?.result?.content?.find((item) => item.type === "text")?.text;
|
|
235
|
+
return text;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function confirmedActionResponse(response) {
|
|
239
|
+
const text = unwrapResponse(response);
|
|
240
|
+
if (typeof text !== "string") {
|
|
241
|
+
const error = new Error("browser returned an unknown action outcome");
|
|
242
|
+
error.code = "action_outcome_unknown";
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
if (
|
|
246
|
+
text === "OK" ||
|
|
247
|
+
/^OK\n(?:\[hint\] |Screenshot (?:\(|saved:)|\[Screenshot failed:)/.test(text) ||
|
|
248
|
+
/^Scrolled to Y:-?\d+(?:\.\d+)?(?: \(page height: \d+(?:\.\d+)?\))?$/.test(text)
|
|
249
|
+
) return;
|
|
250
|
+
let outcome;
|
|
251
|
+
try { outcome = JSON.parse(text); } catch {}
|
|
252
|
+
if (outcome?.success === false || typeof outcome?.error === "string") {
|
|
253
|
+
const error = new Error(outcome.error || "browser action failed");
|
|
254
|
+
error.code = outcome.code || "action_failed";
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
if (outcome?.success === true) return;
|
|
258
|
+
const error = new Error("browser returned an unknown action outcome");
|
|
259
|
+
error.code = "action_outcome_unknown";
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function semanticStatesFromPageContent(pageContent) {
|
|
264
|
+
const states = new Map();
|
|
265
|
+
if (typeof pageContent !== "string") return states;
|
|
266
|
+
for (const line of pageContent.split("\n")) {
|
|
267
|
+
const ref = line.match(/\[(e\d+)\]/)?.[1];
|
|
268
|
+
if (!ref) continue;
|
|
269
|
+
const state = {};
|
|
270
|
+
if (line.includes("[checked=mixed]")) state.checked = "mixed";
|
|
271
|
+
else if (line.includes("[checked]")) state.checked = true;
|
|
272
|
+
else if (line.includes("[unchecked]")) state.checked = false;
|
|
273
|
+
if (line.includes("[not-selected]")) state.selected = false;
|
|
274
|
+
else if (line.includes("[selected]")) state.selected = true;
|
|
275
|
+
if (Object.keys(state).length) states.set(ref, state);
|
|
276
|
+
}
|
|
277
|
+
return states;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function semanticStateEvidence(candidate) {
|
|
281
|
+
const markers = [];
|
|
282
|
+
if (candidate.state?.checked !== undefined) {
|
|
283
|
+
markers.push(candidate.state.checked === "mixed" ? "[checked=mixed]" : candidate.state.checked ? "[checked]" : "[unchecked]");
|
|
284
|
+
}
|
|
285
|
+
if (candidate.state?.selected !== undefined) markers.push(candidate.state.selected ? "[selected]" : "[not-selected]");
|
|
286
|
+
const name = candidate.name ? ` "${candidate.name.replaceAll('"', '\\"')}"` : "";
|
|
287
|
+
return `${candidate.role || "control"}${name} ${markers.join(" ")}`.slice(0, 240);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function semanticObservationFrom(response) {
|
|
291
|
+
const text = unwrapResponse(response);
|
|
292
|
+
let envelope;
|
|
293
|
+
try { envelope = JSON.parse(text); } catch { throw new Error("browser returned an invalid semantic observation"); }
|
|
294
|
+
const observation = envelope?.semanticObservation;
|
|
295
|
+
const identity = observation?.identity;
|
|
296
|
+
if (
|
|
297
|
+
typeof identity?.browserEpoch !== "string" ||
|
|
298
|
+
!Number.isInteger(identity.tabId) ||
|
|
299
|
+
!Number.isInteger(identity.frameId) ||
|
|
300
|
+
typeof identity.fullUrl !== "string" ||
|
|
301
|
+
typeof identity.documentToken !== "string" ||
|
|
302
|
+
!Array.isArray(observation.candidates) ||
|
|
303
|
+
!Array.isArray(observation.chunks)
|
|
304
|
+
) {
|
|
305
|
+
throw new Error("browser returned an invalid semantic observation");
|
|
306
|
+
}
|
|
307
|
+
const pageStates = semanticStatesFromPageContent(envelope.pageContent);
|
|
308
|
+
const addedState = [];
|
|
309
|
+
const candidates = observation.candidates.map((candidate) => {
|
|
310
|
+
const fallback = pageStates.get(candidate.ref);
|
|
311
|
+
if (!fallback) return candidate;
|
|
312
|
+
const state = { ...fallback, ...candidate.state };
|
|
313
|
+
if (JSON.stringify(state) === JSON.stringify(candidate.state)) return candidate;
|
|
314
|
+
const enriched = { ...candidate, state };
|
|
315
|
+
addedState.push(enriched);
|
|
316
|
+
return enriched;
|
|
317
|
+
});
|
|
318
|
+
if (!addedState.length) return observation;
|
|
319
|
+
const addedByRef = new Map(addedState.map((candidate) => [candidate.ref, candidate]));
|
|
320
|
+
const chunks = observation.chunks.map((chunk) => {
|
|
321
|
+
const evidence = (chunk.refs || []).flatMap((ref) => {
|
|
322
|
+
const candidate = addedByRef.get(ref);
|
|
323
|
+
return candidate ? [semanticStateEvidence(candidate)] : [];
|
|
324
|
+
});
|
|
325
|
+
if (!evidence.length) return chunk;
|
|
326
|
+
return { ...chunk, text: `${chunk.text}\n${evidence.join(" ")}`.slice(0, 1_024) };
|
|
327
|
+
});
|
|
328
|
+
return { ...observation, candidates, chunks };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function logicalWriteIdentity(observation, action, candidate) {
|
|
332
|
+
return {
|
|
333
|
+
base: JSON.stringify([
|
|
334
|
+
action.kind,
|
|
335
|
+
observation.identity.fullUrl,
|
|
336
|
+
candidate.role,
|
|
337
|
+
candidate.type,
|
|
338
|
+
candidate.name,
|
|
339
|
+
action.url || canonicalSameOriginDestination(candidate, observation.identity.fullUrl),
|
|
340
|
+
action.kind === "fill" ? action.slot : null,
|
|
341
|
+
]),
|
|
342
|
+
context: normalizedSemanticPart(candidate.nearbyText).slice(0, 240),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function writeWasSpent(observation, action, candidate, spentWrites) {
|
|
347
|
+
const identity = logicalWriteIdentity(observation, action, candidate);
|
|
348
|
+
const spent = spentWrites.filter((item) => item.base === identity.base);
|
|
349
|
+
if (!spent.length) return false;
|
|
350
|
+
if (spent.some((item) => item.context === identity.context)) return true;
|
|
351
|
+
const currentContexts = observation.candidates
|
|
352
|
+
.filter((item) => {
|
|
353
|
+
if (action.kind === "click" && !CLICK_ROLES.has(item.role)) return false;
|
|
354
|
+
if (action.kind === "fill" && !isEditable(item)) return false;
|
|
355
|
+
return logicalWriteIdentity(observation, { ...action, ref: item.ref }, item).base === identity.base;
|
|
356
|
+
})
|
|
357
|
+
.map((item) => normalizedSemanticPart(item.nearbyText).slice(0, 240));
|
|
358
|
+
return spent.some((item) => currentContexts.filter((context) => context === item.context).length !== 1);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function spendWrite(spentWrites, identity) {
|
|
362
|
+
if (!spentWrites.some((item) => item.base === identity.base && item.context === identity.context)) {
|
|
363
|
+
spentWrites.push(identity);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function semanticErrorCode(error, fallback) {
|
|
368
|
+
return typeof error?.code === "string" && /^[A-Za-z0-9_]{1,64}$/.test(error.code)
|
|
369
|
+
? error.code
|
|
370
|
+
: fallback;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isEditable(candidate) {
|
|
374
|
+
return FIELD_ROLES.has(candidate.role) || ["textarea", "select"].includes(candidate.type);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function takeActionVariants(groups, capacity) {
|
|
378
|
+
const variants = groups.filter((group) => group.length);
|
|
379
|
+
const selected = [];
|
|
380
|
+
for (let index = 0; selected.length < capacity && variants.length; index = (index + 1) % variants.length) {
|
|
381
|
+
const action = variants[index].shift();
|
|
382
|
+
if (action) selected.push(action);
|
|
383
|
+
if (!variants[index].length) {
|
|
384
|
+
variants.splice(index, 1);
|
|
385
|
+
if (!variants.length) break;
|
|
386
|
+
index = (index - 1 + variants.length) % variants.length;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return selected;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function buildActions(observation, inputs, allowWrite, allowRefs = [], spentWrites = []) {
|
|
393
|
+
const fixedActions = [
|
|
394
|
+
...SEMANTIC_POLICY.scrolls.map((direction) => ({ id: `scroll:${direction}`, kind: "scroll", direction })),
|
|
395
|
+
...SEMANTIC_POLICY.waitsMs.map((durationMs) => ({ id: `wait:${durationMs}`, kind: "wait", durationMs })),
|
|
396
|
+
];
|
|
397
|
+
const narrowed = allowRefs.length ? new Set(allowRefs) : null;
|
|
398
|
+
const writeCandidates = allowWrite
|
|
399
|
+
? observation.candidates.filter((candidate) => !narrowed || narrowed.has(candidate.ref))
|
|
400
|
+
: [];
|
|
401
|
+
const navigationGroups = new Map();
|
|
402
|
+
for (const [index, candidate] of observation.candidates.entries()) {
|
|
403
|
+
const url = canonicalSameOriginDestination(candidate, observation.identity.fullUrl);
|
|
404
|
+
if (!url || url === observation.identity.fullUrl) continue;
|
|
405
|
+
const current = navigationGroups.get(url);
|
|
406
|
+
const ranked = { ...candidate, index };
|
|
407
|
+
if (!current || concreteCandidateOrder(ranked, current) < 0) navigationGroups.set(url, ranked);
|
|
408
|
+
}
|
|
409
|
+
const navigationActions = Array.from(navigationGroups, ([url, candidate]) => ({
|
|
410
|
+
id: `nav:${candidate.ref}`,
|
|
411
|
+
kind: "navigate",
|
|
412
|
+
url,
|
|
413
|
+
concreteRef: candidate.ref,
|
|
414
|
+
logicalIdentity: stableLogicalId(`navigation:${url}`, "action"),
|
|
415
|
+
}));
|
|
416
|
+
if (narrowed) {
|
|
417
|
+
const mandatoryWrites = [];
|
|
418
|
+
const additionalFills = [];
|
|
419
|
+
for (const candidate of writeCandidates) {
|
|
420
|
+
if (CLICK_ROLES.has(candidate.role)) {
|
|
421
|
+
const action = { id: `click:${candidate.ref}`, kind: "click", ref: candidate.ref };
|
|
422
|
+
if (!writeWasSpent(observation, action, candidate, spentWrites)) mandatoryWrites.push(action);
|
|
423
|
+
} else if (isEditable(candidate)) {
|
|
424
|
+
const slot = Object.keys(inputs)[0];
|
|
425
|
+
if (slot) {
|
|
426
|
+
const action = { id: `fill:${candidate.ref}:${slot}`, kind: "fill", ref: candidate.ref, slot };
|
|
427
|
+
if (!writeWasSpent(observation, action, candidate, spentWrites)) mandatoryWrites.push(action);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (fixedActions.length + mandatoryWrites.length > SEMANTIC_POLICY.limits.actionChoices) {
|
|
432
|
+
throw new SemanticError(
|
|
433
|
+
"semantic_invalid_request",
|
|
434
|
+
`explicitly authorized actions exceed the limit of ${SEMANTIC_POLICY.limits.actionChoices}`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
const fillCandidates = writeCandidates.filter(isEditable);
|
|
438
|
+
for (const slot of Object.keys(inputs)) {
|
|
439
|
+
for (const candidate of fillCandidates) {
|
|
440
|
+
if (mandatoryWrites.some((action) => action.kind === "fill" && action.ref === candidate.ref && action.slot === slot)) continue;
|
|
441
|
+
const action = { id: `fill:${candidate.ref}:${slot}`, kind: "fill", ref: candidate.ref, slot };
|
|
442
|
+
if (!writeWasSpent(observation, action, candidate, spentWrites)) additionalFills.push(action);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const required = [...fixedActions, ...mandatoryWrites];
|
|
446
|
+
return [
|
|
447
|
+
...required,
|
|
448
|
+
...takeActionVariants(
|
|
449
|
+
[navigationActions, additionalFills],
|
|
450
|
+
SEMANTIC_POLICY.limits.actionChoices - required.length,
|
|
451
|
+
),
|
|
452
|
+
];
|
|
453
|
+
}
|
|
454
|
+
const navigationClickActions = [];
|
|
455
|
+
const controlClickActions = [];
|
|
456
|
+
const fillActions = [];
|
|
457
|
+
for (const candidate of observation.candidates) {
|
|
458
|
+
if (allowWrite) {
|
|
459
|
+
if (CLICK_ROLES.has(candidate.role)) {
|
|
460
|
+
const action = { id: `click:${candidate.ref}`, kind: "click", ref: candidate.ref };
|
|
461
|
+
if (!writeWasSpent(observation, action, candidate, spentWrites)) {
|
|
462
|
+
const group = canonicalSameOriginDestination(candidate, observation.identity.fullUrl)
|
|
463
|
+
? navigationClickActions
|
|
464
|
+
: controlClickActions;
|
|
465
|
+
group.push(action);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (isEditable(candidate)) {
|
|
469
|
+
for (const slot of Object.keys(inputs)) {
|
|
470
|
+
const action = { id: `fill:${candidate.ref}:${slot}`, kind: "fill", ref: candidate.ref, slot };
|
|
471
|
+
if (!writeWasSpent(observation, action, candidate, spentWrites)) fillActions.push(action);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return [
|
|
477
|
+
...fixedActions,
|
|
478
|
+
...takeActionVariants(
|
|
479
|
+
[navigationActions, navigationClickActions, controlClickActions, fillActions],
|
|
480
|
+
SEMANTIC_POLICY.limits.actionChoices - fixedActions.length,
|
|
481
|
+
),
|
|
482
|
+
];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function expectedIdentity(observation, candidate) {
|
|
486
|
+
return { ...observation.identity, ref: candidate.ref, role: candidate.role, name: candidate.name, type: candidate.type };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function executeAction(request, observation, action, inputs, timeoutMs, designatedIdentity) {
|
|
490
|
+
const candidate = observation.candidates.find((item) => item.ref === action.ref);
|
|
491
|
+
const guardedRequest = (tool, args) => request(tool, { ...args, semanticExpectedIdentity: observation.identity }, timeoutMs, designatedIdentity);
|
|
492
|
+
if (action.kind === "navigate") return guardedRequest("navigate", { url: action.url });
|
|
493
|
+
if (action.kind === "click") return request("click", { ref: action.ref, semanticExpectedIdentity: expectedIdentity(observation, candidate) }, timeoutMs, designatedIdentity);
|
|
494
|
+
if (action.kind === "fill") return request("form.fill", { data: [{ ref: action.ref, value: inputs[action.slot] }], semanticExpectedIdentity: expectedIdentity(observation, candidate) }, timeoutMs, designatedIdentity);
|
|
495
|
+
if (action.kind === "scroll") {
|
|
496
|
+
if (action.direction === "top" || action.direction === "bottom") return guardedRequest(`scroll.${action.direction}`, {});
|
|
497
|
+
return guardedRequest("scroll", { direction: action.direction.startsWith("up") ? "up" : "down", scroll_pixels: 600 });
|
|
498
|
+
}
|
|
499
|
+
return request("wait", { duration: action.durationMs / 1000 }, timeoutMs, designatedIdentity);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function runBrowserSemantic(options, { request, evaluate, now = () => performance.now() }) {
|
|
503
|
+
const deadline = now() + SEMANTIC_POLICY.limits.defaultWallMs;
|
|
504
|
+
const remaining = () => Math.max(0, Math.floor(deadline - now()));
|
|
505
|
+
let providerCalls = 0;
|
|
506
|
+
const evaluator = async (state, questions, providerOptions = {}) => {
|
|
507
|
+
if (providerCalls >= SEMANTIC_POLICY.limits.providerCalls) throw new SemanticError("provider_call_budget_exhausted", "semantic provider-call budget exhausted");
|
|
508
|
+
if (remaining() < 1) throw new SemanticError("wall_time_budget_exhausted", "semantic wall-clock budget exhausted");
|
|
509
|
+
const controller = new AbortController();
|
|
510
|
+
const timer = setTimeout(() => controller.abort(), Math.min(SEMANTIC_POLICY.timeoutMs, remaining()));
|
|
511
|
+
try {
|
|
512
|
+
providerCalls++;
|
|
513
|
+
return await evaluate(state, questions, { ...providerOptions, signal: controller.signal });
|
|
514
|
+
}
|
|
515
|
+
finally { clearTimeout(timer); }
|
|
516
|
+
};
|
|
517
|
+
let designatedIdentity;
|
|
518
|
+
const observe = async () => {
|
|
519
|
+
const observation = semanticObservationFrom(await request("page.read", { semanticObservation: true }, remaining(), designatedIdentity));
|
|
520
|
+
if (!designatedIdentity) {
|
|
521
|
+
designatedIdentity = observation.identity;
|
|
522
|
+
} else if (
|
|
523
|
+
observation.identity.browserEpoch !== designatedIdentity.browserEpoch ||
|
|
524
|
+
observation.identity.tabId !== designatedIdentity.tabId ||
|
|
525
|
+
observation.identity.frameId !== designatedIdentity.frameId
|
|
526
|
+
) {
|
|
527
|
+
const error = new Error("stale_observation");
|
|
528
|
+
error.code = "stale_observation";
|
|
529
|
+
throw error;
|
|
530
|
+
}
|
|
531
|
+
return observation;
|
|
532
|
+
};
|
|
533
|
+
const settleAfterWrite = async (stateTransition) => {
|
|
534
|
+
let settledObservation = await observe();
|
|
535
|
+
let settledState = providerState(settledObservation);
|
|
536
|
+
if (observedCandidateStateTransition(settledObservation, stateTransition)) {
|
|
537
|
+
return { observation: settledObservation, state: settledState, stateTransitionObserved: true };
|
|
538
|
+
}
|
|
539
|
+
let stateTransitionObserved = false;
|
|
540
|
+
for (const waitMs of POST_WRITE_SETTLE_WAITS_MS) {
|
|
541
|
+
const timeoutMs = remaining();
|
|
542
|
+
if (timeoutMs < 1) break;
|
|
543
|
+
await request("wait", { duration: Math.min(waitMs, timeoutMs) / 1_000 }, timeoutMs, designatedIdentity);
|
|
544
|
+
if (remaining() < 1) break;
|
|
545
|
+
settledObservation = await observe();
|
|
546
|
+
settledState = providerState(settledObservation);
|
|
547
|
+
if (observedCandidateStateTransition(settledObservation, stateTransition)) {
|
|
548
|
+
stateTransitionObserved = true;
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return { observation: settledObservation, state: settledState, stateTransitionObserved };
|
|
553
|
+
};
|
|
554
|
+
let observation = await observe();
|
|
555
|
+
let state = providerState(observation);
|
|
556
|
+
if (options.command === "semantic.find") {
|
|
557
|
+
const logicalCandidates = buildLogicalCandidates(observation, state.candidates);
|
|
558
|
+
const logicalState = {
|
|
559
|
+
...state,
|
|
560
|
+
candidates: logicalCandidates.map(({ id, role, name, type, text }) => ({ id, role, name, type, text })),
|
|
561
|
+
};
|
|
562
|
+
const result = await find({ state: logicalState, goal: options.goal, candidates: logicalCandidates, thresholds: options.thresholds, evaluate: evaluator });
|
|
563
|
+
const logicalCandidate = result.candidate;
|
|
564
|
+
const concrete = logicalCandidate?.concreteCandidates?.[0] || null;
|
|
565
|
+
return {
|
|
566
|
+
...result,
|
|
567
|
+
candidate: concrete ? Object.fromEntries(Object.entries(concrete).filter(([key]) => key !== "index")) : null,
|
|
568
|
+
logicalCandidate: logicalCandidate ? {
|
|
569
|
+
id: logicalCandidate.id,
|
|
570
|
+
identity: logicalCandidate.logicalIdentity,
|
|
571
|
+
refs: logicalCandidate.concreteCandidates.map((candidate) => candidate.id),
|
|
572
|
+
probability: result.decision.probability,
|
|
573
|
+
} : null,
|
|
574
|
+
concreteDecision: concrete ? {
|
|
575
|
+
ref: concrete.id,
|
|
576
|
+
identity: `${concrete.role || ""}:${concrete.type || ""}:${concrete.name || ""}`.slice(0, 1_024),
|
|
577
|
+
probability: result.decision.probability,
|
|
578
|
+
} : null,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
if (options.command === "semantic.verify") return verify({ state, outcome: options.goal, evidence: state.chunks, thresholds: options.thresholds, evaluate: evaluator });
|
|
582
|
+
if (options.command === "semantic.filter") {
|
|
583
|
+
const result = await filter({ state, goal: options.goal, chunks: state.chunks, top: options.top, thresholds: options.thresholds, evaluate: evaluator });
|
|
584
|
+
const relevantRefs = new Set(result.chunks.flatMap((chunk) => chunk.refs || []));
|
|
585
|
+
return { ...result, page: { origin: state.origin, title: state.title, readyState: state.readyState, modals: state.modals }, candidates: state.candidates.filter((candidate) => relevantRefs.has(candidate.id)), omitted: observation.omitted };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const trace = [];
|
|
589
|
+
let staleRefreshes = 0;
|
|
590
|
+
const spentWrites = [];
|
|
591
|
+
let identical = 0;
|
|
592
|
+
let previousHash = semanticProjectionHash(state);
|
|
593
|
+
for (let step = 1; step <= options.maxSteps; step++) {
|
|
594
|
+
if (remaining() < 1) return { status: "stopped", stopReason: "time_budget", trace, providerCalls };
|
|
595
|
+
let choice;
|
|
596
|
+
try {
|
|
597
|
+
let actions = buildActions(observation, options.inputs, options.allowWrite, options.allowRefs, spentWrites);
|
|
598
|
+
for (let retries = 0; ; retries++) {
|
|
599
|
+
try {
|
|
600
|
+
choice = await chooseAction({ state, goal: options.goal, actions, origin: state.origin, allowWrite: options.allowWrite, allowRefs: options.allowRefs, inputSlots: Object.keys(options.inputs), thresholds: options.thresholds, evaluate: evaluator });
|
|
601
|
+
break;
|
|
602
|
+
} catch (error) {
|
|
603
|
+
if (error?.code !== "provider_invalid_response" || retries >= SEMANTIC_POLICY.limits.invalidActionDecisionRetries) throw error;
|
|
604
|
+
if (actions.length > SEMANTIC_POLICY.limits.directActionRetryChoices) {
|
|
605
|
+
const relevant = await filter({
|
|
606
|
+
state,
|
|
607
|
+
goal: `Find page regions containing controls for the next action toward: ${options.goal}`,
|
|
608
|
+
chunks: state.chunks,
|
|
609
|
+
top: SEMANTIC_POLICY.limits.invalidActionDecisionRegionTop,
|
|
610
|
+
thresholds: options.thresholds,
|
|
611
|
+
evaluate: evaluator,
|
|
612
|
+
});
|
|
613
|
+
if (relevant.status === "filtered") {
|
|
614
|
+
const refs = new Set(relevant.chunks.flatMap((chunk) => chunk.refs || []));
|
|
615
|
+
actions = actions.filter((action) => {
|
|
616
|
+
const ref = action.ref || action.concreteRef;
|
|
617
|
+
return !ref || refs.has(ref);
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
} catch (error) {
|
|
624
|
+
return { status: "stopped", stopReason: "decision_failed", errorCode: semanticErrorCode(error, "decision_failed"), trace, providerCalls };
|
|
625
|
+
}
|
|
626
|
+
if (choice.status !== "selected") return {
|
|
627
|
+
status: "stopped",
|
|
628
|
+
stopReason: choice.status === "blocked" ? "prerequisite_blocked" : "uncertain",
|
|
629
|
+
trace,
|
|
630
|
+
providerCalls,
|
|
631
|
+
appliedThreshold: choice.appliedThreshold,
|
|
632
|
+
decision: choice.decision,
|
|
633
|
+
logicalDecision: choice.logicalDecision,
|
|
634
|
+
concreteDecision: choice.concreteDecision,
|
|
635
|
+
prerequisiteStatus: choice.prerequisiteStatus,
|
|
636
|
+
prerequisiteThresholds: choice.prerequisiteThresholds,
|
|
637
|
+
prerequisiteDecision: choice.prerequisiteDecision,
|
|
638
|
+
prerequisiteEvidence: choice.prerequisiteEvidence,
|
|
639
|
+
prerequisiteEvidenceThreshold: choice.prerequisiteEvidenceThreshold,
|
|
640
|
+
prerequisiteEvidenceDecision: choice.prerequisiteEvidenceDecision,
|
|
641
|
+
model: choice.model,
|
|
642
|
+
usage: choice.usage,
|
|
643
|
+
};
|
|
644
|
+
if (remaining() < 1) return { status: "stopped", stopReason: "time_budget", trace, providerCalls };
|
|
645
|
+
const action = choice.action;
|
|
646
|
+
const actionCandidate = observation.candidates.find((item) => item.ref === action.ref);
|
|
647
|
+
const writeIdentity = action.kind === "click" || action.kind === "fill"
|
|
648
|
+
? logicalWriteIdentity(observation, action, actionCandidate)
|
|
649
|
+
: null;
|
|
650
|
+
const preWriteState = action.kind === "click" ? interactiveCandidateState(actionCandidate?.state) : undefined;
|
|
651
|
+
const stateTransition = preWriteState
|
|
652
|
+
? {
|
|
653
|
+
fullUrl: observation.identity.fullUrl,
|
|
654
|
+
documentToken: observation.identity.documentToken,
|
|
655
|
+
ref: actionCandidate.ref,
|
|
656
|
+
role: actionCandidate.role,
|
|
657
|
+
name: actionCandidate.name,
|
|
658
|
+
type: actionCandidate.type,
|
|
659
|
+
state: preWriteState,
|
|
660
|
+
}
|
|
661
|
+
: null;
|
|
662
|
+
const traceAction = { step, kind: action.kind, appliedThreshold: choice.appliedThreshold, logicalProbability: choice.decision.probability, ...(action.logicalIdentity ? { logicalIdentity: action.logicalIdentity } : {}), ...(action.ref ? { ref: action.ref } : {}), ...(action.concreteRef ? { concreteRef: action.concreteRef, concreteProbability: choice.decision.probability } : {}), ...(action.slot ? { slot: action.slot } : {}), ...(action.direction ? { direction: action.direction } : {}), ...(action.durationMs ? { durationMs: action.durationMs } : {}) };
|
|
663
|
+
try { confirmedActionResponse(await executeAction(request, observation, action, options.inputs, remaining(), designatedIdentity)); }
|
|
664
|
+
catch (error) {
|
|
665
|
+
trace.push({ ...traceAction, result: error.code === "stale_observation" ? "stale" : "failed" });
|
|
666
|
+
if (error.code === "stale_observation" && staleRefreshes++ < SEMANTIC_POLICY.limits.staleRefreshes) {
|
|
667
|
+
observation = await observe(); state = providerState(observation); continue;
|
|
668
|
+
}
|
|
669
|
+
const stopReason = error.code === "stale_observation"
|
|
670
|
+
? "stale_observation"
|
|
671
|
+
: error.code === "action_outcome_unknown" ? "outcome_unknown" : "action_failed";
|
|
672
|
+
return { status: "stopped", stopReason, trace, providerCalls };
|
|
673
|
+
}
|
|
674
|
+
trace.push({ ...traceAction, result: "executed" });
|
|
675
|
+
let stateTransitionObserved = false;
|
|
676
|
+
try {
|
|
677
|
+
if (writeIdentity) {
|
|
678
|
+
({ observation, state, stateTransitionObserved } = await settleAfterWrite(stateTransition));
|
|
679
|
+
} else {
|
|
680
|
+
observation = await observe();
|
|
681
|
+
state = providerState(observation);
|
|
682
|
+
}
|
|
683
|
+
} catch {
|
|
684
|
+
return { status: "stopped", stopReason: "outcome_unknown", trace, providerCalls };
|
|
685
|
+
}
|
|
686
|
+
let outcome;
|
|
687
|
+
try {
|
|
688
|
+
outcome = await verify({ state, outcome: options.goal, evidence: state.chunks, thresholds: options.thresholds, evaluate: evaluator });
|
|
689
|
+
} catch (error) {
|
|
690
|
+
return { status: "stopped", stopReason: "verification_failed", errorCode: semanticErrorCode(error, "verification_failed"), trace, providerCalls };
|
|
691
|
+
}
|
|
692
|
+
if (outcome.status === "satisfied") return { status: "complete", stopReason: "complete", trace, verification: outcome, providerCalls };
|
|
693
|
+
if (action.kind === "click" || action.kind === "fill") {
|
|
694
|
+
const verifiedIntermediate = stateTransitionObserved && outcome.decision.label === "not_satisfied";
|
|
695
|
+
if (outcome.status !== "not_satisfied" && !verifiedIntermediate) {
|
|
696
|
+
return { status: "stopped", stopReason: "uncertain", trace, verification: outcome, providerCalls };
|
|
697
|
+
}
|
|
698
|
+
if (verifiedIntermediate) trace[trace.length - 1].verification = "observed_state_transition";
|
|
699
|
+
spendWrite(spentWrites, writeIdentity);
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
const hash = semanticProjectionHash(state);
|
|
703
|
+
identical = hash === previousHash ? identical + 1 : 0;
|
|
704
|
+
previousHash = hash;
|
|
705
|
+
if (identical >= SEMANTIC_POLICY.limits.identicalObservationHashes) return { status: "stopped", stopReason: "no_progress", trace, providerCalls };
|
|
706
|
+
}
|
|
707
|
+
return { status: "stopped", stopReason: "step_budget", trace, providerCalls };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function handleSemanticCli(argv, { endpoint, env = process.env, input = process.stdin, output = process.stderr, openTransport = openClientTransport } = {}) {
|
|
711
|
+
const options = parseSemanticArgs(argv);
|
|
712
|
+
if (!options) return { handled: false };
|
|
713
|
+
if (options.command === "help") return { handled: true, value: SEMANTIC_HELP, raw: true };
|
|
714
|
+
if (options.command === "semantic.auth.set") return { handled: true, value: await setTypeSafeCredentialFromInput({ input, output, env }), json: options.json };
|
|
715
|
+
if (options.command === "semantic.auth.status") return { handled: true, value: credentialStatus(env), json: options.json };
|
|
716
|
+
if (options.command === "semantic.auth.clear") return { handled: true, value: { cleared: clearStoredTypeSafeCredential(env), status: credentialStatus(env) }, json: options.json };
|
|
717
|
+
const credential = resolveTypeSafeCredential(env);
|
|
718
|
+
if (!credential) { const error = new Error("TypeSafe API key is not configured; run `surf semantic auth set` or set TYPESAFE_API_KEY"); error.code = "provider_not_configured"; throw error; }
|
|
719
|
+
const transport = await openTransport(endpoint, { requestTimeoutMs: SEMANTIC_POLICY.limits.maxWallMs });
|
|
720
|
+
let id = 0;
|
|
721
|
+
const environmentSession = !options.session && !options.tabId && typeof env.SURF_SESSION === "string" && env.SURF_SESSION.trim() ? env.SURF_SESSION.trim() : undefined;
|
|
722
|
+
const target = {
|
|
723
|
+
...(options.session ? { session: options.session, sessionSource: "explicit" } : {}),
|
|
724
|
+
...(environmentSession ? { session: environmentSession, sessionSource: "environment" } : {}),
|
|
725
|
+
...(options.tabId ? { tabId: options.tabId } : {}),
|
|
726
|
+
...(options.noWait ? { admission: { wait: false } } : {}),
|
|
727
|
+
};
|
|
728
|
+
try {
|
|
729
|
+
const request = (tool, args, timeoutMs = SEMANTIC_POLICY.limits.maxWallMs, designatedIdentity) => transport.request({
|
|
730
|
+
type: "tool_request",
|
|
731
|
+
method: "execute_tool",
|
|
732
|
+
params: { tool, args: designatedIdentity ? { ...args, semanticFrameId: designatedIdentity.frameId } : args },
|
|
733
|
+
id: `semantic-${++id}`,
|
|
734
|
+
...(designatedIdentity
|
|
735
|
+
? { tabId: designatedIdentity.tabId, ...(target.admission ? { admission: target.admission } : {}) }
|
|
736
|
+
: target),
|
|
737
|
+
}, Math.max(1, timeoutMs));
|
|
738
|
+
const value = await runBrowserSemantic(options, { request, evaluate: createJevEvaluator({ apiKey: credential.apiKey, env }) });
|
|
739
|
+
return { handled: true, value, json: options.json };
|
|
740
|
+
} finally { await transport.close(); }
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function formatSemanticOutput(result) {
|
|
744
|
+
if (result.raw) return result.value;
|
|
745
|
+
if (result.json) return JSON.stringify(result.value, null, 2);
|
|
746
|
+
if (result.value?.source) return result.value.fingerprint ? `${result.value.source} (${result.value.fingerprint})` : result.value.source;
|
|
747
|
+
if (result.value?.cleared !== undefined) return result.value.cleared ? "Shared TypeSafe credential cleared for all clients." : "No shared TypeSafe credential found.";
|
|
748
|
+
return JSON.stringify(result.value, null, 2);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
module.exports = {
|
|
752
|
+
buildActions,
|
|
753
|
+
buildLogicalCandidates,
|
|
754
|
+
canonicalSameOriginDestination,
|
|
755
|
+
confirmedActionResponse,
|
|
756
|
+
expectedIdentity,
|
|
757
|
+
formatSemanticOutput,
|
|
758
|
+
handleSemanticCli,
|
|
759
|
+
normalizeSemanticArgs,
|
|
760
|
+
parseSemanticArgs,
|
|
761
|
+
providerState,
|
|
762
|
+
runBrowserSemantic,
|
|
763
|
+
semanticObservationFrom,
|
|
764
|
+
};
|