opencode-agent-skill 10.0.0 → 11.0.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 (52) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +49 -3
  3. package/bin/ocskill.mjs +330 -5
  4. package/docs/V11-PERCEPTION-ADAPTIVE-EXECUTION.md +75 -0
  5. package/docs/V11-PERCEPTION-ADAPTIVE.md +220 -0
  6. package/evals/router-triggers.json +82 -0
  7. package/evals/routing.json +76 -0
  8. package/evals/v11/tasks.json +122 -0
  9. package/global-config/agents/merge-arbiter.md +12 -0
  10. package/global-config/agents/visual-verifier.md +12 -0
  11. package/global-config/plugins/ues-router/index.js +272 -2
  12. package/global-config/plugins/ues-router/router.js +27 -3
  13. package/global-config/skills/browser-qa/SKILL.md +14 -0
  14. package/global-config/skills/browser-qa/references/workflow.md +11 -0
  15. package/global-config/skills/browser-security/SKILL.md +12 -0
  16. package/global-config/skills/component-visual-testing/SKILL.md +10 -0
  17. package/global-config/skills/design-source/SKILL.md +10 -0
  18. package/global-config/skills/design-source/references/workflow.md +12 -0
  19. package/global-config/skills/dynamic-workflow/SKILL.md +18 -0
  20. package/global-config/skills/dynamic-workflow/references/workflow.md +19 -0
  21. package/global-config/skills/responsive-verification/SKILL.md +10 -0
  22. package/global-config/skills/skill-authoring/SKILL.md +12 -0
  23. package/global-config/skills/skill-evaluation/SKILL.md +17 -0
  24. package/global-config/skills/visual-fidelity/SKILL.md +14 -0
  25. package/global-config/skills/visual-fidelity/references/workflow.md +14 -0
  26. package/lib/browser-adapter.mjs +82 -0
  27. package/lib/browser-runtime.mjs +193 -0
  28. package/lib/capability-registry.mjs +109 -0
  29. package/lib/context-engine-v11.mjs +146 -0
  30. package/lib/context-manifest.mjs +16 -3
  31. package/lib/control-center.mjs +12 -2
  32. package/lib/dynamic-workflow.mjs +179 -0
  33. package/lib/eval-ablation.mjs +43 -1
  34. package/lib/eval-report.mjs +72 -0
  35. package/lib/eval-telemetry.mjs +61 -0
  36. package/lib/evidence-budget.mjs +84 -0
  37. package/lib/evidence-store.mjs +178 -0
  38. package/lib/hermes-bridge.mjs +45 -1
  39. package/lib/model-config.mjs +9 -1
  40. package/lib/model-policy.mjs +52 -1
  41. package/lib/orchestrator-policy.mjs +1 -1
  42. package/lib/png-diff.mjs +229 -0
  43. package/lib/prompt-cache.mjs +60 -0
  44. package/lib/skill-quality.mjs +72 -0
  45. package/lib/task-engine.mjs +78 -4
  46. package/lib/ui-inspector.mjs +152 -0
  47. package/lib/v11-metrics.mjs +64 -0
  48. package/lib/visual-spec.mjs +159 -0
  49. package/package.json +10 -5
  50. package/scripts/eval-ablation.mjs +4 -1
  51. package/scripts/validate-v11-suite.mjs +58 -0
  52. package/scripts/validate.mjs +16 -4
