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.
@@ -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 execFileAsync("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: packageRoot, env: process.env });
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 execFileAsync("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--no-save", packageRoot], { cwd: workspace, env: process.env });
21
- await runNode(packageRoot, ["bin/jev.mjs", "install", "--project", workspace]);
22
- await runNode(packageRoot, ["bin/jev.mjs", "add", "generic", "--project", workspace]);
23
- const doctor = await runNode(packageRoot, ["bin/jev.mjs", "doctor", "--project", workspace]);
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(packageRoot, "examples", "route-request.json")]);
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
- assert.deepEqual(genericServer.args, ["mcp"]);
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(packageRoot, "package.json"), "utf8");
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(packageRoot, ["scripts/replay-eval.mjs", "--provider", "demo", "--input", join(workspace, ".jev", "replay", "cases.jsonl"), "--limit", "1"]);
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 = resolve(new URL("..", import.meta.url).pathname);
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 new Promise((resolveExit) => child.once("close", resolveExit));
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.13";
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
- observation = normalizeObservation(await executor.observe());
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 nextObservation = normalizeObservation(result?.observation ?? await executor.observe());
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
 
@@ -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
@@ -7,6 +7,7 @@ export const DEFAULT_CONFIG = Object.freeze({
7
7
  replay_cases: ".jev/replay/cases.jsonl",
8
8
  features: {
9
9
  browser_fast_path: false,
10
+ computer_use: false,
10
11
  supervision: false,
11
12
  context_filter: null,
12
13
  },
@@ -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,
@@ -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
- ? { id: routed.action.id, operation: routed.action.operation, target_id: routed.action.target_id ?? null,
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
- decision._jev_request = request;
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
+ }