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.
Files changed (93) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +192 -2
  3. package/bin/debutly.mjs +8 -0
  4. package/drizzle/0000_init.sql +107 -0
  5. package/drizzle/0001_predictions.sql +14 -0
  6. package/drizzle/0002_dashboard.sql +15 -0
  7. package/drizzle/meta/0000_snapshot.json +722 -0
  8. package/drizzle/meta/0001_snapshot.json +821 -0
  9. package/drizzle/meta/0002_snapshot.json +942 -0
  10. package/drizzle/meta/_journal.json +27 -0
  11. package/package.json +83 -4
  12. package/src/agent/describe.ts +29 -0
  13. package/src/agent/effect.ts +94 -0
  14. package/src/agent/give-up.ts +42 -0
  15. package/src/agent/label.ts +19 -0
  16. package/src/agent/loop.ts +302 -0
  17. package/src/agent/parse-reply.ts +127 -0
  18. package/src/agent/prompt.ts +85 -0
  19. package/src/agent/run-agent.ts +68 -0
  20. package/src/artifacts/db.ts +55 -0
  21. package/src/artifacts/s3.ts +36 -0
  22. package/src/artifacts/store.ts +26 -0
  23. package/src/bin.ts +140 -0
  24. package/src/browser/ax.ts +77 -0
  25. package/src/browser/execute.ts +401 -0
  26. package/src/browser/observe.ts +62 -0
  27. package/src/browser/reflow.ts +73 -0
  28. package/src/browser/samples.ts +119 -0
  29. package/src/browser/scan-script.ts +108 -0
  30. package/src/browser/settle.ts +32 -0
  31. package/src/browser/signals.ts +73 -0
  32. package/src/cancel.ts +20 -0
  33. package/src/cli.ts +281 -0
  34. package/src/config.ts +62 -0
  35. package/src/cost/estimate.ts +32 -0
  36. package/src/cost/prices.ts +32 -0
  37. package/src/cost/tiers.ts +27 -0
  38. package/src/costs.ts +96 -0
  39. package/src/crew/run-unit.ts +62 -0
  40. package/src/detect/findings.ts +649 -0
  41. package/src/detect/wcag.ts +38 -0
  42. package/src/doctor.ts +145 -0
  43. package/src/hear-cli.ts +137 -0
  44. package/src/hear.ts +309 -0
  45. package/src/import.ts +108 -0
  46. package/src/inbox/mailpit.ts +46 -0
  47. package/src/inbox/types.ts +10 -0
  48. package/src/init.ts +184 -0
  49. package/src/llm/anthropic.ts +41 -0
  50. package/src/llm/credits.ts +35 -0
  51. package/src/llm/factory.ts +65 -0
  52. package/src/llm/meter.ts +39 -0
  53. package/src/llm/openai-compatible.ts +90 -0
  54. package/src/llm/openrouter.ts +9 -0
  55. package/src/llm/scripted.ts +18 -0
  56. package/src/llm/types.ts +36 -0
  57. package/src/perceive.ts +150 -0
  58. package/src/perception/aggregate.ts +111 -0
  59. package/src/perception/compare.ts +77 -0
  60. package/src/perception/evaluate.ts +83 -0
  61. package/src/perception/exhibits.ts +146 -0
  62. package/src/perception/market-context.ts +50 -0
  63. package/src/perception/run-perception.ts +178 -0
  64. package/src/persona/generate.ts +92 -0
  65. package/src/persona/inclusion.ts +53 -0
  66. package/src/persona/language.ts +59 -0
  67. package/src/persona/people.ts +32 -0
  68. package/src/predictions.ts +33 -0
  69. package/src/queue/dispatch.ts +18 -0
  70. package/src/queue/jobs.ts +28 -0
  71. package/src/queue/processor.ts +42 -0
  72. package/src/report/fix-first.ts +121 -0
  73. package/src/report/launch-readiness.ts +106 -0
  74. package/src/report/markdown.ts +202 -0
  75. package/src/run.ts +141 -0
  76. package/src/sandbox/outbox.ts +65 -0
  77. package/src/store/db.ts +40 -0
  78. package/src/store/import-run.ts +116 -0
  79. package/src/store/pg-run-store.ts +163 -0
  80. package/src/store/predictions.ts +70 -0
  81. package/src/store/run-store.ts +78 -0
  82. package/src/store/schema.ts +193 -0
  83. package/src/store/url.ts +15 -0
  84. package/src/types.ts +201 -0
  85. package/src/util/chromium.ts +20 -0
  86. package/src/util/json.ts +7 -0
  87. package/src/util/key-defaults.ts +34 -0
  88. package/src/util/legacy-env.ts +14 -0
  89. package/src/util/load-env.ts +28 -0
  90. package/src/util/pool.ts +12 -0
  91. package/src/util/run-origin.ts +27 -0
  92. package/src/worker.ts +53 -0
  93. package/tsconfig.json +13 -0