@@ -0,0 +1,178 @@
1
+ import { createHash } from "node:crypto"
2
+ import { existsSync } from "node:fs"
3
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"
4
+ import path from "node:path"
5
+
6
+ const STORE_VERSION = 1
7
+ const REF_PREFIX = "evidence:sha256:"
8
+
9
+ function stableValue(value) {
10
+ if (Array.isArray(value)) return value.map(stableValue)
11
+ if (!value || typeof value !== "object" || Buffer.isBuffer(value)) return value
12
+ return Object.fromEntries(
13
+ Object.keys(value).sort().map((key) => [key, stableValue(value[key])]),
14
+ )
15
+ }
16
+
17
+ function toBytes(value, options = {}) {
18
+ if (Buffer.isBuffer(value)) return { bytes: value, encoding: "binary", mediaType: options.mediaType || "application/octet-stream" }
19
+ if (typeof value === "string") return { bytes: Buffer.from(value, "utf8"), encoding: "utf8", mediaType: options.mediaType || "text/plain; charset=utf-8" }
20
+ const text = JSON.stringify(stableValue(value), null, options.pretty === false ? 0 : 2)
21
+ return { bytes: Buffer.from(text, "utf8"), encoding: "utf8", mediaType: options.mediaType || "application/json" }
22
+ }
23
+
24
+ function normalizeHash(ref) {
25
+ const value = String(ref || "").trim()
26
+ const hash = value.startsWith(REF_PREFIX) ? value.slice(REF_PREFIX.length) : value.replace(/^sha256:/, "")
27
+ if (!/^[a-f0-9]{64}$/i.test(hash)) throw new Error("Invalid evidence reference")
28
+ return hash.toLowerCase()
29
+ }
30
+
31
+ export function evidenceReference(hash) {
32
+ return REF_PREFIX + normalizeHash(hash)
33
+ }
34
+
35
+ export function evidenceStoreRoot(root = process.cwd()) {
36
+ return path.join(path.resolve(root), ".ues-cache", "evidence-v1")
37
+ }
38
+
39
+ function evidencePaths(root, hash) {
40
+ const normalized = normalizeHash(hash)
41
+ const dir = path.join(evidenceStoreRoot(root), normalized.slice(0, 2))
42
+ return {
43
+ dir,
44
+ meta: path.join(dir, normalized + ".json"),
45
+ data: path.join(dir, normalized + ".blob"),
46
+ }
47
+ }
48
+
49
+ export async function putEvidence(root, value, options = {}) {
50
+ root = path.resolve(root)
51
+ const encoded = toBytes(value, options)
52
+ const hash = createHash("sha256").update(encoded.bytes).digest("hex")
53
+ const target = evidencePaths(root, hash)
54
+ await mkdir(target.dir, { recursive: true })
55
+
56
+ const now = new Date().toISOString()
57
+ const existing = existsSync(target.meta)
58
+ ? JSON.parse(await readFile(target.meta, "utf8").catch(() => "{}"))
59
+ : null
60
+
61
+ if (!existsSync(target.data)) await writeFile(target.data, encoded.bytes)
62
+
63
+ const preview = encoded.encoding === "utf8"
64
+ ? encoded.bytes.toString("utf8", 0, Math.min(encoded.bytes.length, 600))
65
+ : null
66
+
67
+ const metadata = {
68
+ schemaVersion: STORE_VERSION,
69
+ ref: evidenceReference(hash),
70
+ sha256: hash,
71
+ bytes: encoded.bytes.length,
72
+ encoding: encoded.encoding,
73
+ mediaType: encoded.mediaType,
74
+ kind: options.kind || existing?.kind || "tool-output",
75
+ source: options.source || existing?.source || null,
76
+ summary: options.summary || existing?.summary || null,
77
+ createdAt: existing?.createdAt || now,
78
+ lastSeenAt: now,
79
+ preview,
80
+ }
81
+ await writeFile(target.meta, JSON.stringify(metadata, null, 2) + "\n", "utf8")
82
+ return metadata
83
+ }
84
+
85
+ export async function getEvidence(root, ref, options = {}) {
86
+ const hash = normalizeHash(ref)
87
+ const target = evidencePaths(root, hash)
88
+ const metadata = JSON.parse(await readFile(target.meta, "utf8"))
89
+ const bytes = await readFile(target.data)
90
+ const start = Math.max(0, Number(options.start || 0))
91
+ const maxBytes = Math.max(1, Number(options.maxBytes || options.maxChars || 24_000))
92
+ const slice = bytes.subarray(start, Math.min(bytes.length, start + maxBytes))
93
+ return {
94
+ ...metadata,
95
+ truncated: start + slice.length < bytes.length,
96
+ start,
97
+ returnedBytes: slice.length,
98
+ content: metadata.encoding === "utf8" ? slice.toString("utf8") : slice.toString("base64"),
99
+ }
100
+ }
101
+
102
+ async function metadataFiles(root) {
103
+ const base = evidenceStoreRoot(root)
104
+ const prefixes = await readdir(base, { withFileTypes: true }).catch(() => [])
105
+ const files = []
106
+ for (const prefix of prefixes) {
107
+ if (!prefix.isDirectory()) continue
108
+ const dir = path.join(base, prefix.name)
109
+ for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
110
+ if (entry.isFile() && entry.name.endsWith(".json")) files.push(path.join(dir, entry.name))
111
+ }
112
+ }
113
+ return files
114
+ }
115
+
116
+ export async function evidenceStoreStatus(root = process.cwd()) {
117
+ const files = await metadataFiles(root)
118
+ let bytes = 0
119
+ let oldest = null
120
+ let newest = null
121
+ for (const file of files) {
122
+ const meta = JSON.parse(await readFile(file, "utf8").catch(() => "{}"))
123
+ bytes += Number(meta.bytes || 0)
124
+ if (meta.createdAt && (!oldest || meta.createdAt < oldest)) oldest = meta.createdAt
125
+ if (meta.lastSeenAt && (!newest || meta.lastSeenAt > newest)) newest = meta.lastSeenAt
126
+ }
127
+ return {
128
+ schemaVersion: STORE_VERSION,
129
+ root: evidenceStoreRoot(root),
130
+ entries: files.length,
131
+ bytes,
132
+ oldest,
133
+ newest,
134
+ }
135
+ }
136
+
137
+ export async function gcEvidenceStore(root = process.cwd(), options = {}) {
138
+ const files = await metadataFiles(root)
139
+ const maxEntries = Math.max(10, Number(options.maxEntries || 2_000))
140
+ const maxAgeMs = Math.max(0, Number(options.maxAgeDays ?? 30)) * 86_400_000
141
+ const now = Date.now()
142
+ const rows = []
143
+ for (const metaFile of files) {
144
+ const meta = JSON.parse(await readFile(metaFile, "utf8").catch(() => "{}"))
145
+ const time = Date.parse(meta.lastSeenAt || meta.createdAt || 0) || 0
146
+ rows.push({ metaFile, meta, time })
147
+ }
148
+ rows.sort((a, b) => b.time - a.time)
149
+
150
+ const removed = []
151
+ for (let index = 0; index < rows.length; index += 1) {
152
+ const row = rows[index]
153
+ const tooMany = index >= maxEntries
154
+ const tooOld = maxAgeMs > 0 && row.time > 0 && now - row.time > maxAgeMs
155
+ if (!tooMany && !tooOld) continue
156
+ const hash = row.meta.sha256 || path.basename(row.metaFile, ".json")
157
+ const target = evidencePaths(root, hash)
158
+ await rm(target.meta, { force: true })
159
+ await rm(target.data, { force: true })
160
+ removed.push(evidenceReference(hash))
161
+ }
162
+
163
+ return {
164
+ removed,
165
+ removedCount: removed.length,
166
+ status: await evidenceStoreStatus(root),
167
+ }
168
+ }
169
+
170
+ export async function evidenceExists(root, ref) {
171
+ try {
172
+ const target = evidencePaths(root, normalizeHash(ref))
173
+ const info = await stat(target.data)
174
+ return info.isFile()
175
+ } catch {
176
+ return false
177
+ }
178
+ }
@@ -1,11 +1,20 @@
1
1
  import { spawnSync } from "node:child_process"
