kaloko 0.10.0 → 0.12.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.
@@ -5,6 +5,7 @@ import { probe } from './probe.mjs'
5
5
  import { pageSpecScript } from './devspec.mjs'
6
6
  import { writeJson, readJsonl } from './util.mjs'
7
7
  import { addCapture, runPaths } from './run.mjs'
8
+ import { maskSpec, maskList, inPage, maskInPage, rememberPii, knownPii, maskMailHeaders } from './pii.mjs'
8
9
 
9
10
  /**
10
11
  * Encodes a PNG screenshot inside the page (no native dependency): the image as WebP (or PNG, lossless) and a
@@ -16,7 +17,14 @@ export async function encodeImage (page, png, { format = 'webp', quality = 0.82,
16
17
  await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = dataUrl })
17
18
  const full = document.createElement('canvas'); full.width = img.width; full.height = img.height
18
19
  const g = full.getContext('2d'); g.drawImage(img, 0, 0)
19
- g.fillStyle = '#1f1f1d'; for (const r of masks) g.fillRect(Math.floor(r.x * scale), Math.floor(r.y * scale), Math.ceil(r.w * scale), Math.ceil(r.h * scale))
20
+ // masked personal data: a dark box with diagonal stripes, so a reader sees "hidden" and not a broken page (#64)
21
+ for (const r of masks) {
22
+ const x = Math.floor(r.x * scale); const y = Math.floor(r.y * scale); const w = Math.ceil(r.w * scale); const h = Math.ceil(r.h * scale)
23
+ g.fillStyle = '#1f1f1d'; g.fillRect(x, y, w, h)
24
+ g.save(); g.beginPath(); g.rect(x, y, w, h); g.clip(); g.strokeStyle = '#3d3d38'; g.lineWidth = 3 * scale
25
+ for (let i = -h; i < w; i += 12 * scale) { g.beginPath(); g.moveTo(x + i, y + h); g.lineTo(x + i + h, y); g.stroke() }
26
+ g.restore()
27
+ }
20
28
  for (const m of marks) {
21
29
  const r = 13 * scale; const x = Math.max(r + 2, m.x * scale); const y = Math.max(r + 2, m.y * scale)
22
30
  g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fillStyle = '#ffe452'; g.fill(); g.lineWidth = 2 * scale; g.strokeStyle = '#151514'; g.stroke()
@@ -50,12 +58,15 @@ export async function stepSpec (page, tokens = null, mode = null) {
50
58
 
51
59
  export function captureOptions (scenario, node) {
52
60
  const o = { ...(scenario?.capture || {}), ...(node?.capture || {}) }
61
+ // masks add up: a step's own selectors never drop the scenario's (personal data stays hidden on every step)
62
+ const masks = [maskList(scenario?.capture?.mask), maskList(node?.capture?.mask)]
53
63
  const q = Number(o.quality)
54
64
  return {
55
65
  format: o.format === 'png' ? 'png' : 'webp',
56
66
  scale: Number(o.scale) === 2 ? 2 : 1,
57
67
  quality: Number.isFinite(q) && q > 0 ? Math.min(1, Math.max(0.3, q)) : 0.82,
58
- mask: Array.isArray(o.mask) ? o.mask.map(String).filter(Boolean) : [],
68
+ mask: [...new Set(masks.flatMap((m) => m.selectors))],
69
+ maskAuto: masks.some((m) => m.auto),
59
70
  annotate: o.annotate === true,
60
71
  // stack pages default to the first screen: hundreds of full-page shots would swamp storage
61
72
  fullPage: o.full_page != null ? o.full_page !== false : node?.kind !== 'stack'
@@ -121,6 +132,9 @@ export async function captureStep ({ page, node, locale, viewports, run, console
121
132
  const original = page.viewportSize()
122
133
  const results = []
123
134
  let netResults = null
135
+ // personal data (#64): what this capture masks; recipients of a captured e-mail are masked before the body
136
+ const mask = maskSpec({ run, selectors: opts.mask, auto: opts.maskAuto, locale })
137
+ if (mask.active && extra.mail) extra = { ...extra, mail: maskMailHeaders(extra.mail, mask) }
124
138
  // batch E: the engine of this page, and per key its colour scheme, motion and display mode (run scope variants)
125
139
  const engine = engineOf(page)
126
140
  const scope = run.meta?.scope
@@ -128,14 +142,38 @@ export async function captureStep ({ page, node, locale, viewports, run, console
128
142
  const now = page.viewportSize()
129
143
  if (!now || now.width !== width || now.height !== height) await page.setViewportSize({ width, height })
130
144
  const undoVariant = await applyVariant(page, scope?.variants?.[viewport], scope?.devices?.[scope?.variants?.[viewport]?.device] || null)
145
+ let masking = null
131
146
  try {
132
147
  await settle(page)
133
148
  const html = paths.step(stepId, locale, viewport, 'html')
134
149
  await rm(paths.step(stepId, locale, viewport, 'eval.json'), { force: true })
135
150
  await mkdir(dirname(html), { recursive: true })
136
- // PII selectors are covered by a solid box; at 2× Chrome renders the full page at double scale (CDP), and
137
- // the boxes are drawn while encoding because Playwright's own mask only works at the page's pixel ratio
138
- const maskRects = opts.mask.length ? await page.evaluate((sels) => sels.flatMap((sel) => { try { return [...document.querySelectorAll(sel)] } catch { return [] } }).map((el) => { const r = el.getBoundingClientRect(); return { x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height } }).filter((r) => r.w > 0 && r.h > 0), opts.mask) : []
151
+ // the checks read the page as it is: masks change what is stored, never a verdict
152
+ let result
153
+ for (let attempt = 0; ; attempt++) {
154
+ try { result = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)); break } catch (error) {
155
+ // the page navigated under us (redirect, Turbo); wait once and probe the new document
156
+ if (attempt < 2 && /Execution context was destroyed|navigation/i.test(error.message)) { await page.waitForLoadState('domcontentloaded').catch(() => {}); await page.waitForTimeout(500); continue }
157
+ throw error
158
+ }
159
+ }
160
+ // regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
161
+ const ignore = await resolveIgnore(page, node, locale, viewport)
162
+ // URL, computed styles and read-only expressions (src/lib/assertions.mjs), before the mask and the keyboard walk
163
+ await liveValueChecks({ page, node, result, url: page.url(), tokens })
164
+ // One mask for every output (#64): from here to the end of the capture the page shows placeholders or fake
165
+ // values instead of personal data, so the screenshot, HTML, outline, spec, keyboard shots and HTML view agree
166
+ if (mask.active) {
167
+ masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() }))
168
+ rememberPii(masking.pairs)
169
+ await page.evaluate((known) => window.__kalokoMask?.update({ known }), knownPii()).catch(() => {})
170
+ // the outline the evaluators read comes from the masked page; checks and boxes keep the first probe
171
+ if (masking.rects.length || masking.pairs.length || Object.keys(masking.counts).length) {
172
+ const again = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)).catch(() => null)
173
+ if (again) for (const k of ['state', 'scoped', 'overflow', 'meta', 'design']) if (again[k] !== undefined) result[k] = again[k]
174
+ }
175
+ }
176
+ const maskRects = (masking?.rects || []).filter((r) => r.box)
139
177
  let shot
140
178
  // a device preset's pixel ratio (3 on an iPhone) shapes the layout, not the image: captures stay in CSS pixels
141
179
  // (or 2× for documentation), so the canvas, boxes and diffs work the same for every device
@@ -152,22 +190,12 @@ export async function captureStep ({ page, node, locale, viewports, run, console
152
190
  // WebKit and Firefox have no 2× capture of their own: 2× when the context renders at 2× or more, else 1×
153
191
  // (exactly 2×: a 3× or 2.625× device would give an image the masks and marks are not drawn for)
154
192
  if (opts.scale === 2 && dpr !== 2) shotScale = 1
155
- shot = await page.screenshot({ fullPage: opts.fullPage, scale: shotScale === 2 ? 'device' : 'css', ...(opts.mask.length && shotScale === 1 ? { mask: opts.mask.map((sel) => page.locator(sel)), maskColor: '#1f1f1d' } : {}) })
193
+ shot = await page.screenshot({ fullPage: opts.fullPage, scale: shotScale === 2 ? 'device' : 'css' })
156
194
  }
157
195
  // secrets the CLI used (gate, accounts) never land in a capture, even if the page echoes them
158
196
  await writeFile(html, redact(await page.content()))
159
- let result
160
- for (let attempt = 0; ; attempt++) {
161
- try { result = await page.evaluate(probe, probeArgs(node, maxChars, designSummary)); break } catch (error) {
162
- // the page navigated under us (redirect, Turbo); wait once and probe the new document
163
- if (attempt < 2 && /Execution context was destroyed|navigation/i.test(error.message)) { await page.waitForLoadState('domcontentloaded').catch(() => {}); await page.waitForTimeout(500); continue }
164
- throw error
165
- }
166
- }
167
197
  // the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
168
198
  if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens, scope?.variants?.[viewport]?.token_mode || scope?.variants?.[viewport]?.color_scheme || null)
169
- // regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
170
- const ignore = await resolveIgnore(page, node, locale, viewport)
171
199
  // what moves while the page sits still (walk --stability N): repeated screenshots, compared here
172
200
  const unstable = stability > 1 ? await repeatRegions(page, { n: Math.min(6, stability), fullPage: opts.fullPage }).catch((error) => ({ error: String(error.message).slice(0, 200) })) : null
173
201
  // numbered marks on the highlighted elements, in criteria order (first box of each)
@@ -176,7 +204,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
176
204
  let thumb = paths.step(stepId, locale, viewport, 'thumb.webp')
177
205
  let thumbWritten = true
178
206
  try {
179
- const enc = await encodeImage(page, shot, { format: opts.format, quality: opts.quality, marks: annotations, scale: shotScale, masks: shotScale === 2 ? maskRects : [] })
207
+ // mask boxes are drawn while encoding, at the image's own scale (Playwright's mask only works at the page's pixel ratio)
208
+ const enc = await encodeImage(page, shot, { format: opts.format, quality: opts.quality, marks: annotations, scale: shotScale, masks: maskRects })
180
209
  // the extension follows the bytes (WebKit encodes PNG), so the service accepts the file
181
210
  if (enc.imageType === 'image/png') image = paths.step(stepId, locale, viewport, 'png')
182
211
  if (enc.thumbType === 'image/png') thumb = paths.step(stepId, locale, viewport, 'thumb.png')
@@ -184,13 +213,11 @@ export async function captureStep ({ page, node, locale, viewports, run, console
184
213
  await writeFile(thumb, enc.thumb)
185
214
  } catch (error) {
186
215
  // never store an unmasked image when masks were asked for
187
- if (shotScale === 2 && maskRects.length) throw new Error(`capture: could not draw the masks on ${node.id} (${error.message}); the image was not stored`)
216
+ if (maskRects.length) throw new Error(`capture: could not draw the masks on ${node.id} (${error.message}); the image was not stored`)
188
217
  image = paths.step(stepId, locale, viewport, 'png'); thumbWritten = false
189
218
  await writeFile(image, shot)
190
219
  }
191
220
  const url = page.url()
192
- // URL, computed styles and read-only expressions (src/lib/assertions.mjs), before the keyboard walk moves focus
193
- await liveValueChecks({ page, node, result, url, tokens })
194
221
  const collected = consoleFile ? await readJsonl(consoleFile) : consoleEntriesFor(page)
195
222
  const consoleEntries = (collected || []).filter((e) => !since || e.ts >= since)
196
223
  // requests blocked by the readonly policy surface as ERR_BLOCKED_BY_CLIENT; they are reported separately
@@ -236,7 +263,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
236
263
  let view = null
237
264
  if (htmlView) {
238
265
  try {
239
- // the same PII masks as the screenshot (#27), applied to the text of the copy
266
+ // the page is still masked here (#64); the selectors are covered in the copy as well (#27)
240
267
  const rev = await buildHtmlView({ page, run, scenarioId: htmlView.scenarioId, node: node.id, locale, viewport, mask: [...(opts.mask || []), ...(htmlView.mask || [])] })
241
268
  await writeJson(join(run.dir, 'views', `${rev.id}.json`), rev)
242
269
  view = { revision: rev.id, entry: rev.entry }
@@ -254,6 +281,8 @@ export async function captureStep ({ page, node, locale, viewports, run, console
254
281
  ...extra,
255
282
  http_status: extra.http_status ?? result.http_status ?? null,
256
283
  capture: { format: String(image).split('.').pop(), scale: shotScale, ...(dpr !== 1 ? { dpr } : {}), masked: opts.mask.length, annotations, ...(opts.fullPage ? {} : { full_page: false }) },
284
+ // what was hidden and where (#64): the canvas marks these places, so a blank is not read as a bug
285
+ ...(mask.active ? { pii: { mode: mask.mode, auto: mask.auto, selectors: mask.selectors.length, kinds: masking?.counts || {}, rects: (masking?.rects || []).slice(0, 200).map(({ x, y, w, h, kind }) => ({ x, y, w, h, kind })) } } : {}),
257
286
  ...(ignore.length ? { ignore } : {}),
258
287
  ...(unstable ? { unstable } : {}),
259
288
  ...(keyboard ? { keyboard } : {}),
@@ -265,27 +294,42 @@ export async function captureStep ({ page, node, locale, viewports, run, console
265
294
  await writeJson(file, redactDeep(record))
266
295
  await addCapture(run.dir, { type: 'capture', node: node.id, ...(item ? { item: item.id } : {}), locale, viewport, file: relative(run.dir, file), at: record.captured_at })
267
296
  results.push(record)
268
- } finally { await undoVariant() }
297
+ } finally {
298
+ // the page gets its own text back, also when the capture failed half-way
299
+ if (masking) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {})
300
+ await undoVariant()
301
+ }
269
302
  }
270
303
  const last = page.viewportSize()
271
304
  if (original && (!last || last.width !== original.width || last.height !== original.height)) await page.setViewportSize(original)
272
305
  return results
273
306
  }
274
307
 
275
- export async function captureCrop ({ page, node, locale, selector, title, run, padding = 0 }) {
308
+ export async function captureCrop ({ page, node, locale, selector, title, run, padding = 0, options = null }) {
276
309
  const paths = runPaths(run.dir)
277
310
  const el = page.locator(selector).first()
278
311
  const name = `${node.id}-${locale}-${Date.now().toString(36)}.png`
279
312
  const file = paths.attachment(name)
280
313
  await mkdir(dirname(file), { recursive: true })
281
- if (padding > 0) {
282
- // element plus a margin of surrounding page, clipped from a full-page shot (documentation crops)
314
+ // the same mask as the step's capture (#64): masked text in the page, boxes over masked elements
315
+ const opts = options || captureOptions(null, node)
316
+ const mask = maskSpec({ run, selectors: opts.mask, auto: opts.maskAuto, locale })
317
+ let masking = null
318
+ try {
319
+ if (mask.active) { masking = await page.evaluate(inPage(maskInPage, { ...mask, known: knownPii() })); rememberPii(masking.pairs) }
320
+ const boxes = (masking?.rects || []).filter((r) => r.box)
321
+ const doc = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight, x: scrollX, y: scrollY }))
283
322
  const box = await el.boundingBox()
284
323
  if (!box) throw new Error(`crop: ${selector} is not visible`)
285
- const doc = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight, x: scrollX, y: scrollY }))
324
+ // element plus a margin of surrounding page (documentation crops), clipped from a full-page shot
286
325
  const x = Math.max(0, box.x + doc.x - padding); const y = Math.max(0, box.y + doc.y - padding)
287
- await page.screenshot({ path: file, fullPage: true, clip: { x, y, width: Math.min(doc.w - x, box.width + padding * 2), height: Math.min(doc.h - y, box.height + padding * 2) } })
288
- } else await el.screenshot({ path: file })
326
+ const clip = { x, y, width: Math.min(doc.w - x, box.width + padding * 2), height: Math.min(doc.h - y, box.height + padding * 2) }
327
+ const png = padding > 0 || boxes.length ? await page.screenshot({ fullPage: true, clip }) : await el.screenshot()
328
+ if (boxes.length) {
329
+ const enc = await encodeImage(page, png, { format: 'png', masks: boxes.map((r) => ({ ...r, x: r.x - clip.x, y: r.y - clip.y })) })
330
+ await writeFile(file, enc.image)
331
+ } else await writeFile(file, png)
332
+ } finally { if (masking) await page.evaluate(() => window.__kalokoMaskUndo?.()).catch(() => {}) }
289
333
  const entry = { type: 'attachment', kind: 'crop', node: node.id, locale, title: title || selector, selector, file: relative(run.dir, file), viewport: page.viewportSize(), at: new Date().toISOString() }
290
334
  await addCapture(run.dir, entry)
291
335
  return entry
@@ -237,7 +237,8 @@ async function defaultInstall (engine) {
237
237
 
238
238
  async function defaultEngine (engine) {
239
239
  const pw = await import('playwright')
240
- const browser = await pw[engine].launch({ headless: true })
240
+ const { launchWithRetry } = await import('./browser.mjs')
241
+ const browser = await launchWithRetry(pw[engine], { headless: true })
241
242
  const version = browser.version()
242
243
  await browser.close()
243
244
  return { version }
@@ -245,7 +246,8 @@ async function defaultEngine (engine) {
245
246
 
246
247
  async function defaultLaunch (config) {
247
248
  const { chromium } = await import('playwright')
248
- const browser = await chromium.launch({ headless: true, channel: config.browser?.channel || undefined })
249
+ const { launchWithRetry } = await import('./browser.mjs')
250
+ const browser = await launchWithRetry(chromium, { headless: true, channel: config.browser?.channel || undefined })
249
251
  const version = browser.version()
250
252
  await browser.close()
251
253
  return { version }
@@ -4,6 +4,8 @@ import { readCaptures, runPaths } from './run.mjs'
4
4
  import { buildRequest, evaluateWithJev, verdictFor, DEFAULTS, PROMPT_VERSION, SIGNAL_NAMES, purposeOf } from './jev.mjs'
5
5
  import { visionSettings, visionCriteria, templateReason, VISION_PROMPT_VERSION } from './vision-core.mjs'
6
6
  import { prepareImages, visionPlan, askVision, hostedVisionSender } from './vision.mjs'
7
+ import { triageSettings, probeFindings, TRIAGE_PROMPT_VERSION } from './triage-core.mjs'
8
+ import { askTriage, hostedTriageSender, chooseFrames, frameBatches, mergeFindings } from './triage.mjs'
7
9
  import { KEYBOARD_ASSERTS } from './keyboard.mjs'
8
10
  import { needsDrift, driftForCapture, loadTokens, driftCacheDir } from './drift.mjs'
9
11
  import { packHelp } from './pack-help.mjs'
@@ -31,7 +33,7 @@ export const evaluationSource = (node, capture) => sha256({ capture, criteria: n
31
33
  * asked, the vision evaluator. Every semantic verdict with a score carries a one-sentence `reason`: the vision
32
34
  * model's own, or a sentence saying what the text evaluator read (it returns a probability only).
33
35
  */