@@ -0,0 +1,36 @@
1
+ export interface LlmMessage {
2
+ role: "user" | "assistant";
3
+ content: string;
4
+ }
5
+
6
+ export interface LlmUsage {
7
+ inputTokens: number;
8
+ outputTokens: number;
9
+ /** What the provider actually charged in USD, when it says (OpenRouter does). */
10
+ costUsd?: number;
11
+ }
12
+
13
+ export interface LlmRequest {
14
+ system: string;
15
+ messages: LlmMessage[];
16
+ maxTokens?: number;
17
+ /** Called with this request's token counts, so each session can meter its own spend. */
18
+ onUsage?: (usage: LlmUsage) => void;
19
+ }
20
+
21
+ export interface LlmClient {
22
+ complete(req: LlmRequest): Promise<string>;
23
+ }
24
+
25
+ export const OUT_OF_CREDITS = "The AI provider account is out of credits";
26
+
27
+ /**
28
+ * The provider refused for lack of credit (OpenRouter's 402, Anthropic's "credit balance is too
29
+ * low"). Every later call would be refused too, so clients raise it again without asking.
30
+ */
31
+ export class OutOfCreditsError extends Error {
32
+ constructor(detail: string) {
33
+ super(`${OUT_OF_CREDITS}: ${detail.slice(0, 300)}`);
34
+ this.name = "OutOfCreditsError";
35
+ }
36
+ }
@@ -0,0 +1,150 @@
1
+ // Perception, grounding and experiments, without a browser per buyer:
2
+ //
3
+ // npx debutly perceive --icp "…" --url https://app.test/ --url https://app.test/pricing \
4
+ // [--competitors https://rival.test/pricing,https://other.test/pricing] [--evaluators 50]
5
+ // npx debutly perceive --icp "…" --variant A=https://app.test/pricing --variant B=pricing-b.md
6
+ // npx debutly perceive --icp "…" --from-run runs/<dir> (pages the depth agents saw)
7
+ // npx debutly perceive --icp "…" --file pitch.md (idea stage)
8
+ //
9
+ // Pages are read, never submitted to. Writes launch-readiness.md, verdicts.json and
10
+ // market-context.json; with --store the run and its predictions are recorded.
11
+ import "./util/legacy-env";
12
+ import "./util/key-defaults";
13
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { parseArgs } from "node:util";
16
+ import { chromium } from "playwright";
17
+ import { estimatePerceptionCost } from "./cost/estimate";
18
+ import { fetchModelPrice } from "./cost/prices";
19
+ import { EVALUATORS, type Tier } from "./cost/tiers";
20
+ import { creditCheck, keyCreditsLeft } from "./llm/credits";
21
+ import { createLlms } from "./llm/factory";
22
+ import { captureExhibits, exhibitFromFile, exhibitsFromSessions, type Exhibit } from "./perception/exhibits";
23
+ import { buildMarketContext, type MarketContext } from "./perception/market-context";
24
+ import { runPerception } from "./perception/run-perception";
25
+ import { renderLaunchReadiness } from "./report/launch-readiness";
26
+ import { openDb } from "./store/db";
27
+ import { PgRunStore } from "./store/pg-run-store";
28
+ import { PgPredictionStore } from "./store/predictions";
29
+ import type { Session } from "./types";
30
+
31
+ const { values } = parseArgs({
32
+ options: {
33
+ icp: { type: "string" },
34
+ product: { type: "string" },
35
+ url: { type: "string", multiple: true },
36
+ file: { type: "string", multiple: true },
37
+ "from-run": { type: "string" },
38
+ variant: { type: "string", multiple: true },
39
+ competitors: { type: "string" },
40
+ "market-context": { type: "string" },
41
+ tier: { type: "string", default: "quick" },
42
+ evaluators: { type: "string" },
43
+ concurrency: { type: "string", default: "8" },
44
+ "max-cost": { type: "string" },
45
+ store: { type: "string" },
46
+ out: { type: "string" },
47
+ },
48
+ });
49
+
50
+ const variantArgs = values.variant ?? [];
51
+ const hasMain = !!(values.url?.length || values.file?.length || values["from-run"]);
52
+ if (!values.icp || (!hasMain && variantArgs.length < 2) || (hasMain && variantArgs.length)) {
53
+ console.error(
54
+ 'Usage: npx debutly perceive --icp "<buyers>" (--url <url>… | --file <pitch>… | --from-run <dir> | --variant A=<url|file> --variant B=<url|file>) [--competitors url,url] [--market-context file] [--tier quick|standard|deep] [--evaluators N] [--max-cost USD] [--store postgres://…] [--out dir]',
55
+ );
56
+ process.exit(1);
57
+ }
58
+ const tier = values.tier as Tier;
59
+ if (!(tier in EVALUATORS)) {
60
+ console.error("--tier must be quick, standard or deep");
61
+ process.exit(1);
62
+ }
63
+ const evaluators = values.evaluators !== undefined ? Number(values.evaluators) : EVALUATORS[tier];
64
+ const isUrl = (s: string) => /^https?:\/\//.test(s);
65
+ const experiment = variantArgs.length > 0;
66
+
67
+ // Before anything is spent: what could this cost?
68
+ const navModel = process.env.DEBUTLY_NAV_MODEL ?? "claude-haiku-4-5";
69
+ const price = await fetchModelPrice(navModel);
70
+ const judgePrice = await fetchModelPrice(process.env.DEBUTLY_JUDGE_MODEL ?? "claude-opus-5");
71
+ const estimate = estimatePerceptionCost({ evaluators, experiment, price });
72
+ console.log(estimate ? `Estimated cost: $${estimate.low.toFixed(2)}–$${estimate.high.toFixed(2)} (${evaluators} evaluators on ${navModel})` : `Estimated cost: unknown (no price for ${navModel})`);
73
+ if (values["max-cost"] !== undefined && (!estimate || estimate.high > Number(values["max-cost"]))) {
74
+ console.error(`Refusing to start: ${estimate ? `this could cost up to $${estimate.high.toFixed(2)}` : "the price is unknown"}, and --max-cost is $${values["max-cost"]}.`);
75
+ process.exit(1);
76
+ }
77
+ const credits = creditCheck(await keyCreditsLeft(process.env.DEBUTLY_BASE_URL ?? "", process.env.DEBUTLY_API_KEY ?? ""), estimate);
78
+ if (credits.refuse) {
79
+ console.error(`Refusing to start: ${credits.refuse}`);
80
+ process.exit(1);
81
+ }
82
+ if (credits.warn) console.warn(`Warning: ${credits.warn}`);
83
+
84
+ const { nav, judge } = createLlms(process.env);
85
+ const browser = await chromium.launch();
86
+ const exhibitsByVariant: Record<string, Exhibit[]> = {};
87
+ let market: MarketContext | undefined;
88
+ try {
89
+ if (experiment) {
90
+ for (const arg of variantArgs) {
91
+ const [name, source] = [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)];
92
+ if (!name || !source) throw new Error(`--variant must look like A=<url|file> (got "${arg}")`);
93
+ const found = isUrl(source) ? await captureExhibits(browser, [source]) : [await exhibitFromFile(source)];
94
+ exhibitsByVariant[name] = [...(exhibitsByVariant[name] ?? []), ...found.map((e) => ({ ...e, id: `${name}:${e.id}` }))];
95
+ }
96
+ if (Object.keys(exhibitsByVariant).length !== 2) throw new Error("an experiment needs exactly two variants, e.g. --variant A=… --variant B=…");
97
+ } else {
98
+ const main: Exhibit[] = [];
99
+ if (values["from-run"]) main.push(...exhibitsFromSessions(JSON.parse(await readFile(join(values["from-run"], "sessions.json"), "utf8")) as Session[]));
100
+ if (values.url?.length) main.push(...(await captureExhibits(browser, values.url)));
101
+ for (const f of values.file ?? []) main.push(await exhibitFromFile(f));
102
+ exhibitsByVariant.main = main;
103
+ }
104
+
105
+ if (values["market-context"]) {
106
+ market = JSON.parse(await readFile(values["market-context"], "utf8")) as MarketContext;
107
+ } else if (values.competitors) {
108
+ const urls = values.competitors.split(",").map((u) => u.trim()).filter(Boolean);
109
+ console.log(`Reading ${urls.length} competitor pages for market context...`);
110
+ market = await buildMarketContext(judge, { icp: values.icp, product: values.product ?? "the product", competitorExhibits: await captureExhibits(browser, urls) });
111
+ }
112
+ } finally {
113
+ await browser.close();
114
+ }
115
+
116
+ const product = values.product ?? (values.url?.[0] ? new URL(values.url[0]).host : "the product");
117
+ console.log(`${evaluators} evaluators judging ${experiment ? Object.keys(exhibitsByVariant).join(" vs ") : `${exhibitsByVariant.main.length} pages`}...`);
118
+ const result = await runPerception({
119
+ llm: nav,
120
+ judge,
121
+ icp: values.icp,
122
+ product,
123
+ exhibitsByVariant,
124
+ evaluators,
125
+ concurrency: Number(values.concurrency),
126
+ market,
127
+ price,
128
+ judgePrice,
129
+ });
130
+
131
+ const outDir = values.out ?? `runs/perceive-${new Date().toISOString().replace(/[:.]/g, "-")}`;
132
+ await mkdir(outDir, { recursive: true });
133
+ const report = renderLaunchReadiness({ target: product, icp: values.icp, result });
134
+ await writeFile(join(outDir, "launch-readiness.md"), report);
135
+ await writeFile(join(outDir, "verdicts.json"), JSON.stringify({ verdicts: result.verdicts, pairs: result.pairs, aggregates: result.aggregates, pairAggregate: result.pairAggregate }, null, 2));
136
+ if (market) await writeFile(join(outDir, "market-context.json"), JSON.stringify(market, null, 2));
137
+
138
+ if (values.store) {
139
+ const { db, close } = await openDb(values.store);
140
+ const runs = new PgRunStore(db);
141
+ const runId = await runs.createRun({ targetUrl: values.url?.[0] ?? product, goal: experiment ? "experiment" : "perception", icp: values.icp, tier, unitKind: "evaluators", model: navModel, estimate });
142
+ await runs.finishRun(runId, { status: "finished", findings: [], reportMd: report, usage: result.usage, sideEffects: [] });
143
+ const ids = await new PgPredictionStore(db).add(runId, result.predictions);
144
+ await close();
145
+ console.log(`Stored run ${runId} with ${ids.length} predictions:`);
146
+ result.predictions.forEach((p, i) => console.log(` ${ids[i]} ${p.claim}`));
147
+ }
148
+
149
+ const cost = result.usage.costUsd !== undefined ? `, cost $${result.usage.costUsd.toFixed(4)}` : "";
150
+ console.log(`Done: ${result.evaluators - result.unusable} usable judgments${cost}. Report: ${join(outDir, "launch-readiness.md")}`);
@@ -0,0 +1,111 @@
1
+ import type { PairJudgment } from "./compare";
2
+ import type { ObjectionCategory, Verdict } from "./evaluate";
3
+
4
+ /** Wilson score interval (95%) for a proportion: sensible even for small samples. */
5
+ export function wilson(successes: number, n: number, z = 1.96): { low: number; high: number } {
6
+ if (n === 0) return { low: 0, high: 1 };
7
+ const p = successes / n;
8
+ const denom = 1 + (z * z) / n;
9
+ const centre = (p + (z * z) / (2 * n)) / denom;
10
+ const margin = (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / denom;
11
+ return { low: Math.max(0, centre - margin), high: Math.min(1, centre + margin) };
12
+ }
13
+
14
+ const median = (xs: number[]) => {
15
+ const s = [...xs].sort((a, b) => a - b);
16
+ const m = Math.floor(s.length / 2);
17
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
18
+ };
19
+
20
+ export interface VerdictAggregate {
21
+ evaluators: number;
22
+ unusable: number;
23
+ /** Ranked by how many buyers raised each category (at least once). */
24
+ objections: { category: ObjectionCategory; buyers: number; examples: string[] }[];
25
+ intent: { no: number; maybe: number; yes: number };
26
+ meanScores: { clarity: number; credibility: number; value: number; urgency: number };
27
+ wouldPay: { answers: number; medianUsd: number | null; plans: Record<string, number> };
28
+ confusions: { exhibitId: string; count: number; examples: string[] }[];
29
+ }
30
+
31
+ /** Objection categories ranked by how many buyers raised each (at least once), with example quotes. */
32
+ export function rankObjections(perBuyer: { category: ObjectionCategory; text: string }[][]): VerdictAggregate["objections"] {
33
+ const byCategory = new Map<ObjectionCategory, { buyers: number; examples: string[] }>();
34
+ for (const objections of perBuyer) {
35
+ for (const category of new Set(objections.map((o) => o.category))) {
36
+ const entry = byCategory.get(category) ?? { buyers: 0, examples: [] };
37
+ entry.buyers++;
38
+ const quote = objections.find((o) => o.category === category)!.text;
39
+ if (entry.examples.length < 3 && !entry.examples.includes(quote)) entry.examples.push(quote);
40
+ byCategory.set(category, entry);
41
+ }
42
+ }
43
+ return [...byCategory.entries()].map(([category, e]) => ({ category, ...e })).sort((a, b) => b.buyers - a.buyers);
44
+ }
45
+
46
+ export function aggregateVerdicts(verdicts: Verdict[], unusable = 0): VerdictAggregate {
47
+ const mean = (k: keyof Verdict["scores"]) => (verdicts.length ? verdicts.reduce((n, v) => n + v.scores[k], 0) / verdicts.length : 0);
48
+ const payers = verdicts.filter((v) => v.intent !== "no" && v.wouldPay).map((v) => v.wouldPay!);
49
+ const plans: Record<string, number> = {};
50
+ for (const p of payers) plans[p.plan] = (plans[p.plan] ?? 0) + 1;
51
+ const confusions = new Map<string, { count: number; examples: string[] }>();
52
+ for (const c of verdicts.flatMap((v) => v.confusions)) {
53
+ const key = c.exhibitId ?? "general";
54
+ const entry = confusions.get(key) ?? { count: 0, examples: [] };
55
+ entry.count++;
56
+ if (entry.examples.length < 3 && !entry.examples.includes(c.text)) entry.examples.push(c.text);
57
+ confusions.set(key, entry);
58
+ }
59
+ return {
60
+ evaluators: verdicts.length,
61
+ unusable,
62
+ objections: rankObjections(verdicts.map((v) => v.objections)),
63
+ intent: {
64
+ no: verdicts.filter((v) => v.intent === "no").length,
65
+ maybe: verdicts.filter((v) => v.intent === "maybe").length,
66
+ yes: verdicts.filter((v) => v.intent === "yes").length,
67
+ },
68
+ meanScores: { clarity: mean("clarity"), credibility: mean("credibility"), value: mean("value"), urgency: mean("urgency") },
69
+ wouldPay: { answers: payers.length, medianUsd: payers.length ? median(payers.map((p) => p.monthlyUsd)) : null, plans },
70
+ confusions: [...confusions.entries()].map(([exhibitId, e]) => ({ exhibitId, ...e })).sort((a, b) => b.count - a.count),
71
+ };
72
+ }
73
+
74
+ export interface PairAggregate {
75
+ winsA: number;
76
+ winsB: number;
77
+ neither: number;
78
+ /** The variant more buyers chose (ties go to A), and its share of decided judgments. */
79
+ leader: "A" | "B";
80
+ winRate: number;
81
+ interval: { low: number; high: number };
82
+ /** The interval excludes 50/50: the lead is unlikely to be noise. */
83
+ decisive: boolean;
84
+ /** Share of decided judgments won by whichever option was shown first (0.5 = no position bias). */
85
+ firstShownWinShare: number;
86
+ reasons: { A: string[]; B: string[] };
87
+ }
88
+
89
+ export function aggregatePairs(pairs: PairJudgment[]): PairAggregate {
90
+ const winsA = pairs.filter((p) => p.preferred === "A").length;
91
+ const winsB = pairs.filter((p) => p.preferred === "B").length;
92
+ const decided = winsA + winsB;
93
+ const leader = winsB > winsA ? "B" : "A";
94
+ const leaderWins = Math.max(winsA, winsB);
95
+ const interval = wilson(leaderWins, decided);
96
+ const firstWins = pairs.filter((p) => p.preferred !== "neither" && p.preferred === p.shownFirst).length;
97
+ return {
98
+ winsA,
99
+ winsB,
100
+ neither: pairs.length - decided,
101
+ leader,
102
+ winRate: decided ? leaderWins / decided : 0,
103
+ interval,
104
+ decisive: decided > 0 && interval.low > 0.5,
105
+ firstShownWinShare: decided ? firstWins / decided : 0,
106
+ reasons: {
107
+ A: pairs.filter((p) => p.preferred === "A").flatMap((p) => p.reasons).slice(0, 5),
108
+ B: pairs.filter((p) => p.preferred === "B").flatMap((p) => p.reasons).slice(0, 5),
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+ import type { LlmClient, LlmMessage, LlmUsage } from "../llm/types";
3
+ import type { Persona } from "../types";
4
+ import { extractJsonObject } from "../util/json";
5
+ import { ATTEMPTS, ObjectionSchema, personaBrief, REPLY_TOKENS, RUBRIC } from "./evaluate";
6
+ import { canonicalExhibitId, renderExhibits, type Exhibit } from "./exhibits";
7
+
8
+ const PairSchema = z.object({
9
+ objections1: z.array(ObjectionSchema).min(1),
10
+ objections2: z.array(ObjectionSchema).min(1),
11
+ preferred: z.enum(["1", "2", "neither"]),
12
+ reasons: z.array(z.string()).min(1),
13
+ });
14
+
15
+ export interface PairJudgment {
16
+ evaluatorId: string;
17
+ preferred: "A" | "B" | "neither";
18
+ reasons: string[];
19
+ objectionsA: z.infer<typeof ObjectionSchema>[];
20
+ objectionsB: z.infer<typeof ObjectionSchema>[];
21
+ /** Which variant was shown first, for checking position bias later. */
22
+ shownFirst: "A" | "B";
23
+ }
24
+
25
+ const SHAPE = `Reply with one JSON object: {"objections1":[{"category","text","exhibitId"}],"objections2":[...],"preferred":"1"|"2"|"neither","reasons":[string]}. Name at least one objection to each option before choosing. "neither" is a valid answer.`;
26
+
27
+ /**
28
+ * Experiment mode: one buyer sees two variants and picks one (or neither). Variants
29
+ * appear as Option 1/2 in random order so position bias averages out across evaluators.
30
+ */
31
+ export async function comparePair(
32
+ llm: LlmClient,
33
+ persona: Persona,
34
+ a: Exhibit[],
35
+ b: Exhibit[],
36
+ o: { market?: string; onUsage?: (u: LlmUsage) => void },
37
+ rng: () => number = Math.random,
38
+ ): Promise<PairJudgment | null> {
39
+ const aFirst = rng() < 0.5;
40
+ const [first, second] = aFirst ? [a, b] : [b, a];
41
+ // Blind: no variant names, file names or web addresses, which would give away which is which.
42
+ const blind = (pages: Exhibit[], option: number) => pages.map((e, i) => ({ ...e, id: `${option}.${i + 1}`, url: undefined }));
43
+ const shown1 = blind(first, 1);
44
+ const shown2 = blind(second, 2);
45
+ const messages: LlmMessage[] = [
46
+ {
47
+ role: "user",
48
+ content: `${o.market ? `${o.market}\n\n` : ""}Two versions of the same product. Which would you rather buy?\n\n## Option 1\n\n${renderExhibits(shown1)}\n\n## Option 2\n\n${renderExhibits(shown2)}\n\n${SHAPE}`,
49
+ },
50
+ ];
51
+ // Maps an objection's page reference ("1.2", "pricing"...) back to the real exhibit id.
52
+ const realId = (ref: string | undefined, shown: Exhibit[], real: Exhibit[]) => {
53
+ const id = canonicalExhibitId(ref, shown);
54
+ return id ? real[shown.findIndex((e) => e.id === id)]?.id : undefined;
55
+ };
56
+ const system = `${personaBrief(persona)}\n\n${RUBRIC}`;
57
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
58
+ const raw = await llm.complete({ system, messages: [...messages], maxTokens: REPLY_TOKENS, onUsage: o.onUsage });
59
+ try {
60
+ const p = PairSchema.parse(extractJsonObject(raw));
61
+ const toVariant = (opt: "1" | "2") => ((opt === "1") === aFirst ? "A" : "B");
62
+ p.objections1 = p.objections1.map((o) => ({ ...o, exhibitId: realId(o.exhibitId, shown1, first) }));
63
+ p.objections2 = p.objections2.map((o) => ({ ...o, exhibitId: realId(o.exhibitId, shown2, second) }));
64
+ return {
65
+ evaluatorId: persona.id,
66
+ preferred: p.preferred === "neither" ? "neither" : toVariant(p.preferred),
67
+ reasons: p.reasons,
68
+ objectionsA: aFirst ? p.objections1 : p.objections2,
69
+ objectionsB: aFirst ? p.objections2 : p.objections1,
70
+ shownFirst: aFirst ? "A" : "B",
71
+ };
72
+ } catch (err) {
73
+ messages.push({ role: "assistant", content: raw || "(empty)" }, { role: "user", content: `That reply was not valid (${String(err).slice(0, 200)}). ${SHAPE}` });
74
+ }
75
+ }
76
+ return null;
77
+ }
@@ -0,0 +1,83 @@
1
+ import { z } from "zod";
2
+ import type { LlmClient, LlmMessage, LlmUsage } from "../llm/types";
3
+ import type { Persona } from "../types";
4
+ import { extractJsonObject } from "../util/json";
5
+ import { canonicalExhibitId, renderExhibits, type Exhibit } from "./exhibits";
6
+
7
+ export const OBJECTION_CATEGORIES = [
8
+ "price",
9
+ "unclear_value",
10
+ "trust",
11
+ "missing_feature",
12
+ "complexity",
13
+ "switching_cost",
14
+ "integration",
15
+ "timing",
16
+ "other",
17
+ ] as const;
18
+ export type ObjectionCategory = (typeof OBJECTION_CATEGORIES)[number];
19
+
20
+ // Reasoning models spend part of the reply budget thinking and can return nothing if it is
21
+ // tight; allow room, and ask again on an empty or invalid reply.
22
+ export const REPLY_TOKENS = 4000;
23
+ export const ATTEMPTS = 3;
24
+
25
+ const score = z.number().int().min(1).max(5);
26
+ export const ObjectionSchema = z.object({ category: z.enum(OBJECTION_CATEGORIES), text: z.string().min(3), exhibitId: z.string().optional() });
27
+
28
+ const VerdictSchema = z.object({
29
+ objections: z.array(ObjectionSchema).min(2, "list at least 2 objections"),
30
+ confusions: z.array(z.object({ text: z.string(), exhibitId: z.string().optional() })),
31
+ scores: z.object({ clarity: score, credibility: score, value: score, urgency: score }),
32
+ intent: z.enum(["no", "maybe", "yes"]),
33
+ wouldPay: z.object({ plan: z.string(), monthlyUsd: z.number().nonnegative() }).nullable(),
34
+ summary: z.string(),
35
+ });
36
+
37
+ export type Verdict = z.infer<typeof VerdictSchema> & { evaluatorId: string };
38
+
39
+ // Adversarial by design, to counter LLM agreeableness.
40
+ export const RUBRIC = `You are a skeptical buyer with a limited budget, judging a product only from the pages you are shown.
41
+ Rules:
42
+ - First list at least 2 specific objections, each tied to an exhibit id, with a category: ${OBJECTION_CATEGORIES.join(", ")}.
43
+ - Default to "no". Say "maybe" or "yes" only if the pages give you a concrete reason, and compare against the alternatives you know.
44
+ - Score strictly from 1 (poor) to 5 (excellent): clarity, credibility, value, urgency. A 5 must be earned.
45
+ - Never assume features, prices or guarantees the pages do not show.
46
+ - "wouldPay" is the plan and monthly USD you would actually pay, or null.`;
47
+
48
+ const SHAPE = `Reply with one JSON object: {"objections":[{"category","text","exhibitId"}],"confusions":[{"text","exhibitId"}],"scores":{"clarity","credibility","value","urgency"},"intent":"no"|"maybe"|"yes","wouldPay":{"plan","monthlyUsd"}|null,"summary":string}.`;
49
+
50
+ export const personaBrief = (p: Persona) => `You are ${p.name}. ${p.background} What you want: ${p.goal}. Patience: ${p.patience}.`;
51
+
52
+ /** One evaluator's verdict on the exhibits; null if it never produced a valid one (counted as unusable). */
53
+ export async function evaluate(
54
+ llm: LlmClient,
55
+ persona: Persona,
56
+ exhibits: Exhibit[],
57
+ o: { market?: string; onUsage?: (u: LlmUsage) => void },
58
+ ): Promise<Verdict | null> {
59
+ const messages: LlmMessage[] = [
60
+ { role: "user", content: `${o.market ? `${o.market}\n\n` : ""}The product's pages:\n\n${renderExhibits(exhibits)}\n\n${SHAPE}` },
61
+ ];
62
+ const system = `${personaBrief(persona)}\n\n${RUBRIC}`;
63
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
64
+ const raw = await llm.complete({ system, messages: [...messages], maxTokens: REPLY_TOKENS, onUsage: o.onUsage });
65
+ try {
66
+ const v = VerdictSchema.parse(extractJsonObject(raw));
67
+ const page = (ref?: string) => canonicalExhibitId(ref, exhibits);
68
+ return {
69
+ evaluatorId: persona.id,
70
+ ...v,
71
+ objections: v.objections.map((o) => ({ ...o, exhibitId: page(o.exhibitId) })),
72
+ confusions: v.confusions.map((c) => ({ ...c, exhibitId: page(c.exhibitId) })),
73
+ };
74
+ } catch (err) {
75
+ const problem = err instanceof z.ZodError ? err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") : String(err);
76
+ messages.push(
77
+ { role: "assistant", content: raw || "(empty)" },
78
+ { role: "user", content: `That reply was not valid (${problem.slice(0, 300)}). Follow the rules, including at least 2 objections. ${SHAPE}` },
79
+ );
80
+ }
81
+ }
82
+ return null;
83
+ }
@@ -0,0 +1,146 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import type { Browser, Page } from "playwright";
4
+ import { observe } from "../browser/observe";
5
+ import type { Session } from "../types";
6
+
7
+ export type ExhibitKind = "landing" | "pricing" | "signup" | "onboarding" | "dashboard" | "checkout" | "pitch" | "other";
8
+
9
+ /** What an evaluator is shown: a page (or pitch) as copy and controls, no browser needed. */
10
+ export interface Exhibit {
11
+ id: string;
12
+ kind: ExhibitKind;
13
+ title: string;
14
+ url?: string;
15
+ text: string;
16
+ elements: string[];
17
+ }
18
+
19
+ // Pages most telling for a buying decision come first when exhibits are capped.
20
+ const PRIORITY: ExhibitKind[] = ["pricing", "landing", "signup", "onboarding", "checkout", "dashboard", "pitch", "other"];
21
+ const MAX_TEXT = 8000;
22
+
23
+ const pathOf = (url: string) => {
24
+ try {
25
+ return new URL(url).pathname.toLowerCase();
26
+ } catch {
27
+ return url.toLowerCase();
28
+ }
29
+ };
30
+
31
+ export function kindOf(url: string, title: string, text: string): ExhibitKind {
32
+ const path = pathOf(url);
33
+ const words = `${title} ${text.slice(0, 1500)}`.toLowerCase();
34
+ if (/\/(pricing|plans?|price)(\/|$)/.test(path) || /per month|\/mo(nth)?\b|billed (monthly|annually)/.test(words)) return "pricing";
35
+ if (/\/(sign-?up|register|join|get-started|start)(\/|$)/.test(path)) return "signup";
36
+ if (/\/(checkout|billing\/checkout|upgrade)(\/|$)/.test(path)) return "checkout";
37
+ if (/\/(onboarding|welcome|setup)(\/|$)/.test(path)) return "onboarding";
38
+ if (/\/(dashboard|app|home)(\/|$)/.test(path)) return "dashboard";
39
+ if (path === "/" || path === "") return "landing";
40
+ return "other";
41
+ }
42
+
43
+ const byPriority = (a: Exhibit, b: Exhibit) => PRIORITY.indexOf(a.kind) - PRIORITY.indexOf(b.kind);
44
+
45
+ /** The last snapshot of each page the depth agents saw, key pages first. */
46
+ export function exhibitsFromSessions(sessions: Session[], max = 8): Exhibit[] {
47
+ const latest = new Map<string, Exhibit>();
48
+ for (const s of sessions) {
49
+ for (const step of s.steps) {
50
+ const o = step.observation;
51
+ const path = pathOf(o.url);
52
+ latest.set(path, {
53
+ id: path,
54
+ kind: kindOf(o.url, o.title, o.text),
55
+ title: o.title,
56
+ url: o.url,
57
+ text: o.text,
58
+ elements: o.elements.filter((e) => e.name).map((e) => `${e.role}: ${e.name}`),
59
+ });
60
+ }
61
+ }
62
+ return [...latest.values()].sort(byPriority).slice(0, max);
63
+ }
64
+
65
+ /**
66
+ * A person reading a pricing page opens the FAQ; answers hidden in accordions would otherwise
67
+ * read as unanswered questions. Opens <details> and question-shaped collapsed buttons only.
68
+ */
69
+ async function openFaqAnswers(page: Page): Promise<void> {
70
+ await page.evaluate(() => document.querySelectorAll("details").forEach((d) => (d.open = true)));
71
+ const questions = page.locator('button[aria-expanded="false"]').filter({ hasText: /\?\s*$/ });
72
+ const n = Math.min(await questions.count(), 30);
73
+ for (let i = 0; i < n; i++) await questions.nth(i).click({ timeout: 1000 }).catch(() => {});
74
+ if (n) await page.waitForTimeout(300);
75
+ }
76
+
77
+ /** Visits each URL and reads it: nothing is typed or submitted; only FAQ answers are opened. */
78
+ export async function captureExhibits(browser: Browser, urls: string[]): Promise<Exhibit[]> {
79
+ const context = await browser.newContext();
80
+ try {
81
+ const out: Exhibit[] = [];
82
+ for (const url of urls) {
83
+ const page = await context.newPage();
84
+ await page.goto(url, { waitUntil: "domcontentloaded" });
85
+ await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {});
86
+ await openFaqAnswers(page);
87
+ const o = await observe(page);
88
+ const text = (await page.locator("body").innerText()).replace(/\s+/g, " ").trim().slice(0, MAX_TEXT);
89
+ out.push({
90
+ id: pathOf(o.url) || url,
91
+ kind: kindOf(o.url, o.title, text),
92
+ title: o.title,
93
+ url: o.url,
94
+ text,
95
+ elements: o.elements.filter((e) => e.name).map((e) => `${e.role}: ${e.name}`),
96
+ });
97
+ await page.close();
98
+ }
99
+ return out;
100
+ } finally {
101
+ await context.close();
102
+ }
103
+ }
104
+
105
+ /**
106
+ * An idea-stage artifact or variant: a pitch, pricing sketch or copy draft as text, read as a
107
+ * buyer would see it (HTML comments are authors' notes and are dropped).
108
+ */
109
+ export async function exhibitFromFile(path: string): Promise<Exhibit> {
110
+ const text = (await readFile(path, "utf8")).replace(/<!--[\s\S]*?-->/g, "").trim().slice(0, MAX_TEXT);
111
+ const name = basename(path);
112
+ const heading = text.match(/^#\s+(.+)$/m)?.[1]?.trim();
113
+ const kind = kindOf("file", heading ?? name, text);
114
+ return { id: name, kind: kind === "other" ? "pitch" : kind, title: heading ?? name, text, elements: [] };
115
+ }
116
+
117
+ /** Exhibits as prompt text. */
118
+ export function renderExhibits(exhibits: Exhibit[]): string {
119
+ return exhibits
120
+ .map(
121
+ (e) =>
122
+ `### Exhibit ${e.id} (${e.kind})${e.url ? ` ${e.url}` : ""}\nTitle: ${e.title}\n${e.elements.length ? `Controls: ${e.elements.slice(0, 40).join("; ")}\n` : ""}Copy:\n${e.text}`,
123
+ )
124
+ .join("\n\n");
125
+ }
126
+
127
+ const norm = (s: string) => s.trim().toLowerCase().replace(/\/+$/, "") || "/";
128
+
129
+ /** Evaluators name pages loosely ("landing", "/pricing/", a full URL, the title); map that back to an exhibit id. */
130
+ export function canonicalExhibitId(ref: string | undefined, exhibits: Exhibit[]): string | undefined {
131
+ if (!ref) return undefined;
132
+ const r = norm(ref);
133
+ const path = (() => {
134
+ try {
135
+ return norm(new URL(ref).pathname);
136
+ } catch {
137
+ return r;
138
+ }
139
+ })();
140
+ const hit =
141
+ exhibits.find((e) => norm(e.id) === r || norm(e.id) === path) ??
142
+ exhibits.find((e) => e.url && (norm(e.url) === r || norm(pathOf(e.url)) === path)) ??
143
+ exhibits.find((e) => e.kind === r) ??
144
+ exhibits.find((e) => norm(e.title) === r);
145
+ return hit?.id;
146
+ }
@@ -0,0 +1,50 @@
1
+ import { z } from "zod";
2
+ import type { LlmClient } from "../llm/types";
3
+ import { extractJsonObject } from "../util/json";
4
+ import { renderExhibits, type Exhibit } from "./exhibits";
5
+
6
+ const MarketContextSchema = z.object({
7
+ competitors: z
8
+ .array(z.object({ name: z.string().min(1), url: z.string().optional(), pricing: z.string(), positioning: z.string() }))
9
+ .min(1),
10
+ complaints: z.array(z.string()),
11
+ switchingTriggers: z.array(z.string()),
12
+ });
13
+
14
+ /** What buyers in this market compare against, built before personas and verdicts. */
15
+ export type MarketContext = z.infer<typeof MarketContextSchema> & { sources: string[] };
16
+
17
+ const SYSTEM = `You build a Market Context bundle for a product simulation from competitors' own pages.
18
+ For each competitor, "pricing" and "positioning" must come from the pages you are given (quote or closely summarise; write "not shown" if absent). Never invent prices.
19
+ "complaints" are common frustrations buyers in this category have; "switchingTriggers" are what makes them switch tools. You may use general knowledge for these two, but prefix each such item with "(general knowledge)".
20
+ Reply with one JSON object: {"competitors":[{"name","url","pricing","positioning"}],"complaints":[string],"switchingTriggers":[string]}.`;
21
+
22
+ export async function buildMarketContext(
23
+ llm: LlmClient,
24
+ o: { icp: string; product: string; competitorExhibits: Exhibit[] },
25
+ ): Promise<MarketContext> {
26
+ const raw = await llm.complete({
27
+ system: SYSTEM,
28
+ messages: [
29
+ {
30
+ role: "user",
31
+ content: `Product being tested: ${o.product}\nIts buyers: ${o.icp}\n\nCompetitor pages:\n\n${renderExhibits(o.competitorExhibits)}`,
32
+ },
33
+ ],
34
+ maxTokens: 4000,
35
+ });
36
+ const parsed = MarketContextSchema.parse(extractJsonObject(raw));
37
+ return { ...parsed, sources: [...new Set(o.competitorExhibits.map((e) => e.url).filter((u): u is string => !!u))] };
38
+ }
39
+
40
+ /** A compact brief for persona and evaluator prompts. */
41
+ export function marketBrief(ctx: MarketContext): string {
42
+ return [
43
+ "Alternatives these buyers know:",
44
+ ...ctx.competitors.map((c) => `- ${c.name}: ${c.pricing} (${c.positioning})`),
45
+ ctx.complaints.length ? `Common frustrations in this category: ${ctx.complaints.join("; ")}` : "",
46
+ ctx.switchingTriggers.length ? `What makes buyers switch: ${ctx.switchingTriggers.join("; ")}` : "",
47
+ ]
48
+ .filter(Boolean)
49
+ .join("\n");
50
+ }