2
+ import { resolveWindowsCommand } from "./windows-shim.mjs"
3
+
4
+ function runHermesVersion() {
5
+ if (process.platform !== "win32") return spawnSync("hermes", ["--version"], { encoding: "utf8" })
6
+ const resolved = resolveWindowsCommand("hermes")
7
+ if (!resolved) return { status: 127, stdout: "", stderr: "Hermes CLI not found" }
8
+ return spawnSync(resolved.executable, [...resolved.argsPrefix, "--version"], { encoding: "utf8" })
9
+ }
2
10
 
3
11
  export function hermesStatus() {
4
- const result = spawnSync("hermes", ["--version"], { encoding: "utf8", shell: process.platform === "win32" })
12
+ const result = runHermesVersion()
5
13
  return {
6
14
  available: result.status === 0,
7
15
  version: result.status === 0 ? String(result.stdout || result.stderr || "").trim() : null,
8
16
  error: result.status === 0 ? null : String(result.stderr || result.stdout || "Hermes CLI not found").trim(),
17
+ mode: "optional-sidecar",
9
18
  }
10
19
  }
11
20
 
@@ -15,11 +24,46 @@ export function buildHermesDelegationPrompt(contextPack) {
15
24
  "Implement exactly the approved task described below.",
16
25
  "Do not broaden scope, merge, push, publish, deploy, or alter durable UES state.",
17
26
  "Run the declared verification and return a concise structured report.",
27
+ "Large evidence is referenced by evidence:sha256 pointers; fetch only the slice required for the task.",
28
+ "",
29
+ JSON.stringify(contextPack, null, 2),
30
+ ].join("\n")
31
+ }
32
+
33
+ export function buildHermesWorkflowPrompt(contextPack, schedule) {
34
+ return [
35
+ "You are the optional Hermes sidecar for a UES V11 dynamic workflow.",
36
+ "Follow the supplied bounded wave schedule. Deterministic tasks are not delegated to LLM children.",
37
+ "Never exceed declared task ownership or concurrency. Persist outputs/evidence to files instead of conversational summaries.",
38
+ "Do not merge, push, publish, deploy, or mutate durable UES state except through explicitly supplied UES commands.",
39
+ "",
40
+ "SCHEDULE:",
41
+ JSON.stringify(schedule, null, 2),
18
42
  "",
43
+ "CONTEXT:",
19
44
  JSON.stringify(contextPack, null, 2),
20
45
  ].join("\n")
21
46
  }
