scenescout 3.12.0 → 3.13.1

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.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Measuring the finding-dedup decision against the answer keys, and an
3
+ * optional model-backed judge to measure beside it.
4
+ *
5
+ * The store's dedup rule (memory.ts, isDuplicateFinding) decides whether a
6
+ * newly filed finding is one it already records. Its mistakes are costly both
7
+ * ways: a wrong merge loses a finding, a missed merge shows one bug twice. This
8
+ * file turns the archived benchmark runs into labelled pairs — two findings on
9
+ * one page, "same" when the key classifies both to one entry and "different"
10
+ * when it classifies them to two — and scores any decider on them with
11
+ * accuracy, Brier and ECE.
12
+ *
13
+ * The model judge asks for a verdict (same / different / unsure) and a
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`.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { archiveApp, classify, findingText } from "./bench.js";
21
+ import { MIN_FOR_A_VERDICT } from "./calibration.js";
22
+ import { findingId, isDuplicateFinding } from "./memory.js";
23
+ const pagesOf = (e) => [e.route, ...(e.alsoOn ?? [])];
24
+ const norm = (s) => (s ?? "").toLowerCase().replace(/\s+/g, " ").trim();
25
+ /**
26
+ * Every pair of findings, within one app across all its archived runs, whose
27
+ * key entries share a page, labelled by the key. Across runs because that is
28
+ * where the store dedups: a finding filed by one run against one an earlier
29
+ * run left. Findings with identical category, title and evidence are collapsed
30
+ * first, so a phrasing repeated in many runs is one finding, not a crowd of
31
+ * trivially-same pairs.
32
+ *
33
+ * The labels come from the key's patterns, so they are only as good as the
34
+ * key: a finding the key misclassifies gives a wrong label.
35
+ */
36
+ export function buildPairs(archives, keys) {
37
+ const out = { pairs: [], findings: 0, identicalText: 0, unmatched: 0, ambiguous: 0, nonDefect: 0, archivesWithoutKey: 0 };
38
+ const byApp = new Map();
39
+ const seen = new Set();
40
+ for (const archive of archives) {
41
+ const app = archiveApp(archive);
42
+ const key = keys[app];
43
+ if (!key) {
44
+ out.archivesWithoutKey += 1;
45
+ continue;
46
+ }
47
+ const list = byApp.get(app) ?? [];
48
+ byApp.set(app, list);
49
+ for (const f of archive.findings) {
50
+ const text = `${app}|${norm(f.category)}|${norm(f.title)}|${norm(f.evidence)}`;
51
+ if (seen.has(text)) {
52
+ out.identicalText += 1;
53
+ continue;
54
+ }
55
+ seen.add(text);
56
+ const c = classify(findingText(f), key);
57
+ if (c === null)
58
+ out.unmatched += 1;
59
+ else if (c.kind === "ambiguous")
60
+ out.ambiguous += 1;
61
+ else if (c.kind === "nonDefect")
62
+ out.nonDefect += 1;
63
+ else {
64
+ out.findings += 1;
65
+ list.push({ f: { title: f.title, category: f.category ?? "", evidence: f.evidence, keyId: c.entry.id, run: archive.run }, pages: pagesOf(c.entry) });
66
+ }
67
+ }
68
+ }
69
+ for (const [app, list] of byApp) {
70
+ for (let i = 0; i < list.length; i++) {
71
+ for (let j = i + 1; j < list.length; j++) {
72
+ const route = list[i].pages.find((p) => list[j].pages.includes(p));
73
+ if (route === undefined)
74
+ continue;
75
+ out.pairs.push({ app, route, a: list[i].f, b: list[j].f, same: list[i].f.keyId === list[j].f.keyId });
76
+ }
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ /**
82
+ * At most `cap` pairs, chosen by a hash of their texts: the same pairs every
83
+ * time for the same archives, whatever order they were built in, and no
84
+ * preference for the runs that happen to be listed first.
85
+ */
86
+ export function samplePairs(pairs, cap) {
87
+ if (!Number.isInteger(cap) || cap < 1)
88
+ throw new Error(`The pair cap must be a positive whole number, not ${cap}.`);
89
+ const h = (p) => createHash("sha256")
90
+ .update([p.app, p.route, p.a.run, p.a.title, p.a.evidence ?? "", p.b.run, p.b.title, p.b.evidence ?? ""].join("\n"))
91
+ .digest("hex");
92
+ return pairs
93
+ .map((p) => ({ p, k: h(p) }))
94
+ .sort((x, y) => (x.k < y.k ? -1 : x.k > y.k ? 1 : 0))
95
+ .slice(0, cap)
96
+ .map((x) => x.p);
97
+ }
98
+ const asStored = (f, route) => ({ title: f.title, category: f.category, evidence: f.evidence, detail: "", state: route });
99
+ /**
100
+ * The store's own rule on a pair, as a judgement: the earlier finding is the
101
+ * stored one, the later the one being filed. It states no probability, so its
102
+ * probability is its verdict, 1 or 0.
103
+ */
104
+ export function ruleJudgement(p) {
105
+ const existing = { ...asStored(p.a, p.route), id: findingId(asStored(p.a, p.route)) };
106
+ const same = isDuplicateFinding(existing, asStored(p.b, p.route));
107
+ return { verdict: same ? "same" : "different", pSame: same ? 1 : 0 };
108
+ }
109
+ // ── the model judge ─────────────────────────────────────────────────────────
110
+ export const JUDGE_TOOL = {
111
+ name: "judge_pair",
112
+ description: "Record whether the two findings describe one defect.",
113
+ parameters: {
114
+ type: "object",
115
+ properties: {
116
+ verdict: { type: "string", enum: ["same", "different", "unsure"] },
117
+ p_same: { type: "number", minimum: 0, maximum: 1, description: "Probability the two findings are one defect." },
118
+ },
119
+ required: ["verdict", "p_same"],
120
+ additionalProperties: false,
121
+ },
122
+ };
123
+ export const JUDGE_SYSTEM = "You decide whether two findings filed by exploratory testers of one web app describe the SAME defect or DIFFERENT defects. " +
124
+ "Same: one root cause a single fix would close, however differently it is worded, categorised or evidenced. " +
125
+ "Different: two things a developer would fix separately, even on one page, one control or one endpoint. " +
126
+ "Answer only by calling judge_pair once, with no prose. verdict is same, different or unsure; p_same is your calibrated probability that they are one defect: " +
127
+ "about 0.5 when the text cannot tell, and near 0 or 1 only when it plainly can. Use unsure only when the findings give too little to go on.";
128
+ /** What the judge is shown: the fields a tester filed, nothing from the key. */
129
+ export function judgeKickoff(route, a, b) {
130
+ const show = (f) => ({ title: f.title, category: f.category, evidence: f.evidence ?? "" });
131
+ return `Both findings were filed on the page ${route}.\n\nFinding A: ${JSON.stringify(show(a))}\nFinding B: ${JSON.stringify(show(b))}`;
132
+ }
133
+ /**
134
+ * Read the judge's one tool call. Anything else — no call, another tool, a
135
+ * verdict outside the three, a probability outside 0–1 or one that
136
+ * contradicts its own verdict — is an error, never a guess at what was meant.
137
+ */
138
+ export function parseJudgement(turn) {
139
+ const call = turn.calls.find((c) => c.name === JUDGE_TOOL.name);
140
+ if (!call)
141
+ return { ok: false, error: turn.note ?? `the model did not call ${JUDGE_TOOL.name}` };
142
+ if (call.argsError)
143
+ return { ok: false, error: call.argsError };
144
+ const input = call.input;
145
+ const verdict = input?.verdict;
146
+ const p = input?.p_same;
147
+ if (verdict !== "same" && verdict !== "different" && verdict !== "unsure")
148
+ return { ok: false, error: `the verdict ${JSON.stringify(verdict)} is not same, different or unsure` };
149
+ if (typeof p !== "number" || !Number.isFinite(p) || p < 0 || p > 1)
150
+ return { ok: false, error: `p_same ${JSON.stringify(p)} is not a probability` };
151
+ if ((verdict === "same" && p < 0.5) || (verdict === "different" && p > 0.5))
152
+ return { ok: false, error: `the verdict ${verdict} contradicts p_same ${p}` };
153
+ return { ok: true, judgement: { verdict, pSame: p } };
154
+ }
155
+ /**
156
+ * Decide one pair. With no judge the rule decides, as the store does. With a
157
+ * judge, the model decides when it answers same or different; when it fails
158
+ * for any reason, or is unsure, the rule decides and the note says why, so a
159
+ * run never silently becomes a run of the rule.
160
+ */
161
+ export async function decideDuplicate(p, ask) {
162
+ const rule = () => ruleJudgement(p).verdict === "same";
163
+ if (!ask)
164
+ return { duplicate: rule(), by: "rule" };
165
+ let parsed;
166
+ try {
167
+ parsed = parseJudgement(await ask(JUDGE_SYSTEM, [JUDGE_TOOL], judgeKickoff(p.route, p.a, p.b)));
168
+ }
169
+ catch (err) {
170
+ parsed = { ok: false, error: err instanceof Error ? err.message : String(err) };
171
+ }
172
+ if (!parsed.ok)
173
+ return { duplicate: rule(), by: "rule", note: `the model judge failed (${parsed.error}); the current rule decided` };
174
+ if (parsed.judgement.verdict === "unsure")
175
+ return { duplicate: rule(), by: "rule", judgement: parsed.judgement, note: "the model judge was unsure; the current rule decided" };
176
+ return { duplicate: parsed.judgement.verdict === "same", by: "model", judgement: parsed.judgement };
177
+ }
178
+ /**
179
+ * Accuracy, Brier and ECE of a decider over labelled pairs. `judgements[i]`
180
+ * answers `labels[i]`; null is a judge that gave no usable answer. The
181
+ * probability is scored against the key's label directly (a proper score for
182
+ * a yes/no question), not a confidence in the verdict: the two agree whenever
183
+ * the verdict follows the probability, which parseJudgement enforces.
184
+ */
185
+ export function scorePairs(labels, judgements, bucketCount = 5) {
186
+ if (labels.length !== judgements.length)
187
+ throw new Error(`${labels.length} labels but ${judgements.length} judgements.`);
188
+ const out = { judged: 0, correct: 0, unsure: 0, failed: 0, sameInKey: 0, accuracy: null, brier: null, brierRef: null, buckets: [], ece: null };
189
+ const scored = [];
190
+ labels.forEach((same, i) => {
191
+ const j = judgements[i];
192
+ if (j === null)
193
+ return void (out.failed += 1);
194
+ if (j.verdict === "unsure")
195
+ return void (out.unsure += 1);
196
+ out.judged += 1;
197
+ if (same)
198
+ out.sameInKey += 1;
199
+ if ((j.verdict === "same") === same)
200
+ out.correct += 1;
201
+ scored.push({ p: j.pSame, y: same ? 1 : 0 });
202
+ });
203
+ if (out.judged < MIN_FOR_A_VERDICT)
204
+ return out;
205
+ const n = scored.length;
206
+ out.accuracy = out.correct / n;
207
+ out.brier = scored.reduce((s, x) => s + (x.p - x.y) ** 2, 0) / n;
208
+ const base = out.sameInKey / n;
209
+ out.brierRef = base * (1 - base);
210
+ // Equal-count buckets, cut only between different probabilities.
211
+ const sorted = [...scored].sort((x, y) => x.p - y.p);
212
+ const target = n / bucketCount;
213
+ let cur = [];
214
+ const flush = () => {
215
+ if (cur.length === 0)
216
+ return;
217
+ out.buckets.push({ n: cur.length, stated: cur.reduce((s, x) => s + x.p, 0) / cur.length, actual: cur.reduce((s, x) => s + x.y, 0) / cur.length });
218
+ cur = [];
219
+ };
220
+ sorted.forEach((x, i) => {
221
+ if (cur.length >= target && x.p !== sorted[i - 1].p)
222
+ flush();
223
+ cur.push(x);
224
+ });
225
+ flush();
226
+ out.ece = out.buckets.reduce((s, b) => s + (b.n / n) * Math.abs(b.stated - b.actual), 0);
227
+ return out;
228
+ }
229
+ /** One line per score, for the bench output and the results log. */
230
+ export function formatPairScore(name, s) {
231
+ const head = `${name}: ${s.judged} judged (${s.sameInKey} same in the key), ${s.unsure} unsure, ${s.failed} failed`;
232
+ if (s.accuracy === null)
233
+ return `${head}; fewer than ${MIN_FOR_A_VERDICT} judged, no figures`;
234
+ const skill = s.brierRef ? ` (skill ${(1 - s.brier / s.brierRef).toFixed(2)} vs the base rate)` : "";
235
+ const buckets = s.buckets.map((b) => `n=${b.n} stated ${b.stated.toFixed(2)} actual ${b.actual.toFixed(2)}`).join("; ");
236
+ return `${head}; accuracy ${(s.accuracy * 100).toFixed(1)}%, Brier ${s.brier.toFixed(3)}${skill}, ECE ${s.ece.toFixed(3)} [${buckets}]`;
237
+ }
@@ -631,6 +631,22 @@ function sameFinding(a, b) {
631
631
  return false;
632
632
  return findingJaccard(findingTokens(a.title), findingTokens(b.title)) >= 0.5;
633
633
  }
634
+ /** A finding's stored id: its category, title and route, so the same finding filed twice hashes the same. */
635
+ export function findingId(f) {
636
+ return shortHash(`${f.category}|${f.title.toLowerCase().trim()}|${f.state.split("#")[0]}`);
637
+ }
638
+ /**
639
+ * The dedup rule `addFinding` applies: whether `incoming` is the finding
640
+ * `existing` already records. The same id; or, on the same route, the same
641
+ * finding by evidence, a quoted literal or title overlap (sameFinding); or,
642
+ * from any route, the same endpoint failing the same way (sameEndpointBug).
643
+ * Exported so the benchmark scores the rule the store runs, not a copy of it.
644
+ */
645
+ export function isDuplicateFinding(existing, incoming) {
646
+ return (existing.id === findingId(incoming) ||
647
+ (existing.state.split("#")[0] === incoming.state.split("#")[0] && sameFinding(existing, incoming)) ||
648
+ sameEndpointBug(existing, incoming));
649
+ }
634
650
  /** Per-project directory for memory, session logs, and reports. */
635
651
  export const MEMORY_DIRNAME = ".scenescout";
636
652
  /** The one directory under it that is committed rather than ignored: saved flows (flow.ts). */
@@ -1487,9 +1503,8 @@ export class MemoryStore {
1487
1503
  url: redactSecrets(input.url),
1488
1504
  evidence: input.evidence ? redactSecrets(input.evidence) : input.evidence,
1489
1505
  };
1490
- const route = f.state.split("#")[0];
1491
- const id = shortHash(`${f.category}|${f.title.toLowerCase().trim()}|${route}`);
1492
- const existing = this.data.findings.find((x) => x.id === id || (x.state.split("#")[0] === route && sameFinding(x, f)) || sameEndpointBug(x, f));
1506
+ const id = findingId(f);
1507
+ const existing = this.data.findings.find((x) => isDuplicateFinding(x, f));
1493
1508
  if (existing) {
1494
1509
  existing.runs += 1;
1495
1510
  existing.foundAt = new Date().toISOString();
@@ -134,6 +134,48 @@ export function destructiveRefusal(label, mode = "read-only") {
134
134
  `the user has to re-attach with mode="destructive" against a disposable/seeded environment.`);
135
135
  }
136
136
  export const WRITE_MODES = ["observe", "read-only", "safe-write", "destructive"];
137
+ /** The stricter of two modes: WRITE_MODES runs from the strictest to the loosest. */
138
+ export function stricterMode(a, b) {
139
+ return WRITE_MODES.indexOf(a) <= WRITE_MODES.indexOf(b) ? a : b;
140
+ }
141
+ /**
142
+ * How long a rule that was just loosened still judges writes. A request is
143
+ * judged when the engine hears of it, which can be after the page sent it: a
144
+ * beacon a page sends as it is left reaches the browser-level interception
145
+ * tens of milliseconds after the page has gone, and a routed request waits its
146
+ * turn behind the handler. Five seconds is two orders of magnitude above the
147
+ * delays seen, and the price is only that writes sent in the first seconds
148
+ * after a flow ends are judged by the flow's stricter rule.
149
+ */
150
+ export const LOOSENED_RULE_HOLD_MS = 5000;
151
+ /**
152
+ * The write rule in force, as the judges must read it: a request is judged by
153
+ * the rule in force when the page sent it, which the engine cannot see, so by
154
+ * the strictest rule in force at any time in the hold before it is judged.
155
+ * Tightening the rule applies at once; loosening it (a flow handing back to
156
+ * the crawl) keeps the stricter rule in force for `holdMs`, so a write the
157
+ * flow's page sent under the flow's rule is never judged under the looser one
158
+ * because it was heard of a moment late.
159
+ */
160
+ export class WriteRule {
161
+ current;
162
+ holdMs;
163
+ held = [];
164
+ constructor(current, holdMs = LOOSENED_RULE_HOLD_MS) {
165
+ this.current = current;
166
+ this.holdMs = holdMs;
167
+ }
168
+ set(mode, now = Date.now()) {
169
+ if (stricterMode(this.current, mode) === this.current && this.current !== mode)
170
+ this.held.push({ mode: this.current, until: now + this.holdMs });
171
+ this.current = mode;
172
+ }
173
+ /** The rule a write heard of now is judged by. */
174
+ at(now = Date.now()) {
175
+ this.held = this.held.filter((h) => h.until >= now);
176
+ return this.held.reduce((rule, h) => stricterMode(rule, h.mode), this.current);
177
+ }
178
+ }
137
179
  /**
138
180
  * May this non-GET request leave the page? Auth-flow requests are let through
139
181
  * before this is asked. `owned` means the request addresses a record this run
@@ -0,0 +1,193 @@
1
+ /** Raised for a reply that is not what the API promises: retried like a server error, never run as tool calls. */
2
+ export class MalformedReply extends Error {
3
+ }
4
+ /** The most output one model call may produce. Large enough for a plan of steps; small enough that one reply cannot spend the budget. */
5
+ export const MAX_OUTPUT_TOKENS = 16_000;
6
+ const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : null);
7
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
8
+ function parseArgs(raw) {
9
+ if (raw === undefined || raw === null || raw === "")
10
+ return { input: {} };
11
+ if (typeof raw !== "string")
12
+ return { input: raw };
13
+ try {
14
+ return { input: JSON.parse(raw) };
15
+ }
16
+ catch (err) {
17
+ return { argsError: `the arguments were not valid JSON (${err instanceof Error ? err.message : String(err)})` };
18
+ }
19
+ }
20
+ // ── Anthropic Messages API ──────────────────────────────────────────────────
21
+ export class AnthropicConversation {
22
+ o;
23
+ messages = [];
24
+ constructor(o, kickoff) {
25
+ this.o = o;
26
+ this.messages.push({ role: "user", content: kickoff });
27
+ }
28
+ request(key) {
29
+ return {
30
+ url: `${this.o.baseUrl}/messages`,
31
+ headers: { "content-type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01" },
32
+ body: {
33
+ model: this.o.model,
34
+ max_tokens: MAX_OUTPUT_TOKENS,
35
+ // The method and the tools are the same on every call: cached once, read back at a tenth of the price.
36
+ system: [{ type: "text", text: this.o.system, cache_control: { type: "ephemeral" } }],
37
+ tools: this.o.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters })),
38
+ // And the growing conversation: the top-level breakpoint moves to its last block on every call.
39
+ cache_control: { type: "ephemeral" },
40
+ output_config: { effort: this.o.effort },
41
+ messages: this.messages,
42
+ },
43
+ };
44
+ }
45
+ accept(reply) {
46
+ const r = obj(reply);
47
+ if (!r || !Array.isArray(r.content))
48
+ throw new MalformedReply("the reply has no content list");
49
+ const content = r.content;
50
+ const u = obj(r.usage) ?? {};
51
+ const cached = num(u.cache_read_input_tokens);
52
+ const written = num(u.cache_creation_input_tokens);
53
+ const usage = { input: num(u.input_tokens) + cached + written, cachedInput: cached, cacheWrite: written, output: num(u.output_tokens) };
54
+ // Kept as returned, thinking blocks included: the API requires them back unchanged.
55
+ this.messages.push({ role: "assistant", content });
56
+ const text = content
57
+ .map(obj)
58
+ .filter((b) => b?.type === "text")
59
+ .map((b) => String(b.text ?? ""))
60
+ .join("\n");
61
+ const calls = content
62
+ .map(obj)
63
+ .filter((b) => b?.type === "tool_use")
64
+ .map((b, i) => ({
65
+ id: typeof b.id === "string" ? b.id : `call_${i}`,
66
+ name: typeof b.name === "string" ? b.name : "",
67
+ ...(obj(b.input) || b.input === undefined ? { input: b.input ?? {} } : parseArgs(b.input)),
68
+ }));
69
+ const stop = r.stop_reason;
70
+ if (stop === "pause_turn")
71
+ return { text, calls: [], usage, resume: true };
72
+ const note = stop === "refusal"
73
+ ? "the model declined to continue"
74
+ : stop === "max_tokens" && calls.length === 0
75
+ ? "the model's reply was cut at its output limit"
76
+ : undefined;
77
+ return { text, calls, usage, ...(note ? { note } : {}) };
78
+ }
79
+ addResults(results) {
80
+ // Every result in one user message: splitting them teaches the model to stop calling tools in parallel.
81
+ this.messages.push({
82
+ role: "user",
83
+ content: results.map((r) => ({ type: "tool_result", tool_use_id: r.id, content: r.text, ...(r.isError ? { is_error: true } : {}) })),
84
+ });
85
+ }
86
+ }
87
+ // ── OpenAI Responses API ────────────────────────────────────────────────────
88
+ export class OpenAIConversation {
89
+ o;
90
+ input = [];
91
+ constructor(o, kickoff) {
92
+ this.o = o;
93
+ this.input.push({ role: "user", content: kickoff });
94
+ }
95
+ request(key) {
96
+ return {
97
+ url: `${this.o.baseUrl}/responses`,
98
+ headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
99
+ body: {
100
+ model: this.o.model,
101
+ instructions: this.o.system,
102
+ input: this.input,
103
+ tools: this.o.tools.map((t) => ({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: false })),
104
+ reasoning: { effort: this.o.effort },
105
+ max_output_tokens: MAX_OUTPUT_TOKENS,
106
+ // Stateless: nothing of the tested app is kept on the provider's side
107
+ // between calls, and the reasoning comes back encrypted so it can be
108
+ // handed back with the rest of the turn.
109
+ store: false,
110
+ include: ["reasoning.encrypted_content"],
111
+ },
112
+ };
113
+ }
114
+ accept(reply) {
115
+ const r = obj(reply);
116
+ if (!r || !Array.isArray(r.output))
117
+ throw new MalformedReply("the reply has no output list");
118
+ if (obj(r.error))
119
+ throw new MalformedReply(`the reply carries an error: ${String(obj(r.error).message ?? "unknown")}`);
120
+ const output = r.output;
121
+ const u = obj(r.usage) ?? {};
122
+ const usage = {
123
+ input: num(u.input_tokens),
124
+ cachedInput: num(obj(u.input_tokens_details)?.cached_tokens),
125
+ cacheWrite: 0,
126
+ output: num(u.output_tokens),
127
+ };
128
+ // Every item goes back as it came: reasoning items and function calls must precede their outputs.
129
+ this.input.push(...output);
130
+ const items = output.map(obj);
131
+ const text = items
132
+ .filter((i) => i?.type === "message" && Array.isArray(i.content))
133
+ .flatMap((i) => i.content.map(obj))
134
+ .filter((c) => c?.type === "output_text")
135
+ .map((c) => String(c.text ?? ""))
136
+ .join("\n");
137
+ const calls = items
138
+ .filter((i) => i?.type === "function_call")
139
+ .map((i, k) => ({
140
+ id: typeof i.call_id === "string" ? i.call_id : `call_${k}`,
141
+ name: typeof i.name === "string" ? i.name : "",
142
+ ...parseArgs(i.arguments),
143
+ }));
144
+ const incomplete = r.status === "incomplete" ? String(obj(r.incomplete_details)?.reason ?? "incomplete") : undefined;
145
+ const refused = items.some((i) => i?.type === "message" && Array.isArray(i.content) && i.content.some((c) => obj(c)?.type === "refusal"));
146
+ const note = refused ? "the model declined to continue" : incomplete && calls.length === 0 ? `the model's reply was cut short (${incomplete})` : undefined;
147
+ return { text, calls, usage, ...(note ? { note } : {}) };
148
+ }
149
+ addResults(results) {
150
+ for (const r of results)
151
+ this.input.push({ type: "function_call_output", call_id: r.id, output: r.isError ? `ERROR: ${r.text}` : r.text });
152
+ }
153
+ }
154
+ // ── retries ─────────────────────────────────────────────────────────────────
155
+ /** A status worth another try: throttling, a timeout, an overloaded or failing server. Never a 4xx the request itself caused. */
156
+ export function retryable(status) {
157
+ return status === 408 || status === 409 || status === 429 || status >= 500;
158
+ }
159
+ /**
160
+ * Wait before retry `attempt` (1-based): exponential from `baseMs`, capped,
161
+ * with full jitter so several jobs throttled at once do not retry in step. A
162
+ * `retry-after` the server sent is honoured when it is longer, up to the cap.
163
+ */
164
+ export function backoffMs(attempt, random, o = {}) {
165
+ const base = o.baseMs ?? 1000;
166
+ const cap = o.capMs ?? 30_000;
167
+ const exp = Math.min(cap, base * 2 ** (attempt - 1));
168
+ const jittered = Math.floor(exp / 2 + random() * (exp / 2));
169
+ return Math.min(cap, Math.max(jittered, o.retryAfterMs ?? 0));
170
+ }
171
+ /** A `retry-after` header in milliseconds (seconds or an HTTP date), or undefined. */
172
+ export function retryAfterMs(header, now) {
173
+ if (!header)
174
+ return undefined;
175
+ const secs = Number(header);
176
+ if (Number.isFinite(secs) && secs >= 0)
177
+ return secs * 1000;
178
+ const at = Date.parse(header);
179
+ return Number.isNaN(at) ? undefined : Math.max(0, at - now);
180
+ }
181
+ /** The API's own error message from a failed reply's body, for the log. The caller redacts it. */
182
+ export function errorMessage(status, body) {
183
+ let message = "";
184
+ try {
185
+ const parsed = JSON.parse(body);
186
+ const e = parsed.error;
187
+ message = typeof e === "string" ? e : typeof e?.message === "string" ? e.message : typeof parsed.message === "string" ? parsed.message : "";
188
+ }
189
+ catch {
190
+ message = body.slice(0, 200);
191
+ }
192
+ return `HTTP ${status}${message ? `: ${message.replace(/\s+/g, " ").slice(0, 300)}` : ""}`;
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.12.0",
3
+ "version": "3.13.1",
4
4
  "description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
5
5
  "license": "MIT",
6
6
  "author": "brunoboto96",
@@ -72,7 +72,7 @@
72
72
  "mcp-check": "npm run build && npm run mcp-check:run",
73
73
  "mcp-check:run": "tsx scripts/mcp-check.ts",
74
74
  "test": "npm run build && npm run test:unit && npm run smoke:run && npm run mcp-check:run",
75
- "test:unit": "npm run scan-test && npm run oracle-test && npm run policy-test && npm run fixture-test && npm run dispatch-test && npm run design-test && npm run check-test && npm run contract-test && npm run pace-test && npm run request-test && npm run settle-test && npm run claims-test && npm run verify-test && npm run brief-test && npm run lane-test && npm run calibration-test && npm run bench-test && npm run memory-test && npm run install-test && npm run live-test && npm run demo-test && npm run holdout-test && npm run hygiene-test",
75
+ "test:unit": "npm run scan-test && npm run oracle-test && npm run policy-test && npm run fixture-test && npm run dispatch-test && npm run design-test && npm run check-test && npm run ci-test && npm run qa-test && npm run contract-test && npm run pace-test && npm run request-test && npm run settle-test && npm run claims-test && npm run verify-test && npm run brief-test && npm run lane-test && npm run calibration-test && npm run bench-test && npm run memory-test && npm run install-test && npm run live-test && npm run demo-test && npm run holdout-test && npm run hygiene-test",
76
76
  "scan-test": "tsx scripts/scan-test.ts",
77
77
  "oracle-test": "tsx --test scripts/oracle-test.ts",
78
78
  "policy-test": "tsx --test scripts/policy-test.ts",
@@ -80,6 +80,8 @@
80
80
  "dispatch-test": "tsx --test scripts/dispatch-test.ts",
81
81
  "design-test": "tsx --test scripts/design-test.ts",
82
82
  "check-test": "tsx --test scripts/check-test.ts",
83
+ "ci-test": "tsx --test scripts/ci-test.ts",
84
+ "qa-test": "tsx --test scripts/qa-test.ts",
83
85
  "contract-test": "tsx --test scripts/contract-test.ts",
84
86
  "lane-test": "tsx --test scripts/lane-test.ts",
85
87
  "memory-test": "tsx --test scripts/memory-test.ts",
@@ -96,7 +98,9 @@
96
98
  "brief-test": "tsx --test scripts/brief-test.ts",
97
99
  "calibration-test": "tsx --test scripts/calibration-test.ts",
98
100
  "bench": "tsx scripts/bench.ts",
99
- "bench-test": "tsx --test scripts/bench-test.ts"
101
+ "bench:ci": "tsx scripts/bench/ci-record.ts",
102
+ "bench-test": "tsx --test scripts/bench-test.ts",
103
+ "dedup-bench": "tsx scripts/dedup-bench.ts"
100
104
  },
101
105
  "dependencies": {
102
106
  "@modelcontextprotocol/sdk": "^1.12.0",