34
- export function combine ({ node, capture, jev, vision = null, thresholds, environment = null }) {
36
+ export function combine ({ node, capture, jev, vision = null, triage = null, cascade = false, thresholds, environment = null }) {
35
37
  return (node.criteria || []).map((c) => {
36
38
  if (!appliesOn(c, environment)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies on ${c.environments.join(', ')} only; this walk ran on ${environment}.`, na: true, not_applicable: 'environment' }
37
39
  if (!appliesToState(c, capture?.step_state)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies to the state${c.states.length > 1 ? 's' : ''} ${c.states.join(', ')} only; this capture shows ${capture?.step_state || 'default'}.`, na: true, not_applicable: 'state' }
@@ -48,15 +50,19 @@ export function combine ({ node, capture, jev, vision = null, thresholds, enviro
48
50
  const answer = jev?.response?.answers?.[c.id]
49
51
  const text = answer?.noul ?? null
50
52
  const seen = vision?.answers?.[c.id] || null
51
- const score = seen ? seen.probability : text
53
+ // Clef's answer on the screenshot (#71): recorded beside the others; with cascade on it decides when it is sure
54
+ const clef = triage?.criteria?.[c.id] ?? null
55
+ const clefDecides = cascade && !seen && clef != null && (clef <= thresholds.fail || clef >= thresholds.pass)
56
+ const score = seen ? seen.probability : clefDecides ? clef : text
52
57
  const verdict = verdictFor(score, thresholds)
53
- const reason = seen?.reason || templateReason({ score, scope: c.check.scope, about: c.check.about, verdict })
58
+ const reason = seen?.reason || (clefDecides ? `Judged from the screenshot by Clef: ${Math.round(clef * 100)} % yes, clear enough for a verdict.` : templateReason({ score, scope: c.check.scope, about: c.check.about, verdict }))
54
59
  return {
55
60
  id: c.id, type: 'semantic', verdict, evidence: null, score,
56
- provider: seen ? `${vision.provider || 'anthropic'}-vision` : answer ? 'typesafe-jev' : null,
61
+ provider: seen ? `${vision.provider || 'anthropic'}-vision` : clefDecides ? 'cloudflare-clef' : answer ? 'typesafe-jev' : null,
57
62
  note: score == null ? (jev?.skipped || jev?.error || vision?.skipped || vision?.error || null) : null,
58
63
  ...(reason ? { reason, reason_source: seen?.reason ? 'model' : 'template' } : {}),
59
- ...(seen && text != null ? { text_score: text } : {}),
64
+ ...((seen || clefDecides) && text != null ? { text_score: text } : {}),
65
+ ...(clef != null ? { clef_score: clef } : {}),
60
66
  ...(!seen && (vision?.skipped || vision?.error) && vision.ids?.includes(c.id) ? { vision_note: vision.skipped || vision.error } : {})
61
67
  }
62
68
  }
@@ -80,12 +86,17 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
80
86
  // with an own text-evaluator key the vision evaluator uses an own key too, unless hosted: true says otherwise
81
87
  if (vset && process.env[settings.api_key_env] && config.evaluators?.vision?.hosted !== true) vset.hosted = false
82
88
  const vision = vset ? visionContext({ config, root, settings: vset, report, fetchImpl }) : null
89
+ // triage (#65, experimental): the kind of problem on each screen and in recording frames, advisory only
90
+ const tset = triageSettings(config)
91
+ if (tset) report.triage = { model: tset.model, screens: 0, frames: 0, calls: 0, cached: 0, findings: 0, shown: 0, skipped: null, via: {}, ms: [] }
92
+ const triage = tset ? triageContext({ config, root, settings: tset, report, fetchImpl, uncertain: (vset || {}).uncertain }) : null
83
93
  // checks against the design reference and the token set (pack design, #42) need the service and a browser
84
94
  const drift = scenario.nodes.some(needsDrift) ? await driftContext({ run, scenario, config, root }) : null
85
95
  // style checks against a token (style_equals token: color.brand.primary) use the project's token set
86
96
  const styleTokens = scenario.nodes.some((n) => tokenCriteria(n).length) ? (drift ? { tokens: drift.tokens, mode: drift.mode } : await runTokens({ config, root })) : null
87
97
  try {
88
- const latest = latestCaptures(await readCaptures(run.dir))
98
+ const entries = await readCaptures(run.dir)
99
+ const latest = latestCaptures(entries)
89
100
  // stack steps: semantic criteria only on the first N pages (items.semantic_sample, default 20); cost stays bounded
90
101
  const sampled = new Map()
91
102
  for (const e of latest) {
@@ -129,13 +140,15 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
129
140
  if ((w.jev.skipped || w.jev.error) && w.asks) report.skipped.push(`${w.entry.node}/${w.entry.locale}/${w.entry.viewport}: ${w.jev.skipped || w.jev.error}`)
130
141
  }
131
142
  }))
132
- // second opinion with the screenshot for flagged and uncertain criteria
143
+ // what kind of problem each screen and its recording shows; with cascade on, Clef may settle uncertain criteria
144
+ if (triage) await triage.ask(work.filter((w) => w.current && !w.outOfSample), run.dir, entries)
145
+ // second opinion with the screenshot for flagged and uncertain criteria (those Clef did not settle)
133
146
  if (vision) await vision.ask(work.filter((w) => w.current && !w.outOfSample && w.request), run.dir)
134
- for (const { entry, node, capture, current, outOfSample, request, jev, vision: seen } of work) {
147
+ for (const { entry, node, capture, current, outOfSample, request, jev, vision: seen, triage: tri } of work) {
135
148
  const source = evaluationSource(node, capture)
136
149
  if (drift && current && needsDrift(node)) { capture.checks = { ...capture.checks, ...await driftForCapture({ node, entry, capture, runDir: run.dir, ctx: drift }) }; report.drift++ }
137
150
  if (styleTokens?.tokens && current && tokenCriteria(node).length) capture.checks = { ...capture.checks, ...storedValueChecks(node, capture, { ...styleTokens, recorded: !!capture.rechecked_at, only: (c) => !!c.check.token && STYLE_ASSERTS.has(c.check.assert) }) }
138
- let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, thresholds: settings.thresholds, environment })
151
+ let criteria = combine({ node, capture: current ? capture : {}, jev, vision: seen, triage: tri, cascade: !!tset?.cascade, thresholds: settings.thresholds, environment })
139
152
  // pages outside the semantic sample carry only the deterministic results
140
153
  if (outOfSample) criteria = criteria.filter((c) => c.type !== 'semantic' || c.evidence === 'no console errors')
141
154
  const evaluation = {
@@ -156,6 +169,8 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
156
169
  thresholds: settings.thresholds,
157
170
  state_chars: request ? JSON.stringify(request.state).length : null,
158
171
  ...(signalsOf(jev) ? { signals: signalsOf(jev) } : {}),
172
+ // advisory findings (#65, #66): the kind of problem, never a verdict
173
+ ...(tri ? { triage: tri.findings ? { provider: 'cloudflare-clef', via: tri.via, model: tri.model, prompt_version: TRIAGE_PROMPT_VERSION, requests: tri.requests, cached: tri.cached, ms: tri.ms } : { provider: 'cloudflare-clef', skipped: tri.skipped || null, error: tri.error || null }, ...(tri.findings?.length ? { findings: tri.findings } : {}) } : {}),
159
174
  ...(seen ? { vision: seen.answers ? { provider: seen.provider, via: seen.via, model: seen.model, prompt_version: VISION_PROMPT_VERSION, cached: seen.cached, usage: seen.usage || null, answers: seen.answers } : { provider: seen.provider, skipped: seen.skipped || null, error: seen.error || null } } : {}),
160
175
  criteria
161
176
  }
@@ -165,8 +180,11 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
165
180
  for (const c of criteria) if (c.verdict === 'fail' || c.verdict === 'needs_review') report.findings.push({ node: entry.node, ...(entry.item ? { item: entry.item } : {}), locale: entry.locale, viewport: entry.viewport, id: c.id, verdict: c.verdict, type: c.type, why: c.evidence || (c.score != null ? `AI ${Math.round(c.score * 100)} % yes${c.reason_source === 'model' ? `: ${c.reason}` : ''}` : c.note) || null, ...(c.reason ? { reason: c.reason } : {}), ...((node.criteria || []).find((x) => x.id === c.id)?.pack && packHelp(c.id) ? { fix: packHelp(c.id).fix } : {}) })
166
181
  }
167
182
  }
168
- } finally { if (drift?.browser) await drift.browser.close(); await vision?.close() }
169
- const { hostedStop, visionStop, ...out } = report
183
+ } finally { if (drift?.browser) await drift.browser.close(); await vision?.close(); await triage?.close() }
184
+ // triage is on by default but stays silent where nobody asked for it and it did not run (experiment off, no service)
185
+ if (report.triage && !report.triage.screens && config.evaluators?.triage == null) delete report.triage
186
+ if (report.triage) { const ms = report.triage.ms.filter((x) => x != null).sort((a, b) => a - b); report.triage.median_ms = ms.length ? ms[Math.floor(ms.length / 2)] : null; delete report.triage.ms }
187
+ const { hostedStop, visionStop, triageStop, ...out } = report
170
188
  return { ...out, outdated: [...report.outdated] }