22
47
 
48
+ export function hermesSidecarPlan(input = {}) {
49
+ return {
50
+ schemaVersion: 2,
51
+ adapter: "hermes",
52
+ optional: true,
53
+ mode: input.mode || "one-shot",
54
+ maxConcurrent: Math.max(1, Math.min(16, Number(input.maxConcurrent || 4))),
55
+ durableStateOwner: "ues",
56
+ evidenceTransport: "content-addressed-pointers",
57
+ allowNestedDelegation: input.allowNestedDelegation === true,
58
+ safety: {
59
+ merge: false,
60
+ push: false,
61
+ publish: false,
62
+ deploy: false,
63
+ destructiveGit: false,
64
+ },
65
+ }
66
+ }
23
67
 
24
68
  export function hermesOneShotArgs(prompt) {
25
69
  const text = String(prompt || "").trim()
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs"
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises"
3
3
  import path from "node:path"
4
4
  import { defaultModelPolicy, resolveModel } from "./model-policy.mjs"
5
+ import { normalizeCapabilityProfile } from "./capability-registry.mjs"
5
6
 
6
7
  const TIERS = new Set(["light", "standard", "heavy"])
7
8
 
@@ -23,14 +24,20 @@ function normalize(policy) {
23
24
  if (TIERS.has(tier)) roleTiers[role] = tier
24
25
  }
25
26
 
27
+ const capabilities = {}
28
+ for (const [model, profile] of Object.entries(input.capabilities || {})) {
29
+ if (validModelID(model)) capabilities[model] = normalizeCapabilityProfile(profile)
30
+ }
31
+
26
32
  return {
27
- schemaVersion: 1,
33
+ schemaVersion: 2,
28
34
  enabled: input.enabled === true,
29
35
  maxEscalations: Number.isInteger(input.maxEscalations)
30
36
  ? Math.max(0, Math.min(input.maxEscalations, 2))
31
37
  : base.maxEscalations,
32
38
  tiers,
33
39
  roleTiers,
40
+ capabilities,
34
41
  }
35
42
  }
36
43
 
@@ -56,6 +63,7 @@ export async function writeModelPolicy(configDir, patch = {}) {
56
63
  ...patch,
57
64
  tiers: { ...current.tiers, ...(patch.tiers || {}) },
58
65
  roleTiers: { ...current.roleTiers, ...(patch.roleTiers || {}) },
66
+ capabilities: { ...(current.capabilities || {}), ...(patch.capabilities || {}) },
59
67
  })
60
68
  const file = modelPolicyFile(configDir)
61
69
  await mkdir(path.dirname(file), { recursive: true })
@@ -1,3 +1,5 @@
1
+ import { inferTaskCapabilities, modelCandidatesFromPolicy, selectCapabilityCandidate } from "./capability-registry.mjs"
2
+
1
3
  const ROLE_TIERS = {
2
4
  "codebase-mapper": "standard",
3
5
  architect: "heavy",
@@ -9,6 +11,8 @@ const ROLE_TIERS = {
9
11
  critic: "heavy",
10
12
  verifier: "standard",
11
13
  "integration-verifier": "heavy",
14
+ "visual-verifier": "standard",
15
+ "merge-arbiter": "heavy",
12
16
  }
13
17
 
14
18
  const ORDER = ["light", "standard", "heavy"]
@@ -41,11 +45,12 @@ export function resolveModel(role, attempt, config = {}) {
41
45
 
42
46
  export function defaultModelPolicy() {
43
47
  return {
44
- schemaVersion: 1,
48
+ schemaVersion: 2,
45
49
  enabled: false,
46
50
  maxEscalations: 2,
47
51
  tiers: { light: null, standard: null, heavy: null },
48
52
  roleTiers: { ...ROLE_TIERS },
53
+ capabilities: {},
49
54
  }
50
55
  }
51
56
 
@@ -75,3 +80,49 @@ export function resolveAdaptiveModel(role, attempt, taskPolicy = {}, config = {}
75
80
  },
76
81
  }
77
82
  }
