scenescout 3.14.1 → 3.16.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 (59) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +90 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +211 -24
  5. package/dist/ci-run.js +374 -62
  6. package/dist/cli.js +221 -47
  7. package/dist/commands.js +42 -4
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1660 -386
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +258 -68
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +233 -18
  16. package/dist/engine/claims.js +234 -12
  17. package/dist/engine/collector.js +851 -78
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/dedup.js +329 -9
  20. package/dist/engine/design.js +355 -43
  21. package/dist/engine/export.js +877 -0
  22. package/dist/engine/fingerprint.js +92 -4
  23. package/dist/engine/flow.js +18 -6
  24. package/dist/engine/forms.js +198 -18
  25. package/dist/engine/journey.js +29 -1
  26. package/dist/engine/lane.js +13 -3
  27. package/dist/engine/launch.js +45 -6
  28. package/dist/engine/limits.js +7 -0
  29. package/dist/engine/live-page.js +49 -2
  30. package/dist/engine/live.js +4 -1
  31. package/dist/engine/memory.js +668 -70
  32. package/dist/engine/open.js +118 -0
  33. package/dist/engine/oracles.js +167 -2
  34. package/dist/engine/plain.js +268 -0
  35. package/dist/engine/png.js +127 -0
  36. package/dist/engine/policy.js +379 -9
  37. package/dist/engine/probes.js +3 -2
  38. package/dist/engine/profiles.js +45 -9
  39. package/dist/engine/project-folder.js +191 -0
  40. package/dist/engine/provider.js +4 -3
  41. package/dist/engine/refresh.js +338 -25
  42. package/dist/engine/replay.js +63 -10
  43. package/dist/engine/report.js +248 -40
  44. package/dist/engine/request.js +317 -23
  45. package/dist/engine/sarif.js +120 -0
  46. package/dist/engine/scripted-login.js +342 -62
  47. package/dist/engine/settle.js +67 -0
  48. package/dist/engine/signed-in.js +256 -0
  49. package/dist/engine/tickets.js +671 -0
  50. package/dist/engine/unload.js +3 -2
  51. package/dist/export-run.js +633 -0
  52. package/dist/first-run.js +581 -0
  53. package/dist/installer.js +267 -3
  54. package/dist/intake.js +104 -0
  55. package/dist/login-run.js +386 -71
  56. package/dist/mcp-server.js +637 -68
  57. package/dist/playbook.js +5 -0
  58. package/package.json +6 -3
  59. package/skills/scenescout/SKILL.md +51 -18
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What a crawl makes of one route it loaded: whether an explicitly requested
3
+ * path joins the route contract, and the summary line it prints. Kept out of
4
+ * browser.ts so both rules can be table-tested without a browser.
5
+ */
6
+ import { isNonPageRoute } from "./fingerprint.js";
7
+ /** The crawl flag for a main area showing only its state, or null. */
8
+ export function mainStateFlag(state) {
9
+ return state === "error" ? "ERROR-VIEW" : state === "loading" ? "STILL-LOADING" : null;
10
+ }
11
+ /**
12
+ * The route an explicitly requested crawl path adds to the route contract,
13
+ * or null. A path someone asked for, that answered with a page and stayed
14
+ * where it was asked, is a route of the app whether or not any link names
15
+ * it: left out, a sweep of forty extra paths left "Routes visited: 37/37"
16
+ * unchanged and the report understated what was checked. A path that
17
+ * redirected is not added (the page it landed on is the route that exists),
18
+ * nor one that failed, nor a dead end (an app that answers 200 for every
19
+ * path), nor one whose main area showed only an error view or a loading
20
+ * placeholder (a client-rendered app answers 200 and draws its not-found
21
+ * view), nor an API or download path. Added routes are remembered across
22
+ * runs, so a probe of made-up paths must not become part of the contract.
23
+ */
24
+ export function crawledRoute(o) {
25
+ if (typeof o.status !== "number" || o.status >= 400 || o.deadEnd || o.mainState)
26
+ return null;
27
+ if (o.loginRedirect || o.landedRoute !== o.requestedRoute)
28
+ return null;
29
+ if (isNonPageRoute(o.requestedRoute))
30
+ return null;
31
+ return o.requestedRoute;
32
+ }
33
+ /**
34
+ * The crawl's one line for a route. A path that ended on another route says
35
+ * so, as scout_navigate does: "/old — 200 · 94 el" read as the old page
36
+ * answering, when the 200 and the 94 elements were the page it landed on.
37
+ * The sign-in bounce has its own flag (AUTH-REDIRECT) and is not repeated.
38
+ * `main` is what the page's main area holds (collector mainRegionTag), so
39
+ * "41 el" alone cannot hide a main area that rendered nothing.
40
+ */
41
+ export function crawlLine(o, counts, flags) {
42
+ const redirected = !o.loginRedirect && o.landedRoute !== o.requestedRoute;
43
+ const allFlags = [...(redirected ? [`REDIRECTED → ${o.landedRoute}`] : []), ...flags];
44
+ return (`${o.path} — ${o.status} · ${counts.elements} el` +
45
+ (counts.main ? ` · ${counts.main}` : "") +
46
+ (counts.missingTestid ? ` · ${counts.missingTestid} no-testid` : "") +
47
+ (counts.unnamed ? ` · ${counts.unnamed} unnamed` : "") +
48
+ (allFlags.length ? ` · ${allFlags.join(" ")}` : ""));
49
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Measuring the finding-dedup decision against the answer keys, and an
3
- * optional model-backed judge to measure beside it.
2
+ * Measuring the finding-dedup decision against the answer keys, and the
3
+ * model-backed judge the store can ask beside its rule.
4
4
  *
5
5
  * The store's dedup rule (memory.ts, isDuplicateFinding) decides whether a
6
6
  * newly filed finding is one it already records. Its mistakes are costly both
@@ -12,14 +12,19 @@
12
12
  *
13
13
  * The model judge asks for a verdict (same / different / unsure) and a
14
14
  * probability through one tool call, so its answer is structured, not parsed
15
- * from prose. It is measured here and not used by the store: the rule stays
16
- * the only thing that dedups until the judge's Brier beats it on these pairs.
17
- * Everything here is pure; the network is the caller's, through `ask`.
15
+ * from prose. Measured on these pairs it beat the rule on both apps
16
+ * (docs/benchmark.md), and every pair it won was a merge the rule missed, so
17
+ * the store asks it only about filings the rule keeps apart (DedupJudge,
18
+ * below): a `scenescout ci` run by default, the MCP server when told to.
19
+ * Nothing here touches the network; the caller's `ask` does.
18
20
  */
19
21
  import { createHash } from "node:crypto";
20
22
  import { archiveApp, classify, findingText } from "./bench.js";
21
23
  import { MIN_FOR_A_VERDICT } from "./calibration.js";
22
- import { findingId, isDuplicateFinding } from "./memory.js";
24
+ import { addUsage, dedupModeFromEnv, judgeKeyConfig, NO_USAGE } from "./ci.js";
25
+ import { withWatchdog } from "./dispatch.js";
26
+ import { findingId, isDuplicateFinding, titleSimilarity } from "./memory.js";
27
+ import { obj } from "./provider.js";
23
28
  const pagesOf = (e) => [e.route, ...(e.alsoOn ?? [])];
24
29
  const norm = (s) => (s ?? "").toLowerCase().replace(/\s+/g, " ").trim();
25
30
  /**
@@ -178,10 +183,12 @@ export function parseJudgement(turn) {
178
183
  * Decide one pair. With no judge the rule decides, as the store does. With a
179
184
  * judge, the model decides when it answers same or different; when it fails
180
185
  * for any reason, or is unsure, the rule decides and the note says why, so a
181
- * run never silently becomes a run of the rule.
186
+ * run never silently becomes a run of the rule. `ruleVerdict` is the rule's
187
+ * verdict when the caller already has it: the store asks only about pairs its
188
+ * rule keeps apart, and its rule reads more of a finding than a pair carries.
182
189
  */
183
- export async function decideDuplicate(p, ask) {
184
- const rule = () => ruleJudgement(p).verdict === "same";
190
+ export async function decideDuplicate(p, ask, ruleVerdict) {
191
+ const rule = ruleVerdict ?? (() => ruleJudgement(p).verdict === "same");
185
192
  if (!ask)
186
193
  return { duplicate: rule(), by: "rule" };
187
194
  let parsed;
@@ -203,6 +210,319 @@ export async function decideDuplicate(p, ask) {
203
210
  return { duplicate: rule(), by: "rule", judgement: parsed.judgement, note: "the model judge was unsure; the current rule decided" };
204
211
  return { duplicate: parsed.judgement.verdict === "same", by: "model", judgement: parsed.judgement };
205
212
  }
213
+ // ── the judge the store asks ────────────────────────────────────────────────
214
+ /** Calls made about one filing at most: its most alike open findings on the page. */
215
+ export const JUDGE_MAX_CALLS = 3;
216
+ /** The longest one judge call may take. */
217
+ export const JUDGE_CALL_MS = 15_000;
218
+ /**
219
+ * The longest the judge may spend on one filing, every call together. Under
220
+ * scout_finding's 60-second watchdog, so a slow provider costs a filing its
221
+ * judge and never the filing.
222
+ */
223
+ export const JUDGE_FILING_MS = 40_000;
224
+ /** Failed calls in a row after which the judge is switched off for the rest of the run. */
225
+ export const JUDGE_MAX_FAILURES = 3;
226
+ /** The most output one judge call may produce: the answer is one short tool call (24 to 26 tokens when measured). */
227
+ export const JUDGE_MAX_OUTPUT_TOKENS = 2_000;
228
+ /**
229
+ * The most of a title, and of evidence, the store's judge shows the model. A
230
+ * filing with a pasted log for evidence still makes a question of bounded
231
+ * size, which a client answering the judge accepts (MAX_JUDGE_KICKOFF_CHARS).
232
+ */
233
+ export const JUDGE_TITLE_CHARS = 500;
234
+ export const JUDGE_EVIDENCE_CHARS = 2_000;
235
+ const routeOf = (state) => state.split("#")[0];
236
+ /** A judge time limit as a log line says it: "15s", or "20ms" for a test's. */
237
+ export const durationText = (ms) => (ms < 1000 ? `${ms}ms` : `${Math.round(ms / 1000)}s`);
238
+ /**
239
+ * The model judge as the store asks it (memory.ts DuplicateJudge). It is
240
+ * asked only about a filing the rule keeps apart from everything stored, and
241
+ * only against the open findings on the filing's own page: the most alike
242
+ * title first, at most `maxCalls` of them, within `filingMs`. "Same" merges;
243
+ * anything else (different, unsure, a contradiction, a failed or slow call)
244
+ * leaves the rule's decision, which is to keep the filing apart. So the judge
245
+ * only ever adds merges the rule missed, which is where every pair it won in
246
+ * the measurement was (docs/benchmark.md).
247
+ *
248
+ * Each kind of fall-back is logged once, the cap once per page, and
249
+ * JUDGE_MAX_FAILURES failed calls in a row switch the judge off for the rest
250
+ * of the run. `log` reaches the operator; the caller redacts keys from it.
251
+ */
252
+ export class DedupJudge {
253
+ ask;
254
+ o;
255
+ counts = { calls: 0, same: 0, different: 0, fellBack: 0, capped: 0, usage: { ...NO_USAGE }, ms: 0 };
256
+ failuresInARow = 0;
257
+ offReason = null;
258
+ logged = new Set();
259
+ maxCalls;
260
+ callMs;
261
+ filingMs;
262
+ now;
263
+ redact;
264
+ constructor(ask, o) {
265
+ this.ask = ask;
266
+ this.o = o;
267
+ this.maxCalls = o.maxCalls ?? JUDGE_MAX_CALLS;
268
+ this.callMs = o.callMs ?? JUDGE_CALL_MS;
269
+ this.filingMs = o.filingMs ?? JUDGE_FILING_MS;
270
+ this.now = o.now ?? Date.now;
271
+ this.redact = o.redact ?? ((text) => text);
272
+ }
273
+ /** What the judge has done this run. */
274
+ get tally() {
275
+ return this.counts;
276
+ }
277
+ /** Why the judge is no longer asked, or null while it is. */
278
+ get off() {
279
+ return this.offReason;
280
+ }
281
+ async judge(incoming, stored) {
282
+ try {
283
+ return await this.decide(incoming, stored);
284
+ }
285
+ catch (err) {
286
+ // decideDuplicate catches whatever a call throws, so this is a fault in this
287
+ // class. It would recur on every filing, so the judge stops here, and says why;
288
+ // the log gets the stack, the report only the message.
289
+ const message = err instanceof Error ? err.message : String(err);
290
+ this.switchOff(`a fault in the judge: ${message}`, err instanceof Error ? err.stack : undefined);
291
+ return null;
292
+ }
293
+ }
294
+ async decide(incoming, stored) {
295
+ if (this.offReason)
296
+ return null;
297
+ const route = routeOf(incoming.state);
298
+ const candidates = stored
299
+ .map((x, i) => ({ x, i, alike: titleSimilarity(x.title, incoming.title) }))
300
+ .filter(({ x }) => x.status !== "resolved" && routeOf(x.state) === route)
301
+ // The most alike first; of two equally alike, the newer.
302
+ .sort((p, q) => q.alike - p.alike || q.i - p.i);
303
+ if (candidates.length > this.maxCalls) {
304
+ this.counts.capped += 1;
305
+ this.once(`cap\u0000${route}`, `dedup judge: ${candidates.length} open findings on ${route} could be the one just filed there; the judge is asked about the ` +
306
+ `${this.maxCalls} most alike, and the rule, which kept it apart from all of them, decides the rest ` +
307
+ `(at most ${this.maxCalls} calls per filing; logged once per page)`);
308
+ }
309
+ const started = this.now();
310
+ for (const { x } of candidates.slice(0, this.maxCalls)) {
311
+ if (this.offReason)
312
+ break;
313
+ const left = this.filingMs - (this.now() - started);
314
+ if (left <= 0) {
315
+ this.once("time", `dedup judge: the ${durationText(this.filingMs)} a filing may take ran out; the rule decided the pairs left (logged once)`);
316
+ break;
317
+ }
318
+ const d = await decideDuplicate({ route, a: shown(x), b: shown(incoming) }, this.timed(Math.min(this.callMs, left)), () => false);
319
+ this.count(d);
320
+ if (d.by === "model" && d.duplicate && d.judgement)
321
+ return { sameAs: x.id, pSame: d.judgement.pSame };
322
+ }
323
+ return null;
324
+ }
325
+ /** The ask, ended at `ms` and counted. */
326
+ timed(ms) {
327
+ return async (system, tools, kickoff) => {
328
+ const started = this.now();
329
+ this.counts.calls += 1;
330
+ try {
331
+ const turn = await withWatchdog("dedup judge", this.ask(system, tools, kickoff, ms), ms, () => null);
332
+ if (!turn)
333
+ throw new Error(`no answer within ${durationText(ms)}`);
334
+ this.counts.usage = addUsage(this.counts.usage, turn.usage);
335
+ return turn;
336
+ }
337
+ finally {
338
+ this.counts.ms += this.now() - started;
339
+ }
340
+ };
341
+ }
342
+ count(d) {
343
+ if (d.by === "model") {
344
+ this.failuresInARow = 0;
345
+ if (d.duplicate)
346
+ this.counts.same += 1;
347
+ else
348
+ this.counts.different += 1;
349
+ return;
350
+ }
351
+ this.counts.fellBack += 1;
352
+ if (d.failure !== "error") {
353
+ // Unsure, or an answer that contradicted itself: the provider answered, so neither counts towards switching off.
354
+ this.failuresInARow = 0;
355
+ this.once(d.failure ?? "unsure", `dedup judge: ${d.note ?? "no usable answer"} (later ones are counted, not logged)`);
356
+ return;
357
+ }
358
+ this.failuresInARow += 1;
359
+ this.once("error", `dedup judge: ${d.note ?? "a call failed"} (later failures are counted, not logged)`);
360
+ if (this.failuresInARow >= JUDGE_MAX_FAILURES)
361
+ this.switchOff(`${this.failuresInARow} failed calls in a row, the last: ${d.note ?? "no detail"}`);
362
+ }
363
+ switchOff(reason, detail) {
364
+ this.offReason = this.redact(reason);
365
+ this.o.log(this.redact(`dedup judge: switched off after ${reason}; the rule decides every filing for the rest of this run${detail ? `\n${detail}` : ""}`));
366
+ }
367
+ once(kind, line) {
368
+ if (this.logged.has(kind))
369
+ return;
370
+ this.logged.add(kind);
371
+ this.o.log(this.redact(line));
372
+ }
373
+ describe() {
374
+ const t = this.counts;
375
+ if (t.calls === 0 && !this.offReason)
376
+ return null;
377
+ const tokens = t.usage.input + t.usage.output;
378
+ return (`the rule, then the model judge (${this.o.label}) for filings the rule kept apart: ${t.calls} call(s), ` +
379
+ `${t.same} same, ${t.different} different, ${t.fellBack} left to the rule` +
380
+ (t.capped ? `; ${t.capped} filing(s) had more open findings on their page than the ${this.maxCalls} it asks about` : "") +
381
+ (tokens > 0 ? `; ${tokens.toLocaleString("en-US")} tokens` : "") +
382
+ `; ${(t.ms / 1000).toFixed(1)}s` +
383
+ (this.offReason ? `; switched off after ${this.offReason}` : ""));
384
+ }
385
+ }
386
+ /** A finding as the store's judge shows it: what was filed, each field cut to a length the question can carry. */
387
+ function shown(f) {
388
+ const cut = (text, max) => (text.length <= max ? text : `${text.slice(0, max)}…`);
389
+ return { title: cut(f.title, JUDGE_TITLE_CHARS), category: f.category, ...(f.evidence ? { evidence: cut(f.evidence, JUDGE_EVIDENCE_CHARS) } : {}) };
390
+ }
391
+ /** Whether a client answers the judge's sampling requests: sampling with tools, and DEDUP_JUDGE_CAPABILITY. */
392
+ export function clientAnswersJudge(caps) {
393
+ return !!caps?.sampling?.tools && !!caps.experimental?.[DEDUP_JUDGE_CAPABILITY];
394
+ }
395
+ /**
396
+ * How the MCP server dedups for a run: what an attach of the run named, else
397
+ * DEDUP_ENV. A judge asks through the client when the client answers the
398
+ * judge (a `scenescout ci` run, which holds the key, so the server never
399
+ * does), else with a key from the server's environment; with neither it is
400
+ * off, and the note says why. A malformed DEDUP_ENV or DEDUP_PROVIDER_ENV is
401
+ * refused, naming the variable.
402
+ */
403
+ export function planDedup(choice, env, caps) {
404
+ const mode = choice ?? dedupModeFromEnv(env);
405
+ if (mode === "rule")
406
+ return { mode, note: "" };
407
+ if (clientAnswersJudge(caps)) {
408
+ const label = "the CI run's model";
409
+ return {
410
+ mode,
411
+ via: "client",
412
+ label,
413
+ note: `\nFinding dedup: the rule, then ${label}, asked about a filing the rule keeps apart from everything recorded, against the open findings on its page.`,
414
+ };
415
+ }
416
+ const key = judgeKeyConfig(env);
417
+ if (!key.ok)
418
+ return { mode, via: "off", why: key.error, note: `\n⚠ DEDUP JUDGE OFF: ${key.error}. The rule decides duplicates.` };
419
+ const { provider, model, effort } = key.resolved;
420
+ const label = `${provider} ${model}, effort ${effort}`;
421
+ return {
422
+ mode,
423
+ via: "key",
424
+ resolved: key.resolved,
425
+ key: key.key,
426
+ label,
427
+ note: `\nFinding dedup: the rule, then a model judge (${label}), asked about a filing the rule keeps apart from everything recorded, against the open findings on its page. ` +
428
+ `Each pair it is asked about (titles, categories, evidence and the page's path) is sent to ${provider}.`,
429
+ };
430
+ }
431
+ // ── the judge through an MCP client ─────────────────────────────────────────
432
+ /**
433
+ * The client capability, under `experimental`, by which a client says it
434
+ * answers the dedup judge's sampling requests. `scenescout ci` declares it:
435
+ * the server's judge then asks the run's model through the run, and the key
436
+ * never enters the server's process. No other client is sent a sampling
437
+ * request.
438
+ */
439
+ export const DEDUP_JUDGE_CAPABILITY = "scenescout/dedup-judge";
440
+ /**
441
+ * The longest question a client answering the judge accepts. The store's judge
442
+ * cuts titles and evidence (JUDGE_TITLE_CHARS, JUDGE_EVIDENCE_CHARS), so its
443
+ * questions always fit; a longer one is refused, and counted as a failed call.
444
+ */
445
+ export const MAX_JUDGE_KICKOFF_CHARS = 20_000;
446
+ /** The sampling request (MCP sampling/createMessage, with tools) that carries one judge call to the client. */
447
+ export function judgeSamplingParams(system, tools, kickoff) {
448
+ return {
449
+ systemPrompt: system,
450
+ messages: [{ role: "user", content: { type: "text", text: kickoff } }],
451
+ tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: { ...t.parameters, type: "object" } })),
452
+ toolChoice: { mode: "required" },
453
+ maxTokens: JUDGE_MAX_OUTPUT_TOKENS,
454
+ includeContext: "none",
455
+ };
456
+ }
457
+ /** An Ask that puts the judge's question to the client. `createMessage` is the server's sampling request. */
458
+ export function samplingAsk(createMessage, callMs = JUDGE_CALL_MS) {
459
+ return async (system, tools, kickoff, limitMs) => turnFromSampling(await createMessage(judgeSamplingParams(system, tools, kickoff), { timeout: Math.min(callMs, limitMs ?? callMs) }));
460
+ }
461
+ /** The client's answer read back as a turn, for parseJudgement. Usage is the client's to count, so the turn carries none. */
462
+ export function turnFromSampling(result) {
463
+ const r = obj(result);
464
+ const content = r ? (Array.isArray(r.content) ? r.content : [r.content]) : [];
465
+ const blocks = content.map(obj).filter((b) => b !== null);
466
+ const calls = blocks
467
+ .filter((b) => b.type === "tool_use")
468
+ .map((b, i) => ({ id: typeof b.id === "string" ? b.id : `call_${i}`, name: typeof b.name === "string" ? b.name : "", input: b.input }));
469
+ const text = blocks
470
+ .filter((b) => b.type === "text")
471
+ .map((b) => String(b.text ?? ""))
472
+ .join("\n")
473
+ .trim();
474
+ const note = calls.length > 0
475
+ ? undefined
476
+ : r?.stopReason === "maxTokens"
477
+ ? "the model's reply was cut at its output limit"
478
+ : text
479
+ ? `the model answered without calling a tool: ${text.slice(0, 200)}`
480
+ : undefined;
481
+ return { text, calls, usage: { ...NO_USAGE }, ...(note ? { note } : {}) };
482
+ }
483
+ /**
484
+ * The client's side: the text of a sampling request shaped as the judge's
485
+ * question, checked for shape and size only. A client answering the judge
486
+ * sends this text on as given, under its own JUDGE_SYSTEM, JUDGE_TOOL and
487
+ * output cap, so the server cannot choose the system prompt, the tools or how
488
+ * much the model may write.
489
+ */
490
+ export function judgeKickoffOf(params) {
491
+ const p = obj(params);
492
+ if (!p)
493
+ return { ok: false, error: "the request has no parameters" };
494
+ const tools = Array.isArray(p.tools) ? p.tools.map(obj) : [];
495
+ if (tools.length !== 1 || tools[0]?.name !== JUDGE_TOOL.name)
496
+ return { ok: false, error: `the request does not offer exactly the ${JUDGE_TOOL.name} tool` };
497
+ const messages = Array.isArray(p.messages) ? p.messages.map(obj) : [];
498
+ if (messages.length !== 1 || messages[0]?.role !== "user")
499
+ return { ok: false, error: "the request is not one user message" };
500
+ const content = messages[0].content;
501
+ const blocks = (Array.isArray(content) ? content : [content]).map(obj);
502
+ const text = blocks.length === 1 && blocks[0]?.type === "text" && typeof blocks[0].text === "string" ? blocks[0].text : "";
503
+ if (!text.trim())
504
+ return { ok: false, error: "the message is not one block of text" };
505
+ if (text.length > MAX_JUDGE_KICKOFF_CHARS)
506
+ return { ok: false, error: `the question is longer than ${MAX_JUDGE_KICKOFF_CHARS} characters` };
507
+ return { ok: true, kickoff: text };
508
+ }
509
+ /** The client's answer: the judge's turn as a sampling result, its tool call as tool_use content. */
510
+ export function samplingResultOf(turn, model) {
511
+ const content = [];
512
+ for (const c of turn.calls) {
513
+ const input = obj(c.input);
514
+ if (input && !c.argsError)
515
+ content.push({ type: "tool_use", id: c.id, name: c.name, input });
516
+ else
517
+ content.push({ type: "text", text: `${c.name || "a tool"} was called with arguments that could not be read: ${c.argsError ?? "not an object"}` });
518
+ }
519
+ const said = [turn.text, turn.note].filter((s) => !!s && s.trim()).join("\n");
520
+ if (said)
521
+ content.push({ type: "text", text: said });
522
+ if (content.length === 0)
523
+ content.push({ type: "text", text: "(no answer)" });
524
+ return { model, role: "assistant", content, stopReason: content.some((b) => b.type === "tool_use") ? "toolUse" : "endTurn" };
525
+ }
206
526
  /**
207
527
  * Accuracy, Brier and ECE of a decider over labelled pairs. `judgements[i]`
208
528
  * answers `labels[i]`; null is a judge that gave no usable answer. The