171
189
  }
172
190
 
@@ -249,7 +267,7 @@ function visionContext ({ config, root, settings, report, fetchImpl }) {
249
267
  const queue = []
250
268
  for (const w of work) {
251
269
  const semantic = (w.node.criteria || []).filter((c) => c.check.type === 'semantic' && w.request.questions[c.id])
252
- const crit = visionCriteria({ criteria: semantic, answers: w.jev?.response?.answers, settings })
270
+ const crit = visionCriteria({ criteria: semantic, answers: w.jev?.response?.answers, settings }).filter((c) => !w.triageDecided?.has(c.id))
253
271
  if (crit.length) queue.push({ w, crit })
254
272
  }
255
273
  await Promise.all(Array.from({ length: Math.max(1, Math.min(settings.concurrency || 3, queue.length)) }, async () => {
@@ -281,6 +299,88 @@ function visionContext ({ config, root, settings, report, fetchImpl }) {
281
299
  }
282
300
  }
283
301
 
302
+ /**
303
+ * The triage step of an evaluation (#65): one Clef request per screen (page tiles), and for a step with a recording
304
+ * up to `frames_per_step` chosen frames in requests of four. Uncertain semantic criteria ride along as extra
305
+ * questions (#71). Fills `w.triage` with merged findings; a stopped service (experiment off, allowance) ends triage
306
+ * for this evaluation with one note in the report.
307
+ */
308
+ function triageContext ({ config, root, settings, report, fetchImpl, uncertain = [0.15, 0.85] }) {
309
+ const cacheDir = join(resolve(root, config.output), '.cache', 'triage')
310
+ const hosted = hostedTriageSender(config, report)
311
+ let browser = null; let pagePromise = null
312
+ const page = () => (pagePromise ||= (async () => {
313
+ const { launchEphemeral } = await import('./browser.mjs')
314
+ browser = await launchEphemeral(config)
315
+ const p = await browser.newPage()
316
+ await p.setContent('<!doctype html><title>triage</title>')
317
+ return p
318
+ })())
319
+ const allowCall = () => { if (report.triage.calls >= (settings.max_calls ?? 200)) return false; report.triage.calls++; return true }
320
+ const own = process.env.CLOUDFLARE_API_TOKEN && process.env.CLOUDFLARE_ACCOUNT_ID
321
+ const unavailable = !own && !process.env[config.share?.token_env || 'KALOKO_TOKEN'] ? 'no service token (KALOKO_TOKEN)' : null
322
+ let framesLeft = settings.frames_per_run
323
+ const ask = async (w, payload, where) => {
324
+ const r = await askTriage({ payload, settings, cacheDir, where, fetchImpl, hosted, allowCall })
325
+ if (r.findings) { if (r.cached) report.triage.cached++; report.triage.via[r.via] = (report.triage.via[r.via] || 0) + 1; report.triage.ms.push(r.ms) }
326
+ else if (!report.triage.skipped) report.triage.skipped = r.skipped || r.error
327
+ return r
328
+ }
329
+ return {
330
+ async ask (work, runDir, entries) {
331
+ if (unavailable) { report.triage.skipped = unavailable; return }
332
+ if (!own && !(await hosted.ready())) { report.triage.skipped = report.triageStop; return }
333
+ const queue = [...work]
334
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(3, queue.length)) }, async () => {
335
+ for (let w = queue.shift(); w; w = queue.shift()) {
336
+ if (report.triageStop) break
337
+ const file = w.capture.files?.image || w.capture.files?.png
338
+ if (!file) continue
339
+ try {
340
+ const [lo, hi] = uncertain
341
+ const criteria = (w.node.criteria || []).filter((c) => c.check.type === 'semantic' && c.check.about !== 'console' && w.request?.questions?.[c.id])
342
+ .filter((c) => { const s = w.jev?.response?.answers?.[c.id]?.noul; return s != null && s > lo && s < hi })
343
+ .map((c) => ({ id: c.id, question: c.check.question }))
344
+ const context = { purpose: purposeOf(w.node, w.capture), url: w.capture.url, locale: w.capture.locale, viewport: w.entry.viewport, signals: { overflow: (w.capture.overflow || []).length, console_errors: (w.capture.console_errors || []).length } }
345
+ const { images } = await prepareImages({ page: await page(), file: join(runDir, file), cssWidth: w.capture.size?.width, full: true, settings: { max_width: settings.max_width, tile_height: settings.tile_height, max_tiles: settings.max_tiles } })
346
+ const tiles = images.slice(0, 4).map((i) => `data:${i.media_type};base64,${i.data}`)
347
+ report.triage.screens++
348
+ const lists = []; const results = []
349
+ const screen = await ask(w, { kind: 'screen', context, criteria, images: tiles }, {})
350
+ results.push(screen)
351
+ if (screen.findings) lists.push(screen.findings)
352
+ // the recording of this capture (same walk), as frames
353
+ const rec = entries.filter((e) => e.type === 'recording' && e.node === w.entry.node && e.locale === w.entry.locale && e.viewport === w.entry.viewport && e.captured_at && e.captured_at === w.capture.captured_at).pop()
354
+ if (rec && framesLeft > 0 && !report.triageStop) {
355
+ const side = await readJson(join(runDir, rec.file), null)
356
+ const video = side?.files?.webm || side?.files?.mp4
357
+ if (video) {
358
+ const frames = await chooseFrames({ page: await page(), file: join(runDir, video), taps: (side.taps || []).map((t) => t.t ?? t), max: Math.min(settings.frames_per_step, framesLeft), maxW: settings.max_width })
359
+ framesLeft -= frames.length; report.triage.frames += frames.length
360
+ for (const batch of frameBatches(frames)) {
361
+ const r = await ask(w, { kind: 'motion', context: { ...context, frames: batch.map((f) => ({ t_ms: f.t, why: f.why })) }, images: batch.map((f) => f.data) }, { frames: batch.map((f) => f.t) })
362
+ results.push(r)
363
+ if (r.findings) lists.push(r.findings)
364
+ }
365
+ }
366
+ }
367
+ const done = results.filter((r) => r.findings)
368
+ if (!done.length) { w.triage = { skipped: results[0]?.skipped || null, error: results[0]?.error || null }; continue }
369
+ const findings = mergeFindings([...lists, probeFindings(w.capture)])
370
+ report.triage.findings += findings.length; report.triage.shown += findings.filter((f) => f.shown).length
371
+ w.triage = { findings, criteria: screen.criteria || {}, model: done[0].model, via: done[0].via, cached: done.every((r) => r.cached), requests: done.length, ms: done.reduce((n, r) => n + (r.ms || 0), 0) || null }
372
+ // cascade (#71): criteria Clef is sure about skip the vision evaluator
373
+ if (settings.cascade) w.triageDecided = new Set(Object.entries(screen.criteria || {}).filter(([, p]) => p <= 0.15 || p >= 0.85).map(([id]) => id))
374
+ } catch (error) {
375
+ w.triage = { error: String(error.message || error).slice(0, 300) }
376
+ }
377
+ }
378
+ }))
379
+ },
380
+ async close () { if (browser) await browser.close().catch(() => {}) }
381
+ }
382
+ }
383
+
284
384
  /**
285
385
  * Sends a JEV request through the organization's service (hosted evaluation) when a service token is set.
286
386
  * An allowance used up or a service without hosting stops further hosted calls for this run with one message.
package/src/lib/mail.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // E-mails of a flow, from wherever the project's stack puts them. Every source returns the same
2
- // message shape: { id, subject, from, to: [address], date, html, text }.
2
+ // message shape: { id, subject, from, to: [address], cc: [address], date, html, text }.
3
3
  // mailpit — built in (REST API), optional gate auth;
4
4
  // script — a project module for anything else (letter_opener, Mailosaur, IMAP, the app's own API);
5
5
  // file — `kaloko mail --file`: a message the agent obtained itself (.eml, .html, .json, .txt).
@@ -40,7 +40,7 @@ export function mailpit (mail) {
40
40
  hitId = hit.ID
41
41
  }
42
42
  const m = await get(`/api/v1/message/${encodeURIComponent(hitId)}`)
43
- return { id: m.ID, subject: m.Subject || '', from: address(m.From), to: (m.To || []).map((t) => t.Address), date: m.Date, html: m.HTML || null, text: m.Text || null }
43
+ return { id: m.ID, subject: m.Subject || '', from: address(m.From), to: (m.To || []).map((t) => t.Address), cc: (m.Cc || []).map((t) => t.Address), date: m.Date, html: m.HTML || null, text: m.Text || null }
44
44
  }
45
45
  }
46
46
  }
@@ -95,6 +95,7 @@ export function normalize (m) {
95
95
  subject: m.subject ?? m.Subject ?? '',
96
96
  from: typeof from === 'object' && from ? address(from) : from || '',
97
97
  to: list(m.to ?? (m.To || []).map?.((t) => t.Address ?? t)),
98
+ cc: list(m.cc ?? (m.Cc || []).map?.((t) => t.Address ?? t)),
98
99
  date: m.date ?? m.Date ?? null,
99
100
  html: m.html ?? m.HTML ?? null,
100
101
  text: m.text ?? m.Text ?? null
@@ -109,8 +110,8 @@ export async function readMessageFile (file) {
109
110
  if (ext === '.eml' || ext === '.msg' || ext === '.mbox') return parseEml(raw)
110
111
  if (ext === '.json') return normalize(JSON.parse(raw.toString('utf8')))
111
112
  const body = raw.toString('utf8')
112
- if (ext === '.html' || ext === '.htm') return { id: '', subject: (body.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || '').trim(), from: '', to: [], date: null, html: body, text: null }
113
- return { id: '', subject: '', from: '', to: [], date: null, html: null, text: body }
113
+ if (ext === '.html' || ext === '.htm') return { id: '', subject: (body.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || '').trim(), from: '', to: [], cc: [], date: null, html: body, text: null }
114
+ return { id: '', subject: '', from: '', to: [], cc: [], date: null, html: null, text: body }
114
115
  }
115
116
 
116
117
  // --- RFC 5322 / MIME, enough for what mailers send: multipart, base64, quoted-printable, charsets ---
@@ -175,6 +176,7 @@ export function parseEml (raw) {
175
176
  subject: h('subject'),
176
177
  from: h('from'),
177
178
  to: list(h('to')),
179
+ cc: list(h('cc')),
178
180
  date: top.headers.date ? new Date(top.headers.date).toISOString() : null,
179
181
  html: found.html ?? null,
180
182
  text: found.text ?? null
@@ -63,10 +63,21 @@ export function slimCapture (cap) {
63
63
  ...(cap.design ? { design: cap.design } : {}), ...(cap.capture ? { capture: cap.capture } : {}),
64
64
  ...(cap.criteria_hash ? { criteria_hash: cap.criteria_hash } : {}),
65
65
  ...Object.fromEntries(DIM_FIELDS.filter((k) => cap[k]).map((k) => [k, cap[k]])),
66
- ...(cap.ignore?.length ? { ignore: cap.ignore } : {}), ...(cap.unstable ? { unstable: cap.unstable } : {}), ...(cap.keyboard ? { keyboard: cap.keyboard } : {})
66
+ ...(cap.ignore?.length ? { ignore: cap.ignore } : {}), ...(cap.unstable ? { unstable: cap.unstable } : {}), ...(cap.keyboard ? { keyboard: cap.keyboard } : {}),
67
+ ...(cap.pii ? { pii: cap.pii } : {})
67
68
  }
68
69
  }
69
70
 
71
+ /** Masked personal data of a capture as the canvas marks it (#64): mode, kinds found and up to 200 places. */
72
+ export const piiOf = (p) => ({ mode: p.mode || 'redact', auto: !!p.auto, kinds: p.kinds || {}, rects: (p.rects || []).slice(0, 200) })
73
+
74
+ /** The run's flag "personal data hidden" (#64): set when the run masks automatically or any capture was masked. */
75
+ export function runPii (meta, nodes) {
76
+ const masked = nodes.flatMap((n) => Object.values(n.captures || {}).flatMap((byVp) => Object.values(byVp || {}))).filter((c) => c?.pii)
77
+ if (!meta?.pii && !masked.length) return null
78
+ return { hidden: true, mode: meta?.pii?.mode || masked[0]?.pii?.mode || 'redact', auto: !!meta?.pii, captures: masked.length }
79
+ }
80
+
70
81
  /** The keyboard walk of a capture as the canvas draws it: tab stops (numbered boxes) and the focus screenshots. */
