debutly 0.0.0-stage → 0.2.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/LICENSE +662 -0
- package/README.md +192 -2
- package/bin/debutly.mjs +8 -0
- package/drizzle/0000_init.sql +107 -0
- package/drizzle/0001_predictions.sql +14 -0
- package/drizzle/0002_dashboard.sql +15 -0
- package/drizzle/meta/0000_snapshot.json +722 -0
- package/drizzle/meta/0001_snapshot.json +821 -0
- package/drizzle/meta/0002_snapshot.json +942 -0
- package/drizzle/meta/_journal.json +27 -0
- package/package.json +83 -4
- package/src/agent/describe.ts +29 -0
- package/src/agent/effect.ts +94 -0
- package/src/agent/give-up.ts +42 -0
- package/src/agent/label.ts +19 -0
- package/src/agent/loop.ts +302 -0
- package/src/agent/parse-reply.ts +127 -0
- package/src/agent/prompt.ts +85 -0
- package/src/agent/run-agent.ts +68 -0
- package/src/artifacts/db.ts +55 -0
- package/src/artifacts/s3.ts +36 -0
- package/src/artifacts/store.ts +26 -0
- package/src/bin.ts +140 -0
- package/src/browser/ax.ts +77 -0
- package/src/browser/execute.ts +401 -0
- package/src/browser/observe.ts +62 -0
- package/src/browser/reflow.ts +73 -0
- package/src/browser/samples.ts +119 -0
- package/src/browser/scan-script.ts +108 -0
- package/src/browser/settle.ts +32 -0
- package/src/browser/signals.ts +73 -0
- package/src/cancel.ts +20 -0
- package/src/cli.ts +281 -0
- package/src/config.ts +62 -0
- package/src/cost/estimate.ts +32 -0
- package/src/cost/prices.ts +32 -0
- package/src/cost/tiers.ts +27 -0
- package/src/costs.ts +96 -0
- package/src/crew/run-unit.ts +62 -0
- package/src/detect/findings.ts +649 -0
- package/src/detect/wcag.ts +38 -0
- package/src/doctor.ts +145 -0
- package/src/hear-cli.ts +137 -0
- package/src/hear.ts +309 -0
- package/src/import.ts +108 -0
- package/src/inbox/mailpit.ts +46 -0
- package/src/inbox/types.ts +10 -0
- package/src/init.ts +184 -0
- package/src/llm/anthropic.ts +41 -0
- package/src/llm/credits.ts +35 -0
- package/src/llm/factory.ts +65 -0
- package/src/llm/meter.ts +39 -0
- package/src/llm/openai-compatible.ts +90 -0
- package/src/llm/openrouter.ts +9 -0
- package/src/llm/scripted.ts +18 -0
- package/src/llm/types.ts +36 -0
- package/src/perceive.ts +150 -0
- package/src/perception/aggregate.ts +111 -0
- package/src/perception/compare.ts +77 -0
- package/src/perception/evaluate.ts +83 -0
- package/src/perception/exhibits.ts +146 -0
- package/src/perception/market-context.ts +50 -0
- package/src/perception/run-perception.ts +178 -0
- package/src/persona/generate.ts +92 -0
- package/src/persona/inclusion.ts +53 -0
- package/src/persona/language.ts +59 -0
- package/src/persona/people.ts +32 -0
- package/src/predictions.ts +33 -0
- package/src/queue/dispatch.ts +18 -0
- package/src/queue/jobs.ts +28 -0
- package/src/queue/processor.ts +42 -0
- package/src/report/fix-first.ts +121 -0
- package/src/report/launch-readiness.ts +106 -0
- package/src/report/markdown.ts +202 -0
- package/src/run.ts +141 -0
- package/src/sandbox/outbox.ts +65 -0
- package/src/store/db.ts +40 -0
- package/src/store/import-run.ts +116 -0
- package/src/store/pg-run-store.ts +163 -0
- package/src/store/predictions.ts +70 -0
- package/src/store/run-store.ts +78 -0
- package/src/store/schema.ts +193 -0
- package/src/store/url.ts +15 -0
- package/src/types.ts +201 -0
- package/src/util/chromium.ts +20 -0
- package/src/util/json.ts +7 -0
- package/src/util/key-defaults.ts +34 -0
- package/src/util/legacy-env.ts +14 -0
- package/src/util/load-env.ts +28 -0
- package/src/util/pool.ts +12 -0
- package/src/util/run-origin.ts +27 -0
- package/src/worker.ts +53 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Meter, type ModelPrice } from "../llm/meter";
|
|
3
|
+
import type { LlmClient } from "../llm/types";
|
|
4
|
+
import { generatePersonas } from "../persona/generate";
|
|
5
|
+
import type { NewPrediction } from "../store/predictions";
|
|
6
|
+
import type { Finding, Persona } from "../types";
|
|
7
|
+
import { extractJsonObject } from "../util/json";
|
|
8
|
+
import { mapWithConcurrency } from "../util/pool";
|
|
9
|
+
import { aggregatePairs, aggregateVerdicts, rankObjections, type PairAggregate, type VerdictAggregate } from "./aggregate";
|
|
10
|
+
import { comparePair, type PairJudgment } from "./compare";
|
|
11
|
+
import { evaluate, type Verdict } from "./evaluate";
|
|
12
|
+
import type { Exhibit } from "./exhibits";
|
|
13
|
+
import { marketBrief, type MarketContext } from "./market-context";
|
|
14
|
+
|
|
15
|
+
const PERSONA_BATCH = 25;
|
|
16
|
+
|
|
17
|
+
export interface PerceptionOptions {
|
|
18
|
+
/** Cheap model for the evaluators. */
|
|
19
|
+
llm: LlmClient;
|
|
20
|
+
/** Stronger model for evaluator personas and the summary. */
|
|
21
|
+
judge: LlmClient;
|
|
22
|
+
icp: string;
|
|
23
|
+
product: string;
|
|
24
|
+
/** One key ("main") for a perception run; two (e.g. A and B) for an experiment. */
|
|
25
|
+
exhibitsByVariant: Record<string, Exhibit[]>;
|
|
26
|
+
evaluators: number;
|
|
27
|
+
concurrency: number;
|
|
28
|
+
market?: MarketContext;
|
|
29
|
+
price?: ModelPrice | null;
|
|
30
|
+
judgePrice?: ModelPrice | null;
|
|
31
|
+
/** Functional findings from a depth run, for the Launch Readiness verdict. */
|
|
32
|
+
functional?: Finding[];
|
|
33
|
+
rng?: () => number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Summary {
|
|
37
|
+
verdict: string;
|
|
38
|
+
fixes: { fix: string; addresses: string }[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PerceptionResult {
|
|
42
|
+
mode: "single" | "experiment";
|
|
43
|
+
variants: string[];
|
|
44
|
+
evaluators: number;
|
|
45
|
+
unusable: number;
|
|
46
|
+
verdicts: Verdict[];
|
|
47
|
+
pairs: PairJudgment[];
|
|
48
|
+
/** Per variant. In an experiment only objections are filled (from the pairwise judgments). */
|
|
49
|
+
aggregates: Record<string, VerdictAggregate>;
|
|
50
|
+
pairAggregate?: PairAggregate;
|
|
51
|
+
summary: Summary;
|
|
52
|
+
predictions: NewPrediction[];
|
|
53
|
+
usage: { inputTokens: number; outputTokens: number; costUsd?: number };
|
|
54
|
+
market?: MarketContext;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SummarySchema = z.object({ verdict: z.string().min(1), fixes: z.array(z.object({ fix: z.string(), addresses: z.string() })) });
|
|
58
|
+
|
|
59
|
+
const SUMMARY_SYSTEM = `You write the verdict of a Launch Readiness report from simulated buyer evidence.
|
|
60
|
+
Use only the numbers and quotes you are given; never invent statistics, percentages or conversion rates. Scores are on a 1–5 scale. This is simulated evidence, not real traction: say what the simulated buyers did, comparatively.
|
|
61
|
+
Reply with one JSON object: {"verdict": 3-5 plain sentences, "fixes": [{"fix": a concrete change to the product or its pages, "addresses": the objection category or finding it answers}]} with at most 5 fixes, most important first.`;
|
|
62
|
+
|
|
63
|
+
async function evaluatorPersonas(judge: LlmClient, icp: string, market: string | undefined, count: number): Promise<Persona[]> {
|
|
64
|
+
const out: Persona[] = [];
|
|
65
|
+
while (out.length < count) {
|
|
66
|
+
const n = Math.min(PERSONA_BATCH, count - out.length);
|
|
67
|
+
const batch = await generatePersonas(judge, market ? `${icp}\n\n${market}` : icp, n);
|
|
68
|
+
out.push(...batch.map((p, i) => ({ ...p, id: `e${out.length + i + 1}` })));
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const emptyAggregate = (evaluators: number, objections: VerdictAggregate["objections"]): VerdictAggregate => ({
|
|
74
|
+
evaluators,
|
|
75
|
+
unusable: 0,
|
|
76
|
+
objections,
|
|
77
|
+
intent: { no: 0, maybe: 0, yes: 0 },
|
|
78
|
+
meanScores: { clarity: 0, credibility: 0, value: 0, urgency: 0 },
|
|
79
|
+
wouldPay: { answers: 0, medianUsd: null, plans: {} },
|
|
80
|
+
confusions: [],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function predictionsFor(r: Omit<PerceptionResult, "predictions" | "summary" | "usage">): NewPrediction[] {
|
|
84
|
+
const out: NewPrediction[] = [];
|
|
85
|
+
if (r.mode === "experiment" && r.pairAggregate) {
|
|
86
|
+
const p = r.pairAggregate;
|
|
87
|
+
const other = r.variants.find((v) => v !== p.leader) ?? "?";
|
|
88
|
+
out.push({
|
|
89
|
+
metric: "winner",
|
|
90
|
+
claim: `${p.leader} beats ${other} (${Math.round(p.winRate * 100)}% of buyers who chose; 95% interval ${Math.round(p.interval.low * 100)}–${Math.round(p.interval.high * 100)}%${p.decisive ? "" : ", not decisive"})`,
|
|
91
|
+
predicted: { winner: p.leader, winRate: p.winRate, interval: p.interval, decisive: p.decisive },
|
|
92
|
+
});
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
const a = r.aggregates[r.variants[0]];
|
|
96
|
+
const top = a.objections[0];
|
|
97
|
+
if (top) out.push({ metric: "top_objection", claim: `The top objection is ${top.category} (${top.buyers} of ${a.evaluators} buyers)`, predicted: { category: top.category, buyers: top.buyers, of: a.evaluators } });
|
|
98
|
+
const share = a.evaluators ? (a.intent.yes + a.intent.maybe) / a.evaluators : 0;
|
|
99
|
+
out.push({ metric: "intent_share", claim: `${Math.round(share * 100)}% of simulated buyers would consider it (not a conversion rate)`, predicted: { share, of: a.evaluators } });
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Breadth evaluators judge the exhibits; an experiment compares two variants pairwise. */
|
|
104
|
+
export async function runPerception(o: PerceptionOptions): Promise<PerceptionResult> {
|
|
105
|
+
const variants = Object.keys(o.exhibitsByVariant);
|
|
106
|
+
if (!variants.length || variants.length > 2) throw new Error("give one set of exhibits, or two variants to compare");
|
|
107
|
+
const nav = new Meter();
|
|
108
|
+
const judgeMeter = new Meter();
|
|
109
|
+
const brief = o.market ? marketBrief(o.market) : undefined;
|
|
110
|
+
const personas = await evaluatorPersonas({ complete: (req) => o.judge.complete({ ...req, onUsage: judgeMeter.add }) }, o.icp, brief, o.evaluators);
|
|
111
|
+
|
|
112
|
+
let base: Omit<PerceptionResult, "predictions" | "summary" | "usage">;
|
|
113
|
+
if (variants.length === 1) {
|
|
114
|
+
const [only] = variants;
|
|
115
|
+
const results = await mapWithConcurrency(personas, o.concurrency, (p) => evaluate(o.llm, p, o.exhibitsByVariant[only], { market: brief, onUsage: nav.add }));
|
|
116
|
+
const verdicts = results.filter((v): v is Verdict => v !== null);
|
|
117
|
+
const unusable = results.length - verdicts.length;
|
|
118
|
+
base = { mode: "single", variants, evaluators: personas.length, unusable, verdicts, pairs: [], aggregates: { [only]: aggregateVerdicts(verdicts, unusable) }, market: o.market };
|
|
119
|
+
} else {
|
|
120
|
+
const [a, b] = variants;
|
|
121
|
+
const rng = o.rng ?? Math.random;
|
|
122
|
+
const results = await mapWithConcurrency(personas, o.concurrency, (p) =>
|
|
123
|
+
comparePair(o.llm, p, o.exhibitsByVariant[a], o.exhibitsByVariant[b], { market: brief, onUsage: nav.add }, rng),
|
|
124
|
+
);
|
|
125
|
+
const pairs = results.filter((x): x is PairJudgment => x !== null);
|
|
126
|
+
base = {
|
|
127
|
+
mode: "experiment",
|
|
128
|
+
variants,
|
|
129
|
+
evaluators: personas.length,
|
|
130
|
+
unusable: results.length - pairs.length,
|
|
131
|
+
verdicts: [],
|
|
132
|
+
pairs,
|
|
133
|
+
aggregates: {
|
|
134
|
+
[a]: emptyAggregate(pairs.length, rankObjections(pairs.map((p) => p.objectionsA))),
|
|
135
|
+
[b]: emptyAggregate(pairs.length, rankObjections(pairs.map((p) => p.objectionsB))),
|
|
136
|
+
},
|
|
137
|
+
pairAggregate: aggregatePairs(pairs),
|
|
138
|
+
market: o.market,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const evidence = {
|
|
143
|
+
product: o.product,
|
|
144
|
+
buyers: o.icp,
|
|
145
|
+
mode: base.mode,
|
|
146
|
+
evaluators: base.evaluators,
|
|
147
|
+
unusable: base.unusable,
|
|
148
|
+
perVariant: base.aggregates,
|
|
149
|
+
experiment: base.pairAggregate,
|
|
150
|
+
functionalBlockers: (o.functional ?? []).filter((f) => f.severity !== "minor" && !f.sandboxCause).map((f) => `${f.severity}: ${f.title}`),
|
|
151
|
+
market: brief,
|
|
152
|
+
};
|
|
153
|
+
let summary: Summary = { verdict: "Summary unavailable.", fixes: [] };
|
|
154
|
+
try {
|
|
155
|
+
const raw = await o.judge.complete({
|
|
156
|
+
system: SUMMARY_SYSTEM,
|
|
157
|
+
messages: [{ role: "user", content: `Evidence:\n${JSON.stringify(evidence, null, 2)}` }],
|
|
158
|
+
maxTokens: 1500,
|
|
159
|
+
onUsage: judgeMeter.add,
|
|
160
|
+
});
|
|
161
|
+
summary = SummarySchema.parse(extractJsonObject(raw));
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.warn(`Launch Readiness summary unavailable: ${err instanceof Error ? err.message : err}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const navCost = nav.costUsd(o.price);
|
|
167
|
+
const judgeCost = judgeMeter.costUsd(o.judgePrice);
|
|
168
|
+
return {
|
|
169
|
+
...base,
|
|
170
|
+
summary,
|
|
171
|
+
predictions: predictionsFor(base),
|
|
172
|
+
usage: {
|
|
173
|
+
inputTokens: nav.total.inputTokens + judgeMeter.total.inputTokens,
|
|
174
|
+
outputTokens: nav.total.outputTokens + judgeMeter.total.outputTokens,
|
|
175
|
+
costUsd: navCost !== undefined && judgeCost !== undefined ? navCost + judgeCost : undefined,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { LlmClient } from "../llm/types";
|
|
3
|
+
import { languageRequest } from "./language";
|
|
4
|
+
import type { CustomerUnit, Persona } from "../types";
|
|
5
|
+
|
|
6
|
+
const PersonaSchema = z.object({
|
|
7
|
+
// A period would break "You are <name>." in the agent prompt.
|
|
8
|
+
name: z
|
|
9
|
+
.string()
|
|
10
|
+
.min(1)
|
|
11
|
+
.refine((n) => !n.includes("."), "name must not contain a period"),
|
|
12
|
+
background: z.string(),
|
|
13
|
+
goal: z.string(),
|
|
14
|
+
patience: z.enum(["low", "medium", "high"]),
|
|
15
|
+
techSavvy: z.enum(["low", "medium", "high"]),
|
|
16
|
+
// Asked for only with --languages; withLanguages() keeps it only if it's on the list.
|
|
17
|
+
language: z
|
|
18
|
+
.string()
|
|
19
|
+
.transform((l) => l.trim().toLowerCase())
|
|
20
|
+
.optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const BusinessUnitSchema = z.object({
|
|
24
|
+
business: z.object({
|
|
25
|
+
name: z.string().min(1),
|
|
26
|
+
archetype: z.string().min(1),
|
|
27
|
+
size: z.number().int().positive(),
|
|
28
|
+
monthlyBudget: z.number().nonnegative(),
|
|
29
|
+
goal: z.string(),
|
|
30
|
+
}),
|
|
31
|
+
owner: PersonaSchema,
|
|
32
|
+
employees: z.array(PersonaSchema.extend({ role: z.string().min(1) })),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Personas describe customers, not their beliefs: no politics or religion.
|
|
36
|
+
const NEUTRAL = "Leave out politics and religion.";
|
|
37
|
+
const SYSTEM = `You design realistic, diverse customer personas for product simulation. Vary role, company size, budget, urgency, patience and technical skill. Include skeptical and hard-to-please people, not just enthusiasts. ${NEUTRAL} Output only a JSON array.`;
|
|
38
|
+
const INDIVIDUAL_SYSTEM = `You design realistic, diverse individual consumers for product simulation. Vary life and career stage, urgency, price sensitivity, region and technical skill. Include skeptical and hard-to-please people, not just enthusiasts. ${NEUTRAL} Output only a JSON array.`;
|
|
39
|
+
const BUSINESS_SYSTEM = `You design realistic, diverse businesses for product simulation. Vary archetype (agency, SME, small business, freelancer, startup, enterprise team), industry, size, budget and urgency. Give employees distinct everyday roles. Include skeptical and hard-to-please people, not just enthusiasts. ${NEUTRAL} Output only a JSON array.`;
|
|
40
|
+
const PERSONA_SHAPE = `{"name": first name only, "background": 1-2 sentences, "goal": what they want from this product, "patience": "low"|"medium"|"high", "techSavvy": "low"|"medium"|"high"}`;
|
|
41
|
+
|
|
42
|
+
function extractJsonArray(raw: string): unknown {
|
|
43
|
+
const start = raw.indexOf("[");
|
|
44
|
+
const end = raw.lastIndexOf("]");
|
|
45
|
+
if (start === -1 || end <= start) throw new Error("no JSON array in persona reply");
|
|
46
|
+
return JSON.parse(raw.slice(start, end + 1));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function ask(llm: LlmClient, system: string, content: string): Promise<unknown> {
|
|
50
|
+
return extractJsonArray(await llm.complete({ system, messages: [{ role: "user", content }], maxTokens: 16000 }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function generatePersonas(llm: LlmClient, icp: string, count: number, system = SYSTEM, languages: string[] = []): Promise<Persona[]> {
|
|
54
|
+
const raw = await ask(llm, system, `Ideal customer profile:\n${icp}\n\nReturn a JSON array of ${count} distinct personas. Each item: ${PERSONA_SHAPE}.${withRequest(languages)}`);
|
|
55
|
+
const parsed = z.array(PersonaSchema).parse(raw);
|
|
56
|
+
return parsed.slice(0, count).map((persona, i) => ({ id: `p${i + 1}`, ...persona }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UnitOptions {
|
|
60
|
+
kind: CustomerUnit["kind"];
|
|
61
|
+
count: number;
|
|
62
|
+
/** Employees per business crew (0 = owner only). */
|
|
63
|
+
employees: number;
|
|
64
|
+
/** First languages to give the people (lang codes); none means English. */
|
|
65
|
+
languages?: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const withRequest = (languages: string[]) => (languages.length ? ` ${languageRequest(languages)}` : "");
|
|
69
|
+
|
|
70
|
+
/** Customer units from an ICP: individuals (B2C) or business crews of owner + employees (B2B). */
|
|
71
|
+
export async function generateUnits(llm: LlmClient, icp: string, o: UnitOptions): Promise<CustomerUnit[]> {
|
|
72
|
+
if (o.kind === "individual") {
|
|
73
|
+
const people = await generatePersonas(llm, icp, o.count, INDIVIDUAL_SYSTEM, o.languages);
|
|
74
|
+
return people.map((person) => ({ kind: "individual", id: person.id, person }));
|
|
75
|
+
}
|
|
76
|
+
const raw = await ask(
|
|
77
|
+
llm,
|
|
78
|
+
BUSINESS_SYSTEM,
|
|
79
|
+
`Ideal customer profile:\n${icp}\n\nReturn a JSON array of ${o.count} distinct businesses. Each item: {"business": {"name": string, "archetype": string, "size": number of people, "monthlyBudget": number in USD they would spend on a tool like this, "goal": string}, "owner": the decision-maker, ${PERSONA_SHAPE}, "employees": ${o.employees} employees, each ${PERSONA_SHAPE.slice(0, -1)}, "role": their job title}}.${withRequest(o.languages ?? [])}`,
|
|
80
|
+
);
|
|
81
|
+
const parsed = z.array(BusinessUnitSchema).parse(raw);
|
|
82
|
+
return parsed.slice(0, o.count).map((u, i) => {
|
|
83
|
+
const id = `u${i + 1}`;
|
|
84
|
+
return {
|
|
85
|
+
kind: "business",
|
|
86
|
+
id,
|
|
87
|
+
business: u.business,
|
|
88
|
+
owner: { id: `${id}-o`, ...u.owner, role: "Owner" },
|
|
89
|
+
employees: u.employees.slice(0, o.employees).map((e, j) => ({ id: `${id}-e${j + 1}`, ...e })),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Browser, BrowserContext, Page } from "playwright";
|
|
2
|
+
import { INCLUSION_NEEDS, type CustomerUnit, type InclusionNeed, type Persona } from "../types";
|
|
3
|
+
|
|
4
|
+
const BRIEFS: Record<InclusionNeed, string> = {
|
|
5
|
+
screen_reader:
|
|
6
|
+
'You are blind and use a screen reader with your keyboard; you cannot use a mouse. You only know what it announces: element roles and names, and the page text. A button or link with an empty name is announced only as "button" or "link", so you cannot tell what it does; avoid it unless you have no other choice. You reach controls with the Tab key, so one the keyboard cannot reach is one you cannot use.',
|
|
7
|
+
low_vision:
|
|
8
|
+
"You have low vision and browse zoomed in to 200%, so pages look cramped and you often have to scroll. Small or low-contrast controls are hard for you to find.",
|
|
9
|
+
slow_connection:
|
|
10
|
+
"You are on a slow, unreliable mobile connection. Pages take several seconds to load; you wait a while before assuming something is broken, but not forever.",
|
|
11
|
+
non_native_english:
|
|
12
|
+
"English is not your first language. You read carefully and slowly, jargon and idioms confuse you, and you sometimes make small typing mistakes.",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const inclusionBrief = (need: InclusionNeed) => BRIEFS[need];
|
|
16
|
+
|
|
17
|
+
/** Assigns one need to each of the first agents of a run (owner before employees), starting at `offset` so needs rotate between runs. */
|
|
18
|
+
export function withInclusion(units: CustomerUnit[], offset: number): CustomerUnit[] {
|
|
19
|
+
let next = 0;
|
|
20
|
+
const assign = (p: Persona): Persona => {
|
|
21
|
+
if (next >= INCLUSION_NEEDS.length) return p;
|
|
22
|
+
const inclusion = INCLUSION_NEEDS[(offset + next++) % INCLUSION_NEEDS.length];
|
|
23
|
+
return { ...p, inclusion };
|
|
24
|
+
};
|
|
25
|
+
return units.map((u) =>
|
|
26
|
+
u.kind === "individual" ? { ...u, person: assign(u.person) } : { ...u, owner: assign(u.owner), employees: u.employees.map(assign) },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Chrome DevTools' "Slow 3G"-like: 400 ms round trips, 500 kbit/s each way.
|
|
31
|
+
const SLOW_NETWORK = { offline: false, latency: 400, downloadThroughput: (500 * 1024) / 8, uploadThroughput: (500 * 1024) / 8 };
|
|
32
|
+
const SLOW_NAVIGATION_MS = 90_000;
|
|
33
|
+
|
|
34
|
+
/** A fresh browser context and page for one agent, shaped by its inclusion need and first language. */
|
|
35
|
+
export async function openAgentPage(browser: Browser, need?: InclusionNeed, language?: string): Promise<{ context: BrowserContext; page: Page }> {
|
|
36
|
+
const context = await browser.newContext({
|
|
37
|
+
// 200% zoom on a 1280×800 laptop lays pages out like a 640×400 viewport.
|
|
38
|
+
...(need === "low_vision" ? { viewport: { width: 640, height: 400 } } : {}),
|
|
39
|
+
// Someone whose first language isn't English usually has their browser set to it, so the
|
|
40
|
+
// site hears it in Accept-Language and navigator.language, as it would from them.
|
|
41
|
+
...(language && language !== "en" ? { locale: language } : {}),
|
|
42
|
+
});
|
|
43
|
+
const page = await context.newPage();
|
|
44
|
+
if (need === "slow_connection") {
|
|
45
|
+
const cdp = await context.newCDPSession(page);
|
|
46
|
+
await cdp.send("Network.enable");
|
|
47
|
+
await cdp.send("Network.emulateNetworkConditions", SLOW_NETWORK);
|
|
48
|
+
// At 500 kbit/s a page can take longer than Playwright's 30 s default to arrive; a person on
|
|
49
|
+
// that connection waits, so the agent's navigations do too.
|
|
50
|
+
page.setDefaultNavigationTimeout(SLOW_NAVIGATION_MS);
|
|
51
|
+
}
|
|
52
|
+
return { context, page };
|
|
53
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CustomerUnit, Persona } from "../types";
|
|
2
|
+
|
|
3
|
+
// Customers' first languages (--languages ar,en): agents take them in turn, and one whose first
|
|
4
|
+
// language isn't English uses the site in it when it can. Arabic speakers also type as an Arabic
|
|
5
|
+
// phone keyboard does, which is where Gulf sign-up forms often break.
|
|
6
|
+
|
|
7
|
+
const NAMES: Record<string, string> = {
|
|
8
|
+
ar: "Arabic",
|
|
9
|
+
en: "English",
|
|
10
|
+
fr: "French",
|
|
11
|
+
de: "German",
|
|
12
|
+
es: "Spanish",
|
|
13
|
+
hi: "Hindi",
|
|
14
|
+
ur: "Urdu",
|
|
15
|
+
fa: "Persian",
|
|
16
|
+
tr: "Turkish",
|
|
17
|
+
ru: "Russian",
|
|
18
|
+
zh: "Chinese",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const languageName = (code: string) => NAMES[code] ?? code;
|
|
22
|
+
|
|
23
|
+
/** "ar, en" → ["ar", "en"]. Language codes as in a page's lang attribute. */
|
|
24
|
+
export function parseLanguages(value: string | undefined): string[] {
|
|
25
|
+
if (!value?.trim()) return [];
|
|
26
|
+
const codes = value.split(",").map((c) => c.trim().toLowerCase()).filter(Boolean);
|
|
27
|
+
const bad = codes.find((c) => !/^[a-z]{2,3}$/.test(c));
|
|
28
|
+
if (bad) throw new Error(`--languages takes language codes such as ar,en (got "${bad}")`);
|
|
29
|
+
return [...new Set(codes)];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What the persona generator is told, so each person gets a first language and a name to match. */
|
|
33
|
+
export function languageRequest(languages: string[]): string {
|
|
34
|
+
if (!languages.length) return "";
|
|
35
|
+
const list = languages.map((c) => `"${c}" (${languageName(c)})`).join(", ");
|
|
36
|
+
return `Give each person a "language": their first language, one of ${list}, mixing them across the people, and a name that fits it.`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Each person gets one of the languages: the one the generator gave, if it's on the list, or the next in turn. */
|
|
40
|
+
export function withLanguages(units: CustomerUnit[], languages: string[]): CustomerUnit[] {
|
|
41
|
+
if (!languages.length) return units;
|
|
42
|
+
let next = 0;
|
|
43
|
+
const assign = (p: Persona): Persona => {
|
|
44
|
+
const chosen = p.language && languages.includes(p.language) ? p.language : languages[next % languages.length];
|
|
45
|
+
next++;
|
|
46
|
+
return { ...p, language: chosen };
|
|
47
|
+
};
|
|
48
|
+
return units.map((u) =>
|
|
49
|
+
u.kind === "individual" ? { ...u, person: assign(u.person) } : { ...u, owner: assign(u.owner), employees: u.employees.map(assign) },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The agent's instructions for its first language; nothing for English. */
|
|
54
|
+
export function languageBrief(code: string | undefined): string {
|
|
55
|
+
if (!code || code === "en") return "";
|
|
56
|
+
if (code === "ar")
|
|
57
|
+
return "Your first language is Arabic. If the site offers Arabic, switch to it. Write your name in Arabic script, and type numbers such as phone numbers the way your phone's Arabic keyboard does, with Arabic-Indic digits (٠١٢٣٤٥٦٧٨٩), unless the field shows another format.";
|
|
58
|
+
return `Your first language is ${languageName(code)}. If the site offers ${languageName(code)}, switch to it.`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import type { CustomerUnit } from "../types";
|
|
3
|
+
|
|
4
|
+
/** Each run writes its people here, next to the report, so a later run can meet the same ones. */
|
|
5
|
+
export const PEOPLE_FILE = "people.json";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The people of an earlier run, to run the same agents again: after a fix, whether it worked is
|
|
9
|
+
* only clear with the same people, needs and languages. Generated afresh, a run had a different
|
|
10
|
+
* crew every day (needs rotate daily), and two runs of one app could not be compared.
|
|
11
|
+
*/
|
|
12
|
+
export async function readPeople(path: string): Promise<CustomerUnit[]> {
|
|
13
|
+
let parsed: unknown;
|
|
14
|
+
try {
|
|
15
|
+
parsed = JSON.parse(await readFile(path, "utf8"));
|
|
16
|
+
} catch (err) {
|
|
17
|
+
throw new Error(`${path} can't be read: ${err instanceof Error ? err.message : err}`);
|
|
18
|
+
}
|
|
19
|
+
const ok = (u: unknown) => {
|
|
20
|
+
const unit = u as Partial<CustomerUnit> | null;
|
|
21
|
+
if (!unit || typeof unit !== "object" || typeof unit.id !== "string") return false;
|
|
22
|
+
if (unit.kind === "individual") return !!unit.person?.id;
|
|
23
|
+
return unit.kind === "business" && !!unit.owner?.id && Array.isArray(unit.employees);
|
|
24
|
+
};
|
|
25
|
+
if (!Array.isArray(parsed) || !parsed.length || !parsed.every(ok)) {
|
|
26
|
+
throw new Error(`${path} doesn't hold a run's people: use the ${PEOPLE_FILE} from an earlier run's report folder.`);
|
|
27
|
+
}
|
|
28
|
+
return parsed as CustomerUnit[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** How many agents the units make: everyone in a crew is one. */
|
|
32
|
+
export const peopleCount = (units: CustomerUnit[]) => units.reduce((n, u) => n + (u.kind === "individual" ? 1 : 1 + u.employees.length), 0);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The Calibration Loop from the command line:
|
|
2
|
+
// npm run debutly:predictions -- list [--unresolved] --store <url>
|
|
3
|
+
// npm run debutly:predictions -- record <predictionId> <actual> --store <url> e.g. B, or price
|
|
4
|
+
// npm run debutly:predictions -- accuracy --store <url>
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { openDb } from "./store/db";
|
|
7
|
+
import { PgPredictionStore } from "./store/predictions";
|
|
8
|
+
|
|
9
|
+
const { values, positionals } = parseArgs({
|
|
10
|
+
allowPositionals: true,
|
|
11
|
+
options: { store: { type: "string" }, unresolved: { type: "boolean", default: false } },
|
|
12
|
+
});
|
|
13
|
+
const [command, id, actual] = positionals;
|
|
14
|
+
const url = values.store ?? process.env.DATABASE_URL;
|
|
15
|
+
if (!url || !["list", "record", "accuracy"].includes(command ?? "") || (command === "record" && (!id || !actual))) {
|
|
16
|
+
console.error("Usage: npm run debutly:predictions -- list [--unresolved] | record <id> <actual> | accuracy --store postgres://… (or DATABASE_URL)");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
const { db, close } = await openDb(url);
|
|
20
|
+
const store = new PgPredictionStore(db);
|
|
21
|
+
if (command === "list") {
|
|
22
|
+
for (const p of await store.list({ unresolved: values.unresolved })) {
|
|
23
|
+
const outcome = p.checkedAt ? `actual ${JSON.stringify(p.actual)} → ${p.correct === null ? "not scored" : p.correct ? "correct" : "wrong"}` : "awaiting outcome";
|
|
24
|
+
console.log(`${p.id} [${p.metric}] ${p.claim} (${outcome})`);
|
|
25
|
+
}
|
|
26
|
+
} else if (command === "record") {
|
|
27
|
+
const p = await store.record(id, actual);
|
|
28
|
+
console.log(`Recorded: ${p.claim} → actual ${actual}: ${p.correct === null ? "stored (not scored yet)" : p.correct ? "correct" : "wrong"}`);
|
|
29
|
+
} else {
|
|
30
|
+
const a = await store.accuracy();
|
|
31
|
+
console.log(a.rate === null ? "No scored predictions yet." : `Accuracy: ${a.correct} of ${a.scored} correct (${Math.round(a.rate * 100)}%)`);
|
|
32
|
+
}
|
|
33
|
+
await close();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Queue, QueueEvents } from "bullmq";
|
|
2
|
+
import type { RunAgent } from "../crew/run-unit";
|
|
3
|
+
import type { Session } from "../types";
|
|
4
|
+
import type { AgentJob } from "./jobs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A RunAgent that queues the session for a worker and waits for its result. Crew order is
|
|
8
|
+
* unchanged: runUnit still waits for the owner before queuing the employees.
|
|
9
|
+
*/
|
|
10
|
+
export function queueDispatch(queue: Queue, events: QueueEvents, base: Omit<AgentJob, "persona" | "goal" | "brief">): RunAgent {
|
|
11
|
+
return async (persona, goal, brief) => {
|
|
12
|
+
const job = await queue.add("agent-session", { ...base, persona, goal, brief } satisfies AgentJob, {
|
|
13
|
+
removeOnComplete: 1000,
|
|
14
|
+
removeOnFail: 1000,
|
|
15
|
+
});
|
|
16
|
+
return (await job.waitUntilFinished(events)) as Session;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Redis } from "ioredis";
|
|
2
|
+
import type { ModelPrice } from "../llm/meter";
|
|
3
|
+
import type { Persona } from "../types";
|
|
4
|
+
|
|
5
|
+
export const AGENT_QUEUE = "debutly-agent-sessions";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One agent session to run on a worker. No secrets: workers take the LLM key, database and
|
|
9
|
+
* artifact credentials from their own environment.
|
|
10
|
+
*/
|
|
11
|
+
export interface AgentJob {
|
|
12
|
+
runId: string;
|
|
13
|
+
targetUrl: string;
|
|
14
|
+
persona: Persona;
|
|
15
|
+
goal: string;
|
|
16
|
+
brief?: string;
|
|
17
|
+
maxSteps: number;
|
|
18
|
+
caps?: { maxTokens?: number; maxMs?: number };
|
|
19
|
+
price?: ModelPrice | null;
|
|
20
|
+
/** The run's test inbox (Mailpit), for magic links and invites. */
|
|
21
|
+
mailpitUrl?: string;
|
|
22
|
+
emailDomain: string;
|
|
23
|
+
/** Keeps agent email addresses the same whichever worker runs them. */
|
|
24
|
+
runTag: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A Redis client for BullMQ (it needs one it can block on: no request retry limit). */
|
|
28
|
+
export const redisClient = (url: string) => new Redis(url, { maxRetriesPerRequest: null });
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Job } from "bullmq";
|
|
2
|
+
import type { Browser } from "playwright";
|
|
3
|
+
import { runAgentSession } from "../agent/run-agent";
|
|
4
|
+
import type { ArtifactStore } from "../artifacts/store";
|
|
5
|
+
import type { Inbox } from "../inbox/types";
|
|
6
|
+
import type { LlmClient } from "../llm/types";
|
|
7
|
+
import { agentEmail } from "../run";
|
|
8
|
+
import type { RunStore } from "../store/run-store";
|
|
9
|
+
import type { Session } from "../types";
|
|
10
|
+
import type { AgentJob } from "./jobs";
|
|
11
|
+
|
|
12
|
+
export interface ProcessorDeps {
|
|
13
|
+
browser: Browser;
|
|
14
|
+
llm: LlmClient;
|
|
15
|
+
store: RunStore;
|
|
16
|
+
artifactsFor: (runId: string) => ArtifactStore;
|
|
17
|
+
inboxFor: (mailpitUrl: string) => Inbox;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Runs one queued agent session, recording it to the store, and returns the session. */
|
|
21
|
+
export function createAgentProcessor(d: ProcessorDeps): (job: Pick<Job<AgentJob>, "data">) => Promise<Session> {
|
|
22
|
+
return async ({ data: j }) =>
|
|
23
|
+
runAgentSession(
|
|
24
|
+
{
|
|
25
|
+
browser: d.browser,
|
|
26
|
+
llm: d.llm,
|
|
27
|
+
origin: new URL(j.targetUrl).origin,
|
|
28
|
+
maxSteps: j.maxSteps,
|
|
29
|
+
artifacts: d.artifactsFor(j.runId),
|
|
30
|
+
emailOf: (p) => agentEmail(p, j.runTag, j.emailDomain),
|
|
31
|
+
inbox: j.mailpitUrl ? d.inboxFor(j.mailpitUrl) : undefined,
|
|
32
|
+
caps: j.caps,
|
|
33
|
+
price: j.price,
|
|
34
|
+
shouldStop: () => d.store.isCancelled(j.runId),
|
|
35
|
+
store: d.store,
|
|
36
|
+
runId: j.runId,
|
|
37
|
+
},
|
|
38
|
+
j.persona,
|
|
39
|
+
j.goal,
|
|
40
|
+
j.brief,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { UNCOMPRESSED } from "../detect/findings";
|
|
2
|
+
import type { Finding, Session } from "../types";
|
|
3
|
+
|
|
4
|
+
/** At most this many items, so the section reads in a couple of minutes. */
|
|
5
|
+
const MAX_ITEMS = 5;
|
|
6
|
+
const MAX_QUOTE = 200;
|
|
7
|
+
const RANK: Record<Finding["severity"], number> = { blocker: 0, major: 1, minor: 2 };
|
|
8
|
+
|
|
9
|
+
// Agents that didn't reach the goal because of the app, or seemingly so. An engine error or a
|
|
10
|
+
// cancelled run says nothing about the app; a run cut short for credit has its own section.
|
|
11
|
+
const STOPPED: Partial<Record<Session["outcome"], string>> = {
|
|
12
|
+
gave_up: "gave up",
|
|
13
|
+
budget_exhausted: "ran out of steps",
|
|
14
|
+
not_invited: "never got an invite",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** What to do about a finding, in a line. */
|
|
18
|
+
export function nextStep(f: Finding): string {
|
|
19
|
+
switch (f.kind) {
|
|
20
|
+
case "http_error": {
|
|
21
|
+
const status = Number(/^HTTP (\d{3})/.exec(f.title)?.[1] ?? 0);
|
|
22
|
+
return status >= 500
|
|
23
|
+
? "The server failed: its log for this request says why."
|
|
24
|
+
: "The server refused the request: compare what the page sent with what the server expects.";
|
|
25
|
+
}
|
|
26
|
+
case "page_error":
|
|
27
|
+
return "Fix the JavaScript error; the reproduction below shows the step that sets it off.";
|
|
28
|
+
case "console_error":
|
|
29
|
+
return "Read the error in the browser console on that page.";
|
|
30
|
+
case "a11y_unlabeled":
|
|
31
|
+
if (f.control?.problem === "placeholder") return "Give the field a visible <label>, and keep the placeholder as an example.";
|
|
32
|
+
if (f.control?.problem === "described") return "Name it: visible text or an aria-label, with the description kept as a hint.";
|
|
33
|
+
if (f.control && !/^(button|link|menuitem|tab|summary)/.test(f.control.role)) return "Give each one a <label> (or an aria-label) that says what goes in it.";
|
|
34
|
+
return 'Give each one visible text, or an aria-label that says what it does (aria-label="Open menu" on a menu icon).';
|
|
35
|
+
case "stuck":
|
|
36
|
+
return "Watch the reproduction below: the agent repeated an action that seemed to do nothing. Make the page show that it worked, or why it didn't.";
|
|
37
|
+
case "slow_load":
|
|
38
|
+
return f.detail.includes(UNCOMPRESSED)
|
|
39
|
+
? "Turn on compression (gzip or Brotli) for scripts and styles, or check that the real site has it."
|
|
40
|
+
: "Find what holds the page up: the agents sat waiting for it.";
|
|
41
|
+
case "covered":
|
|
42
|
+
return "Move or close whatever lies over the control, so it can be clicked.";
|
|
43
|
+
case "keyboard":
|
|
44
|
+
if (f.signature.startsWith("enter:")) return "Let Enter press it too: look for a key handler that catches Enter and stops it (a page-wide shortcut, a form's own Enter handling).";
|
|
45
|
+
return 'Use a real <button> or <a>, or add tabindex="0" and handle Enter and Space.';
|
|
46
|
+
case "focus_obscured":
|
|
47
|
+
return "Keep the control with keyboard focus in view: move or shrink what covers it.";
|
|
48
|
+
case "reflow":
|
|
49
|
+
return "Let the page wrap at narrow widths instead of scrolling sideways: look for fixed widths.";
|
|
50
|
+
case "language":
|
|
51
|
+
if (f.signature.startsWith("dir:")) return 'Set dir="rtl" on Arabic pages (on <html>, or on the part that holds the Arabic).';
|
|
52
|
+
if (f.signature === "lang:missing") return 'Add a lang attribute to the <html> element, e.g. lang="en".';
|
|
53
|
+
return 'Set lang="ar" on Arabic pages.';
|
|
54
|
+
case "zoom":
|
|
55
|
+
return "Remove user-scalable=no and maximum-scale from the page's viewport meta tag.";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const lastPath = (s: Session) => {
|
|
60
|
+
try {
|
|
61
|
+
return new URL(s.steps.at(-1)?.observation.url ?? "").pathname;
|
|
62
|
+
} catch {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const quote = (s: string) => `"${s.length > MAX_QUOTE ? `${s.slice(0, MAX_QUOTE)}…` : s}"`;
|
|
68
|
+
|
|
69
|
+
/** A screenshot as the report's reader finds it: next to the report, not on the machine that ran it. */
|
|
70
|
+
export function shotPath(path: string): string {
|
|
71
|
+
if (/^https?:/.test(path)) return path;
|
|
72
|
+
const at = path.replace(/\\/g, "/").lastIndexOf("/screenshots/");
|
|
73
|
+
return at >= 0 ? path.replace(/\\/g, "/").slice(at + 1) : path;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stoppedItem(s: Session, needs: string, environment: boolean): string {
|
|
77
|
+
const who = [s.role, needs !== "—" ? needs : ""].filter(Boolean).join("; ");
|
|
78
|
+
const where = lastPath(s) ? ` on ${lastPath(s)}` : "";
|
|
79
|
+
const shot = [...s.steps].reverse().find((st) => st.screenshotPath)?.screenshotPath;
|
|
80
|
+
// Out of steps, the note only says so; the agent's last thought says where it was.
|
|
81
|
+
const said = s.outcome === "budget_exhausted" && s.steps.at(-1)?.reasoning ? `Its last thought: ${quote(s.steps.at(-1)!.reasoning)}` : `In the agent's words: ${quote(s.outcomeNote)}`;
|
|
82
|
+
return [
|
|
83
|
+
`**${s.personaName}${who ? ` (${who})` : ""} ${STOPPED[s.outcome]}${where}.** ${said}`,
|
|
84
|
+
environment ? "Part of it may be the test environment: see the end of the report." : "",
|
|
85
|
+
shot ? `Start with the last screenshot, ${shotPath(shot)}.` : "",
|
|
86
|
+
]
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join(" ");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The few things to look at first: agents the app seemingly stopped, in their own words, then the
|
|
93
|
+
* blocker and major findings, those that stopped an agent first, each with what to do.
|
|
94
|
+
*/
|
|
95
|
+
export function renderFixFirst(sessions: Session[], findings: Finding[], needLabel: (s: Session) => string): string[] {
|
|
96
|
+
const stopped = sessions.filter((s) => STOPPED[s.outcome]);
|
|
97
|
+
const failed = new Set(stopped.map((s) => s.agentId));
|
|
98
|
+
const caused = findings.filter((f) => f.sandboxCause);
|
|
99
|
+
const serious = findings
|
|
100
|
+
.filter((f) => !f.sandboxCause && f.confidence !== "low" && f.severity !== "minor")
|
|
101
|
+
.map((f, order) => ({ f, order, stoppedSome: f.hitBy.some((id) => failed.has(id)) }))
|
|
102
|
+
// Most severe first; within that, one an agent didn't get past.
|
|
103
|
+
.sort((a, b) => RANK[a.f.severity] - RANK[b.f.severity] || Number(b.stoppedSome) - Number(a.stoppedSome) || a.order - b.order)
|
|
104
|
+
.map(({ f }) => f);
|
|
105
|
+
const reached = sessions.filter((s) => s.outcome === "success").length;
|
|
106
|
+
const stops = stopped.slice(0, MAX_ITEMS).map((s) => stoppedItem(s, needLabel(s), caused.some((f) => f.hitBy.includes(s.agentId))));
|
|
107
|
+
const fixes = serious.slice(0, MAX_ITEMS - stops.length).map((f) => `**${f.title}** (${f.severity}). ${nextStep(f)}`);
|
|
108
|
+
const shown = [...stops, ...fixes];
|
|
109
|
+
const rest = findings.filter((f) => !f.sandboxCause).length - fixes.length;
|
|
110
|
+
return [
|
|
111
|
+
`## Fix these first`,
|
|
112
|
+
"",
|
|
113
|
+
`${reached} of ${sessions.length} agents reached the goal.`,
|
|
114
|
+
"",
|
|
115
|
+
...(shown.length
|
|
116
|
+
? shown.map((item, i) => `${i + 1}. ${item}`)
|
|
117
|
+
: ["Nothing stopped an agent, and nothing serious was found."]),
|
|
118
|
+
...(rest > 0 ? ["", `${rest === 1 ? "One" : rest}${fixes.length ? " more" : ""} ${rest === 1 ? "finding is" : "findings are"} below.`] : []),
|
|
119
|
+
"",
|
|
120
|
+
];
|
|
121
|
+
}
|