83
+
84
+
85
+ export function resolveCapabilityModel(role, attempt, taskText = "", taskPolicy = {}, config = {}, facts = {}) {
86
+ const base = resolveAdaptiveModel(role, attempt, taskPolicy, config)
87
+ const requirements = inferTaskCapabilities(taskText, {
88
+ ...facts,
89
+ ...(role === "visual-verifier" && facts.coding === undefined ? { coding: false } : {}),
90
+ })
91
+ const candidates = modelCandidatesFromPolicy(config)
92
+ .filter((candidate) => tierIndex(candidate.tier) >= tierIndex(base.tier))
93
+ const selection = selectCapabilityCandidate(requirements, candidates, {
94
+ role,
95
+ preferredTier: base.tier,
96
+ })
97
+ const capabilityEnforced = config.enabled === true && candidates.length > 0
98
+ if (capabilityEnforced && !selection.selected) {
99
+ return {
100
+ ...base,
101
+ model: null,
102
+ capabilityRequirements: requirements,
103
+ capabilitySelection: selection,
104
+ capabilityFallback: true,
105
+ capabilityBlocked: true,
106
+ capabilityBlockReason: "no-configured-model-satisfies-required-capabilities",
107
+ }
108
+ }
109
+ if (!config.enabled || !selection.selected) {
110
+ return {
111
+ ...base,
112
+ capabilityRequirements: requirements,
113
+ capabilitySelection: selection,
114
+ capabilityFallback: selection.fallbackNeeded,
115
+ capabilityBlocked: false,
116
+ }
117
+ }
118
+ const selected = selection.selected
119
+ return {
120
+ ...base,
121
+ tier: selected.tier || base.tier,
122
+ model: selected.id,
123
+ capabilityRequirements: requirements,
124
+ capabilitySelection: selection,
125
+ capabilityFallback: false,
126
+ capabilityBlocked: false,
127
+ }
128
+ }
@@ -1,4 +1,4 @@
1
- const HIGH_RISK = /(auth|security|permission|payment|migration|schema|database|production|deploy|public api|breaking|secret|credential|phân quyền|bảo mật|thanh toán|cơ sở dữ liệu|triển khai|migrate|migration)/i
1
+ const HIGH_RISK = /(\bauth\b|authorization|authentication|security|permission|payment|migration|schema|database|production|deploy|public api|breaking|secret|credential|phân quyền|bảo mật|thanh toán|cơ sở dữ liệu|triển khai|migrate|migration)/i
2
2
  const LONG = /(whole repo|whole repository|whole project|entire repo|entire project|large refactor|major refactor|long[- ](?:running|horizon)|durable state|dependency graph|integration verification|resume|migration|refactor all|toàn bộ repo|toàn bộ repository|toàn bộ dự án|toàn bộ project|refactor lớn|tác vụ dài|nhiều file|nhiều module|tiếp tục công việc|refactor toàn bộ|xác minh tích hợp|kiểm tra tích hợp|chia (?:công việc|task|tác vụ).*(?:dependency|phụ thuộc))/i
3
3
  const DEBUG = /(fix|bug|debug|crash|regression|failure|error|broken|sửa lỗi|lỗi|điều tra lỗi|không chạy)/i
4
4
  const CONTRACT = /(public api|api contract|openapi|response schema|request schema|breaking api|hợp đồng api|api công khai)/i
