jev-layer 0.1.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/CHANGELOG.md +34 -0
- package/CONTRIBUTING.md +146 -0
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/README.ru.md +117 -0
- package/README.zh-CN.md +117 -0
- package/RELEASE.md +53 -0
- package/SECURITY.md +56 -0
- package/bin/jev.mjs +214 -0
- package/config/codex.mcp.toml +8 -0
- package/config/generic-mcp.json +13 -0
- package/config/hermes.mcp.yaml +9 -0
- package/config/jev.example.json +22 -0
- package/config/omp.mcp.json +14 -0
- package/config/providers.env.example +13 -0
- package/docs/COMPATIBILITY.md +15 -0
- package/docs/SCHEMA-VERSIONING.md +94 -0
- package/examples/capabilities.json +39 -0
- package/examples/route-request.json +28 -0
- package/integrations/codex/.codex-plugin/plugin.json +19 -0
- package/integrations/codex/.mcp.json +11 -0
- package/integrations/codex/AGENTS.md +1 -0
- package/integrations/codex/run-mcp.mjs +9 -0
- package/integrations/codex/skills/jev-route/SKILL.md +17 -0
- package/integrations/hermes/__init__.py +51 -0
- package/integrations/hermes/plugin.yaml +5 -0
- package/integrations/hermes/schemas.py +12 -0
- package/integrations/omp/extension.js +105 -0
- package/integrations/template/README.md +10 -0
- package/integrations/template/adapter.mjs +87 -0
- package/package.json +59 -0
- package/scripts/benchmark.mjs +35 -0
- package/scripts/browser-benchmark.mjs +92 -0
- package/scripts/browser-e2e.mjs +117 -0
- package/scripts/capability-e2e.mjs +31 -0
- package/scripts/clean-install-smoke.mjs +162 -0
- package/scripts/codex-mcp-smoke.mjs +119 -0
- package/scripts/context-filter-e2e.mjs +45 -0
- package/scripts/fail-open-smoke.mjs +120 -0
- package/scripts/feature-flags-smoke.mjs +46 -0
- package/scripts/mcp-receipt-smoke.mjs +98 -0
- package/scripts/openrouter-choice.mjs +59 -0
- package/scripts/replay-eval.mjs +124 -0
- package/scripts/smoke.mjs +34 -0
- package/scripts/supervision-e2e.mjs +109 -0
- package/src/browser.mjs +569 -0
- package/src/cli.mjs +33 -0
- package/src/config.mjs +75 -0
- package/src/context-filter.mjs +56 -0
- package/src/contract.mjs +72 -0
- package/src/discovery.mjs +65 -0
- package/src/mcp-server.mjs +210 -0
- package/src/providers/demo.mjs +52 -0
- package/src/providers/typesafe.mjs +126 -0
- package/src/receipts.mjs +226 -0
- package/src/registry.mjs +109 -0
- package/src/relevance-filter.mjs +99 -0
- package/src/route.mjs +221 -0
- package/src/supervision.mjs +244 -0
- package/test/browser.test.mjs +199 -0
- package/test/capability.test.mjs +54 -0
- package/test/context-filter.test.mjs +65 -0
- package/test/openrouter-provider.test.mjs +55 -0
- package/test/receipts.test.mjs +71 -0
- package/test/route.test.mjs +74 -0
- package/test/supervision.test.mjs +99 -0
package/src/browser.mjs
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import { byteLength, decisionEnvelope, sha256 } from "./contract.mjs";
|
|
2
|
+
import { appendExecutionReceipt, appendRoutingCase } from "./receipts.mjs";
|
|
3
|
+
import { routeRequest } from "./route.mjs";
|
|
4
|
+
|
|
5
|
+
export const BROWSER_FAST_PATH_ENV = "JEV_BROWSER_FAST_PATH";
|
|
6
|
+
const MAX_TARGETS = 64;
|
|
7
|
+
const MAX_TABS = 16;
|
|
8
|
+
const MAX_OPTIONS = 32;
|
|
9
|
+
const MAX_LABEL = 240;
|
|
10
|
+
const SAFE_OPERATIONS = new Set(["scroll", "switch_tab"]);
|
|
11
|
+
const CONSEQUENTIAL_OPERATIONS = new Set(["click", "select", "navigate"]);
|
|
12
|
+
|
|
13
|
+
export function browserFastPathEnabled(options = {}) {
|
|
14
|
+
if (options.enabled === true) return true;
|
|
15
|
+
if (options.enabled === false) return false;
|
|
16
|
+
return process.env[BROWSER_FAST_PATH_ENV] === "1";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildBrowserRequest({ goal, observation = {}, harness = "browser", start_url = null, actor_permissions = ["browser"], policy = {}, progress = null }) {
|
|
20
|
+
if (typeof goal !== "string" || goal.trim() === "") throw new TypeError("browser goal must be a non-empty string");
|
|
21
|
+
const normalizedObservation = normalizeObservation(observation);
|
|
22
|
+
const baseProgress = normalizeProgress(progress, goal.trim(), normalizedObservation);
|
|
23
|
+
const candidates = collectBrowserActions(normalizedObservation, start_url);
|
|
24
|
+
const inferredExpectedProgress = inferExpectedProgress(goal.trim(), candidates);
|
|
25
|
+
const completedProgress = new Set(baseProgress.executed_actions.filter((item) => item.status === "completed").map((item) => item.id));
|
|
26
|
+
const remainingExpectedProgress = baseProgress.remaining_expected_progress ?? inferredExpectedProgress?.filter((id) => !completedProgress.has(id)) ?? null;
|
|
27
|
+
const normalizedProgress = normalizeProgress({ ...baseProgress, remaining_expected_progress: remainingExpectedProgress }, goal.trim(), normalizedObservation);
|
|
28
|
+
const actions = candidates.filter((candidate) => browserActionAllowed(candidate, normalizedObservation, normalizedProgress));
|
|
29
|
+
return {
|
|
30
|
+
schema_version: 1,
|
|
31
|
+
harness,
|
|
32
|
+
intent: `Browser goal: ${goal.trim()}`,
|
|
33
|
+
context: {
|
|
34
|
+
browser: {
|
|
35
|
+
goal: goal.trim(),
|
|
36
|
+
start_url: typeof start_url === "string" ? start_url : null,
|
|
37
|
+
observation: normalizedObservation,
|
|
38
|
+
progress: normalizedProgress,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
actor_permissions,
|
|
42
|
+
capabilities: actions.map(toCapability),
|
|
43
|
+
policy,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function browserActions({ goal, observation, start_url = null, progress = null }) {
|
|
47
|
+
const normalizedObservation = normalizeObservation(observation);
|
|
48
|
+
const normalizedProgress = normalizeProgress(progress, typeof goal === "string" ? goal : "", normalizedObservation);
|
|
49
|
+
return collectBrowserActions(normalizedObservation, start_url).filter((candidate) => browserActionAllowed(candidate, normalizedObservation, normalizedProgress));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function collectBrowserActions(normalizedObservation, start_url) {
|
|
53
|
+
const actions = [];
|
|
54
|
+
const currentUrl = normalizedObservation.url;
|
|
55
|
+
if (typeof start_url === "string" && start_url && currentUrl !== start_url) {
|
|
56
|
+
actions.push(action("navigate", `browser:navigate:${encodeURIComponent(start_url)}`, `Navigate to ${start_url}`, { url: start_url, consequential: true }));
|
|
57
|
+
}
|
|
58
|
+
if (normalizedObservation.scroll?.up) actions.push(action("scroll", "browser:scroll:up", "Scroll the visible page upward", { direction: "up" }));
|
|
59
|
+
if (normalizedObservation.scroll?.down) actions.push(action("scroll", "browser:scroll:down", "Scroll the visible page downward", { direction: "down" }));
|
|
60
|
+
for (const tab of normalizedObservation.tabs) {
|
|
61
|
+
if (!tab.active) actions.push(action("switch_tab", `browser:switch_tab:${tab.id}`, `Switch to visible tab ${tab.title || tab.id}`, { tab_id: tab.id }));
|
|
62
|
+
}
|
|
63
|
+
for (const target of normalizedObservation.targets) {
|
|
64
|
+
const label = target.name || target.role || target.id;
|
|
65
|
+
const linkUrl = target.visible !== false ? resolveLinkUrl(target.href ?? target.url, currentUrl) : null;
|
|
66
|
+
if (linkUrl) {
|
|
67
|
+
actions.push(action("navigate", `browser:navigate:${encodeURIComponent(linkUrl)}:${target.id}`, `Navigate via visible link ${label} to ${linkUrl}`, {
|
|
68
|
+
target_id: target.id,
|
|
69
|
+
url: linkUrl,
|
|
70
|
+
consequential: true,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
if (target.visible !== false && target.clickable) {
|
|
74
|
+
actions.push(action("click", `browser:click:${target.id}`, `Click visible ${label}`, { target_id: target.id, consequential: true }));
|
|
75
|
+
}
|
|
76
|
+
if (target.visible !== false && Array.isArray(target.options)) {
|
|
77
|
+
for (const option of target.options.slice(0, MAX_OPTIONS)) {
|
|
78
|
+
const optionId = typeof option === "string" ? option : option.id ?? option.value ?? option.label;
|
|
79
|
+
if (!optionId) continue;
|
|
80
|
+
const optionLabel = typeof option === "string" ? option : option.label ?? option.value ?? option.id;
|
|
81
|
+
actions.push(action("select", `browser:select:${target.id}:${optionId}`, `Select ${optionLabel} in ${label}`, {
|
|
82
|
+
target_id: target.id,
|
|
83
|
+
option_id: String(optionId),
|
|
84
|
+
consequential: true,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
actions.push(action("handoff", "browser:handoff", "Return browser state to the host", { consequential: false }));
|
|
90
|
+
return actions;
|
|
91
|
+
}
|
|
92
|
+
export async function decideBrowserStep(input, options = {}) {
|
|
93
|
+
const request = buildBrowserRequest(input);
|
|
94
|
+
const actions = browserActions({
|
|
95
|
+
goal: input.goal,
|
|
96
|
+
observation: request.context.browser.observation,
|
|
97
|
+
start_url: input.start_url,
|
|
98
|
+
progress: request.context.browser.progress,
|
|
99
|
+
});
|
|
100
|
+
if (!browserFastPathEnabled(options)) {
|
|
101
|
+
return {
|
|
102
|
+
request,
|
|
103
|
+
action: null,
|
|
104
|
+
decision: disabledDecision(request, actions),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const decision = await routeRequest(request, {
|
|
108
|
+
provider: options.provider ?? "demo",
|
|
109
|
+
engine: options.engine ?? "native",
|
|
110
|
+
policy: options.policy,
|
|
111
|
+
maxContextBytes: options.maxContextBytes,
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
request,
|
|
115
|
+
decision,
|
|
116
|
+
action: actions.find((candidate) => candidate.id === decision.selected) ?? null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
export async function runBrowserFastPath({
|
|
122
|
+
goal,
|
|
123
|
+
executor,
|
|
124
|
+
harness = "browser",
|
|
125
|
+
start_url = null,
|
|
126
|
+
provider = "demo",
|
|
127
|
+
enabled,
|
|
128
|
+
maxSteps = 8,
|
|
129
|
+
maxSeconds = 30,
|
|
130
|
+
policy,
|
|
131
|
+
receiptPath,
|
|
132
|
+
mainModelTurns = 1,
|
|
133
|
+
} = {}) {
|
|
134
|
+
const started = performance.now();
|
|
135
|
+
const metrics = {
|
|
136
|
+
wall_time_ms: null,
|
|
137
|
+
main_model_turns: mainModelTurns,
|
|
138
|
+
jev_calls: 0,
|
|
139
|
+
browser_actions: 0,
|
|
140
|
+
failures: 0,
|
|
141
|
+
cost_usd: 0,
|
|
142
|
+
};
|
|
143
|
+
const trace = [];
|
|
144
|
+
const finish = (status, reason, observation = null) => ({
|
|
145
|
+
status,
|
|
146
|
+
reason,
|
|
147
|
+
observation,
|
|
148
|
+
trace,
|
|
149
|
+
metrics: { ...metrics, wall_time_ms: elapsed(started) },
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!browserFastPathEnabled({ enabled })) return finish("handoff", "browser_fast_path_disabled");
|
|
153
|
+
const hasActionExecutor = typeof executor?.execute === "function" || typeof executor?.select === "function";
|
|
154
|
+
if (!executor || typeof executor.observe !== "function" || !hasActionExecutor) {
|
|
155
|
+
return finish("handoff", "browser_executor_unavailable");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let observation;
|
|
159
|
+
try {
|
|
160
|
+
observation = normalizeObservation(await executor.observe());
|
|
161
|
+
} catch (error) {
|
|
162
|
+
metrics.failures += 1;
|
|
163
|
+
return finish("handoff", "browser_observation_failed", { error: message(error) });
|
|
164
|
+
}
|
|
165
|
+
let progress = createProgress(goal, observation);
|
|
166
|
+
|
|
167
|
+
for (let step = 1; step <= maxSteps; step += 1) {
|
|
168
|
+
if (elapsed(started) > maxSeconds * 1_000) return finish("handoff", "browser_fast_path_timeout", observation);
|
|
169
|
+
let routed;
|
|
170
|
+
try {
|
|
171
|
+
routed = await decideBrowserStep({ goal, observation, harness, start_url, progress }, { enabled: true, provider, policy });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
metrics.failures += 1;
|
|
174
|
+
return finish("handoff", "browser_decision_failed", observation);
|
|
175
|
+
}
|
|
176
|
+
metrics.jev_calls += 1;
|
|
177
|
+
metrics.cost_usd += routed.decision.receipt?.cost_usd ?? routed.decision.raw_jev?.usage?.cost ?? 0;
|
|
178
|
+
const stepTrace = {
|
|
179
|
+
step,
|
|
180
|
+
correlation_id: routed.decision.correlation_id,
|
|
181
|
+
selected: routed.decision.selected,
|
|
182
|
+
status: routed.decision.status,
|
|
183
|
+
confidence: routed.decision.confidence,
|
|
184
|
+
operation: routed.action?.operation ?? null,
|
|
185
|
+
executed: false,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (!routed.action || routed.decision.status !== "selected") {
|
|
189
|
+
await persistBrowserStep(receiptPath, routed, harness, {
|
|
190
|
+
status: "not_started",
|
|
191
|
+
capability_id: routed.decision.selected,
|
|
192
|
+
result: { handoff: true, reason: routed.decision.fallback?.type ?? routed.decision.status },
|
|
193
|
+
browser: { operation: routed.action?.operation ?? null, executed: false },
|
|
194
|
+
});
|
|
195
|
+
trace.push(stepTrace);
|
|
196
|
+
return finish("handoff", routed.decision.fallback?.type ?? routed.decision.status, observation);
|
|
197
|
+
}
|
|
198
|
+
if (routed.action.operation === "handoff") {
|
|
199
|
+
await persistBrowserStep(receiptPath, routed, harness, {
|
|
200
|
+
status: "not_started",
|
|
201
|
+
capability_id: routed.action.id,
|
|
202
|
+
result: { handoff: true, reason: "host_handoff" },
|
|
203
|
+
browser: { operation: routed.action.operation, executed: false },
|
|
204
|
+
});
|
|
205
|
+
trace.push(stepTrace);
|
|
206
|
+
return finish("handoff", "host_handoff", observation);
|
|
207
|
+
}
|
|
208
|
+
if (routed.action.operation === "visible_target") {
|
|
209
|
+
await persistBrowserStep(receiptPath, routed, harness, {
|
|
210
|
+
status: "not_started",
|
|
211
|
+
capability_id: routed.action.id,
|
|
212
|
+
result: { handoff: true, reason: "visible_target", target_id: routed.action.target_id },
|
|
213
|
+
browser: { operation: routed.action.operation, target_id: routed.action.target_id, executed: false },
|
|
214
|
+
});
|
|
215
|
+
trace.push(stepTrace);
|
|
216
|
+
return finish("handoff", "visible_target", observation);
|
|
217
|
+
}
|
|
218
|
+
if (CONSEQUENTIAL_OPERATIONS.has(routed.action.operation)) {
|
|
219
|
+
const approved = typeof executor.approve === "function" && await executor.approve(routed.action, routed.decision);
|
|
220
|
+
if (!approved) {
|
|
221
|
+
await persistBrowserStep(receiptPath, routed, harness, {
|
|
222
|
+
status: "not_started",
|
|
223
|
+
capability_id: routed.action.id,
|
|
224
|
+
result: { handoff: true, reason: "host_confirmation_required" },
|
|
225
|
+
browser: { operation: routed.action.operation, approved: false, executed: false },
|
|
226
|
+
});
|
|
227
|
+
trace.push(stepTrace);
|
|
228
|
+
return finish("handoff", "host_confirmation_required", observation);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (!SAFE_OPERATIONS.has(routed.action.operation) && !CONSEQUENTIAL_OPERATIONS.has(routed.action.operation)) {
|
|
232
|
+
await persistBrowserStep(receiptPath, routed, harness, {
|
|
233
|
+
status: "not_started",
|
|
234
|
+
capability_id: routed.action.id,
|
|
235
|
+
result: { handoff: true, reason: "unsupported_browser_operation" },
|
|
236
|
+
browser: { operation: routed.action.operation, executed: false },
|
|
237
|
+
});
|
|
238
|
+
trace.push(stepTrace);
|
|
239
|
+
return finish("handoff", "unsupported_browser_operation", observation);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const actionStarted = performance.now();
|
|
243
|
+
try {
|
|
244
|
+
const execute = routed.action.operation === "select" && typeof executor.select === "function"
|
|
245
|
+
? executor.select.bind(executor)
|
|
246
|
+
: executor.execute.bind(executor);
|
|
247
|
+
const result = await execute(routed.action);
|
|
248
|
+
metrics.browser_actions += 1;
|
|
249
|
+
stepTrace.executed = true;
|
|
250
|
+
const nextObservation = normalizeObservation(result?.observation ?? await executor.observe());
|
|
251
|
+
progress = advanceProgress(progress, routed.action, observation, nextObservation, {
|
|
252
|
+
status: "completed",
|
|
253
|
+
result: result?.result ?? result ?? null,
|
|
254
|
+
});
|
|
255
|
+
const execution = {
|
|
256
|
+
status: "completed",
|
|
257
|
+
capability_id: routed.action.id,
|
|
258
|
+
result: result?.result ?? result ?? null,
|
|
259
|
+
exit_status: 0,
|
|
260
|
+
duration_ms: elapsed(actionStarted),
|
|
261
|
+
browser: { operation: routed.action.operation, target_id: routed.action.target_id ?? null, option_id: routed.action.option_id ?? null, executed: true },
|
|
262
|
+
};
|
|
263
|
+
await persistBrowserStep(receiptPath, routed, harness, execution);
|
|
264
|
+
trace.push(stepTrace);
|
|
265
|
+
observation = nextObservation;
|
|
266
|
+
if (result?.handoff || result?.done) return finish(result.done ? "done" : "handoff", result.done ? "goal_completed_by_host" : "host_handoff", observation);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
metrics.failures += 1;
|
|
269
|
+
progress = advanceProgress(progress, routed.action, observation, observation, { status: "failed", result: null, error: message(error) });
|
|
270
|
+
const execution = {
|
|
271
|
+
status: "failed",
|
|
272
|
+
capability_id: routed.action.id,
|
|
273
|
+
result: null,
|
|
274
|
+
error: message(error),
|
|
275
|
+
exit_status: 1,
|
|
276
|
+
duration_ms: elapsed(actionStarted),
|
|
277
|
+
browser: { operation: routed.action.operation, target_id: routed.action.target_id ?? null, executed: false },
|
|
278
|
+
};
|
|
279
|
+
await persistBrowserStep(receiptPath, routed, harness, execution);
|
|
280
|
+
trace.push(stepTrace);
|
|
281
|
+
return finish("handoff", "browser_action_failed", observation);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return finish("handoff", "browser_step_budget_exhausted", observation);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function toCapability(candidate) {
|
|
288
|
+
return {
|
|
289
|
+
id: candidate.id,
|
|
290
|
+
kind: "tool",
|
|
291
|
+
name: `Browser ${candidate.operation}`,
|
|
292
|
+
description: candidate.description,
|
|
293
|
+
permissions: ["browser"],
|
|
294
|
+
risk: "low",
|
|
295
|
+
policy: { requires_confirmation: false },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function action(operation, id, description, extra = {}) {
|
|
299
|
+
return { operation, id, description, consequential: Boolean(extra.consequential), ...extra };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function normalizeObservation(observation) {
|
|
303
|
+
if (!observation || typeof observation !== "object") return { url: null, title: null, visible_text: "", targets: [], tabs: [], scroll: { up: false, down: false, position: null, max: null, viewport: null } };
|
|
304
|
+
const scroll = observation.scroll && typeof observation.scroll === "object" ? observation.scroll : {};
|
|
305
|
+
return {
|
|
306
|
+
url: typeof observation.url === "string" ? observation.url.slice(0, 2_000) : null,
|
|
307
|
+
title: typeof observation.title === "string" ? observation.title.slice(0, 500) : null,
|
|
308
|
+
visible_text: typeof observation.visible_text === "string" ? observation.visible_text.slice(0, 8_000) : "",
|
|
309
|
+
targets: Array.isArray(observation.targets) ? observation.targets.slice(0, MAX_TARGETS).map(normalizeTarget).filter(Boolean) : [],
|
|
310
|
+
tabs: Array.isArray(observation.tabs) ? observation.tabs.slice(0, MAX_TABS).map(normalizeTab).filter(Boolean) : [],
|
|
311
|
+
scroll: {
|
|
312
|
+
up: scroll.up === true,
|
|
313
|
+
down: scroll.down === true,
|
|
314
|
+
position: finiteNumber(scroll.position ?? observation.scroll_y),
|
|
315
|
+
max: finiteNumber(scroll.max ?? observation.scroll_max),
|
|
316
|
+
viewport: finiteNumber(scroll.viewport ?? observation.viewport_height),
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function normalizeTarget(target) {
|
|
322
|
+
if (!target || typeof target !== "object" || typeof target.id !== "string" || target.id.trim() === "") return null;
|
|
323
|
+
const role = typeof target.role === "string" ? target.role.slice(0, 80) : null;
|
|
324
|
+
const roleIsClickable = role === "a" || role === "button" || role === "link";
|
|
325
|
+
return {
|
|
326
|
+
id: target.id.slice(0, 160),
|
|
327
|
+
role,
|
|
328
|
+
name: typeof target.name === "string" ? target.name.slice(0, MAX_LABEL) : null,
|
|
329
|
+
href: typeof target.href === "string" ? target.href.slice(0, 2_000) : null,
|
|
330
|
+
url: typeof target.url === "string" ? target.url.slice(0, 2_000) : null,
|
|
331
|
+
visible: target.visible !== false,
|
|
332
|
+
clickable: target.clickable === true || roleIsClickable,
|
|
333
|
+
options: Array.isArray(target.options) ? target.options.slice(0, MAX_OPTIONS).map((option) => typeof option === "string" ? option.slice(0, MAX_LABEL) : {
|
|
334
|
+
id: typeof option?.id === "string" ? option.id.slice(0, MAX_LABEL) : undefined,
|
|
335
|
+
value: typeof option?.value === "string" ? option.value.slice(0, MAX_LABEL) : undefined,
|
|
336
|
+
label: typeof option?.label === "string" ? option.label.slice(0, MAX_LABEL) : undefined,
|
|
337
|
+
}) : [],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function normalizeProgress(progress, goal, observation) {
|
|
342
|
+
const source = progress && typeof progress === "object" ? progress : {};
|
|
343
|
+
return {
|
|
344
|
+
goal: goal.slice(0, MAX_LABEL),
|
|
345
|
+
current_observation: observation,
|
|
346
|
+
executed_actions: Array.isArray(source.executed_actions) ? source.executed_actions.slice(-16).map(compactActionRecord) : [],
|
|
347
|
+
used_targets: Array.isArray(source.used_targets) ? [...new Set(source.used_targets.filter((value) => typeof value === "string").slice(-32))] : [],
|
|
348
|
+
blocked_actions: Array.isArray(source.blocked_actions) ? [...new Set(source.blocked_actions.filter((value) => typeof value === "string").slice(-32))] : [],
|
|
349
|
+
current_url: typeof source.current_url === "string" ? source.current_url.slice(0, 2_000) : observation.url,
|
|
350
|
+
current_state: typeof source.current_state === "string" ? source.current_state : browserStateFingerprint(observation),
|
|
351
|
+
last_action: source.last_action ? compactActionRecord(source.last_action) : null,
|
|
352
|
+
last_result: source.last_result ? compactResult(source.last_result) : null,
|
|
353
|
+
remaining_expected_progress: source.remaining_expected_progress ?? null,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function createProgress(goal, observation) {
|
|
358
|
+
return normalizeProgress({ remaining_expected_progress: null }, goal, observation);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function advanceProgress(progress, action, before, after, result) {
|
|
362
|
+
const beforeObservation = normalizeObservation(before);
|
|
363
|
+
const afterObservation = normalizeObservation(after);
|
|
364
|
+
const beforeState = browserStateFingerprint(beforeObservation);
|
|
365
|
+
const afterState = browserStateFingerprint(afterObservation);
|
|
366
|
+
const stateChanged = beforeState !== afterState;
|
|
367
|
+
const scrollProgress = action.operation === "scroll" && scrollStateProgress(action.direction, beforeObservation, afterObservation, stateChanged);
|
|
368
|
+
const record = compactActionRecord({ ...action, status: result.status });
|
|
369
|
+
const executedActions = result.status === "completed"
|
|
370
|
+
? [...progress.executed_actions, record].slice(-16)
|
|
371
|
+
: progress.executed_actions;
|
|
372
|
+
const usedTargets = [...new Set([
|
|
373
|
+
...progress.used_targets,
|
|
374
|
+
...(action.target_id ? [String(action.target_id)] : []),
|
|
375
|
+
])].slice(-32);
|
|
376
|
+
const blockedActions = [...new Set([
|
|
377
|
+
...progress.blocked_actions,
|
|
378
|
+
...(result.status !== "completed" && stateChanged ? [actionKey(action)] : []),
|
|
379
|
+
])].slice(-32);
|
|
380
|
+
const remainingExpectedProgress = Array.isArray(progress.remaining_expected_progress)
|
|
381
|
+
? progress.remaining_expected_progress.filter((id) => id !== action.id)
|
|
382
|
+
: progress.remaining_expected_progress;
|
|
383
|
+
return normalizeProgress({
|
|
384
|
+
...progress,
|
|
385
|
+
executed_actions: executedActions,
|
|
386
|
+
used_targets: usedTargets,
|
|
387
|
+
blocked_actions: blockedActions,
|
|
388
|
+
current_url: afterObservation.url,
|
|
389
|
+
current_state: afterState,
|
|
390
|
+
last_action: record,
|
|
391
|
+
last_result: {
|
|
392
|
+
status: result.status,
|
|
393
|
+
state_changed: stateChanged,
|
|
394
|
+
scroll_progress: scrollProgress,
|
|
395
|
+
before_state: beforeState,
|
|
396
|
+
after_state: afterState,
|
|
397
|
+
result: compactValue(result.result),
|
|
398
|
+
error: typeof result.error === "string" ? result.error.slice(0, MAX_LABEL) : null,
|
|
399
|
+
},
|
|
400
|
+
remaining_expected_progress: remainingExpectedProgress,
|
|
401
|
+
}, progress.goal, afterObservation);
|
|
402
|
+
}
|
|
403
|
+
function compactActionRecord(action) {
|
|
404
|
+
return {
|
|
405
|
+
id: typeof action?.id === "string" ? action.id.slice(0, 240) : null,
|
|
406
|
+
operation: typeof action?.operation === "string" ? action.operation : null,
|
|
407
|
+
target_id: typeof action?.target_id === "string" ? action.target_id.slice(0, 160) : null,
|
|
408
|
+
option_id: typeof action?.option_id === "string" ? action.option_id.slice(0, MAX_LABEL) : null,
|
|
409
|
+
url: typeof action?.url === "string" ? action.url.slice(0, 2_000) : null,
|
|
410
|
+
direction: typeof action?.direction === "string" ? action.direction : null,
|
|
411
|
+
status: typeof action?.status === "string" ? action.status : null,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function compactResult(result) {
|
|
416
|
+
return {
|
|
417
|
+
status: typeof result?.status === "string" ? result.status : null,
|
|
418
|
+
state_changed: result?.state_changed === true,
|
|
419
|
+
scroll_progress: result?.scroll_progress === true,
|
|
420
|
+
before_state: typeof result?.before_state === "string" ? result.before_state : null,
|
|
421
|
+
after_state: typeof result?.after_state === "string" ? result.after_state : null,
|
|
422
|
+
result: compactValue(result?.result),
|
|
423
|
+
error: typeof result?.error === "string" ? result.error.slice(0, MAX_LABEL) : null,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function compactValue(value) {
|
|
428
|
+
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return typeof value === "string" ? value.slice(0, MAX_LABEL) : value;
|
|
429
|
+
if (!value || typeof value !== "object") return null;
|
|
430
|
+
return {
|
|
431
|
+
operation: typeof value.operation === "string" ? value.operation : null,
|
|
432
|
+
status: typeof value.status === "string" ? value.status : null,
|
|
433
|
+
done: value.done === true,
|
|
434
|
+
handoff: value.handoff === true,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function inferExpectedProgress(goal, candidates) {
|
|
439
|
+
const clauses = String(goal).split(/\bthen\b|,|;|\bafter\b|\band\b/gi).map((clause) => clause.trim()).filter(Boolean);
|
|
440
|
+
const expected = [];
|
|
441
|
+
for (const clause of clauses) {
|
|
442
|
+
const operation = clauseOperation(clause);
|
|
443
|
+
if (!operation) continue;
|
|
444
|
+
const options = candidates.filter((candidate) => candidate.operation === operation);
|
|
445
|
+
if (options.length === 0) return null;
|
|
446
|
+
const direction = clause.match(/\b(up|down|upward|downward)\b/i)?.[1]?.toLowerCase();
|
|
447
|
+
const directional = operation === "scroll" && direction
|
|
448
|
+
? options.filter((candidate) => candidate.direction === (direction.startsWith("up") ? "up" : "down"))
|
|
449
|
+
: options;
|
|
450
|
+
const ranked = directional.map((candidate) => ({ candidate, score: clauseScore(clause, candidate) })).sort((left, right) => right.score - left.score);
|
|
451
|
+
const best = ranked[0];
|
|
452
|
+
if (!best || (best.score === 0 && directional.length > 1) || (ranked[1] && ranked[1].score === best.score && best.score > 0)) return null;
|
|
453
|
+
if (!expected.includes(best.candidate.id)) expected.push(best.candidate.id);
|
|
454
|
+
}
|
|
455
|
+
return expected.length > 0 ? expected : null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function clauseOperation(clause) {
|
|
459
|
+
if (/\b(click|press|tap)\b/i.test(clause)) return "click";
|
|
460
|
+
if (/\b(select|choose|pick)\b/i.test(clause)) return "select";
|
|
461
|
+
if (/\b(scroll)\b/i.test(clause)) return "scroll";
|
|
462
|
+
if (/\b(navigate|go|open|visit)\b/i.test(clause)) return "navigate";
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function clauseScore(clause, candidate) {
|
|
467
|
+
const ignored = new Set(["click", "press", "tap", "select", "choose", "pick", "scroll", "navigate", "go", "open", "visit", "then", "after", "the", "to", "via", "visible", "page", "down", "up", "upward", "downward", "and", "a", "an", "in", "on", "from"]);
|
|
468
|
+
const haystackTokens = new Set(`${candidate.description} ${candidate.target_id ?? ""} ${candidate.option_id ?? ""} ${candidate.url ?? ""}`.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
469
|
+
return clause.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length > 1 && !ignored.has(token)).filter((token) => haystackTokens.has(token)).length;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function browserActionAllowed(candidate, observation, progress) {
|
|
473
|
+
if (candidate.operation === "handoff") return true;
|
|
474
|
+
const expected = Array.isArray(progress.remaining_expected_progress) ? progress.remaining_expected_progress.filter(Boolean) : [];
|
|
475
|
+
if (expected.length > 0 && candidate.id !== expected[0]) return false;
|
|
476
|
+
if (candidate.operation === "scroll") return scrollActionAllowed(candidate, observation, progress);
|
|
477
|
+
const completed = new Set(progress.executed_actions.filter((item) => item.status === "completed").map((item) => item.id));
|
|
478
|
+
return !completed.has(actionKey(candidate)) && !progress.blocked_actions.includes(actionKey(candidate));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function scrollActionAllowed(candidate, observation, progress) {
|
|
482
|
+
const last = progress.last_action;
|
|
483
|
+
if (!last || last.operation !== "scroll" || last.direction !== candidate.direction) return canScroll(candidate.direction, observation);
|
|
484
|
+
const result = progress.last_result;
|
|
485
|
+
return result?.state_changed === true && result.scroll_progress === true && canScroll(candidate.direction, observation);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function canScroll(direction, observation) {
|
|
489
|
+
return direction === "up" ? observation.scroll.up : observation.scroll.down;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function scrollStateProgress(direction, before, after, stateChanged) {
|
|
493
|
+
const beforePosition = before.scroll.position;
|
|
494
|
+
const afterPosition = after.scroll.position;
|
|
495
|
+
if (Number.isFinite(beforePosition) && Number.isFinite(afterPosition)) {
|
|
496
|
+
return direction === "up" ? afterPosition < beforePosition : afterPosition > beforePosition;
|
|
497
|
+
}
|
|
498
|
+
return stateChanged;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function browserStateFingerprint(observation) {
|
|
502
|
+
return sha256({
|
|
503
|
+
url: observation.url,
|
|
504
|
+
title: observation.title,
|
|
505
|
+
visible_text: observation.visible_text,
|
|
506
|
+
targets: observation.targets,
|
|
507
|
+
tabs: observation.tabs,
|
|
508
|
+
scroll: observation.scroll,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function actionKey(action) {
|
|
513
|
+
return typeof action?.id === "string" ? action.id : `${action?.operation ?? ""}:${action?.target_id ?? ""}:${action?.option_id ?? ""}:${action?.url ?? ""}`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function resolveLinkUrl(raw, currentUrl) {
|
|
517
|
+
if (typeof raw !== "string" || raw.trim() === "") return null;
|
|
518
|
+
try {
|
|
519
|
+
const resolved = currentUrl ? new URL(raw, currentUrl) : new URL(raw);
|
|
520
|
+
return resolved.protocol === "http:" || resolved.protocol === "https:" ? resolved.href : null;
|
|
521
|
+
} catch {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function finiteNumber(value) {
|
|
527
|
+
return Number.isFinite(Number(value)) ? Number(value) : null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function normalizeTab(tab) {
|
|
531
|
+
if (!tab || typeof tab !== "object" || typeof tab.id !== "string") return null;
|
|
532
|
+
return {
|
|
533
|
+
id: tab.id.slice(0, 160),
|
|
534
|
+
title: typeof tab.title === "string" ? tab.title.slice(0, MAX_LABEL) : null,
|
|
535
|
+
url: typeof tab.url === "string" ? tab.url.slice(0, 2_000) : null,
|
|
536
|
+
active: tab.active === true,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function disabledDecision(request, actions) {
|
|
541
|
+
return decisionEnvelope({
|
|
542
|
+
status: "fallback",
|
|
543
|
+
reason: `${BROWSER_FAST_PATH_ENV} is not enabled`,
|
|
544
|
+
provider: "browser-fast-path:disabled",
|
|
545
|
+
candidateCount: actions.length,
|
|
546
|
+
contextBytes: byteLength(request.context),
|
|
547
|
+
candidates: actions.map((candidate) => ({ id: candidate.id, kind: "tool", name: `Browser ${candidate.operation}`, risk: candidate.consequential ? "medium" : "low", available: true, filtered: false, filter_reason: null, probability: null, confidence: null, requires_confirmation: Boolean(candidate.consequential) })),
|
|
548
|
+
fallback: { type: "browser_fast_path_disabled", reason: "host must continue with its normal browser path" },
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function persistBrowserStep(path, routed, harness, host) {
|
|
553
|
+
if (!path) return;
|
|
554
|
+
await appendRoutingCase({ path, request: routed.request, decision: routed.decision });
|
|
555
|
+
await appendExecutionReceipt({
|
|
556
|
+
path,
|
|
557
|
+
request: routed.request,
|
|
558
|
+
decision: routed.decision,
|
|
559
|
+
host: { harness, ...host },
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function elapsed(started) {
|
|
564
|
+
return Number((performance.now() - started).toFixed(3));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function message(error) {
|
|
568
|
+
return error instanceof Error ? error.message : String(error);
|
|
569
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { configuredProvider, loadConfig } from "./config.mjs";
|
|
5
|
+
import { routeRequest } from "./route.mjs";
|
|
6
|
+
|
|
7
|
+
const args = parseArgs(process.argv.slice(2));
|
|
8
|
+
const { config } = await loadConfig();
|
|
9
|
+
const input = args.input ? await readFile(args.input, "utf8") : null;
|
|
10
|
+
const lines = input === null ? createInterface({ input: process.stdin, crlfDelay: Infinity }) : [input];
|
|
11
|
+
|
|
12
|
+
for await (const line of lines) {
|
|
13
|
+
if (!line.trim()) continue;
|
|
14
|
+
try {
|
|
15
|
+
const request = JSON.parse(line);
|
|
16
|
+
const { provider = args.provider ?? configuredProvider(config), engine = args.engine ?? "native", ...payload } = request;
|
|
17
|
+
const decision = await routeRequest(payload, { provider, engine });
|
|
18
|
+
process.stdout.write(`${JSON.stringify(decision)}\n`);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
process.stdout.write(`${JSON.stringify({ status: "error", reason: error instanceof Error ? error.message : String(error), execution: { enabled: false, status: "not_started" } })}\n`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const result = {};
|
|
26
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
27
|
+
const value = argv[index];
|
|
28
|
+
if (value === "--input") result.input = argv[++index];
|
|
29
|
+
else if (value === "--provider") result.provider = argv[++index];
|
|
30
|
+
else if (value === "--engine") result.engine = argv[++index];
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { access, readFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
5
|
+
schema_version: 1,
|
|
6
|
+
provider: "demo",
|
|
7
|
+
replay_cases: ".jev/replay/cases.jsonl",
|
|
8
|
+
features: {
|
|
9
|
+
browser_fast_path: false,
|
|
10
|
+
supervision: false,
|
|
11
|
+
context_filter: null,
|
|
12
|
+
},
|
|
13
|
+
providers: {
|
|
14
|
+
openrouter: {
|
|
15
|
+
api_key_env: "OPENROUTER_API_KEY",
|
|
16
|
+
endpoint_env: "OPENROUTER_DECISIONS_ENDPOINT",
|
|
17
|
+
model_env: "OPENROUTER_DECISIONS_MODEL",
|
|
18
|
+
},
|
|
19
|
+
typesafe: {
|
|
20
|
+
api_key_env: "TYPESAFE_API_KEY",
|
|
21
|
+
endpoint_env: "TYPESAFE_ENDPOINT",
|
|
22
|
+
model_env: "TYPESAFE_MODEL",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export async function loadConfig({ cwd = process.cwd(), path = process.env.JEV_CONFIG } = {}) {
|
|
28
|
+
const configPath = resolve(path || join(cwd, ".jev", "config.json"));
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(await readFile(configPath, "utf8"));
|
|
31
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
32
|
+
return { config: { ...DEFAULT_CONFIG }, path: configPath, error: "config must be a JSON object" };
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
config: mergeConfig(parsed),
|
|
36
|
+
path: configPath,
|
|
37
|
+
error: null,
|
|
38
|
+
};
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error?.code === "ENOENT") return { config: { ...DEFAULT_CONFIG }, path: configPath, error: null };
|
|
41
|
+
return { config: { ...DEFAULT_CONFIG }, path: configPath, error: error instanceof Error ? error.message : String(error) };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function configuredProvider(config, explicitProvider) {
|
|
46
|
+
return explicitProvider ?? process.env.JEV_LAYER_PROVIDER ?? config?.provider ?? DEFAULT_CONFIG.provider;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function configuredReplayPath(config) {
|
|
50
|
+
return process.env.JEV_REPLAY_CASES ?? config?.replay_cases ?? DEFAULT_CONFIG.replay_cases;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function pathExists(path) {
|
|
54
|
+
try {
|
|
55
|
+
await access(path);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mergeConfig(parsed) {
|
|
63
|
+
return {
|
|
64
|
+
...DEFAULT_CONFIG,
|
|
65
|
+
...parsed,
|
|
66
|
+
features: {
|
|
67
|
+
...DEFAULT_CONFIG.features,
|
|
68
|
+
...(parsed.features && typeof parsed.features === "object" ? parsed.features : {}),
|
|
69
|
+
},
|
|
70
|
+
providers: {
|
|
71
|
+
...DEFAULT_CONFIG.providers,
|
|
72
|
+
...(parsed.providers && typeof parsed.providers === "object" ? parsed.providers : {}),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|