kaloko 0.4.8 → 0.4.9
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 +23 -1
- package/package.json +1 -1
- package/schemas/config.v1.json +26 -0
- package/schemas/scenario.v1.json +32 -0
- package/schemas/spec.v1.json +60 -0
- package/skills/kaloko/SKILL.md +12 -2
- package/skills/kaloko/reference/acceptance.md +34 -2
- package/skills/kaloko/reference/design.md +35 -2
- package/skills/kaloko/reference/evaluation.md +46 -3
- package/skills/kaloko/reference/execution.md +18 -0
- package/skills/kaloko/reference/mcp.md +3 -1
- package/src/cli.mjs +44 -4
- package/src/lib/build.mjs +30 -5
- package/src/lib/capture.mjs +43 -1
- package/src/lib/design-cli.mjs +7 -3
- package/src/lib/devspec.mjs +821 -0
- package/src/lib/doctor.mjs +7 -0
- package/src/lib/drift.mjs +16 -8
- package/src/lib/evaluate.mjs +94 -12
- package/src/lib/keyboard.mjs +261 -0
- package/src/lib/manifest-core.mjs +16 -5
- package/src/lib/pack-help.mjs +7 -0
- package/src/lib/packs.mjs +9 -2
- package/src/lib/png.mjs +61 -0
- package/src/lib/probe.mjs +57 -0
- package/src/lib/recheck.mjs +3 -1
- package/src/lib/spec-cli.mjs +90 -0
- package/src/lib/stability-core.mjs +114 -0
- package/src/lib/stability.mjs +249 -0
- package/src/lib/vision-core.mjs +132 -0
- package/src/lib/vision.mjs +127 -0
- package/templates/kaloko.config.yml +6 -0
- package/viewer/index.html +500 -44
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Vision evaluator (#17), shared by the CLI and the service: which criteria go to it, the request for a
|
|
2
|
+
// vision-capable model (Anthropic Messages API with image input and a strict JSON answer), the parsed answer, and the
|
|
3
|
+
// one-sentence reason every semantic verdict carries. Pure: no Node APIs (the worker imports it).
|
|
4
|
+
//
|
|
5
|
+
// The default text evaluator (JEV) reads a reduced page state and returns a probability only; it takes no images
|
|
6
|
+
// and gives no reasons. This second provider sees the screenshot and explains itself. It is asked only for criteria
|
|
7
|
+
// marked `vision: true` and for text answers in the uncertain band, so the cost stays bounded.
|
|
8
|
+
|
|
9
|
+
export const VISION_PROMPT_VERSION = 'kaloko-vision-v1'
|
|
10
|
+
export const VISION_DEFAULTS = {
|
|
11
|
+
provider: 'anthropic', model: 'claude-opus-5-5', api_key_env: 'ANTHROPIC_API_KEY', base_url: 'https://api.anthropic.com',
|
|
12
|
+
when: 'uncertain', uncertain: [0.15, 0.85], max_calls: 40, max_width: 1000, tile_height: 1400, max_tiles: 2,
|
|
13
|
+
effort: 'low', reason_language: 'en', hosted: true, concurrency: 3
|
|
14
|
+
}
|
|
15
|
+
/** Models the service accepts for hosted vision questions. */
|
|
16
|
+
export const VISION_MODEL = /^claude-[a-z0-9-]{3,40}$/
|
|
17
|
+
/** One hosted vision question counts as this many questions of the monthly allowance. */
|
|
18
|
+
export const VISION_WEIGHT = 5
|
|
19
|
+
export const MAX_VISION_IMAGES = 5
|
|
20
|
+
// models that take the server-side refusal fallback ("default" routing)
|
|
21
|
+
const FALLBACK_MODELS = new Set(['claude-opus-5-5', 'claude-opus-5', 'claude-fable-5-1', 'claude-sonnet-5-5'])
|
|
22
|
+
|
|
23
|
+
/** Vision settings from the config (`evaluators.vision`; false or when: off turns it off). */
|
|
24
|
+
export function visionSettings (config) {
|
|
25
|
+
const v = config?.evaluators?.vision
|
|
26
|
+
if (v === false || v === null) return null
|
|
27
|
+
const s = { ...VISION_DEFAULTS, ...(v || {}) }
|
|
28
|
+
return s.when === 'off' ? null : s
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The semantic criteria of one capture that go to the vision evaluator: flagged ones (check.vision) always,
|
|
33
|
+
* every one with when: always, and with when: uncertain those whose text score lies strictly inside the band.
|
|
34
|
+
* Console triage stays with the text evaluator.
|
|
35
|
+
*/
|
|
36
|
+
export function visionCriteria ({ criteria, answers = {}, settings }) {
|
|
37
|
+
if (!settings) return []
|
|
38
|
+
const [lo, hi] = settings.uncertain || VISION_DEFAULTS.uncertain
|
|
39
|
+
return criteria.filter((c) => {
|
|
40
|
+
if (c.check?.type !== 'semantic' || c.check.about === 'console') return false
|
|
41
|
+
if (c.check.vision === true) return true
|
|
42
|
+
if (settings.when === 'always') return true
|
|
43
|
+
if (settings.when !== 'uncertain') return false
|
|
44
|
+
const s = answers?.[c.id]?.noul
|
|
45
|
+
return s != null && s > lo && s < hi
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SYSTEM = 'You check web pages captured during acceptance tests. You see screenshots of one page and answer yes/no questions about it. Judge only what is visible in the images and the stated facts; do not guess about content you cannot see. For each question give the probability (0 to 1) that the answer is yes, and one short sentence that names what in the screenshot decided it.'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The Messages API request body for one capture.
|
|
53
|
+
* questions: [{ id, question, true?, false?, image? }] (image: label of the crop meant for this question)
|
|
54
|
+
* images: [{ label, media_type, data }] (base64, at most MAX_VISION_IMAGES)
|
|
55
|
+
*/
|
|
56
|
+
export function buildVisionRequest ({ model, effort = 'low', context = {}, questions, images, reasonLanguage = 'en' }) {
|
|
57
|
+
const content = []
|
|
58
|
+
for (const img of images.slice(0, MAX_VISION_IMAGES)) {
|
|
59
|
+
content.push({ type: 'text', text: `Image "${img.label}"` })
|
|
60
|
+
content.push({ type: 'image', source: { type: 'base64', media_type: img.media_type, data: img.data } })
|
|
61
|
+
}
|
|
62
|
+
const facts = [
|
|
63
|
+
context.purpose ? `What this screen must achieve: ${context.purpose}` : null,
|
|
64
|
+
context.url ? `URL: ${context.url}` : null,
|
|
65
|
+
context.locale ? `Expected language: ${context.locale}` : null,
|
|
66
|
+
context.viewport ? `Viewport: ${context.viewport}` : null
|
|
67
|
+
].filter(Boolean)
|
|
68
|
+
const list = questions.map((q) => ({
|
|
69
|
+
id: q.id,
|
|
70
|
+
question: q.question,
|
|
71
|
+
...(q.true ? { counts_as_yes: q.true } : {}),
|
|
72
|
+
...(q.false ? { counts_as_no: q.false } : {}),
|
|
73
|
+
look_at: q.image ? `image "${q.image}" (the part of the page the question is about)` : 'the full-page images'
|
|
74
|
+
}))
|
|
75
|
+
content.push({ type: 'text', text: `${facts.join('\n')}\n\nQuestions (JSON):\n${JSON.stringify(list, null, 1)}\n\nAnswer every question once, by id. "reason": one sentence of at most 25 words${reasonLanguage && reasonLanguage !== 'en' ? ` in the language with code "${reasonLanguage}"` : ''}, saying what you saw.` })
|
|
76
|
+
const body = {
|
|
77
|
+
model,
|
|
78
|
+
max_tokens: 4096,
|
|
79
|
+
system: SYSTEM,
|
|
80
|
+
messages: [{ role: 'user', content }],
|
|
81
|
+
output_config: {
|
|
82
|
+
effort,
|
|
83
|
+
format: {
|
|
84
|
+
type: 'json_schema',
|
|
85
|
+
schema: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
properties: { answers: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, probability: { type: 'number' }, reason: { type: 'string' } }, required: ['id', 'probability', 'reason'], additionalProperties: false } } },
|
|
88
|
+
required: ['answers'],
|
|
89
|
+
additionalProperties: false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// a declined request is re-run on a fallback model in the same call instead of ending without answers
|
|
95
|
+
if (FALLBACK_MODELS.has(model)) body.fallbacks = 'default'
|
|
96
|
+
return body
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Headers of the Messages API call (beta header only with the refusal fallback). */
|
|
100
|
+
export function visionHeaders (apiKey, body) {
|
|
101
|
+
return { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json', ...(body.fallbacks ? { 'anthropic-beta': 'server-side-fallback-2026-07-01' } : {}) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sentence = (s) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); const m = t.match(/^.{10,300}?[.!?](?=\s|$)/); return (m ? m[0] : t).slice(0, 300) }
|
|
105
|
+
|
|
106
|
+
/** { model, answers: { id: { probability, reason } }, usage } from a Messages API response; throws on refusal. */
|
|
107
|
+
export function parseVisionResponse (resp, ids = null) {
|
|
108
|
+
if (resp?.stop_reason === 'refusal') throw new Error(`the vision evaluator declined (${resp.stop_details?.category || 'refusal'})`)
|
|
109
|
+
const text = (resp?.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('')
|
|
110
|
+
let parsed
|
|
111
|
+
try { parsed = JSON.parse(text) } catch { throw new Error(`the vision evaluator did not answer in JSON${resp?.stop_reason === 'max_tokens' ? ' (cut off)' : ''}`) }
|
|
112
|
+
const answers = {}
|
|
113
|
+
for (const a of parsed?.answers || []) {
|
|
114
|
+
if (!a || typeof a.id !== 'string' || (ids && !ids.includes(a.id))) continue
|
|
115
|
+
const p = Number(a.probability)
|
|
116
|
+
if (!Number.isFinite(p)) continue
|
|
117
|
+
answers[a.id] = { probability: Math.round(Math.min(1, Math.max(0, p)) * 1000) / 1000, reason: sentence(a.reason) }
|
|
118
|
+
}
|
|
119
|
+
return { model: resp.model || null, answers, usage: resp.usage ? { input_tokens: resp.usage.input_tokens ?? null, output_tokens: resp.usage.output_tokens ?? null } : null }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The one sentence stored with a semantic verdict when no model explained it (the text evaluator returns a
|
|
124
|
+
* probability only): what was asked of what, and how sure the answer was.
|
|
125
|
+
*/
|
|
126
|
+
export function templateReason ({ score, scope = null, about = null, verdict = null }) {
|
|
127
|
+
if (score == null) return null
|
|
128
|
+
const p = Math.round(score * 100)
|
|
129
|
+
const what = about === 'console' ? 'the console errors' : scope ? `the text of ${scope}` : 'the page text'
|
|
130
|
+
const sure = verdict === 'pass' || verdict === 'fail' ? 'clear enough for a verdict' : 'not sure either way'
|
|
131
|
+
return `Judged from ${what} without the screenshot: ${p} % yes, ${sure}.`
|
|
132
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Vision evaluator calls from the CLI (#17): screenshots scaled and cut in a browser page (WebP and PNG decode
|
|
2
|
+
// there, no native dependency), one request per capture with the criteria that need it, cached by content like
|
|
3
|
+
// the text evaluator. With ANTHROPIC_API_KEY (or the configured env) the call goes to the provider directly;
|
|
4
|
+
// without it, through the organization's service, which counts it against the monthly allowance.
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { readFile } from 'node:fs/promises'
|
|
7
|
+
import { readJson, writeJson, sha256 } from './util.mjs'
|
|
8
|
+
import { buildVisionRequest, parseVisionResponse, visionHeaders, VISION_PROMPT_VERSION, MAX_VISION_IMAGES } from './vision-core.mjs'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Images for one capture: tiles of the full page from the top (when a question needs the whole page) and a crop per
|
|
12
|
+
* scoped criterion (its highlight box, padded). Runs in `page` (a blank browser page).
|
|
13
|
+
* crops: [{ id, box: { x, y, w, h } }] in CSS px of the captured page; cssWidth: the page width in CSS px.
|
|
14
|
+
* @returns {Promise<{ images: Array<{ label, media_type, data, id? }>, failed: string[] }>} failed: crops that could not be cut (their
|
|
15
|
+
* questions look at the full page)
|
|
16
|
+
*/
|
|
17
|
+
export async function prepareImages ({ page, file, cssWidth, crops = [], full = true, settings }) {
|
|
18
|
+
const src = `data:image/${file.endsWith('.png') ? 'png' : 'webp'};base64,${(await readFile(file)).toString('base64')}`
|
|
19
|
+
return page.evaluate(async ({ src, cssWidth, crops, full, maxW, tileH, maxTiles, maxImages }) => {
|
|
20
|
+
const img = await new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = () => no(new Error('the screenshot could not be decoded')); i.src = src })
|
|
21
|
+
const k = img.naturalWidth / (cssWidth || img.naturalWidth)
|
|
22
|
+
const out = []
|
|
23
|
+
const encode = (c) => c.toDataURL('image/webp', 0.82).split(',')[1]
|
|
24
|
+
const draw = (sx, sy, sw, sh, dw, dh) => { const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(dw)); c.height = Math.max(1, Math.round(dh)); const g = c.getContext('2d'); g.fillStyle = '#fff'; g.fillRect(0, 0, c.width, c.height); g.drawImage(img, sx, sy, sw, sh, 0, 0, c.width, c.height); return encode(c) }
|
|
25
|
+
const failed = []
|
|
26
|
+
for (const { id, box } of crops) {
|
|
27
|
+
if (out.length >= maxImages - (full ? maxTiles : 0)) { failed.push(id); continue }
|
|
28
|
+
const pad = 24
|
|
29
|
+
const sx = Math.max(0, (box.x - pad) * k); const sy = Math.max(0, (box.y - pad) * k)
|
|
30
|
+
const sw = Math.min(img.naturalWidth - sx, (box.w + pad * 2) * k); let sh = Math.min(img.naturalHeight - sy, (box.h + pad * 2) * k)
|
|
31
|
+
if (sw < 4 || sh < 4) { failed.push(id); continue }
|
|
32
|
+
const s = Math.min(1, maxW / sw)
|
|
33
|
+
if (sh * s > tileH * 1.2) sh = (tileH * 1.2) / s // a very tall region: its top part
|
|
34
|
+
out.push({ id, label: `crop ${id}`, media_type: 'image/webp', data: draw(sx, sy, sw, sh, sw * s, sh * s) })
|
|
35
|
+
}
|
|
36
|
+
if (full || failed.length) {
|
|
37
|
+
const s = Math.min(1, maxW / img.naturalWidth)
|
|
38
|
+
const H = img.naturalHeight * s
|
|
39
|
+
const parts = Math.ceil(H / tileH)
|
|
40
|
+
const tiles = Math.min(maxTiles, parts, maxImages - out.length)
|
|
41
|
+
const list = []
|
|
42
|
+
for (let t = 0; t < tiles; t++) {
|
|
43
|
+
const h = Math.min(tileH, H - t * tileH)
|
|
44
|
+
const label = parts === 1 ? 'page' : `page, part ${t + 1} of ${parts} from the top${t === tiles - 1 && tiles < parts ? ' (the rest of the page is not shown)' : ''}`
|
|
45
|
+
list.push({ label, media_type: 'image/webp', data: draw(0, (t * tileH) / s, img.naturalWidth, h / s, img.naturalWidth * s, h) })
|
|
46
|
+
}
|
|
47
|
+
out.unshift(...list)
|
|
48
|
+
}
|
|
49
|
+
return { images: out, failed }
|
|
50
|
+
}, { src, cssWidth, crops, full, maxW: settings.max_width, tileH: settings.tile_height, maxTiles: settings.max_tiles, maxImages: MAX_VISION_IMAGES })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The crops and questions for one capture: a scoped criterion (or one with a highlight) gets the crop of its first
|
|
55
|
+
* box unless that box is most of the page; the rest look at the full page.
|
|
56
|
+
*/
|
|
57
|
+
export function visionPlan ({ criteria, capture }) {
|
|
58
|
+
const page = capture.size ? capture.size.width * capture.size.height : 0
|
|
59
|
+
const crops = []; const questions = []
|
|
60
|
+
for (const c of criteria) {
|
|
61
|
+
const box = (c.check.scope || c.highlight) ? capture.boxes?.[c.id]?.[0] : null
|
|
62
|
+
const useCrop = box && box.w > 0 && box.h > 0 && (!page || (box.w * box.h) / page < 0.6)
|
|
63
|
+
if (useCrop) crops.push({ id: c.id, box })
|
|
64
|
+
questions.push({ id: c.id, question: c.check.question, ...(c.check.true ? { true: c.check.true } : {}), ...(c.check.false ? { false: c.check.false } : {}), ...(useCrop ? { image: `crop ${c.id}` } : {}) })
|
|
65
|
+
}
|
|
66
|
+
return { crops, questions, full: questions.some((q) => !q.image) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Asks the vision evaluator with a content-addressed cache. Returns { answers, model, usage, cached, via } or
|
|
71
|
+
* { skipped } / { error }. `hosted(payload)` sends through the service when no local key is set; `allowCall()` is asked
|
|
72
|
+
* before every uncached request (false: skipped).
|
|
73
|
+
*/
|
|
74
|
+
export async function askVision ({ payload, settings, cacheDir, fetchImpl = fetch, hosted = null, allowCall = () => true }) {
|
|
75
|
+
const body = buildVisionRequest({ model: settings.model, effort: settings.effort, context: payload.context, questions: payload.questions, images: payload.images, reasonLanguage: settings.reason_language })
|
|
76
|
+
const key = sha256(JSON.stringify(body) + VISION_PROMPT_VERSION)
|
|
77
|
+
const cacheFile = join(cacheDir, `${key}.json`)
|
|
78
|
+
const cached = await readJson(cacheFile, null)
|
|
79
|
+
if (cached) return { ...cached, cached: true, key }
|
|
80
|
+
const apiKey = process.env[settings.api_key_env]
|
|
81
|
+
// uncached requests are bounded per evaluation (max_calls); the rest keep the text answer
|
|
82
|
+
if ((apiKey || (hosted && settings.hosted !== false)) && !allowCall()) return { skipped: `vision call limit reached (max_calls ${settings.max_calls})` }
|
|
83
|
+
if (!apiKey && hosted && settings.hosted !== false) {
|
|
84
|
+
const r = await hosted({ model: settings.model, effort: settings.effort, reason_language: settings.reason_language, ...payload })
|
|
85
|
+
if (r.skipped || r.error) return r
|
|
86
|
+
const out = { answers: r.response.answers || {}, model: r.response.model || settings.model, usage: r.response.usage || null, via: 'qawalk', hosted: r.response.qawalk || null }
|
|
87
|
+
await writeJson(cacheFile, out)
|
|
88
|
+
return { ...out, cached: !!r.response.qawalk?.cached, key }
|
|
89
|
+
}
|
|
90
|
+
if (!apiKey) return { skipped: `${settings.api_key_env} is not set${hosted ? '' : ' and the service is not configured (KALOKO_TOKEN)'}` }
|
|
91
|
+
const ids = payload.questions.map((q) => q.id)
|
|
92
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
93
|
+
let res
|
|
94
|
+
try {
|
|
95
|
+
res = await fetchImpl(`${settings.base_url.replace(/\/$/, '')}/v1/messages`, { method: 'POST', headers: visionHeaders(apiKey, body), body: JSON.stringify(body) })
|
|
96
|
+
} catch (error) { return { error: `vision evaluator unreachable: ${error.message}` } }
|
|
97
|
+
if (res.status === 429 || res.status === 529 || res.status === 503) {
|
|
98
|
+
const wait = Number(res.headers.get('retry-after')) * 1000 || 1000 * 2 ** attempt
|
|
99
|
+
await new Promise((r) => setTimeout(r, Math.min(wait, 20000)))
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (!res.ok) return { error: `vision evaluator HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` }
|
|
103
|
+
let parsed
|
|
104
|
+
try { parsed = parseVisionResponse(await res.json(), ids) } catch (error) { return { error: error.message } }
|
|
105
|
+
const out = { ...parsed, model: parsed.model || settings.model, via: 'own-key' }
|
|
106
|
+
await writeJson(cacheFile, out)
|
|
107
|
+
return { ...out, cached: false, key }
|
|
108
|
+
}
|
|
109
|
+
return { error: 'vision evaluator rate limited after 4 attempts' }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Sends a vision payload through the organization's service. An allowance used up or a service without hosted
|
|
114
|
+
* vision stops further hosted vision calls for this evaluation with one message.
|
|
115
|
+
*/
|
|
116
|
+
export function hostedVisionSender (config, report) {
|
|
117
|
+
let client = null
|
|
118
|
+
return async (payload) => {
|
|
119
|
+
if (report.visionStop) return { skipped: report.visionStop }
|
|
120
|
+
try { if (!client) { const { service } = await import('./service.mjs'); client = service(config) } } catch { return { skipped: 'no vision key (ANTHROPIC_API_KEY) and no service token (KALOKO_TOKEN) for hosted evaluation' } }
|
|
121
|
+
try { return { response: await client.request('POST', `/orgs/${config.org}/semantic/vision`, payload) } } catch (e) {
|
|
122
|
+
const m = String(e.message || e)
|
|
123
|
+
if (/HTTP (402|403|404|503)/.test(m)) { report.visionStop = m.replace(/^.*HTTP \d+: /, ''); return { skipped: report.visionStop } }
|
|
124
|
+
return { error: m }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -69,6 +69,12 @@ evaluators:
|
|
|
69
69
|
model: jev-1.13.0
|
|
70
70
|
api_key_env: TYPESAFE_API_KEY
|
|
71
71
|
thresholds: { pass: 0.85, fail: 0.15 }
|
|
72
|
+
# second look with the screenshot for criteria with vision: true and unsure text answers (false: off)
|
|
73
|
+
vision:
|
|
74
|
+
model: claude-opus-5-5
|
|
75
|
+
api_key_env: ANTHROPIC_API_KEY
|
|
76
|
+
when: uncertain
|
|
77
|
+
max_calls: 40
|
|
72
78
|
|
|
73
79
|
share:
|
|
74
80
|
url: https://kaloko.app
|