jev-layer 0.1.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +73 -34
  2. package/CONTRIBUTING.md +146 -146
  3. package/LICENSE +21 -21
  4. package/README.md +125 -126
  5. package/README.ru.md +140 -117
  6. package/README.zh-CN.md +139 -117
  7. package/RELEASE.md +53 -53
  8. package/SECURITY.md +64 -56
  9. package/bin/jev.mjs +215 -213
  10. package/config/codex.mcp.toml +8 -8
  11. package/config/generic-mcp.json +13 -13
  12. package/config/hermes.mcp.yaml +9 -9
  13. package/config/jev.example.json +23 -22
  14. package/config/omp.mcp.json +14 -14
  15. package/config/providers.env.example +13 -13
  16. package/docs/AGENT-IMPLEMENTATION.md +306 -0
  17. package/docs/COMPATIBILITY.md +24 -15
  18. package/docs/HERMES-LOCAL-AGENT-HANDOFF.ru.md +187 -0
  19. package/docs/PROVIDERS.md +109 -0
  20. package/docs/SCHEMA-VERSIONING.md +101 -94
  21. package/docs/architecture.svg +77 -0
  22. package/docs/jev-layer-banner.svg +38 -0
  23. package/examples/capabilities.json +39 -39
  24. package/examples/route-request.json +28 -28
  25. package/integrations/codex/.codex-plugin/plugin.json +19 -19
  26. package/integrations/codex/.mcp.json +11 -11
  27. package/integrations/codex/AGENTS.md +1 -1
  28. package/integrations/codex/run-mcp.mjs +9 -9
  29. package/integrations/codex/skills/jev-route/SKILL.md +17 -17
  30. package/integrations/hermes/__init__.py +156 -51
  31. package/integrations/hermes/plugin.yaml +11 -5
  32. package/integrations/hermes/schemas.py +120 -12
  33. package/integrations/omp/extension.js +106 -105
  34. package/integrations/template/README.md +10 -10
  35. package/integrations/template/adapter.mjs +87 -87
  36. package/package.json +60 -59
  37. package/scripts/benchmark.mjs +35 -35
  38. package/scripts/browser-benchmark.mjs +92 -92
  39. package/scripts/browser-e2e.mjs +117 -117
  40. package/scripts/capability-e2e.mjs +31 -31
  41. package/scripts/clean-install-smoke.mjs +176 -162
  42. package/scripts/codex-mcp-smoke.mjs +119 -119
  43. package/scripts/context-filter-e2e.mjs +45 -45
  44. package/scripts/fail-open-smoke.mjs +120 -120
  45. package/scripts/feature-flags-smoke.mjs +46 -46
  46. package/scripts/mcp-receipt-smoke.mjs +115 -98
  47. package/scripts/model-route-report.mjs +15 -0
  48. package/scripts/openrouter-choice.mjs +59 -59
  49. package/scripts/replay-eval.mjs +124 -124
  50. package/scripts/smoke.mjs +34 -34
  51. package/scripts/supervision-e2e.mjs +138 -109
  52. package/src/browser.mjs +583 -569
  53. package/src/cli.mjs +33 -33
  54. package/src/computer.mjs +196 -0
  55. package/src/config.mjs +86 -75
  56. package/src/context-filter.mjs +56 -56
  57. package/src/contract.mjs +74 -72
  58. package/src/discovery.mjs +65 -65
  59. package/src/hermes-adapter.mjs +134 -0
  60. package/src/images.mjs +98 -0
  61. package/src/mcp-server.mjs +313 -210
  62. package/src/model-route-metrics.mjs +89 -0
  63. package/src/model-routing.mjs +87 -0
  64. package/src/providers/demo.mjs +52 -52
  65. package/src/providers/index.mjs +12 -0
  66. package/src/providers/typesafe.mjs +206 -126
  67. package/src/receipts.mjs +228 -226
  68. package/src/registry.mjs +109 -109
  69. package/src/relevance-filter.mjs +99 -99
  70. package/src/route.mjs +228 -221
  71. package/src/shadow-compaction.mjs +170 -0
  72. package/src/supervision.mjs +268 -244
  73. package/test/browser.test.mjs +222 -199
  74. package/test/capability.test.mjs +54 -54
  75. package/test/computer.test.mjs +195 -0
  76. package/test/context-filter.test.mjs +65 -65
  77. package/test/hermes-adapter.test.mjs +63 -0
  78. package/test/image-fixture.mjs +2 -0
  79. package/test/images.test.mjs +85 -0
  80. package/test/model-route-metrics.test.mjs +61 -0
  81. package/test/model-routing.test.mjs +59 -0
  82. package/test/openrouter-provider.test.mjs +55 -55
  83. package/test/provider-transports.test.mjs +205 -0
  84. package/test/providers.test.mjs +125 -0
  85. package/test/receipts.test.mjs +71 -71
  86. package/test/route.test.mjs +86 -74
  87. package/test/shadow-compaction.test.mjs +67 -0
  88. package/test/supervision.test.mjs +117 -99
