jev-layer 0.2.0 → 0.3.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 +47 -19
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/README.zh-CN.md +2 -0
- package/SECURITY.md +8 -0
- package/bin/jev.mjs +7 -5
- package/config/jev.example.json +1 -0
- package/config/providers.env.example +2 -2
- package/docs/AGENT-IMPLEMENTATION.md +56 -0
- package/docs/COMPATIBILITY.md +9 -0
- package/docs/HERMES-LOCAL-AGENT-HANDOFF.ru.md +12 -2
- package/docs/PROVIDERS.md +45 -3
- package/docs/SCHEMA-VERSIONING.md +5 -0
- package/integrations/hermes/__init__.py +8 -2
- package/integrations/hermes/plugin.yaml +3 -2
- package/integrations/hermes/schemas.py +31 -0
- package/integrations/omp/extension.js +1 -0
- package/package.json +1 -1
- package/scripts/clean-install-smoke.mjs +24 -10
- package/scripts/mcp-receipt-smoke.mjs +4 -2
- package/scripts/openrouter-choice.mjs +1 -1
- package/src/browser.mjs +15 -3
- package/src/computer.mjs +196 -0
- package/src/config.mjs +1 -0
- package/src/context-filter.mjs +1 -1
- package/src/contract.mjs +2 -0
- package/src/hermes-adapter.mjs +14 -3
- package/src/images.mjs +98 -0
- package/src/mcp-server.mjs +56 -10
- package/src/providers/typesafe.mjs +94 -14
- package/src/receipts.mjs +1 -0
- package/src/route.mjs +11 -2
- package/test/browser.test.mjs +23 -0
- package/test/computer.test.mjs +195 -0
- package/test/hermes-adapter.test.mjs +2 -1
- package/test/image-fixture.mjs +2 -0
- package/test/images.test.mjs +85 -0
- package/test/providers.test.mjs +5 -6
- package/test/route.test.mjs +12 -0
|
@@ -8,30 +8,40 @@ import { tmpdir } from "node:os";
|
|
|
8
8
|
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
const sourceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
|
+
const npmCli = process.env.npm_execpath;
|
|
12
|
+
assert.ok(npmCli, "Run this smoke with npm run clean-install-smoke so npm_execpath identifies the npm CLI.");
|
|
11
13
|
const stage = await mkdtemp(join(tmpdir(), "jev-clean-install-"));
|
|
12
14
|
const packageRoot = join(stage, "package");
|
|
13
15
|
const workspace = join(stage, "workspace");
|
|
16
|
+
const installedRoot = join(workspace, "node_modules", "jev-layer");
|
|
14
17
|
|
|
15
18
|
try {
|
|
16
19
|
await copyDistribution(packageRoot);
|
|
17
|
-
await
|
|
20
|
+
await runNpm(packageRoot, ["install", "--ignore-scripts", "--no-audit", "--no-fund"]);
|
|
18
21
|
await mkdir(workspace, { recursive: true });
|
|
19
22
|
await writeFile(join(workspace, "package.json"), JSON.stringify({ name: "clean-install-workspace", private: true }, null, 2) + "\n", "utf8");
|
|
20
|
-
await
|
|
21
|
-
await
|
|
22
|
-
await
|
|
23
|
-
const doctor = await
|
|
23
|
+
await runNpm(workspace, ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--no-save", "--install-links", packageRoot]);
|
|
24
|
+
await runJev(workspace, ["install", "--project", workspace]);
|
|
25
|
+
await runJev(workspace, ["add", "generic", "--project", workspace]);
|
|
26
|
+
const doctor = await runJev(workspace, ["doctor", "--project", workspace]);
|
|
24
27
|
const doctorReport = JSON.parse(doctor.stdout);
|
|
25
28
|
assert.equal(doctorReport.ok, true);
|
|
29
|
+
assert.equal(doctorReport.package_root, installedRoot);
|
|
26
30
|
|
|
27
|
-
const cli = await runJev(workspace, ["cli", "--provider", "demo", "--input", join(
|
|
31
|
+
const cli = await runJev(workspace, ["cli", "--provider", "demo", "--input", join(installedRoot, "examples", "route-request.json")]);
|
|
28
32
|
const cliDecision = JSON.parse(cli.stdout.trim().split(/\r?\n/).at(-1));
|
|
29
33
|
assert.equal(cliDecision.execution.enabled, false);
|
|
30
34
|
assert.ok(cliDecision.correlation_id);
|
|
31
35
|
|
|
32
36
|
const genericConfig = JSON.parse(await readFile(join(workspace, ".jev", "adapters", "generic.json"), "utf8"));
|
|
33
37
|
const genericServer = genericConfig.mcpServers.jev;
|
|
34
|
-
|
|
38
|
+
if (process.platform === "win32") {
|
|
39
|
+
assert.equal(genericServer.command, process.execPath);
|
|
40
|
+
assert.deepEqual(genericServer.args, [join(installedRoot, "bin", "jev.mjs"), "mcp"]);
|
|
41
|
+
} else {
|
|
42
|
+
assert.equal(genericServer.command, "jev");
|
|
43
|
+
assert.deepEqual(genericServer.args, ["mcp"]);
|
|
44
|
+
}
|
|
35
45
|
const mcpEnv = localEnv(workspace);
|
|
36
46
|
const mcp = spawn(genericServer.command, genericServer.args, {
|
|
37
47
|
cwd: workspace,
|
|
@@ -88,7 +98,7 @@ try {
|
|
|
88
98
|
});
|
|
89
99
|
const decision = routed.result.structuredContent;
|
|
90
100
|
assert.equal(decision.status, "selected");
|
|
91
|
-
const hostResult = await readFile(join(
|
|
101
|
+
const hostResult = await readFile(join(installedRoot, "package.json"), "utf8");
|
|
92
102
|
const recorded = await request("tools/call", {
|
|
93
103
|
name: "jev_record_execution",
|
|
94
104
|
arguments: {
|
|
@@ -114,7 +124,7 @@ try {
|
|
|
114
124
|
assert.equal(records.length, 2);
|
|
115
125
|
assert.deepEqual(records.map((record) => record.record_type), ["routing_case", "execution_receipt"]);
|
|
116
126
|
assert.equal(records[0].correlation_id, records[1].correlation_id);
|
|
117
|
-
const replay = await runNode(
|
|
127
|
+
const replay = await runNode(installedRoot, ["scripts/replay-eval.mjs", "--provider", "demo", "--input", join(workspace, ".jev", "replay", "cases.jsonl"), "--limit", "1"]);
|
|
118
128
|
const replayReport = JSON.parse(replay.stdout);
|
|
119
129
|
assert.equal(replayReport.replayed_cases, 1);
|
|
120
130
|
assert.equal(replayReport.host_execution_calls, 0);
|
|
@@ -143,8 +153,12 @@ function runNode(cwd, args) {
|
|
|
143
153
|
return execFileAsync(process.execPath, args, { cwd, env: process.env, maxBuffer: 2 * 1024 * 1024 });
|
|
144
154
|
}
|
|
145
155
|
|
|
156
|
+
function runNpm(cwd, args) {
|
|
157
|
+
return runNode(cwd, [npmCli, ...args]);
|
|
158
|
+
}
|
|
159
|
+
|
|
146
160
|
function runJev(cwd, args) {
|
|
147
|
-
return execFileAsync("jev", args, { cwd, env: localEnv(cwd), maxBuffer: 2 * 1024 * 1024 });
|
|
161
|
+
return execFileAsync(process.execPath, [join(installedRoot, "bin", "jev.mjs"), ...args], { cwd, env: localEnv(cwd), maxBuffer: 2 * 1024 * 1024 });
|
|
148
162
|
}
|
|
149
163
|
|
|
150
164
|
function localEnv(workspaceRoot) {
|
|
@@ -4,14 +4,16 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { createInterface } from "node:readline";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
7
8
|
|
|
8
|
-
const root =
|
|
9
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
9
10
|
const casesPath = join(await mkdtemp(join(tmpdir(), "jev-mcp-receipt-")), "cases.jsonl");
|
|
10
11
|
const child = spawn(process.env.JEV_NODE ?? "node", [resolve(root, "src/mcp-server.mjs")], {
|
|
11
12
|
cwd: root,
|
|
12
13
|
env: { ...process.env, JEV_REPLAY_CASES: casesPath },
|
|
13
14
|
stdio: ["pipe", "pipe", "pipe"],
|
|
14
15
|
});
|
|
16
|
+
const closed = new Promise((resolveExit) => child.once("close", resolveExit));
|
|
15
17
|
const lines = createInterface({ input: child.stdout });
|
|
16
18
|
const pending = new Map();
|
|
17
19
|
lines.on("line", (line) => {
|
|
@@ -90,7 +92,7 @@ try {
|
|
|
90
92
|
console.log(JSON.stringify({ ok: true, cases_path: casesPath, receipt }, null, 2));
|
|
91
93
|
} finally {
|
|
92
94
|
child.kill();
|
|
93
|
-
await
|
|
95
|
+
await closed;
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
function request(id, method, params) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { routeRequest } from "../src/route.mjs";
|
|
2
2
|
|
|
3
3
|
const endpoint = process.env.OPENROUTER_DECISIONS_ENDPOINT ?? "https://openrouter.ai/api/alpha/decisions";
|
|
4
|
-
const model = process.env.OPENROUTER_DECISIONS_MODEL ?? "typesafe/jev-1.
|
|
4
|
+
const model = process.env.OPENROUTER_DECISIONS_MODEL ?? "typesafe/jev-1.14";
|
|
5
5
|
const decision = await routeRequest({
|
|
6
6
|
schema_version: 1,
|
|
7
7
|
harness: "jev-layer-openrouter-smoke",
|
package/src/browser.mjs
CHANGED
|
@@ -41,6 +41,9 @@ export function buildBrowserRequest({ goal, observation = {}, harness = "browser
|
|
|
41
41
|
actor_permissions,
|
|
42
42
|
capabilities: actions.map(toCapability),
|
|
43
43
|
policy,
|
|
44
|
+
...(observation?.screenshot ? { assets: { "jev-browser-screenshot": {
|
|
45
|
+
type: "image", format: observation.screenshot.format, data_base64: observation.screenshot.data_base64,
|
|
46
|
+
} } } : {}),
|
|
44
47
|
};
|
|
45
48
|
}
|
|
46
49
|
export function browserActions({ goal, observation, start_url = null, progress = null }) {
|
|
@@ -159,7 +162,8 @@ export async function runBrowserFastPath({
|
|
|
159
162
|
|
|
160
163
|
let observation;
|
|
161
164
|
try {
|
|
162
|
-
|
|
165
|
+
const captured = await executor.observe();
|
|
166
|
+
observation = { ...normalizeObservation(captured), ...(captured?.screenshot ? { screenshot: captured.screenshot } : {}) };
|
|
163
167
|
} catch (error) {
|
|
164
168
|
metrics.failures += 1;
|
|
165
169
|
return finish("handoff", "browser_observation_failed", { error: message(error) });
|
|
@@ -249,7 +253,8 @@ export async function runBrowserFastPath({
|
|
|
249
253
|
const result = await execute(routed.action);
|
|
250
254
|
metrics.browser_actions += 1;
|
|
251
255
|
stepTrace.executed = true;
|
|
252
|
-
const
|
|
256
|
+
const captured = result?.observation ?? await executor.observe();
|
|
257
|
+
const nextObservation = { ...normalizeObservation(captured), ...(captured?.screenshot ? { screenshot: captured.screenshot } : {}) };
|
|
253
258
|
progress = advanceProgress(progress, routed.action, observation, nextObservation, {
|
|
254
259
|
status: "completed",
|
|
255
260
|
result: result?.result ?? result ?? null,
|
|
@@ -309,6 +314,12 @@ function normalizeObservation(observation) {
|
|
|
309
314
|
title: typeof observation.title === "string" ? observation.title.slice(0, 500) : null,
|
|
310
315
|
visible_text: typeof observation.visible_text === "string" ? observation.visible_text.slice(0, 8_000) : "",
|
|
311
316
|
targets: Array.isArray(observation.targets) ? observation.targets.slice(0, MAX_TARGETS).map(normalizeTarget).filter(Boolean) : [],
|
|
317
|
+
...(observation.screenshot ? { screenshot: {
|
|
318
|
+
format: observation.screenshot.format,
|
|
319
|
+
width: observation.screenshot.width,
|
|
320
|
+
height: observation.screenshot.height,
|
|
321
|
+
sha256: observation.screenshot.sha256 ?? (typeof observation.screenshot.data_base64 === "string" ? sha256(observation.screenshot.data_base64) : null),
|
|
322
|
+
} } : {}),
|
|
312
323
|
tabs: Array.isArray(observation.tabs) ? observation.tabs.slice(0, MAX_TABS).map(normalizeTab).filter(Boolean) : [],
|
|
313
324
|
scroll: {
|
|
314
325
|
up: scroll.up === true,
|
|
@@ -344,7 +355,7 @@ function normalizeProgress(progress, goal, observation) {
|
|
|
344
355
|
const source = progress && typeof progress === "object" ? progress : {};
|
|
345
356
|
return {
|
|
346
357
|
goal: goal.slice(0, MAX_LABEL),
|
|
347
|
-
current_observation: observation,
|
|
358
|
+
current_observation: normalizeObservation(observation),
|
|
348
359
|
executed_actions: Array.isArray(source.executed_actions) ? source.executed_actions.slice(-16).map(compactActionRecord) : [],
|
|
349
360
|
used_targets: Array.isArray(source.used_targets) ? [...new Set(source.used_targets.filter((value) => typeof value === "string").slice(-32))] : [],
|
|
350
361
|
blocked_actions: Array.isArray(source.blocked_actions) ? [...new Set(source.blocked_actions.filter((value) => typeof value === "string").slice(-32))] : [],
|
|
@@ -508,6 +519,7 @@ function browserStateFingerprint(observation) {
|
|
|
508
519
|
targets: observation.targets,
|
|
509
520
|
tabs: observation.tabs,
|
|
510
521
|
scroll: observation.scroll,
|
|
522
|
+
screenshot: normalizeObservation(observation).screenshot ?? null,
|
|
511
523
|
});
|
|
512
524
|
}
|
|
513
525
|
|
package/src/computer.mjs
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { decisionEnvelope } from "./contract.mjs";
|
|
3
|
+
import { imageDimensions, normalizeImageAssets } from "./images.mjs";
|
|
4
|
+
import { resolveProvider } from "./providers/index.mjs";
|
|
5
|
+
import { routeRequest } from "./route.mjs";
|
|
6
|
+
|
|
7
|
+
const OPERATIONS = new Set(["click", "type", "key", "scroll", "handoff"]);
|
|
8
|
+
const CONSEQUENTIAL = new Set(["click", "type", "key"]);
|
|
9
|
+
const ARGUMENTS = {
|
|
10
|
+
click: ["x", "y", "button"],
|
|
11
|
+
type: ["text"],
|
|
12
|
+
key: ["keys"],
|
|
13
|
+
scroll: ["direction", "amount", "x", "y"],
|
|
14
|
+
handoff: ["reason"],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export async function decideComputerStep(input, options = {}) {
|
|
18
|
+
const enabled = options.enabled === true || options.enabled !== false && process.env.JEV_COMPUTER_USE === "1";
|
|
19
|
+
if (!enabled) return failed(null, null, "computer_use_disabled", "Computer-use advice is disabled", options);
|
|
20
|
+
|
|
21
|
+
let request;
|
|
22
|
+
let actions;
|
|
23
|
+
let screenshot;
|
|
24
|
+
let thresholds;
|
|
25
|
+
try {
|
|
26
|
+
if (!object(input) || !text(input.goal, 8_000)) throw new TypeError("goal is required");
|
|
27
|
+
const supplied = input.observation?.screenshot;
|
|
28
|
+
if (!object(supplied)) throw new TypeError("screenshot is required");
|
|
29
|
+
const reference = randomUUID();
|
|
30
|
+
const assets = normalizeImageAssets({ [reference]: { type: "image", format: supplied.format, data_base64: supplied.data_base64 } });
|
|
31
|
+
const dimensions = imageDimensions(assets[reference]);
|
|
32
|
+
if (supplied.width !== dimensions.width || supplied.height !== dimensions.height) throw new TypeError("screenshot dimensions do not match image");
|
|
33
|
+
screenshot = {
|
|
34
|
+
...dimensions,
|
|
35
|
+
format: assets[reference].format,
|
|
36
|
+
sha256: `sha256:${createHash("sha256").update(Buffer.from(assets[reference].data_base64, "base64")).digest("hex")}`,
|
|
37
|
+
requires_current: true,
|
|
38
|
+
};
|
|
39
|
+
if (!Array.isArray(input.actions) || input.actions.length > 32) throw new TypeError("actions must contain at most 32 candidates");
|
|
40
|
+
actions = input.actions.map((candidate) => normalizeAction(candidate, dimensions));
|
|
41
|
+
if (new Set(actions.map((candidate) => candidate.id)).size !== actions.length) throw new TypeError("action ids must be unique");
|
|
42
|
+
if (input.policy !== undefined && !object(input.policy) || options.policy !== undefined && !object(options.policy)) throw new TypeError("invalid policy");
|
|
43
|
+
const policy = { ...(input.policy ?? {}), ...(options.policy ?? {}) };
|
|
44
|
+
thresholds = {
|
|
45
|
+
visibility: policy.min_target_visibility ?? 0.9,
|
|
46
|
+
box: policy.min_box_confidence ?? 0.8,
|
|
47
|
+
};
|
|
48
|
+
if (!probability(thresholds.visibility) || thresholds.visibility === 0 || !probability(thresholds.box) || thresholds.box === 0) throw new TypeError("invalid grounding thresholds");
|
|
49
|
+
const observation = { screenshot };
|
|
50
|
+
for (const key of ["visible_text", "application"]) {
|
|
51
|
+
if (input.observation[key] !== undefined) {
|
|
52
|
+
if (!text(input.observation[key], key === "visible_text" ? 8_000 : 240, true)) throw new TypeError(`invalid ${key}`);
|
|
53
|
+
observation[key] = input.observation[key];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (input.actor_permissions !== undefined && (!Array.isArray(input.actor_permissions) || input.actor_permissions.length > 32 || input.actor_permissions.some((permission) => !text(permission, 240)))) throw new TypeError("invalid actor_permissions");
|
|
57
|
+
request = {
|
|
58
|
+
schema_version: 1,
|
|
59
|
+
harness: text(input.harness, 240) ? input.harness : "computer",
|
|
60
|
+
intent: `Computer goal: ${input.goal}`,
|
|
61
|
+
context: {
|
|
62
|
+
computer: {
|
|
63
|
+
observation,
|
|
64
|
+
actions: actions.map(({ id, operation, args, target }) => ({ id, operation, args, ...(target ? { target } : {}) })),
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
assets,
|
|
68
|
+
actor_permissions: input.actor_permissions ?? [],
|
|
69
|
+
capabilities: actions.map((candidate) => ({
|
|
70
|
+
...candidate,
|
|
71
|
+
kind: "tool",
|
|
72
|
+
name: candidate.name ?? `${candidate.operation}: ${candidate.id}`,
|
|
73
|
+
description: candidate.description ?? `${candidate.operation} ${candidate.target ?? JSON.stringify(candidate.args)}`,
|
|
74
|
+
permissions: [...new Set(["computer", ...(candidate.permissions ?? [])])],
|
|
75
|
+
risk: candidate.risk ?? (CONSEQUENTIAL.has(candidate.operation) ? "medium" : "low"),
|
|
76
|
+
policy: { ...candidate.policy, requires_confirmation: CONSEQUENTIAL.has(candidate.operation) || candidate.consequential === true || candidate.requires_confirmation === true || candidate.policy?.requires_confirmation === true },
|
|
77
|
+
execution: { mode: "host", target: candidate.id },
|
|
78
|
+
})),
|
|
79
|
+
policy,
|
|
80
|
+
};
|
|
81
|
+
} catch {
|
|
82
|
+
return failed(null, null, "invalid_input", "Computer advice requires valid bounded actions and a matching PNG/JPEG screenshot", options);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let provider;
|
|
86
|
+
try {
|
|
87
|
+
provider = resolveProvider(options.provider ?? "typesafe", options);
|
|
88
|
+
} catch {
|
|
89
|
+
return failed(request, null, "provider_error", "Computer advice provider is unavailable", options);
|
|
90
|
+
}
|
|
91
|
+
if (provider.supportsImages !== true) return failed(request, null, "unsupported_images", "Provider does not support image input", options);
|
|
92
|
+
|
|
93
|
+
let decision;
|
|
94
|
+
try {
|
|
95
|
+
decision = await routeRequest(request, { ...options, engine: "native", discovery: undefined, provider });
|
|
96
|
+
} catch {
|
|
97
|
+
return failed(request, null, "invalid_input", "Computer routing request could not be validated", options);
|
|
98
|
+
}
|
|
99
|
+
if (!["selected", "needs_confirmation"].includes(decision.status)) return { request, decision, action: null };
|
|
100
|
+
const selected = actions.find((candidate) => candidate.id === decision.selected);
|
|
101
|
+
if (!selected) return failed(request, decision, "invalid_selection", "Selected action is outside the host candidate set", options);
|
|
102
|
+
let action = { ...selected, screenshot };
|
|
103
|
+
if (selected.operation === "click" && selected.args.x === undefined) {
|
|
104
|
+
if (typeof provider.evaluate !== "function") return failed(request, decision, "grounding_unavailable", "Provider cannot ground the selected target", options);
|
|
105
|
+
let raw;
|
|
106
|
+
try {
|
|
107
|
+
raw = await provider.evaluate({
|
|
108
|
+
state: { screenshot: Object.keys(request.assets)[0], target: selected.target },
|
|
109
|
+
assets: request.assets,
|
|
110
|
+
questions: {
|
|
111
|
+
target_visible: { type: "noul", instructions: "Is the exact target described in `target` clearly visible and uniquely identifiable in `screenshot`?" },
|
|
112
|
+
target_box: { type: "box", instructions: "Find the exact target described in `target` in `screenshot`." },
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
return failed(request, decision, "provider_error", "Selected target grounding failed", options);
|
|
117
|
+
}
|
|
118
|
+
const visible = raw?.answers?.target_visible;
|
|
119
|
+
const located = raw?.answers?.target_box;
|
|
120
|
+
const box = located?.box;
|
|
121
|
+
if (visible?.type !== "noul" || !probability(visible.noul) || located?.type !== "box" || !probability(located.confidence) || !object(box)
|
|
122
|
+
|| ![box.left, box.right, box.top, box.bottom].every(probability) || box.left >= box.right || box.top >= box.bottom) {
|
|
123
|
+
return failed(request, decision, "malformed_grounding", "Provider returned invalid target visibility or box", options);
|
|
124
|
+
}
|
|
125
|
+
if (visible.noul < thresholds.visibility) return failed(request, decision, "target_not_visible", "Selected target is not confidently visible", options);
|
|
126
|
+
if (located.confidence < thresholds.box) return failed(request, decision, "low_grounding_confidence", "Selected target box is below the host confidence threshold", options);
|
|
127
|
+
action = {
|
|
128
|
+
...action,
|
|
129
|
+
args: {
|
|
130
|
+
...selected.args,
|
|
131
|
+
x: Math.min(screenshot.width - 1, Math.floor((box.left + box.right) / 2 * screenshot.width)),
|
|
132
|
+
y: Math.min(screenshot.height - 1, Math.floor((box.top + box.bottom) / 2 * screenshot.height)),
|
|
133
|
+
},
|
|
134
|
+
grounding: {
|
|
135
|
+
box: { left: box.left, right: box.right, top: box.top, bottom: box.bottom },
|
|
136
|
+
visibility: visible.noul,
|
|
137
|
+
confidence: located.confidence,
|
|
138
|
+
coordinate_space: "screenshot_pixels",
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return { request, decision, action };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeAction(raw, dimensions) {
|
|
146
|
+
if (!object(raw) || !text(raw.id, 240) || raw.id !== raw.id.trim() || !OPERATIONS.has(raw.operation)) throw new TypeError("invalid action");
|
|
147
|
+
const args = raw.args ?? {};
|
|
148
|
+
if (!object(args) || Object.keys(args).some((key) => !ARGUMENTS[raw.operation].includes(key))) throw new TypeError("invalid action arguments");
|
|
149
|
+
const hasCoordinates = args.x !== undefined || args.y !== undefined;
|
|
150
|
+
if (hasCoordinates && (!Number.isInteger(args.x) || args.x < 0 || args.x >= dimensions.width || !Number.isInteger(args.y) || args.y < 0 || args.y >= dimensions.height)) throw new TypeError("coordinates must be inside screenshot");
|
|
151
|
+
if (raw.target !== undefined && !text(raw.target, 1_000)) throw new TypeError("invalid target description");
|
|
152
|
+
if (raw.operation === "click" && (!hasCoordinates && !raw.target || args.button !== undefined && !["left", "middle", "right"].includes(args.button))) throw new TypeError("invalid click");
|
|
153
|
+
if (raw.operation === "type" && !text(args.text, 8_000)) throw new TypeError("invalid text");
|
|
154
|
+
if (raw.operation === "key" && (!Array.isArray(args.keys) || args.keys.length < 1 || args.keys.length > 8 || args.keys.some((key) => !text(key, 80)))) throw new TypeError("invalid keys");
|
|
155
|
+
if (raw.operation === "scroll" && (!["up", "down", "left", "right"].includes(args.direction) || args.amount !== undefined && (!Number.isInteger(args.amount) || args.amount < 1 || args.amount > 10_000))) throw new TypeError("invalid scroll");
|
|
156
|
+
if (raw.operation === "handoff" && args.reason !== undefined && !text(args.reason, 1_000)) throw new TypeError("invalid handoff reason");
|
|
157
|
+
if (raw.permissions !== undefined && (!Array.isArray(raw.permissions) || raw.permissions.some((permission) => !text(permission, 240)))) throw new TypeError("invalid permissions");
|
|
158
|
+
for (const key of ["name", "description"]) {
|
|
159
|
+
if (raw[key] !== undefined && !text(raw[key], key === "name" ? 240 : 2_000)) throw new TypeError(`invalid ${key}`);
|
|
160
|
+
}
|
|
161
|
+
for (const key of ["available", "requires_confirmation", "consequential", "verified"]) {
|
|
162
|
+
if (raw[key] !== undefined && typeof raw[key] !== "boolean") throw new TypeError(`invalid ${key}`);
|
|
163
|
+
}
|
|
164
|
+
if (raw.policy !== undefined && (!object(raw.policy) || raw.policy.requires_confirmation !== undefined && typeof raw.policy.requires_confirmation !== "boolean")) throw new TypeError("invalid confirmation policy");
|
|
165
|
+
if (raw.availability !== undefined && (!object(raw.availability) || raw.availability.available !== undefined && typeof raw.availability.available !== "boolean")) throw new TypeError("invalid availability");
|
|
166
|
+
const action = { id: raw.id, operation: raw.operation, args: structuredClone(args) };
|
|
167
|
+
for (const key of ["target", "name", "description", "permissions", "risk", "available", "availability", "requires_confirmation", "policy", "consequential", "source", "verified", "metadata"]) {
|
|
168
|
+
if (raw[key] !== undefined) action[key] = structuredClone(raw[key]);
|
|
169
|
+
}
|
|
170
|
+
return action;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function failed(request, decision, type, reason, options) {
|
|
174
|
+
const fallback = { type, reason };
|
|
175
|
+
return {
|
|
176
|
+
request,
|
|
177
|
+
action: null,
|
|
178
|
+
decision: decision ? { ...decision, status: "fallback", reason, fallback } : decisionEnvelope({
|
|
179
|
+
status: "fallback", reason, fallback,
|
|
180
|
+
provider: typeof options.provider === "string" ? options.provider : options.provider?.name ?? "typesafe",
|
|
181
|
+
latencyMs: 0, candidateCount: request?.capabilities.length ?? 0, contextBytes: 0,
|
|
182
|
+
}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function object(value) {
|
|
187
|
+
return value !== null && typeof value === "object" && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function text(value, maximum, allowEmpty = false) {
|
|
191
|
+
return typeof value === "string" && value.length <= maximum && (allowEmpty || value.trim().length > 0);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function probability(value) {
|
|
195
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
196
|
+
}
|
package/src/config.mjs
CHANGED
package/src/context-filter.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { byteLength, stableJson } from "./contract.mjs";
|
|
2
2
|
import { filterContext, normalizeRelevanceMode } from "./relevance-filter.mjs";
|
|
3
|
-
const SECRET_KEY = /(token|secret|password|passwd|api[_-]?key|authorization|cookie|credential|private[_-]?key|session)/i;
|
|
3
|
+
const SECRET_KEY = /(token|secret|password|passwd|api[_-]?key|authorization|cookie|credential|private[_-]?key|session|data_base64)/i;
|
|
4
4
|
const MAX_STRING = 2_000;
|
|
5
5
|
const MAX_DEPTH = 4;
|
|
6
6
|
|
package/src/contract.mjs
CHANGED
|
@@ -39,6 +39,8 @@ export function normalizeRequest(input) {
|
|
|
39
39
|
harness: typeof input.harness === "string" ? input.harness : "unknown",
|
|
40
40
|
intent: intent.trim(),
|
|
41
41
|
context: input.context && typeof input.context === "object" ? input.context : {},
|
|
42
|
+
assets: input.assets,
|
|
43
|
+
assets_omitted: input.assets_omitted === true,
|
|
42
44
|
actor: typeof input.actor === "string" ? input.actor : undefined,
|
|
43
45
|
actor_permissions: Array.isArray(input.actor_permissions) ? input.actor_permissions.filter((value) => typeof value === "string") : undefined,
|
|
44
46
|
capabilities: input.capabilities,
|
package/src/hermes-adapter.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { createInterface } from "node:readline";
|
|
10
10
|
import { decideBrowserStep } from "./browser.mjs";
|
|
11
|
+
import { decideComputerStep } from "./computer.mjs";
|
|
11
12
|
import { configuredProvider, configuredReplayPath, loadConfig } from "./config.mjs";
|
|
12
13
|
import { appendExecutionReceipt, appendRoutingCase, buildExecutionReceipt, replayCasePath } from "./receipts.mjs";
|
|
13
14
|
import { routeRequest } from "./route.mjs";
|
|
@@ -51,12 +52,20 @@ async function handle({ operation, args = {}, decision = null } = {}) {
|
|
|
51
52
|
config,
|
|
52
53
|
});
|
|
53
54
|
routed.decision.browser_action = routed.action
|
|
54
|
-
? {
|
|
55
|
-
option_id: routed.action.option_id ?? null, consequential: routed.action.consequential }
|
|
55
|
+
? { ...routed.action, target_id: routed.action.target_id ?? null, option_id: routed.action.option_id ?? null }
|
|
56
56
|
: null;
|
|
57
57
|
await persistRoutingCase(routed.request, routed.decision);
|
|
58
58
|
return routed.decision;
|
|
59
59
|
}
|
|
60
|
+
if (operation === "computer_step") {
|
|
61
|
+
const { provider: _provider, enabled, ...input } = args;
|
|
62
|
+
const routed = await decideComputerStep(input, {
|
|
63
|
+
enabled: enabled ?? (process.env.JEV_COMPUTER_USE === "1" || config.features?.computer_use), provider, config,
|
|
64
|
+
});
|
|
65
|
+
routed.decision.computer_action = routed.action;
|
|
66
|
+
await persistRoutingCase(routed.request ?? { harness: input.harness ?? "computer", intent: input.goal }, routed.decision);
|
|
67
|
+
return routed.decision;
|
|
68
|
+
}
|
|
60
69
|
if (operation === "supervise") {
|
|
61
70
|
const { provider: _provider, enabled, ...input } = args;
|
|
62
71
|
return superviseWork({
|
|
@@ -106,7 +115,9 @@ async function handle({ operation, args = {}, decision = null } = {}) {
|
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
async function persistRoutingCase(request, decision) {
|
|
109
|
-
|
|
118
|
+
const { assets, ...retainedRequest } = request;
|
|
119
|
+
if (Object.keys(assets ?? {}).length) retainedRequest.assets_omitted = true;
|
|
120
|
+
decision._jev_request = retainedRequest;
|
|
110
121
|
try {
|
|
111
122
|
await appendRoutingCase({ path: casesPath, request, decision });
|
|
112
123
|
decision.receipt = { ...decision.receipt, replay_case_id: decision.correlation_id, replay_persisted: true };
|
package/src/images.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
|
|
2
|
+
const MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
3
|
+
|
|
4
|
+
function imageSize(bytes, format) {
|
|
5
|
+
if (format === "png") {
|
|
6
|
+
if (bytes.length < 33 || !bytes.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) {
|
|
7
|
+
throw new Error("image data is not PNG");
|
|
8
|
+
}
|
|
9
|
+
let width;
|
|
10
|
+
let height;
|
|
11
|
+
let hasData = false;
|
|
12
|
+
for (let offset = 8; offset + 12 <= bytes.length;) {
|
|
13
|
+
const length = bytes.readUInt32BE(offset);
|
|
14
|
+
const type = bytes.toString("ascii", offset + 4, offset + 8);
|
|
15
|
+
if (offset + 12 + length > bytes.length) throw new Error("truncated PNG image");
|
|
16
|
+
if (offset === 8) {
|
|
17
|
+
if (type !== "IHDR" || length !== 13) throw new Error("invalid PNG header");
|
|
18
|
+
width = bytes.readUInt32BE(offset + 8);
|
|
19
|
+
height = bytes.readUInt32BE(offset + 12);
|
|
20
|
+
}
|
|
21
|
+
if (type === "acTL") throw new Error("animated PNG images are unsupported");
|
|
22
|
+
if (type === "IDAT") hasData = true;
|
|
23
|
+
offset += 12 + length;
|
|
24
|
+
if (type === "IEND") {
|
|
25
|
+
if (length !== 0 || !hasData || offset !== bytes.length) throw new Error("invalid PNG image end");
|
|
26
|
+
return { width, height };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
throw new Error("incomplete PNG image");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (bytes.length < 4 || bytes.readUInt16BE(0) !== 0xffd8 || bytes.readUInt16BE(bytes.length - 2) !== 0xffd9) {
|
|
33
|
+
throw new Error("image data is not a complete JPEG");
|
|
34
|
+
}
|
|
35
|
+
for (let offset = 2; offset + 4 <= bytes.length;) {
|
|
36
|
+
if (bytes[offset++] !== 0xff) throw new Error("invalid JPEG marker");
|
|
37
|
+
while (bytes[offset] === 0xff) offset += 1;
|
|
38
|
+
const marker = bytes[offset++];
|
|
39
|
+
if (marker === 0xda || marker === 0xd9) break;
|
|
40
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
41
|
+
if (offset + 2 > bytes.length) break;
|
|
42
|
+
const length = bytes.readUInt16BE(offset);
|
|
43
|
+
if (length < 2 || offset + length > bytes.length) throw new Error("truncated JPEG image");
|
|
44
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
|
45
|
+
if (length < 8) throw new Error("invalid JPEG frame");
|
|
46
|
+
return { width: bytes.readUInt16BE(offset + 5), height: bytes.readUInt16BE(offset + 3) };
|
|
47
|
+
}
|
|
48
|
+
offset += length;
|
|
49
|
+
}
|
|
50
|
+
throw new Error("JPEG image has no frame dimensions");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Call after normalizeImageAssets when grounding coordinates in supplied pixels.
|
|
54
|
+
export function imageDimensions(asset) {
|
|
55
|
+
return imageSize(Buffer.from(asset.data_base64, "base64"), asset.format);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function normalizeImageAssets(assets) {
|
|
59
|
+
if (assets === undefined) return {};
|
|
60
|
+
if (!assets || typeof assets !== "object" || Array.isArray(assets)
|
|
61
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(assets))) {
|
|
62
|
+
throw new Error("assets must be an image reference map");
|
|
63
|
+
}
|
|
64
|
+
let referenceBytes = 0;
|
|
65
|
+
let bodyBytes = 2;
|
|
66
|
+
const normalized = [];
|
|
67
|
+
for (const [reference, asset] of Object.entries(assets)) {
|
|
68
|
+
const length = Buffer.byteLength(reference);
|
|
69
|
+
referenceBytes += length;
|
|
70
|
+
if (!reference.trim() || length > 1024 || referenceBytes > 65_536) {
|
|
71
|
+
throw new Error("image references must be nonblank, at most 1024 UTF-8 bytes each and 65536 bytes total");
|
|
72
|
+
}
|
|
73
|
+
if (!asset || typeof asset !== "object" || Array.isArray(asset)
|
|
74
|
+
|| Object.keys(asset).some((key) => !["type", "format", "data_base64"].includes(key))
|
|
75
|
+
|| (asset.type !== undefined && asset.type !== "image") || !["png", "jpg", "jpeg"].includes(asset.format)) {
|
|
76
|
+
throw new Error("assets accept only base64 PNG/JPG/JPEG images");
|
|
77
|
+
}
|
|
78
|
+
const data = asset.data_base64;
|
|
79
|
+
if (typeof data !== "string" || !data.length || data.length % 4 !== 0
|
|
80
|
+
|| data.length > 4 * Math.ceil(MAX_IMAGE_BYTES / 3)) {
|
|
81
|
+
throw new Error("image base64 is invalid or exceeds 16 MiB decoded");
|
|
82
|
+
}
|
|
83
|
+
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
84
|
+
if (data.length / 4 * 3 - padding > MAX_IMAGE_BYTES) throw new Error("image exceeds 16 MiB decoded");
|
|
85
|
+
if (/[^A-Za-z0-9+/]/.test(data.slice(0, data.length - padding))) throw new Error("invalid image base64");
|
|
86
|
+
const bytes = Buffer.from(data, "base64");
|
|
87
|
+
if (bytes.toString("base64") !== data) throw new Error("invalid image base64");
|
|
88
|
+
const { width, height } = imageSize(bytes, asset.format);
|
|
89
|
+
if (width < 256 || height < 256 || width > 1568 || height > 1568 || width * height < 78_400) {
|
|
90
|
+
throw new Error("image dimensions must be 256..1568 pixels per side and at least 78400 pixels total");
|
|
91
|
+
}
|
|
92
|
+
const value = { type: "image", format: asset.format, data_base64: data };
|
|
93
|
+
bodyBytes += Buffer.byteLength(JSON.stringify(reference)) + Buffer.byteLength(JSON.stringify(value)) + 1 + (normalized.length ? 1 : 0);
|
|
94
|
+
if (bodyBytes > MAX_BODY_BYTES) throw new Error("image assets exceed the 32 MiB request body limit");
|
|
95
|
+
normalized.push([reference, value]);
|
|
96
|
+
}
|
|
97
|
+
return Object.fromEntries(normalized);
|
|
98
|
+
}
|