scenescout 3.12.0 → 3.13.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/CHANGELOG.md +17 -0
- package/README.md +24 -2
- package/dist/ci-run.js +366 -0
- package/dist/cli.js +69 -0
- package/dist/engine/browser.js +63 -34
- package/dist/engine/check.js +3 -1
- package/dist/engine/ci.js +633 -0
- package/dist/engine/dedup.js +237 -0
- package/dist/engine/memory.js +18 -3
- package/dist/engine/policy.js +42 -0
- package/dist/engine/provider.js +193 -0
- package/package.json +6 -3
|
@@ -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
|
+
}
|
package/dist/engine/memory.js
CHANGED
|
@@ -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
|
|
1491
|
-
const
|
|
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();
|
package/dist/engine/policy.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "3.13.0",
|
|
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,8 @@
|
|
|
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-test": "tsx --test scripts/bench-test.ts",
|
|
102
|
+
"dedup-bench": "tsx scripts/dedup-bench.ts"
|
|
100
103
|
},
|
|
101
104
|
"dependencies": {
|
|
102
105
|
"@modelcontextprotocol/sdk": "^1.12.0",
|