@@ -0,0 +1,229 @@
1
+ import { inflateSync, deflateSync } from "node:zlib"
2
+ import { readFile, writeFile } from "node:fs/promises"
3
+
4
+ const SIGNATURE = Buffer.from([137,80,78,71,13,10,26,10])
5
+
6
+ function paeth(a, b, c) {
7
+ const p = a + b - c
8
+ const pa = Math.abs(p - a)
9
+ const pb = Math.abs(p - b)
10
+ const pc = Math.abs(p - c)
11
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c
12
+ }
13
+
14
+ function bytesPerPixel(colorType) {
15
+ if (colorType === 0 || colorType === 3) return 1
16
+ if (colorType === 2) return 3
17
+ if (colorType === 4) return 2
18
+ if (colorType === 6) return 4
19
+ throw new Error("Unsupported PNG color type: " + colorType)
20
+ }
21
+
22
+ function parseChunks(buffer) {
23
+ if (buffer.length < 8 || !buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error("Invalid PNG signature")
24
+ const chunks = []
25
+ let offset = 8
26
+ while (offset + 12 <= buffer.length) {
27
+ const length = buffer.readUInt32BE(offset)
28
+ const type = buffer.toString("ascii", offset + 4, offset + 8)
29
+ const dataStart = offset + 8
30
+ const dataEnd = dataStart + length
31
+ if (dataEnd + 4 > buffer.length) throw new Error("Truncated PNG chunk")
32
+ chunks.push({ type, data: buffer.subarray(dataStart, dataEnd) })
33
+ offset = dataEnd + 4
34
+ if (type === "IEND") break
35
+ }
36
+ return chunks
37
+ }
38
+
39
+ export function decodePng(buffer) {
40
+ const chunks = parseChunks(buffer)
41
+ const ihdr = chunks.find((chunk) => chunk.type === "IHDR")?.data
42
+ if (!ihdr || ihdr.length < 13) throw new Error("PNG missing IHDR")
43
+ const width = ihdr.readUInt32BE(0)
44
+ const height = ihdr.readUInt32BE(4)
45
+ const bitDepth = ihdr[8]
46
+ const colorType = ihdr[9]
47
+ const interlace = ihdr[12]
48
+ if (bitDepth !== 8) throw new Error("Only 8-bit PNG is supported")
49
+ if (interlace !== 0) throw new Error("Interlaced PNG is not supported")
50
+
51
+ const paletteChunk = chunks.find((chunk) => chunk.type === "PLTE")?.data || null
52
+ const transparency = chunks.find((chunk) => chunk.type === "tRNS")?.data || null
53
+ const idat = Buffer.concat(chunks.filter((chunk) => chunk.type === "IDAT").map((chunk) => chunk.data))
54
+ const bpp = bytesPerPixel(colorType)
55
+ const pixelsCount = width * height
56
+ if (!Number.isSafeInteger(pixelsCount) || pixelsCount <= 0 || pixelsCount > 50_000_000) {
57
+ throw new Error("PNG dimensions exceed the V11 visual safety budget")
58
+ }
59
+ const stride = width * bpp
60
+ const expectedRawBytes = height * (1 + stride)
61
+ if (!Number.isSafeInteger(expectedRawBytes) || expectedRawBytes > 256 * 1024 * 1024) {
62
+ throw new Error("PNG decompressed payload exceeds the V11 visual safety budget")
63
+ }
64
+ const raw = inflateSync(idat, { maxOutputLength: expectedRawBytes })
65
+ if (raw.length !== expectedRawBytes) throw new Error("PNG decompressed payload length does not match IHDR")
66
+ const pixels = Buffer.alloc(stride * height)
67
+ let input = 0
68
+
69
+ for (let y = 0; y < height; y += 1) {
70
+ const filter = raw[input++]
71
+ const row = y * stride
72
+ for (let x = 0; x < stride; x += 1) {
73
+ const byte = raw[input++]
74
+ const left = x >= bpp ? pixels[row + x - bpp] : 0
75
+ const up = y > 0 ? pixels[row - stride + x] : 0
76
+ const upLeft = y > 0 && x >= bpp ? pixels[row - stride + x - bpp] : 0
77
+ let value
78
+ if (filter === 0) value = byte
79
+ else if (filter === 1) value = (byte + left) & 255
80
+ else if (filter === 2) value = (byte + up) & 255
81
+ else if (filter === 3) value = (byte + Math.floor((left + up) / 2)) & 255
82
+ else if (filter === 4) value = (byte + paeth(left, up, upLeft)) & 255
83
+ else throw new Error("Unsupported PNG filter: " + filter)
84
+ pixels[row + x] = value
85
+ }
86
+ }
87
+
88
+ const rgba = Buffer.alloc(width * height * 4)
89
+ for (let i = 0, p = 0; i < pixels.length; i += bpp, p += 4) {
90
+ if (colorType === 6) {
91
+ rgba[p] = pixels[i]; rgba[p+1] = pixels[i+1]; rgba[p+2] = pixels[i+2]; rgba[p+3] = pixels[i+3]
92
+ } else if (colorType === 2) {
93
+ rgba[p] = pixels[i]; rgba[p+1] = pixels[i+1]; rgba[p+2] = pixels[i+2]; rgba[p+3] = 255
94
+ } else if (colorType === 0) {
95
+ rgba[p] = pixels[i]; rgba[p+1] = pixels[i]; rgba[p+2] = pixels[i]; rgba[p+3] = 255
96
+ } else if (colorType === 4) {
97
+ rgba[p] = pixels[i]; rgba[p+1] = pixels[i]; rgba[p+2] = pixels[i]; rgba[p+3] = pixels[i+1]
98
+ } else if (colorType === 3) {
99
+ if (!paletteChunk) throw new Error("Indexed PNG missing PLTE")
100
+ const index = pixels[i]
101
+ rgba[p] = paletteChunk[index * 3] ?? 0
102
+ rgba[p+1] = paletteChunk[index * 3 + 1] ?? 0
103
+ rgba[p+2] = paletteChunk[index * 3 + 2] ?? 0
104
+ rgba[p+3] = transparency?.[index] ?? 255
105
+ }
106
+ }
107
+ return { width, height, rgba }
108
+ }
109
+
110
+ let crcTable = null
111
+ function crc32(buffer) {
112
+ if (!crcTable) {
113
+ crcTable = Array.from({ length: 256 }, (_, n) => {
114
+ let c = n
115
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
116
+ return c >>> 0
117
+ })
118
+ }
119
+ let crc = 0xffffffff
120
+ for (const byte of buffer) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8)
121
+ return (crc ^ 0xffffffff) >>> 0
122
+ }
123
+
124
+ function chunk(type, data) {
125
+ const typeBytes = Buffer.from(type, "ascii")
126
+ const length = Buffer.alloc(4)
127
+ length.writeUInt32BE(data.length)
128
+ const crc = Buffer.alloc(4)
129
+ crc.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])))
130
+ return Buffer.concat([length, typeBytes, data, crc])
131
+ }
132
+
133
+ export function encodeRgbaPng({ width, height, rgba }) {
134
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw new Error("Invalid PNG dimensions")
135
+ if (!Buffer.isBuffer(rgba) || rgba.length !== width * height * 4) throw new Error("Invalid RGBA buffer")
136
+ const ihdr = Buffer.alloc(13)
137
+ ihdr.writeUInt32BE(width, 0)
138
+ ihdr.writeUInt32BE(height, 4)
139
+ ihdr[8] = 8
140
+ ihdr[9] = 6
141
+ const scan = Buffer.alloc(height * (1 + width * 4))
142
+ for (let y = 0; y < height; y += 1) {
143
+ const row = y * (1 + width * 4)
144
+ scan[row] = 0
145
+ rgba.copy(scan, row + 1, y * width * 4, (y + 1) * width * 4)
146
+ }
147
+ return Buffer.concat([SIGNATURE, chunk("IHDR", ihdr), chunk("IDAT", deflateSync(scan)), chunk("IEND", Buffer.alloc(0))])
148
+ }
149
+
150
+ export function comparePngBuffers(expectedBuffer, actualBuffer, options = {}) {
151
+ const expected = decodePng(expectedBuffer)
152
+ const actual = decodePng(actualBuffer)
153
+ if (expected.width !== actual.width || expected.height !== actual.height) {
154
+ return {
155
+ schemaVersion: 1,
156
+ verdict: "FAIL",
157
+ reason: "dimension-mismatch",
158
+ expected: { width: expected.width, height: expected.height },
159
+ actual: { width: actual.width, height: actual.height },
160
+ differentPixels: expected.width * expected.height,
161
+ differentRatio: 1,
162
+ bounds: { x: 0, y: 0, width: Math.max(expected.width, actual.width), height: Math.max(expected.height, actual.height) },
163
+ }
164
+ }
165
+
166
+ const threshold = Math.max(0, Math.min(255, Number(options.threshold ?? 16)))
167
+ const allowedRatio = Math.max(0, Math.min(1, Number(options.maxDiffRatio ?? 0)))
168
+ let differentPixels = 0
169
+ let minX = expected.width
170
+ let minY = expected.height
171
+ let maxX = -1
172
+ let maxY = -1
173
+
174
+ for (let pixel = 0; pixel < expected.width * expected.height; pixel += 1) {
175
+ const offset = pixel * 4
176
+ let delta = 0
177
+ for (let channel = 0; channel < 4; channel += 1) {
178
+ delta = Math.max(delta, Math.abs(expected.rgba[offset + channel] - actual.rgba[offset + channel]))
179
+ }
180
+ if (delta <= threshold) continue
181
+ differentPixels += 1
182
+ const x = pixel % expected.width
183
+ const y = Math.floor(pixel / expected.width)
184
+ minX = Math.min(minX, x); maxX = Math.max(maxX, x)
185
+ minY = Math.min(minY, y); maxY = Math.max(maxY, y)
186
+ }
187
+
188
+ const totalPixels = expected.width * expected.height
189
+ const differentRatio = totalPixels ? differentPixels / totalPixels : 0
190
+ const bounds = differentPixels
191
+ ? { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 }
192
+ : null
193
+
194
+ return {
195
+ schemaVersion: 1,
196
+ verdict: differentRatio <= allowedRatio ? "PASS" : "FAIL",
197
+ width: expected.width,
198
+ height: expected.height,
199
+ differentPixels,
200
+ differentRatio,
201
+ threshold,
202
+ maxDiffRatio: allowedRatio,
203
+ bounds,
204
+ }
205
+ }
206
+
207
+ export async function comparePngFiles(expectedFile, actualFile, options = {}) {
208
+ return comparePngBuffers(await readFile(expectedFile), await readFile(actualFile), options)
209
+ }
210
+
211
+ export function cropDecodedPng(decoded, bounds) {
212
+ const x = Math.max(0, Math.floor(Number(bounds?.x || 0)))
213
+ const y = Math.max(0, Math.floor(Number(bounds?.y || 0)))
214
+ const width = Math.max(1, Math.min(decoded.width - x, Math.floor(Number(bounds?.width || decoded.width))))
215
+ const height = Math.max(1, Math.min(decoded.height - y, Math.floor(Number(bounds?.height || decoded.height))))
216
+ const rgba = Buffer.alloc(width * height * 4)
217
+ for (let row = 0; row < height; row += 1) {
218
+ const sourceStart = ((y + row) * decoded.width + x) * 4
219
+ decoded.rgba.copy(rgba, row * width * 4, sourceStart, sourceStart + width * 4)
220
+ }
221
+ return { width, height, rgba }
222
+ }
223
+
224
+ export async function cropPngFile(inputFile, outputFile, bounds) {
225
+ const decoded = decodePng(await readFile(inputFile))
226
+ const cropped = cropDecodedPng(decoded, bounds)
227
+ await writeFile(outputFile, encodeRgbaPng(cropped))
228
+ return { file: outputFile, width: cropped.width, height: cropped.height }
229
+ }
@@ -0,0 +1,60 @@
1
+ import { createHash } from "node:crypto"
2
+
3
+ function canonical(value) {
4
+ if (Array.isArray(value)) return value.map(canonical)
5
+ if (!value || typeof value !== "object") return value
6
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]))
7
+ }
8
+
9
+ function stringify(value) {
10
+ return typeof value === "string" ? value : JSON.stringify(canonical(value))
11
+ }
12
+
13
+ function digest(value) {
14
+ return createHash("sha256").update(stringify(value)).digest("hex")
15
+ }
16
+
17
+ function chars(value) {
18
+ return stringify(value ?? "").length
19
+ }
20
+
21
+ export function buildPromptEnvelope(input = {}) {
22
+ const stable = {
23
+ invariants: input.invariants || "",
24
+ role: input.role || "",
25
+ skills: input.skills || [],
26
+ projectFacts: input.projectFacts || {},
27
+ toolPolicy: input.toolPolicy || {},
28
+ }
29
+ const dynamic = {
30
+ task: input.task || {},
31
+ evidence: input.evidence || [],
32
+ recentFailure: input.recentFailure || null,
33
+ nextAction: input.nextAction || null,
34
+ recentMessages: input.recentMessages || [],
35
+ }
36
+ const stableChars = chars(stable)
37
+ const dynamicChars = chars(dynamic)
38
+ return {
39
+ schemaVersion: 1,
40
+ stable,
41
+ dynamic,
42
+ stablePrefixHash: digest(stable),
43
+ dynamicHash: digest(dynamic),
44
+ stableChars,
45
+ dynamicChars,
46
+ cacheableRatio: stableChars + dynamicChars ? stableChars / (stableChars + dynamicChars) : 0,
47
+ }
48
+ }
49
+
50
+ export function comparePromptEnvelopes(previous, current) {
51
+ const stableReused = Boolean(previous?.stablePrefixHash && previous.stablePrefixHash === current?.stablePrefixHash)
52
+ const repeatedStableChars = stableReused ? Number(current?.stableChars || 0) : 0
53
+ return {
54
+ schemaVersion: 1,
55
+ stableReused,
56
+ repeatedStableChars,
57
+ avoidableRepeatedChars: stableReused ? 0 : Math.min(Number(previous?.stableChars || 0), Number(current?.stableChars || 0)),
58
+ currentCacheableRatio: Number(current?.cacheableRatio || 0),
59
+ }
60
+ }