package/src/contract.mjs CHANGED
@@ -1,72 +1,74 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
-
3
- export const RISK_ORDER = Object.freeze({ low: 0, medium: 1, high: 2, critical: 3 });
4
- export const CAPABILITY_KINDS = new Set(["skill", "tool", "mcp", "cli", "dsh", "subagent", "model"]);
5
-
6
- export function stableJson(value) {
7
- return JSON.stringify(sortJson(value));
8
- }
9
-
10
- function sortJson(value) {
11
- if (Array.isArray(value)) return value.map(sortJson);
12
- if (value && typeof value === "object") {
13
- return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])]));
14
- }
15
- return value;
16
- }
17
-
18
- export function sha256(value) {
19
- return `sha256:${createHash("sha256").update(typeof value === "string" ? value : stableJson(value)).digest("hex")}`;
20
- }
21
-
22
- export function byteLength(value) {
23
- return Buffer.byteLength(typeof value === "string" ? value : stableJson(value), "utf8");
24
- }
25
-
26
- export function normalizeRequest(input) {
27
- if (!input || typeof input !== "object" || Array.isArray(input)) {
28
- throw new TypeError("route request must be an object");
29
- }
30
- const intent = typeof input.intent === "string" ? input.intent : input.request;
31
- if (typeof intent !== "string" || intent.trim() === "") {
32
- throw new TypeError("route request requires a non-empty intent");
33
- }
34
- if (!Array.isArray(input.capabilities)) {
35
- throw new TypeError("route request requires capabilities[]");
36
- }
37
- return {
38
- schema_version: input.schema_version ?? 1,
39
- harness: typeof input.harness === "string" ? input.harness : "unknown",
40
- intent: intent.trim(),
41
- context: input.context && typeof input.context === "object" ? input.context : {},
42
- actor: typeof input.actor === "string" ? input.actor : undefined,
43
- actor_permissions: Array.isArray(input.actor_permissions) ? input.actor_permissions.filter((value) => typeof value === "string") : undefined,
44
- capabilities: input.capabilities,
45
- policy: input.policy && typeof input.policy === "object" ? input.policy : {},
46
- };
47
- }
48
-
49
- export function decisionEnvelope({ status, selected, reason, provider, latencyMs, candidateCount, contextBytes, candidates = [], probabilities = {}, confidence = null, rawJev = null, fallback = null, requestId = randomUUID(), correlationId = requestId, costUsd = rawJev?.usage?.cost ?? null }) {
50
- return {
51
- schema_version: 1,
52
- correlation_id: correlationId,
53
- status,
54
- selected: selected ?? null,
55
- confidence,
56
- probabilities,
57
- reason,
58
- candidates,
59
- fallback,
60
- execution: { enabled: false, status: "not_started" },
61
- raw_jev: rawJev,
62
- receipt: {
63
- correlation_id: correlationId,
64
- request_id: requestId,
65
- provider,
66
- latency_ms: latencyMs,
67
- cost_usd: costUsd,
68
- candidate_count: candidateCount,
69
- context_bytes: contextBytes,
70
- },
71
- };
72
- }
1
+ import { createHash, randomUUID } from "node:crypto";
2
+
3
+ export const RISK_ORDER = Object.freeze({ low: 0, medium: 1, high: 2, critical: 3 });
4
+ export const CAPABILITY_KINDS = new Set(["skill", "tool", "mcp", "cli", "dsh", "subagent", "model"]);
5
+
6
+ export function stableJson(value) {
7
+ return JSON.stringify(sortJson(value));
8
+ }
9
+
10
+ function sortJson(value) {
11
+ if (Array.isArray(value)) return value.map(sortJson);
12
+ if (value && typeof value === "object") {
13
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])]));
14
+ }
15
+ return value;
16
+ }
17
+
18
+ export function sha256(value) {
19
+ return `sha256:${createHash("sha256").update(typeof value === "string" ? value : stableJson(value)).digest("hex")}`;
20
+ }
21
+
22
+ export function byteLength(value) {
23
+ return Buffer.byteLength(typeof value === "string" ? value : stableJson(value), "utf8");
24
+ }
25
+
26
+ export function normalizeRequest(input) {
27
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
28
+ throw new TypeError("route request must be an object");
29
+ }
30
+ const intent = typeof input.intent === "string" ? input.intent : input.request;
31
+ if (typeof intent !== "string" || intent.trim() === "") {
32
+ throw new TypeError("route request requires a non-empty intent");
33
+ }
34
+ if (!Array.isArray(input.capabilities)) {
35
+ throw new TypeError("route request requires capabilities[]");
36
+ }
37
+ return {
38
+ schema_version: input.schema_version ?? 1,
39
+ harness: typeof input.harness === "string" ? input.harness : "unknown",
40
+ intent: intent.trim(),
41
+ context: input.context && typeof input.context === "object" ? input.context : {},
42
+ assets: input.assets,
43
+ assets_omitted: input.assets_omitted === true,
44
+ actor: typeof input.actor === "string" ? input.actor : undefined,
45
+ actor_permissions: Array.isArray(input.actor_permissions) ? input.actor_permissions.filter((value) => typeof value === "string") : undefined,
46
+ capabilities: input.capabilities,
47
+ policy: input.policy && typeof input.policy === "object" ? input.policy : {},
48
+ };
49
+ }
50
+
51
+ export function decisionEnvelope({ status, selected, reason, provider, latencyMs, candidateCount, contextBytes, candidates = [], probabilities = {}, confidence = null, rawJev = null, fallback = null, requestId = randomUUID(), correlationId = requestId, costUsd = rawJev?.usage?.cost ?? null }) {
52
+ return {
53
+ schema_version: 1,
54
+ correlation_id: correlationId,
55
+ status,
56
+ selected: selected ?? null,
57
+ confidence,
58
+ probabilities,
59
+ reason,
60
+ candidates,
61
+ fallback,
62
+ execution: { enabled: false, status: "not_started" },
63
+ raw_jev: rawJev,
64
+ receipt: {
65
+ correlation_id: correlationId,
66
+ request_id: requestId,
67
+ provider,
68
+ latency_ms: latencyMs,
69
+ cost_usd: costUsd,
70
+ candidate_count: candidateCount,
71
+ context_bytes: contextBytes,
72
+ },
73
+ };
74
+ }
package/src/discovery.mjs CHANGED
@@ -1,65 +1,65 @@
1
- import { readFile } from "node:fs/promises";
2
-
3
- const DISCOVERY_SOURCES = Object.freeze([
4
- ["skills", "skill"],
5
- ["mcp", "mcp"],
6
- ["cli", "cli"],
7
- ["dsh", "dsh"],
8
- ["tools", "tool"],
9
- ["subagents", "subagent"],
10
- ["models", "model"],
11
- ]);
12
-
13
- export function discoverCapabilities(input = {}) {
14
- const entries = [];
15
- for (const [field, kind] of DISCOVERY_SOURCES) {
16
- const values = Array.isArray(input[field]) ? input[field] : [];
17
- for (const value of values) entries.push(normalizeDiscovered(value, kind, input.source ?? `${kind}-discovery`));
18
- }
19
- const manifests = Array.isArray(input.manifests) ? input.manifests : [];
20
- for (const manifest of manifests) {
21
- const values = Array.isArray(manifest?.capabilities) ? manifest.capabilities : [manifest];
22
- for (const value of values) {
23
- const kind = value?.kind ?? value?.type ?? manifest?.kind ?? "tool";
24
- entries.push(normalizeDiscovered(value, kind, value?.source ?? manifest?.source ?? "manifest"));
25
- }
26
- }
27
- const seen = new Set();
28
- return entries.filter((entry) => {
29
- if (seen.has(entry.id)) return false;
30
- seen.add(entry.id);
31
- return true;
32
- });
33
- }
34
-
35
- export async function loadCapabilityManifest(path, options = {}) {
36
- const parsed = JSON.parse(await readFile(path, "utf8"));
37
- const manifest = Array.isArray(parsed) ? { capabilities: parsed } : parsed;
38
- return discoverCapabilities({ manifests: [{ ...manifest, source: options.source ?? manifest.source ?? "manifest" }] });
39
- }
40
-
41
- function normalizeDiscovered(value, kind, source) {
42
- if (!value || typeof value !== "object") throw new TypeError("discovered capability must be an object");
43
- const id = stringValue(value.id ?? value.name, `${kind}-capability`);
44
- const name = stringValue(value.name ?? value.id, id);
45
- const description = stringValue(value.description ?? value.summary, `${kind} capability ${name}`);
46
- const metadata = {
47
- ...(value.metadata && typeof value.metadata === "object" ? value.metadata : {}),
48
- [kind]: stringValue(value.target ?? value.command ?? value.name, name),
49
- };
50
- return {
51
- ...value,
52
- id,
53
- kind,
54
- name,
55
- description,
56
- source: stringValue(value.source, source),
57
- verified: value.verified === true,
58
- metadata,
59
- available: value.available !== false && value.availability?.available !== false,
60
- };
61
- }
62
-
63
- function stringValue(value, fallback) {
64
- return typeof value === "string" && value.trim() ? value.trim() : fallback;
65
- }
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ const DISCOVERY_SOURCES = Object.freeze([
4
+ ["skills", "skill"],
5
+ ["mcp", "mcp"],
6
+ ["cli", "cli"],
7
+ ["dsh", "dsh"],
8
+ ["tools", "tool"],
9
+ ["subagents", "subagent"],
10
+ ["models", "model"],
11
+ ]);
12
+
13
+ export function discoverCapabilities(input = {}) {
14
+ const entries = [];
15
+ for (const [field, kind] of DISCOVERY_SOURCES) {
16
+ const values = Array.isArray(input[field]) ? input[field] : [];
17
+ for (const value of values) entries.push(normalizeDiscovered(value, kind, input.source ?? `${kind}-discovery`));
18
+ }
19
+ const manifests = Array.isArray(input.manifests) ? input.manifests : [];
20
+ for (const manifest of manifests) {
21
+ const values = Array.isArray(manifest?.capabilities) ? manifest.capabilities : [manifest];
22
+ for (const value of values) {
23
+ const kind = value?.kind ?? value?.type ?? manifest?.kind ?? "tool";
24
+ entries.push(normalizeDiscovered(value, kind, value?.source ?? manifest?.source ?? "manifest"));
25
+ }
26
+ }
27
+ const seen = new Set();
28
+ return entries.filter((entry) => {
29
+ if (seen.has(entry.id)) return false;
30
+ seen.add(entry.id);
31
+ return true;
32
+ });
33
+ }
34
+
35
+ export async function loadCapabilityManifest(path, options = {}) {
36
+ const parsed = JSON.parse(await readFile(path, "utf8"));
37
+ const manifest = Array.isArray(parsed) ? { capabilities: parsed } : parsed;
38
+ return discoverCapabilities({ manifests: [{ ...manifest, source: options.source ?? manifest.source ?? "manifest" }] });
39
+ }
40
+
41
+ function normalizeDiscovered(value, kind, source) {
42
+ if (!value || typeof value !== "object") throw new TypeError("discovered capability must be an object");
43
+ const id = stringValue(value.id ?? value.name, `${kind}-capability`);
44
+ const name = stringValue(value.name ?? value.id, id);
45
+ const description = stringValue(value.description ?? value.summary, `${kind} capability ${name}`);
46
+ const metadata = {
47
+ ...(value.metadata && typeof value.metadata === "object" ? value.metadata : {}),
48
+ [kind]: stringValue(value.target ?? value.command ?? value.name, name),
49
+ };
50
+ return {
51
+ ...value,
52
+ id,
53
+ kind,
54
+ name,
55
+ description,
56
+ source: stringValue(value.source, source),
57
+ verified: value.verified === true,
58
+ metadata,
59
+ available: value.available !== false && value.availability?.available !== false,
60
+ };
61
+ }
62
+
63
+ function stringValue(value, fallback) {
64
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
65
+ }
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One-shot transport used by the Hermes plugin.
4
+ *
5
+ * This is deliberately an adapter, not an executor: it accepts a closed host
6
+ * request and returns bounded Jev decisions/receipts. Native Hermes retains
7
+ * registry lookup, approvals, execution, retries, recovery, and final output.
8
+ */
9
+ import { createInterface } from "node:readline";
10
+ import { decideBrowserStep } from "./browser.mjs";
11
+ import { decideComputerStep } from "./computer.mjs";
12
+ import { configuredProvider, configuredReplayPath, loadConfig } from "./config.mjs";
13
+ import { appendExecutionReceipt, appendRoutingCase, buildExecutionReceipt, replayCasePath } from "./receipts.mjs";
14
+ import { routeRequest } from "./route.mjs";
15
+ import { superviseWork } from "./supervision.mjs";
16
+ import { buildShadowCompactionReport } from "./shadow-compaction.mjs";
17
+ import { recommendModelRoute } from "./model-routing.mjs";
18
+
19
+ const { config } = await loadConfig();
20
+ const casesPath = replayCasePath(configuredReplayPath(config));
21
+ const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
22
+
23
+ for await (const line of input) {
24
+ if (!line.trim()) continue;
25
+ try {
26
+ const envelope = JSON.parse(line);
27
+ const result = await handle(envelope);
28
+ process.stdout.write(`${JSON.stringify(result)}\n`);
29
+ } catch (error) {
30
+ process.stdout.write(`${JSON.stringify(fallback(error))}\n`);
31
+ }
32
+ }
33
+
34
+ async function handle({ operation, args = {}, decision = null } = {}) {
35
+ const provider = configuredProvider(config, args.provider);
36
+ if (operation === "route") {
37
+ const { provider: _provider, engine = "native", ...request } = args;
38
+ const result = await routeRequest(request, {
39
+ provider,
40
+ config,
41
+ engine,
42
+ contextFilterMode: request.policy?.context_filter_mode ?? config.features?.context_filter,
43
+ });
44
+ await persistRoutingCase(request, result);
45
+ return result;
46
+ }
47
+ if (operation === "browser_step") {
48
+ const { provider: _provider, enabled, ...input } = args;
49
+ const routed = await decideBrowserStep(input, {
50
+ enabled: enabled ?? config.features?.browser_fast_path,
51
+ provider,
52
+ config,
53
+ });
54
+ routed.decision.browser_action = routed.action
55
+ ? { ...routed.action, target_id: routed.action.target_id ?? null, option_id: routed.action.option_id ?? null }
56
+ : null;
57
+ await persistRoutingCase(routed.request, routed.decision);
58
+ return routed.decision;
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
+ }
69
+ if (operation === "supervise") {
70
+ const { provider: _provider, enabled, ...input } = args;
71
+ return superviseWork({
72
+ ...input,
73
+ config,
74
+ enabled: enabled ?? config.features?.supervision,
75
+ provider,
76
+ receiptPath: casesPath,
77
+ });
78
+ }
79
+ if (operation === "model_route") {
80
+ const { provider: _provider, ...input } = args;
81
+ const result = await recommendModelRoute({ ...input, provider, config });
82
+ await persistRoutingCase({
83
+ schema_version: 1,
84
+ harness: input.harness ?? "hermes",
85
+ intent: input.intent,
86
+ context: { ...(input.context ?? {}), model_route: { mode: "shadow" } },
87
+ capabilities: (input.models ?? []).map((profile) => ({
88
+ id: profile.id,
89
+ kind: "model",
90
+ name: profile.model ?? profile.id,
91
+ description: profile.description ?? "",
92
+ source: profile.provider ?? null,
93
+ risk: "low",
94
+ })),
95
+ policy: input.policy ?? {},
96
+ }, result);
97
+ return result;
98
+ }
99
+ if (operation === "shadow_compaction") {
100
+ const { provider: _provider, ...input } = args;
101
+ return buildShadowCompactionReport({ ...input, provider, config });
102
+ }
103
+ if (operation === "record_execution") {
104
+ if (!decision || typeof decision !== "object") throw new TypeError("decision is required for record_execution");
105
+ if (args.correlation_id !== decision.correlation_id) throw new TypeError("correlation_id does not match the routed decision");
106
+ try {
107
+ const { record } = await appendExecutionReceipt({ path: casesPath, request: decision._jev_request ?? {}, decision, host: args });
108
+ return record;
109
+ } catch (error) {
110
+ const record = buildExecutionReceipt({ request: decision._jev_request ?? {}, decision, host: args });
111
+ return { ...record, persistence: { persisted: false, error: error instanceof Error ? error.message : String(error) } };
112
+ }
113
+ }
114
+ throw new TypeError(`unsupported Hermes adapter operation: ${operation}`);
115
+ }
116
+
117
+ async function persistRoutingCase(request, decision) {
118
+ const { assets, ...retainedRequest } = request;
119
+ if (Object.keys(assets ?? {}).length) retainedRequest.assets_omitted = true;
120
+ decision._jev_request = retainedRequest;
121
+ try {
122
+ await appendRoutingCase({ path: casesPath, request, decision });
123
+ decision.receipt = { ...decision.receipt, replay_case_id: decision.correlation_id, replay_persisted: true };
124
+ } catch (error) {
125
+ decision.receipt = { ...decision.receipt, replay_persisted: false,
126
+ replay_error: error instanceof Error ? error.message : String(error) };
127
+ }
128
+ }
129
+
130
+ function fallback(error) {
131
+ const reason = error instanceof Error ? error.message : String(error);
132
+ return { schema_version: 1, status: "fallback", selected: null, reason,
133
+ fallback: { type: "adapter_error", reason }, execution: { enabled: false, status: "not_started" } };
134
+ }
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
+ }