71
82
  export const keyboardOf = (k) => k ? { complete: !!k.complete, total: k.total ?? (k.stops || []).length, stops: (k.stops || []).slice(0, 40), shots: (k.shots || []).slice(0, 10) } : null
72
83
 
@@ -100,12 +111,16 @@ export function assembleNode ({ node, pos, locales, viewportNames: allKeys, capt
100
111
  mail: cap.mail || null, eval: ev ? c.files.eval : null, ...(c.files.spec ? { spec: c.files.spec } : {}),
101
112
  // quick signals from the evaluator (probabilities: purpose met, expected language, looks broken)
102
113
  ...(ev?.signals ? { signals: ev.signals } : {}),
114
+ // what kind of problem Clef saw (#65, experimental): advisory findings, never a verdict
115
+ ...(ev?.findings?.length ? { findings: ev.findings.slice(0, 30) } : {}),
103
116
  ...(c.live ? { live: c.live } : {}),
104
117
  ...(cap.view?.revision ? { view: cap.view } : {}),
105
118
  // regions left out of pixel diffs (#20), regions that moved between repeated shots, the keyboard walk (#19)
106
119
  ...(cap.ignore?.length ? { ignore: cap.ignore.slice(0, 50) } : {}),
107
120
  ...(cap.unstable?.regions?.length ? { unstable: cap.unstable.regions.slice(0, 50) } : {}),
108
- ...(cap.keyboard ? { keyboard: keyboardOf(cap.keyboard) } : {})
121
+ ...(cap.keyboard ? { keyboard: keyboardOf(cap.keyboard) } : {}),
122
+ // personal data hidden on this capture (#64): mode and the places the canvas marks
123
+ ...(cap.pii ? { pii: piiOf(cap.pii) } : {})
109
124
  }
110
125
  if (ev?.semantic || ev?.jev) semanticByKey[`${c.locale}|${c.viewport}`] = ev.semantic || ev.jev
111
126
  if (cap.metadata && !metadata[c.locale]) metadata[c.locale] = cap.metadata