kaloko 0.11.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.
- package/README.md +14 -0
- package/package.json +1 -1
- package/schemas/config.v1.json +31 -0
- package/schemas/run.v1.json +11 -0
- package/schemas/scenario.v1.json +9 -5
- package/skills/kaloko/SKILL.md +3 -1
- package/skills/kaloko/reference/acceptance.md +8 -2
- package/skills/kaloko/reference/evaluation.md +12 -0
- package/src/cli.mjs +25 -10
- package/src/lib/build.mjs +3 -1
- package/src/lib/capture.mjs +72 -28
- package/src/lib/evaluate.mjs +112 -12
- package/src/lib/mail.mjs +6 -4
- package/src/lib/manifest-core.mjs +17 -2
- package/src/lib/pii.mjs +607 -0
- package/src/lib/recording.mjs +11 -2
- package/src/lib/run.mjs +3 -1
- package/src/lib/secrets.mjs +5 -3
- package/src/lib/triage-core.mjs +165 -0
- package/src/lib/triage.mjs +123 -0
- package/templates/scenario.yml +1 -0
- package/viewer/fonts/OFL-IBMPlexMono.txt +93 -0
- package/viewer/fonts/OFL-InstrumentSans.txt +93 -0
- package/viewer/fonts/ibm-plex-mono-400-latin-ext.woff2 +0 -0
- package/viewer/fonts/ibm-plex-mono-400-latin.woff2 +0 -0
- package/viewer/fonts/ibm-plex-mono-500-latin-ext.woff2 +0 -0
- package/viewer/fonts/ibm-plex-mono-500-latin.woff2 +0 -0
- package/viewer/fonts/instrument-sans-latin-ext.woff2 +0 -0
- package/viewer/fonts/instrument-sans-latin.woff2 +0 -0
- package/viewer/index.html +112 -13
package/src/lib/evaluate.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|