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,127 @@
1
+ import { z } from "zod";
2
+ import { PRESS_KEYS, type Action } from "../types";
3
+
4
+ const ActionSchema = z.discriminatedUnion("type", [
5
+ z.object({ type: z.literal("click"), id: z.number().int() }),
6
+ z.object({ type: z.literal("fill"), id: z.number().int(), text: z.string() }),
7
+ z.object({ type: z.literal("upload"), id: z.number().int(), file: z.enum(["photo", "document"]).optional() }),
8
+ z.object({ type: z.literal("goto"), path: z.string() }),
9
+ z.object({ type: z.literal("check_email") }),
10
+ z.object({ type: z.literal("press"), key: z.enum(PRESS_KEYS) }),
11
+ z.object({ type: z.literal("wait") }),
12
+ z.object({ type: z.literal("done"), success: z.boolean(), note: z.string() }),
13
+ ]);
14
+
15
+ // Real models sometimes drop the reasoning; the action is what matters.
16
+ const ReplySchema = z.object({ reasoning: z.string().default(""), action: ActionSchema });
17
+
18
+ export function parseAgentReply(raw: string): { reasoning: string; action: Action } {
19
+ const start = raw.indexOf("{");
20
+ const end = raw.lastIndexOf("}");
21
+ if (start === -1 || end <= start) throw new Error("no JSON object in agent reply");
22
+ const reply = actionAsObject(parseQuotedAction(raw.slice(start, end + 1)));
23
+ const parsed = ReplySchema.safeParse(reply);
24
+ if (!parsed.success) throw new Error(problemWith(reply, parsed.error));
25
+ return parsed.data;
26
+ }
27
+
28
+ /** A reply with reasoning and no action. It carries the reasoning, so the agent can be left to wait. */
29
+ export class NoActionError extends Error {
30
+ constructor(readonly reasoning: string) {
31
+ super(`the reply has no "action". Add one, for example {"reasoning":"…","action":{"type":"wait"}}`);
32
+ }
33
+ }
34
+
35
+ // Said back to the agent when it is asked again, so in words it can act on. Shown the validator's
36
+ // own JSON for a reply with reasoning and no action, one model sent the same reply three times.
37
+ function problemWith(reply: unknown, error: z.ZodError): string {
38
+ if (reply && typeof reply === "object" && !("action" in reply)) {
39
+ const reasoning = (reply as { reasoning?: unknown }).reasoning;
40
+ throw new NoActionError(typeof reasoning === "string" ? reasoning : "");
41
+ }
42
+ return error.issues.map((i) => `${i.path.join(".") || "reply"}: ${i.message}`).join("; ");
43
+ }
44
+
45
+ /**
46
+ * The reply split into objects one after another, merged: {"reasoning":"…"} and on the next line
47
+ * {"action":"press","key":"Enter"} (GLM 5.3 Flash, 2026-10-24, twice in one run). When two whole
48
+ * replies come, the first one counts. Undefined when there is only one object or one doesn't parse.
49
+ */
50
+ function splitReply(text: string): Record<string, unknown> | undefined {
51
+ const objects: string[] = [];
52
+ let depth = 0;
53
+ let from = 0;
54
+ let inString = false;
55
+ for (let i = 0; i < text.length; i++) {
56
+ const c = text[i];
57
+ if (inString) {
58
+ if (c === "\\") i++;
59
+ else if (c === '"') inString = false;
60
+ } else if (c === '"') inString = true;
61
+ else if (c === "{" && depth++ === 0) from = i;
62
+ else if (c === "}" && depth > 0 && --depth === 0) objects.push(text.slice(from, i + 1));
63
+ }
64
+ if (objects.length < 2) return undefined;
65
+ try {
66
+ const parsed = objects.map((o) => JSON.parse(o) as unknown);
67
+ if (!parsed.every((p) => p && typeof p === "object" && !Array.isArray(p))) return undefined;
68
+ return Object.assign({}, ...(parsed as Record<string, unknown>[]).reverse());
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ // Some models (GLM 5.3 Flash) send the action as a string. Unescaped,
75
+ // {"reasoning":"…","action":"{"type":"fill","id":1,"text":"x"}"} is not JSON at all, and asking
76
+ // again got the same reply three times, ending the agent. An action is a flat object, so its
77
+ // quotes can be dropped and the rest parsed as meant. Sometimes the closing quote and brace never
78
+ // come at all; then the reasoning and the action are read out separately.
79
+ function parseQuotedAction(text: string): unknown {
80
+ try {
81
+ return JSON.parse(text);
82
+ } catch (err) {
83
+ const split = splitReply(text);
84
+ if (split) return split;
85
+ const unquoted = text.replace(/("action"\s*:\s*)"(\{[^{}]*\})"/, "$1$2");
86
+ if (unquoted !== text) {
87
+ try {
88
+ return JSON.parse(unquoted);
89
+ } catch {
90
+ // fall through to reading the parts
91
+ }
92
+ }
93
+ const parts = readParts(text);
94
+ if (!parts) throw err;
95
+ return parts;
96
+ }
97
+ }
98
+
99
+ function readParts(text: string): { reasoning: string; action: unknown } | undefined {
100
+ const action = text.match(/"action"\s*:\s*"?\s*(\{[^{}]*\})/)?.[1];
101
+ if (!action) return undefined;
102
+ const reasoning = text.match(/"reasoning"\s*:\s*"((?:[^"\\]|\\.)*)"/)?.[1];
103
+ try {
104
+ return { reasoning: reasoning ? JSON.parse(`"${reasoning}"`) : "", action: JSON.parse(action.replace(/\\"/g, '"')) };
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ const ACTION_TYPES = new Set(["click", "fill", "upload", "goto", "check_email", "press", "wait", "done"]);
111
+ const ACTION_FIELDS = ["id", "text", "file", "path", "key", "success", "note"] as const;
112
+
113
+ // Escaped, the same mistake parses, with the action as a string holding its JSON. Or the action is
114
+ // flattened into the reply, {"reasoning":"…","action":"click","id":40}: its type as a string, its
115
+ // fields beside it (2026-10-18; asking again got the same reply).
116
+ function actionAsObject(reply: unknown): unknown {
117
+ if (!reply || typeof reply !== "object" || typeof (reply as { action?: unknown }).action !== "string") return reply;
118
+ const r = reply as Record<string, unknown> & { action: string };
119
+ try {
120
+ return { ...r, action: JSON.parse(r.action) };
121
+ } catch {
122
+ if (!ACTION_TYPES.has(r.action)) return reply; // the schema says what is wrong with it
123
+ const action: Record<string, unknown> = { type: r.action };
124
+ for (const field of ACTION_FIELDS) if (field in r) action[field] = r[field];
125
+ return { reasoning: r.reasoning, action };
126
+ }
127
+ }
@@ -0,0 +1,85 @@
1
+ import { inclusionBrief } from "../persona/inclusion";
2
+ import { languageBrief } from "../persona/language";
3
+ import type { InteractiveElement, Observation, Persona } from "../types";
4
+ import { controlLabel, type StepView } from "./label";
5
+
6
+ export type { StepView } from "./label";
7
+
8
+ // Stays constant for the whole session so providers can cache it; everything that changes goes in the user message.
9
+ export interface AgentIdentity {
10
+ email: string;
11
+ }
12
+
13
+ // `brief` carries shared context, e.g. the company a crew member works at and what their owner reported.
14
+ export function buildSystemPrompt(persona: Persona, goal: string, identity?: AgentIdentity, brief?: string): string {
15
+ const email = identity
16
+ ? `Your email address is ${identity.email}. Use it whenever the site asks for your email.
17
+ To read your inbox (for example a login link or a confirmation), reply {"type":"check_email"}. To open a link from an email, use {"type":"goto","path":"<the full link>"}.`
18
+ : `Use emails at example.com.`;
19
+ const about = [
20
+ `You are ${persona.name}. ${persona.background}`,
21
+ `Patience: ${persona.patience}. Tech savvy: ${persona.techSavvy}.`,
22
+ persona.role ? `Your role: ${persona.role}.` : "",
23
+ brief ?? "",
24
+ persona.inclusion ? inclusionBrief(persona.inclusion) : "",
25
+ languageBrief(persona.language),
26
+ ]
27
+ .filter(Boolean)
28
+ .join("\n");
29
+ return `${about}
30
+
31
+ You are a real prospective customer using this web product for the first time. Act exactly as this person would: read what is on screen, decide what they would do next, and react honestly to confusion or breakage. If something fails, try a reasonable alternative like a real person. Give up (done with success false) at the point this person would give up, and say why.
32
+
33
+ Your goal: ${goal}
34
+
35
+ ${email}
36
+ Use test data only: card number 4242 4242 4242 4242. Never enter real personal or payment data.
37
+
38
+ Reply with exactly one JSON object: {"reasoning": string, "action": Action}
39
+ Action is one of:
40
+ {"type":"click","id":number}
41
+ {"type":"fill","id":number,"text":string} (on a select or dropdown, text is the option to choose)
42
+ {"type":"upload","id":number,"file":"photo"|"document"} (attach a file through an upload button or file field: you have a sample photo and a sample CV/document)
43
+ {"type":"goto","path":string}
44
+ {"type":"press","key":"Escape"|"Enter"|"Space"|"Tab"|"ArrowDown"|"ArrowUp"} (e.g. Escape closes a popup that blocks the page; Space presses the focused button or ticks a box)
45
+ {"type":"wait"} (the page is still loading, e.g. a spinner or a form that has not appeared yet: wait a few seconds rather than leave)${identity ? `\n{"type":"check_email"}` : ""}
46
+ {"type":"done","success":boolean,"note":string}`;
47
+ }
48
+
49
+ /** A control's state, as a person sees it (and a screen reader announces it). */
50
+ function stateOf(e: InteractiveElement): string {
51
+ const states = [
52
+ e.disabled ? "disabled" : "",
53
+ e.readOnly ? "read-only" : "",
54
+ e.checked === undefined ? "" : e.checked ? "checked" : "not checked",
55
+ e.expanded === undefined ? "" : e.expanded ? "open" : "closed",
56
+ e.filled ? "filled in" : "",
57
+ !e.options && e.value ? `holds "${e.value}"` : "",
58
+ ].filter(Boolean);
59
+ return states.length ? ` (${states.join(", ")})` : "";
60
+ }
61
+
62
+ function describeElement(e: InteractiveElement, screenReader = false): string {
63
+ const line = `[${e.id}] ${controlLabel(e, screenReader)}${stateOf(e)}`;
64
+ if (!e.options) return line;
65
+ return `${line} set to "${e.value ?? ""}", options: ${e.options.map((o) => `"${o}"`).join(" | ")}`;
66
+ }
67
+
68
+ export function buildStepMessage(obs: Observation, history: string[], view: StepView = {}): string {
69
+ const shown = view.screenReader ? obs.elements.filter((e) => !e.ax?.hidden) : obs.elements;
70
+ const elements = shown.map((e) => describeElement(e, view.screenReader)).join("\n");
71
+ const past = history.length ? history.map((h, i) => `${i + 1}. ${h}`).join("\n") : "(none yet)";
72
+ return `URL: ${obs.url}
73
+ Title: ${obs.title}
74
+
75
+ Interactive elements:
76
+ ${elements}
77
+
78
+ Visible text:
79
+ ${obs.text}
80
+
81
+ Your actions so far:
82
+ ${past}
83
+
84
+ What do you do next?`;
85
+ }
@@ -0,0 +1,68 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Browser } from "playwright";
4
+ import type { ArtifactStore } from "../artifacts/store";
5
+ import type { Inbox } from "../inbox/types";
6
+ import type { ModelPrice } from "../llm/meter";
7
+ import type { LlmClient } from "../llm/types";
8
+ import { openAgentPage } from "../persona/inclusion";
9
+ import type { RunStore } from "../store/run-store";
10
+ import type { Persona, Session } from "../types";
11
+ import { runSession } from "./loop";
12
+
13
+ export interface AgentRunDeps {
14
+ browser: Browser;
15
+ llm: LlmClient;
16
+ origin: string;
17
+ maxSteps: number;
18
+ artifacts: ArtifactStore;
19
+ emailOf: (persona: Persona) => string;
20
+ inbox?: Inbox;
21
+ caps?: { maxTokens?: number; maxMs?: number };
22
+ price?: ModelPrice | null;
23
+ shouldStop?: () => boolean | Promise<boolean>;
24
+ /** With a store and run id, the session and each step are recorded as they happen. */
25
+ store?: RunStore;
26
+ runId?: string;
27
+ /** Keeps the agent's browser state here as <agent id>.json when it finishes, for `debutly hear`. */
28
+ browserStateDir?: string;
29
+ }
30
+
31
+ /**
32
+ * One agent, start to finish, in its own browser context (no shared cookies or login, even
33
+ * inside a crew). Used in-process by runSimulation and by queue workers alike.
34
+ */
35
+ export async function runAgentSession(d: AgentRunDeps, persona: Persona, goal: string, brief: string | undefined): Promise<Session> {
36
+ const record = d.store && d.runId ? { store: d.store, runId: d.runId } : undefined;
37
+ const { context, page } = await openAgentPage(d.browser, persona.inclusion, persona.language);
38
+ if (record) await record.store.startSession(record.runId, persona.id, goal);
39
+ let session: Session;
40
+ try {
41
+ session = await runSession({
42
+ page,
43
+ llm: d.llm,
44
+ persona,
45
+ goal,
46
+ origin: d.origin,
47
+ maxSteps: d.maxSteps,
48
+ artifacts: d.artifacts,
49
+ identity: d.inbox ? { email: d.emailOf(persona) } : undefined,
50
+ inbox: d.inbox,
51
+ brief,
52
+ caps: d.caps,
53
+ price: d.price,
54
+ shouldStop: d.shouldStop,
55
+ onStep: record ? (step) => record.store.appendStep(record.runId, persona.id, step) : undefined,
56
+ });
57
+ } catch (err) {
58
+ session = { agentId: persona.id, personaName: persona.name, goal, steps: [], outcome: "error", outcomeNote: err instanceof Error ? err.message : String(err) };
59
+ } finally {
60
+ if (d.browserStateDir) {
61
+ await mkdir(d.browserStateDir, { recursive: true });
62
+ await context.storageState({ path: join(d.browserStateDir, `${persona.id}.json`) }).catch(() => {});
63
+ }
64
+ await context.close();
65
+ }
66
+ if (record) await record.store.finishSession(record.runId, session);
67
+ return session;
68
+ }
@@ -0,0 +1,55 @@
1
+ import { and, eq, lt } from "drizzle-orm";
2
+ import type { Db } from "../store/db";
3
+ import { screenshots } from "../store/schema";
4
+ import type { ArtifactStore, ScreenshotFormat } from "./store";
5
+
6
+ /** What a step's screenshot reference looks like when the image is in the database. */
7
+ export const DB_SCREENSHOT_PREFIX = "db:";
8
+
9
+ /**
10
+ * Screenshots in the run's own Postgres, for deployments with no object storage: a free
11
+ * Postgres (Neon's 0.5 GB) is all the dashboard needs. Compact JPEGs, and prune() drops old ones.
12
+ */
13
+ export class DbArtifactStore implements ArtifactStore {
14
+ readonly screenshotFormat: ScreenshotFormat = { type: "jpeg", quality: 50 };
15
+
16
+ constructor(
17
+ private readonly db: Db,
18
+ private readonly runId: string,
19
+ ) {}
20
+
21
+ async put(key: string, body: Buffer, contentType: string): Promise<string> {
22
+ await this.db.insert(screenshots).values({ runId: this.runId, key, contentType, data: body }).onConflictDoNothing();
23
+ return `${DB_SCREENSHOT_PREFIX}${this.runId}/${key}`;
24
+ }
25
+
26
+ /** Delete screenshots older than `days`: steps keep their text, the pictures go. */
27
+ static async prune(db: Db, days: number, now = new Date()): Promise<number> {
28
+ const cutoff = new Date(now.getTime() - days * 86_400_000);
29
+ const deleted = await db.delete(screenshots).where(lt(screenshots.createdAt, cutoff)).returning({ key: screenshots.key });
30
+ return deleted.length;
31
+ }
32
+
33
+ static async get(db: Db, runId: string, key: string): Promise<{ contentType: string; data: Buffer } | null> {
34
+ const [row] = await db
35
+ .select({ contentType: screenshots.contentType, data: screenshots.data })
36
+ .from(screenshots)
37
+ .where(and(eq(screenshots.runId, runId), eq(screenshots.key, key)));
38
+ return row ?? null;
39
+ }
40
+ }
41
+
42
+ /** Every screenshot to each store (e.g. the CI artifact folder and the database); the last one's reference is kept. */
43
+ export class TeeArtifactStore implements ArtifactStore {
44
+ readonly screenshotFormat?: ScreenshotFormat;
45
+
46
+ constructor(private readonly stores: ArtifactStore[]) {
47
+ this.screenshotFormat = stores.find((s) => s.screenshotFormat)?.screenshotFormat;
48
+ }
49
+
50
+ async put(key: string, body: Buffer, contentType: string): Promise<string> {
51
+ let ref = "";
52
+ for (const store of this.stores) ref = await store.put(key, body, contentType);
53
+ return ref;
54
+ }
55
+ }
@@ -0,0 +1,36 @@
1
+ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
2
+ import type { ArtifactStore } from "./store";
3
+
4
+ export interface S3ArtifactOptions {
5
+ bucket: string;
6
+ /** Usually the run id, so each run's artifacts sit together. */
7
+ prefix: string;
8
+ /** Base URL the bucket is served at, for links in reports (e.g. MinIO's http://localhost:9000/<bucket>). */
9
+ publicBaseUrl: string;
10
+ client: Pick<S3Client, "send">;
11
+ }
12
+
13
+ /** Any S3-compatible store: MinIO in the self-hosted stack, S3/R2/etc. in production. */
14
+ export class S3ArtifactStore implements ArtifactStore {
15
+ constructor(private readonly o: S3ArtifactOptions) {}
16
+
17
+ /** From DEBUTLY_S3_ENDPOINT, DEBUTLY_S3_BUCKET, DEBUTLY_S3_ACCESS_KEY, DEBUTLY_S3_SECRET_KEY (and optional DEBUTLY_S3_REGION, DEBUTLY_S3_PUBLIC_URL); undefined if unset. */
18
+ static fromEnv(env: Record<string, string | undefined>, prefix: string): S3ArtifactStore | undefined {
19
+ const endpoint = env.DEBUTLY_S3_ENDPOINT;
20
+ if (!endpoint) return undefined;
21
+ const bucket = env.DEBUTLY_S3_BUCKET ?? "debutly";
22
+ const client = new S3Client({
23
+ endpoint,
24
+ region: env.DEBUTLY_S3_REGION ?? "us-east-1",
25
+ forcePathStyle: true,
26
+ credentials: { accessKeyId: env.DEBUTLY_S3_ACCESS_KEY ?? "", secretAccessKey: env.DEBUTLY_S3_SECRET_KEY ?? "" },
27
+ });
28
+ return new S3ArtifactStore({ bucket, prefix, publicBaseUrl: env.DEBUTLY_S3_PUBLIC_URL ?? `${endpoint.replace(/\/+$/, "")}/${bucket}`, client });
29
+ }
30
+
31
+ async put(key: string, body: Buffer, contentType: string): Promise<string> {
32
+ const fullKey = `${this.o.prefix}/${key}`;
33
+ await this.o.client.send(new PutObjectCommand({ Bucket: this.o.bucket, Key: fullKey, Body: body, ContentType: contentType }));
34
+ return `${this.o.publicBaseUrl.replace(/\/+$/, "")}/${fullKey}`;
35
+ }
36
+ }
@@ -0,0 +1,26 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ /** How a store wants screenshots taken; PNG when it doesn't say. */
5
+ export interface ScreenshotFormat {
6
+ type: "jpeg";
7
+ quality: number;
8
+ }
9
+
10
+ /** Where step artifacts (screenshots) go. Returns a path or URL the report can show. */
11
+ export interface ArtifactStore {
12
+ put(key: string, body: Buffer, contentType: string): Promise<string>;
13
+ readonly screenshotFormat?: ScreenshotFormat;
14
+ }
15
+
16
+ /** A local folder: what file-only CLI runs (and CI) have always used. */
17
+ export class LocalArtifactStore implements ArtifactStore {
18
+ constructor(private readonly dir: string) {}
19
+
20
+ async put(key: string, body: Buffer, _contentType?: string): Promise<string> {
21
+ const path = join(this.dir, key);
22
+ await mkdir(dirname(path), { recursive: true });
23
+ await writeFile(path, body);
24
+ return path;
25
+ }
26
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,140 @@
1
+ // The `debutly` command: `npx debutly init` once, then `npx debutly` runs the agents.
2
+ import "./util/load-env";
3
+ import "./util/legacy-env";
4
+ import "./util/key-defaults";
5
+ import { createRequire } from "node:module";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { CONFIG_FILE, configArgs, readConfig } from "./config";
9
+ import { OutOfCreditsError } from "./llm/types";
10
+
11
+ const HELP = `Debutly: AI agents use a test copy of your app like your first customers, and report what breaks.
12
+
13
+ Usage: npx debutly [command] [flags]
14
+
15
+ init a few questions, then a ${CONFIG_FILE} (and, if you want, a weekly GitHub workflow)
16
+ run the agents, with the settings in ${CONFIG_FILE} (the default command)
17
+ doctor checks the key, the browser, the config and that the app answers
18
+ perceive skeptical-buyer evaluators judge your pages, variants or an idea
19
+ hear checks a run's screen-reader findings with NVDA (Windows) or VoiceOver (macOS)
20
+ worker an agent worker for queued runs
21
+ cancel stops a run: cancel <run id> --store <url>
22
+ import loads earlier runs from GitHub Actions report artifacts
23
+ predictions records real outcomes and shows prediction accuracy
24
+ costs your OpenRouter key's spend and each app's latest run
25
+
26
+ Flags override ${CONFIG_FILE}, e.g. npx debutly --tier standard --max-cost 3
27
+ The model key is read from DEBUTLY_API_KEY (or OPENROUTER_API_KEY, ANTHROPIC_API_KEY), and from a .env file here.
28
+ Docs: https://debutly.dev/docs`;
29
+
30
+ const [first, ...rest] = process.argv.slice(2);
31
+ const flagsOnly = !first || (first.startsWith("-") && !["--help", "-h", "--version", "-v"].includes(first));
32
+ const command = flagsOnly ? "run" : first;
33
+ const args = flagsOnly ? process.argv.slice(2) : rest;
34
+
35
+ // The other entry points read their own flags from process.argv, as if run directly.
36
+ const handOver = (entry: string, argv: string[]) => {
37
+ process.argv = [process.argv[0], fileURLToPath(new URL(entry, import.meta.url)), ...argv];
38
+ };
39
+
40
+ async function run(): Promise<void> {
41
+ if (args.includes("--help") || args.includes("-h")) {
42
+ console.log(HELP);
43
+ return;
44
+ }
45
+ let config;
46
+ try {
47
+ config = readConfig();
48
+ } catch (err) {
49
+ console.error(err instanceof Error ? err.message : err);
50
+ process.exit(1);
51
+ }
52
+ if (!config && !args.includes("--url")) {
53
+ console.error(`No ${CONFIG_FILE} here. Set one up with: npx debutly init\n(or pass --url, --goal and --icp)`);
54
+ process.exit(1);
55
+ }
56
+ let argv: string[];
57
+ try {
58
+ argv = [...(config ? configArgs(config) : []), ...args];
59
+ } catch (err) {
60
+ console.error(err instanceof Error ? err.message : err);
61
+ process.exit(1);
62
+ }
63
+ if (process.env.DEBUTLY_STORE_URL && !argv.includes("--store")) argv.push("--store", process.env.DEBUTLY_STORE_URL);
64
+ if (!argv.includes("--out")) argv.push("--out", join(".debutly", "runs", new Date().toISOString().replace(/[:.]/g, "-")));
65
+
66
+ const { chromiumInstalled, installChromium } = await import("./util/chromium");
67
+ if (!chromiumInstalled()) {
68
+ console.log("Downloading Chromium for the agents (once per machine, about 150 MB)...");
69
+ if (!installChromium()) {
70
+ console.error("Couldn't download Chromium. Try: npx playwright install chromium");
71
+ process.exit(1);
72
+ }
73
+ }
74
+ handOver("./cli.ts", argv);
75
+ await import("./cli");
76
+ }
77
+
78
+ try {
79
+ switch (command) {
80
+ case "run":
81
+ await run();
82
+ break;
83
+ case "init": {
84
+ const { init } = await import("./init");
85
+ process.exitCode = await init();
86
+ break;
87
+ }
88
+ case "doctor": {
89
+ const { doctor } = await import("./doctor");
90
+ process.exitCode = await doctor();
91
+ break;
92
+ }
93
+ case "perceive":
94
+ handOver("./perceive.ts", args);
95
+ await import("./perceive");
96
+ break;
97
+ case "hear":
98
+ handOver("./hear-cli.ts", args);
99
+ await import("./hear-cli");
100
+ break;
101
+ case "worker":
102
+ handOver("./worker.ts", args);
103
+ await import("./worker");
104
+ break;
105
+ case "cancel":
106
+ handOver("./cancel.ts", args);
107
+ await import("./cancel");
108
+ break;
109
+ case "import":
110
+ handOver("./import.ts", args);
111
+ await import("./import");
112
+ break;
113
+ case "predictions":
114
+ handOver("./predictions.ts", args);
115
+ await import("./predictions");
116
+ break;
117
+ case "costs":
118
+ handOver("./costs.ts", args);
119
+ await import("./costs");
120
+ break;
121
+ case "help":
122
+ case "--help":
123
+ case "-h":
124
+ console.log(HELP);
125
+ break;
126
+ case "version":
127
+ case "--version":
128
+ case "-v":
129
+ console.log(createRequire(import.meta.url)("../package.json").version);
130
+ break;
131
+ default:
132
+ console.error(`Unknown command "${command}".\n\n${HELP}`);
133
+ process.exitCode = 1;
134
+ }
135
+ } catch (err) {
136
+ // A known dead end gets its fix, not a stack trace.
137
+ if (!(err instanceof OutOfCreditsError)) throw err;
138
+ console.error(`${err.message}\n\nTop up at https://openrouter.ai/settings/credits, or raise the key's limit, then run again.`);
139
+ process.exitCode = 1;
140
+ }
@@ -0,0 +1,77 @@
1
+ import type { CDPSession, Page } from "playwright";
2
+ import type { AccessibleName } from "../types";
3
+
4
+ // The name and role Chromium gives assistive technology for each element Debutly tagged, read
5
+ // from its accessibility tree. Debutly's own reading of names (scan-script) is close but not the
6
+ // same: an icon button named by its SVG <title> has a name here and none there, and a control
7
+ // hidden with aria-hidden is listed there but hidden here. Chromium only; undefined elsewhere.
8
+
9
+ interface AXNode {
10
+ ignored: boolean;
11
+ ignoredReasons?: { name: string }[];
12
+ role?: { value?: string };
13
+ name?: { value?: string };
14
+ description?: { value?: string };
15
+ backendDOMNodeId?: number;
16
+ }
17
+
18
+ interface DOMNode {
19
+ backendNodeId: number;
20
+ attributes?: string[];
21
+ children?: DOMNode[];
22
+ shadowRoots?: DOMNode[];
23
+ contentDocument?: DOMNode;
24
+ }
25
+
26
+ const sessions = new WeakMap<Page, Promise<CDPSession | undefined>>();
27
+
28
+ function cdpFor(page: Page): Promise<CDPSession | undefined> {
29
+ let session = sessions.get(page);
30
+ if (!session) {
31
+ session = page
32
+ .context()
33
+ .newCDPSession(page)
34
+ .then(async (s) => {
35
+ await s.send("Accessibility.enable");
36
+ return s;
37
+ })
38
+ .catch(() => undefined);
39
+ sessions.set(page, session);
40
+ }
41
+ return session;
42
+ }
43
+
44
+ /** Debutly id → what assistive technology is told about that element. */
45
+ export async function accessibleNames(page: Page): Promise<Map<number, AccessibleName> | undefined> {
46
+ const cdp = await cdpFor(page);
47
+ if (!cdp) return undefined;
48
+ try {
49
+ const [{ nodes }, { root }] = await Promise.all([
50
+ cdp.send("Accessibility.getFullAXTree") as Promise<{ nodes: AXNode[] }>,
51
+ cdp.send("DOM.getDocument", { depth: -1, pierce: true }) as Promise<{ root: DOMNode }>,
52
+ ]);
53
+ const tagged = new Map<number, number>();
54
+ const walk = (n: DOMNode) => {
55
+ const attrs = n.attributes ?? [];
56
+ for (let i = 0; i < attrs.length; i += 2) if (attrs[i] === "data-debutly-id") tagged.set(n.backendNodeId, Number(attrs[i + 1]));
57
+ for (const child of [...(n.children ?? []), ...(n.shadowRoots ?? []), ...(n.contentDocument ? [n.contentDocument] : [])]) walk(child);
58
+ };
59
+ walk(root);
60
+ const out = new Map<number, AccessibleName>();
61
+ for (const node of nodes) {
62
+ const id = node.backendDOMNodeId === undefined ? undefined : tagged.get(node.backendDOMNodeId);
63
+ if (id === undefined) continue;
64
+ const description = (node.description?.value ?? "").replace(/\s+/g, " ").trim();
65
+ out.set(id, {
66
+ name: (node.name?.value ?? "").replace(/\s+/g, " ").trim(),
67
+ role: node.role?.value ?? "",
68
+ hidden: node.ignored,
69
+ ...(description ? { description } : {}),
70
+ });
71
+ }
72
+ return out;
73
+ } catch {
74
+ // A page navigating away mid-read, or a detached frame: this look goes without them.
75
+ return undefined;
76
+ }
77
+ }