debutly 0.0.0-stage → 0.1.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 +37 -0
- package/src/agent/loop.ts +232 -0
- package/src/agent/parse-reply.ts +76 -0
- package/src/agent/prompt.ts +76 -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 +338 -0
- package/src/browser/observe.ts +56 -0
- package/src/browser/reflow.ts +73 -0
- package/src/browser/samples.ts +119 -0
- package/src/browser/scan-script.ts +62 -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 +265 -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 +617 -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 +183 -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/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/launch-readiness.ts +106 -0
- package/src/report/markdown.ts +200 -0
- package/src/run.ts +139 -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 +187 -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
package/src/cli.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import "./util/legacy-env";
|
|
2
|
+
import "./util/key-defaults";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { Queue, QueueEvents } from "bullmq";
|
|
5
|
+
import { writeFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { estimatePerceptionCost, estimateRunCost } from "./cost/estimate";
|
|
8
|
+
import { fetchModelPrice } from "./cost/prices";
|
|
9
|
+
import { agentCount, resolveRunSize } from "./cost/tiers";
|
|
10
|
+
import { DbArtifactStore, TeeArtifactStore } from "./artifacts/db";
|
|
11
|
+
import { S3ArtifactStore } from "./artifacts/s3";
|
|
12
|
+
import { LocalArtifactStore, type ArtifactStore } from "./artifacts/store";
|
|
13
|
+
import { MailpitInbox } from "./inbox/mailpit";
|
|
14
|
+
import { creditCheck, keyCreditsLeft } from "./llm/credits";
|
|
15
|
+
import { createLlms } from "./llm/factory";
|
|
16
|
+
import { OUT_OF_CREDITS } from "./llm/types";
|
|
17
|
+
import { generateUnits } from "./persona/generate";
|
|
18
|
+
import { withInclusion } from "./persona/inclusion";
|
|
19
|
+
import { languageName, parseLanguages, withLanguages } from "./persona/language";
|
|
20
|
+
import { exhibitsFromSessions } from "./perception/exhibits";
|
|
21
|
+
import { runPerception } from "./perception/run-perception";
|
|
22
|
+
import { renderLaunchReadiness } from "./report/launch-readiness";
|
|
23
|
+
import { queueDispatch } from "./queue/dispatch";
|
|
24
|
+
import { AGENT_QUEUE, redisClient } from "./queue/jobs";
|
|
25
|
+
import { HttpSandboxOutbox } from "./sandbox/outbox";
|
|
26
|
+
import { runSimulation } from "./run";
|
|
27
|
+
import { openDb } from "./store/db";
|
|
28
|
+
import { PgRunStore } from "./store/pg-run-store";
|
|
29
|
+
import type { Persona } from "./types";
|
|
30
|
+
import { runOrigin } from "./util/run-origin";
|
|
31
|
+
|
|
32
|
+
const MAX_EMPLOYEES = 4;
|
|
33
|
+
|
|
34
|
+
const { values } = parseArgs({
|
|
35
|
+
options: {
|
|
36
|
+
url: { type: "string" },
|
|
37
|
+
goal: { type: "string" },
|
|
38
|
+
icp: { type: "string" },
|
|
39
|
+
// Customer units: "individual" (B2C, one person each) or "business" (owner + employees).
|
|
40
|
+
unit: { type: "string", default: "individual" },
|
|
41
|
+
// Run size: quick (3 units, owners only), standard (10, 2 employees each) or deep (30, 3 each).
|
|
42
|
+
tier: { type: "string", default: "quick" },
|
|
43
|
+
// How many units (individuals or business crews); overrides the tier.
|
|
44
|
+
agents: { type: "string" },
|
|
45
|
+
// Employees per business crew (0 = owner only); overrides the tier.
|
|
46
|
+
employees: { type: "string" },
|
|
47
|
+
// Refuse to start if the run could cost more than this many USD.
|
|
48
|
+
"max-cost": { type: "string" },
|
|
49
|
+
// Screen-reader, low-vision, slow-connection and non-native-English agents, rotating nightly.
|
|
50
|
+
inclusion: { type: "string", default: "on" },
|
|
51
|
+
// GLM-5.3 Flash, the default agent model, takes about 40% more steps than Gemini.
|
|
52
|
+
"max-steps": { type: "string", default: "30" },
|
|
53
|
+
concurrency: { type: "string", default: "3" },
|
|
54
|
+
out: { type: "string" },
|
|
55
|
+
// Local mail catcher (e.g. Mailpit from `supabase start`) that gives agents real inboxes.
|
|
56
|
+
mailpit: { type: "string" },
|
|
57
|
+
// Keeps each agent's signed-in browser for `debutly hear`; holds session cookies, so keep it out of the report.
|
|
58
|
+
"browser-state": { type: "string" },
|
|
59
|
+
"email-domain": { type: "string", default: "agents.test" },
|
|
60
|
+
// The app's sandbox outbox (token in DEBUTLY_SANDBOX_TOKEN), to separate failures the test environment caused.
|
|
61
|
+
"sandbox-outbox": { type: "string" },
|
|
62
|
+
// Record the run as it happens: postgres://… (or memory: for a throwaway in-process database).
|
|
63
|
+
store: { type: "string" },
|
|
64
|
+
// For the dashboard: which app this is, what started the run, and its CI page. In GitHub
|
|
65
|
+
// Actions all three default from the workflow (repository name, event, run URL).
|
|
66
|
+
app: { type: "string" },
|
|
67
|
+
trigger: { type: "string" },
|
|
68
|
+
"ci-url": { type: "string" },
|
|
69
|
+
// Days to keep screenshots stored in the database (with --store and no S3), default 7.
|
|
70
|
+
"screenshot-days": { type: "string", default: "7" },
|
|
71
|
+
// Run agents on queue workers (npm run debutly:worker) instead of here: redis://…; needs a postgres --store.
|
|
72
|
+
queue: { type: "string" },
|
|
73
|
+
// Per-agent caps on top of --max-steps.
|
|
74
|
+
"agent-token-cap": { type: "string" },
|
|
75
|
+
"agent-minutes": { type: "string" },
|
|
76
|
+
// After the depth run, this many breadth evaluators judge the pages the agents saw (Launch Readiness report).
|
|
77
|
+
evaluators: { type: "string" },
|
|
78
|
+
// Customers' first languages, as lang codes (ar,en): agents take them in turn.
|
|
79
|
+
languages: { type: "string" },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const unitKind = values.unit;
|
|
84
|
+
if (!values.url || !values.goal || !values.icp || (unitKind !== "individual" && unitKind !== "business")) {
|
|
85
|
+
console.error(
|
|
86
|
+
'Usage: npx debutly (settings in debutly.json; npx debutly init writes one) or npx debutly --url <url> --goal "<goal>" --icp "<ideal customer profile>" [--unit individual|business] [--tier quick|standard|deep] [--agents N] [--employees N] [--max-cost USD] [--inclusion on|off] [--max-steps 30] [--concurrency 3] [--out runs/x] [--mailpit http://127.0.0.1:54324] [--email-domain agents.test] [--sandbox-outbox http://localhost:3000/api/sandbox/outbox] [--store postgres://…] [--app name] [--trigger nightly|pr|manual] [--ci-url url] [--screenshot-days 7] [--browser-state dir] [--queue redis://…] [--agent-token-cap N] [--agent-minutes N] [--evaluators N]',
|
|
87
|
+
);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
// Checked before anything is spent: a wrong code stops the run here.
|
|
91
|
+
let languages: string[];
|
|
92
|
+
try {
|
|
93
|
+
languages = parseLanguages(values.languages);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error(err instanceof Error ? err.message : err);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
const size = resolveRunSize({
|
|
99
|
+
tier: values.tier,
|
|
100
|
+
units: values.agents !== undefined ? Number(values.agents) : undefined,
|
|
101
|
+
employees: values.employees !== undefined ? Number(values.employees) : undefined,
|
|
102
|
+
});
|
|
103
|
+
const employees = Math.min(Math.max(size.employees || 0, 0), MAX_EMPLOYEES);
|
|
104
|
+
const count = size.units;
|
|
105
|
+
const maxSteps = Number(values["max-steps"]);
|
|
106
|
+
if (unitKind === "business" && employees > 0 && !values.mailpit) {
|
|
107
|
+
console.warn("Business crews need --mailpit for invites; employees will be recorded as not invited.");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Before anything is spent: what could this run cost?
|
|
111
|
+
const navModel = process.env.DEBUTLY_NAV_MODEL ?? (process.env.DEBUTLY_PROVIDER === "openai-compatible" ? "" : "claude-haiku-4-5");
|
|
112
|
+
const agents = agentCount(unitKind, { ...size, employees });
|
|
113
|
+
const price = await fetchModelPrice(navModel);
|
|
114
|
+
const evaluatorCount = values.evaluators !== undefined ? Number(values.evaluators) : 0;
|
|
115
|
+
const depthEstimate = estimateRunCost({ agents, maxSteps, price });
|
|
116
|
+
const breadthEstimate = evaluatorCount ? estimatePerceptionCost({ evaluators: evaluatorCount, experiment: false, price }) : { low: 0, high: 0 };
|
|
117
|
+
const estimate = depthEstimate && breadthEstimate ? { low: depthEstimate.low + breadthEstimate.low, high: depthEstimate.high + breadthEstimate.high } : null;
|
|
118
|
+
console.log(
|
|
119
|
+
estimate
|
|
120
|
+
? `Estimated cost: $${estimate.low.toFixed(2)}–$${estimate.high.toFixed(2)} (${agents} agents × up to ${maxSteps} steps${evaluatorCount ? ` + ${evaluatorCount} evaluators` : ""} on ${navModel})`
|
|
121
|
+
: `Estimated cost: unknown (no price found for ${navModel})`,
|
|
122
|
+
);
|
|
123
|
+
if (values["max-cost"] !== undefined) {
|
|
124
|
+
const ceiling = Number(values["max-cost"]);
|
|
125
|
+
if (!estimate) {
|
|
126
|
+
console.error(`Refusing to start: --max-cost is set but the price of ${navModel} is unknown.`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
if (estimate.high > ceiling) {
|
|
130
|
+
console.error(`Refusing to start: this run could cost up to $${estimate.high.toFixed(2)}, above --max-cost $${ceiling.toFixed(2)}.`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A key with a spending limit says what it has left: don't start a run it can't finish.
|
|
136
|
+
const credits = creditCheck(await keyCreditsLeft(process.env.DEBUTLY_BASE_URL ?? "", process.env.DEBUTLY_API_KEY ?? ""), estimate);
|
|
137
|
+
if (credits.refuse) {
|
|
138
|
+
console.error(`Refusing to start: ${credits.refuse}`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
if (credits.warn) console.warn(`Warning: ${credits.warn}`);
|
|
142
|
+
|
|
143
|
+
const { nav, judge } = createLlms(process.env);
|
|
144
|
+
console.log(`Generating ${count} ${unitKind === "business" ? `business crew(s) with ${employees} employees each` : "individual(s)"}...`);
|
|
145
|
+
let units = await generateUnits(judge, values.icp, { kind: unitKind, count, employees, languages });
|
|
146
|
+
if (values.inclusion !== "off") {
|
|
147
|
+
const dayOfYear = Math.floor((Date.now() - Date.UTC(new Date().getUTCFullYear(), 0, 1)) / 86_400_000);
|
|
148
|
+
units = withInclusion(units, dayOfYear);
|
|
149
|
+
}
|
|
150
|
+
units = withLanguages(units, languages);
|
|
151
|
+
|
|
152
|
+
const describe = (p: Persona) =>
|
|
153
|
+
`${p.id} ${p.name}${p.role ? ` (${p.role})` : ""}${p.inclusion ? ` [${p.inclusion.replace(/_/g, " ")}]` : ""}${p.language && p.language !== "en" ? ` [${languageName(p.language)}]` : ""}: ${p.background}`;
|
|
154
|
+
for (const u of units) {
|
|
155
|
+
if (u.kind === "individual") console.log(` ${describe(u.person)}`);
|
|
156
|
+
else {
|
|
157
|
+
console.log(` ${u.id} ${u.business.name}, ${u.business.archetype} of ~${u.business.size}, $${u.business.monthlyBudget}/mo`);
|
|
158
|
+
for (const p of [u.owner, ...u.employees]) console.log(` ${describe(p)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (values.queue && (!values.store || values.store === "memory:")) {
|
|
163
|
+
console.error("--queue needs --store postgres://…: workers record the agents there.");
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
const outDir = values.out ?? `runs/${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
167
|
+
const opened = values.store ? await openDb(values.store) : undefined;
|
|
168
|
+
const store = opened ? new PgRunStore(opened.db) : undefined;
|
|
169
|
+
const origin = runOrigin({ app: values.app, trigger: values.trigger, ciUrl: values["ci-url"] }, process.env);
|
|
170
|
+
const runId = store
|
|
171
|
+
? await store.createRun({ ...origin, targetUrl: values.url, goal: values.goal, icp: values.icp, tier: size.tier, unitKind, model: navModel, estimate })
|
|
172
|
+
: undefined;
|
|
173
|
+
// Screenshots: S3 when it is configured; otherwise, with a store, the database (compact JPEGs,
|
|
174
|
+
// old ones pruned) as well as the run folder, so the dashboard has them with nothing but Postgres.
|
|
175
|
+
const s3 = runId ? S3ArtifactStore.fromEnv(process.env, runId) : undefined;
|
|
176
|
+
let artifacts: ArtifactStore | undefined = s3;
|
|
177
|
+
if (!s3 && opened && runId && values.store !== "memory:") {
|
|
178
|
+
const pruned = await DbArtifactStore.prune(opened.db, Number(values["screenshot-days"]));
|
|
179
|
+
if (pruned) console.log(`Pruned ${pruned} screenshots older than ${values["screenshot-days"]} days from the store.`);
|
|
180
|
+
artifacts = new TeeArtifactStore([new LocalArtifactStore(join(outDir, "screenshots")), new DbArtifactStore(opened.db, runId)]);
|
|
181
|
+
}
|
|
182
|
+
if (runId) console.log(`Run ${runId} (cancel from anywhere with: npm run debutly:cancel -- ${runId} --store <url>)`);
|
|
183
|
+
|
|
184
|
+
// Kill switch: the first Ctrl+C lets agents stop at their next step and still writes
|
|
185
|
+
// the report; a second one exits at once.
|
|
186
|
+
let cancelled = false;
|
|
187
|
+
process.on("SIGINT", () => {
|
|
188
|
+
if (cancelled) process.exit(130);
|
|
189
|
+
cancelled = true;
|
|
190
|
+
console.warn("\nCancelling: agents stop at their next step (Ctrl+C again to quit now)...");
|
|
191
|
+
if (store && runId) void store.requestCancel(runId);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const queue = values.queue ? new Queue(AGENT_QUEUE, { connection: redisClient(values.queue) }) : undefined;
|
|
195
|
+
const queueEvents = values.queue ? new QueueEvents(AGENT_QUEUE, { connection: redisClient(values.queue) }) : undefined;
|
|
196
|
+
const caps = {
|
|
197
|
+
maxTokens: values["agent-token-cap"] !== undefined ? Number(values["agent-token-cap"]) : undefined,
|
|
198
|
+
maxMs: values["agent-minutes"] !== undefined ? Number(values["agent-minutes"]) * 60_000 : undefined,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
console.log(`Running against ${values.url}${values.mailpit ? " (with inboxes)" : ""}...`);
|
|
202
|
+
const result = await runSimulation({
|
|
203
|
+
url: values.url,
|
|
204
|
+
goal: values.goal,
|
|
205
|
+
units,
|
|
206
|
+
llm: nav,
|
|
207
|
+
maxSteps,
|
|
208
|
+
concurrency: Number(values.concurrency),
|
|
209
|
+
outDir,
|
|
210
|
+
inbox: values.mailpit ? new MailpitInbox(values.mailpit) : undefined,
|
|
211
|
+
emailDomain: values["email-domain"],
|
|
212
|
+
sandboxOutbox: values["sandbox-outbox"] ? new HttpSandboxOutbox(values["sandbox-outbox"], process.env.DEBUTLY_SANDBOX_TOKEN ?? "") : undefined,
|
|
213
|
+
store,
|
|
214
|
+
runId,
|
|
215
|
+
price,
|
|
216
|
+
caps,
|
|
217
|
+
shouldStop: () => cancelled,
|
|
218
|
+
artifacts,
|
|
219
|
+
browserStateDir: values["browser-state"],
|
|
220
|
+
dispatch:
|
|
221
|
+
queue && queueEvents && runId
|
|
222
|
+
? ({ runTag }) =>
|
|
223
|
+
queueDispatch(queue, queueEvents, {
|
|
224
|
+
runId,
|
|
225
|
+
targetUrl: values.url!,
|
|
226
|
+
maxSteps,
|
|
227
|
+
caps,
|
|
228
|
+
price,
|
|
229
|
+
mailpitUrl: values.mailpit,
|
|
230
|
+
emailDomain: values["email-domain"] ?? "agents.test",
|
|
231
|
+
runTag,
|
|
232
|
+
})
|
|
233
|
+
: undefined,
|
|
234
|
+
});
|
|
235
|
+
await queueEvents?.close();
|
|
236
|
+
await queue?.close();
|
|
237
|
+
await opened?.close();
|
|
238
|
+
|
|
239
|
+
if (evaluatorCount > 0) {
|
|
240
|
+
console.log(`${evaluatorCount} evaluators judging the pages the agents saw...`);
|
|
241
|
+
const perception = await runPerception({
|
|
242
|
+
llm: nav,
|
|
243
|
+
judge,
|
|
244
|
+
icp: values.icp,
|
|
245
|
+
product: values.url,
|
|
246
|
+
exhibitsByVariant: { main: exhibitsFromSessions(result.sessions) },
|
|
247
|
+
evaluators: evaluatorCount,
|
|
248
|
+
concurrency: Math.max(Number(values.concurrency), 8),
|
|
249
|
+
price,
|
|
250
|
+
judgePrice: await fetchModelPrice(process.env.DEBUTLY_JUDGE_MODEL ?? ""),
|
|
251
|
+
functional: result.findings,
|
|
252
|
+
});
|
|
253
|
+
const readiness = join(outDir, "launch-readiness.md");
|
|
254
|
+
await writeFile(readiness, renderLaunchReadiness({ target: values.url, icp: values.icp, result: perception, functional: result.findings }));
|
|
255
|
+
console.log(`Launch Readiness: ${readiness}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const noCredit = result.sessions.filter((s) => s.outcome === "error" && s.outcomeNote.startsWith(OUT_OF_CREDITS)).length;
|
|
259
|
+
if (noCredit) {
|
|
260
|
+
const message = `The AI provider account ran out of credits: ${noCredit} of ${result.sessions.length} agents stopped early. Top up and run again.`;
|
|
261
|
+
// On GitHub Actions, an annotation puts it on the run's summary page.
|
|
262
|
+
console.error(process.env.GITHUB_ACTIONS ? `::error title=Debutly run cut short::${message}` : message);
|
|
263
|
+
}
|
|
264
|
+
const cost = result.usage.costUsd !== undefined ? `, cost $${result.usage.costUsd.toFixed(4)}` : "";
|
|
265
|
+
console.log(`Done: ${result.findings.length} findings${cost}. Report: ${result.reportPath}${runId ? ` (run ${runId})` : ""}`);
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Settings for `debutly run`, kept in the app's repo so a run needs no flags. */
|
|
5
|
+
export const CONFIG_FILE = "debutly.json";
|
|
6
|
+
|
|
7
|
+
// What debutly.json may set: the run's flags, camelCased. The store's address is a secret, so it
|
|
8
|
+
// comes from DEBUTLY_STORE_URL instead; where reports go and what started a run are per run.
|
|
9
|
+
const SETTINGS = [
|
|
10
|
+
"url",
|
|
11
|
+
"goal",
|
|
12
|
+
"icp",
|
|
13
|
+
"unit",
|
|
14
|
+
"tier",
|
|
15
|
+
"agents",
|
|
16
|
+
"employees",
|
|
17
|
+
"maxCost",
|
|
18
|
+
"inclusion",
|
|
19
|
+
"maxSteps",
|
|
20
|
+
"concurrency",
|
|
21
|
+
"mailpit",
|
|
22
|
+
"emailDomain",
|
|
23
|
+
"sandboxOutbox",
|
|
24
|
+
"app",
|
|
25
|
+
"screenshotDays",
|
|
26
|
+
"agentTokenCap",
|
|
27
|
+
"agentMinutes",
|
|
28
|
+
"evaluators",
|
|
29
|
+
"languages",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
export type Setting = (typeof SETTINGS)[number];
|
|
33
|
+
export type DebutlyConfig = Partial<Record<Setting, string | number | boolean>> & { $schema?: string };
|
|
34
|
+
|
|
35
|
+
const flagOf = (setting: string) => `--${setting.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
36
|
+
|
|
37
|
+
/** The config file in `dir`, or undefined when there is none. */
|
|
38
|
+
export function readConfig(dir: string = process.cwd()): DebutlyConfig | undefined {
|
|
39
|
+
const path = join(dir, CONFIG_FILE);
|
|
40
|
+
if (!existsSync(path)) return undefined;
|
|
41
|
+
try {
|
|
42
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
43
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("it should hold one JSON object");
|
|
44
|
+
return parsed as DebutlyConfig;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
throw new Error(`${CONFIG_FILE} can't be read: ${err instanceof Error ? err.message : err}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The config as command-line flags. Flags given on the command line come after, so they win. */
|
|
51
|
+
export function configArgs(config: DebutlyConfig): string[] {
|
|
52
|
+
const args: string[] = [];
|
|
53
|
+
for (const [key, value] of Object.entries(config)) {
|
|
54
|
+
if (key === "$schema" || value === undefined || value === null) continue;
|
|
55
|
+
if (!(SETTINGS as readonly string[]).includes(key)) {
|
|
56
|
+
throw new Error(`${CONFIG_FILE}: "${key}" is not a setting. Settings: ${SETTINGS.join(", ")}.`);
|
|
57
|
+
}
|
|
58
|
+
if (typeof value === "object") throw new Error(`${CONFIG_FILE}: "${key}" should be text or a number.`);
|
|
59
|
+
args.push(flagOf(key), key === "inclusion" && typeof value === "boolean" ? (value ? "on" : "off") : String(value));
|
|
60
|
+
}
|
|
61
|
+
return args;
|
|
62
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ModelPrice } from "../llm/meter";
|
|
2
|
+
|
|
3
|
+
export interface CostEstimate {
|
|
4
|
+
low: number;
|
|
5
|
+
high: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Typical navigation step on the 2026-09-27/28 runs: ~2,500 prompt tokens (page + history, with
|
|
9
|
+
// provider-side caching) and ~150 reply tokens. That brackets the observed ~$0.08 per 3-agent run.
|
|
10
|
+
// Replace with averages from stored runs once enough are recorded.
|
|
11
|
+
const TOKENS_PER_STEP = { input: 2500, output: 150 };
|
|
12
|
+
|
|
13
|
+
/** Cost range for a run: agents using 60% (low) to 100% (high) of their step budget. */
|
|
14
|
+
export function estimateRunCost(o: { agents: number; maxSteps: number; price: ModelPrice | null; tokensPerStep?: { input: number; output: number } }): CostEstimate | null {
|
|
15
|
+
if (!o.price) return null;
|
|
16
|
+
const t = o.tokensPerStep ?? TOKENS_PER_STEP;
|
|
17
|
+
const perStep = (t.input * o.price.inputPerMTok + t.output * o.price.outputPerMTok) / 1_000_000;
|
|
18
|
+
const high = o.agents * o.maxSteps * perStep;
|
|
19
|
+
return { low: high * 0.6, high };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// One evaluator call: the pages (~3,000 tokens; two variants ~5,000) plus rubric and persona in,
|
|
23
|
+
// a JSON verdict (~450 tokens) out. Persona generation and the summary add little.
|
|
24
|
+
const EVALUATOR_TOKENS = { single: { input: 3500, output: 450 }, experiment: { input: 5500, output: 500 } };
|
|
25
|
+
|
|
26
|
+
/** Cost range for a perception run: ±20% around the per-call estimate. */
|
|
27
|
+
export function estimatePerceptionCost(o: { evaluators: number; experiment: boolean; price: ModelPrice | null }): CostEstimate | null {
|
|
28
|
+
if (!o.price) return null;
|
|
29
|
+
const t = o.experiment ? EVALUATOR_TOKENS.experiment : EVALUATOR_TOKENS.single;
|
|
30
|
+
const mid = (o.evaluators * (t.input * o.price.inputPerMTok + t.output * o.price.outputPerMTok)) / 1_000_000;
|
|
31
|
+
return { low: mid * 0.8, high: mid * 1.2 };
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ModelPrice } from "../llm/meter";
|
|
2
|
+
|
|
3
|
+
// USD per million tokens, from OpenRouter's model list on 2026-09-28. Used when it can't be reached.
|
|
4
|
+
const KNOWN: Record<string, ModelPrice> = {
|
|
5
|
+
"google/gemini-3.8-flash": { inputPerMTok: 0.75, outputPerMTok: 3.75 },
|
|
6
|
+
"z-ai/glm-5.3-flash": { inputPerMTok: 0.15, outputPerMTok: 0.5 },
|
|
7
|
+
"deepseek/deepseek-v4-flash": { inputPerMTok: 0.079, outputPerMTok: 0.159 },
|
|
8
|
+
"anthropic/claude-haiku-4.5": { inputPerMTok: 1, outputPerMTok: 5 },
|
|
9
|
+
"claude-haiku-4-5": { inputPerMTok: 1, outputPerMTok: 5 },
|
|
10
|
+
"anthropic/claude-sonnet-5": { inputPerMTok: 2, outputPerMTok: 10 },
|
|
11
|
+
"anthropic/claude-opus-5": { inputPerMTok: 5, outputPerMTok: 25 },
|
|
12
|
+
"claude-opus-5": { inputPerMTok: 5, outputPerMTok: 25 },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** A model's price per million tokens: OpenRouter's live list, then the table above, else null (unknown). */
|
|
16
|
+
export async function fetchModelPrice(model: string, fetchFn: typeof fetch = fetch): Promise<ModelPrice | null> {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetchFn("https://openrouter.ai/api/v1/models");
|
|
19
|
+
if (res.ok) {
|
|
20
|
+
const { data = [] } = (await res.json()) as { data?: { id: string; pricing?: { prompt?: string; completion?: string } }[] };
|
|
21
|
+
const pricing = data.find((m) => m.id === model)?.pricing;
|
|
22
|
+
const input = Number(pricing?.prompt);
|
|
23
|
+
const output = Number(pricing?.completion);
|
|
24
|
+
if (Number.isFinite(input) && Number.isFinite(output) && pricing) {
|
|
25
|
+
return { inputPerMTok: Math.round(input * 1e9) / 1e3, outputPerMTok: Math.round(output * 1e9) / 1e3 };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// Offline or blocked: fall through to the table.
|
|
30
|
+
}
|
|
31
|
+
return KNOWN[model] ?? null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Run sizes. Units are individuals or business crews; breadth evaluators judge pages on top.
|
|
2
|
+
export const TIERS = {
|
|
3
|
+
quick: { units: 3, employees: 0 }, // CI / pre-deploy smoke: owners only
|
|
4
|
+
standard: { units: 10, employees: 2 },
|
|
5
|
+
deep: { units: 30, employees: 3 },
|
|
6
|
+
} as const;
|
|
7
|
+
export type Tier = keyof typeof TIERS;
|
|
8
|
+
|
|
9
|
+
export interface RunSize {
|
|
10
|
+
tier: Tier;
|
|
11
|
+
units: number;
|
|
12
|
+
employees: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A tier's size, with explicitly given counts taking precedence. */
|
|
16
|
+
export function resolveRunSize(o: { tier?: string; units?: number; employees?: number }): RunSize {
|
|
17
|
+
const tier = (o.tier ?? "quick") as Tier;
|
|
18
|
+
if (!(tier in TIERS)) throw new Error(`--tier must be quick, standard or deep (got "${o.tier}")`);
|
|
19
|
+
return { tier, units: o.units ?? TIERS[tier].units, employees: o.employees ?? TIERS[tier].employees };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Agents a run will use: individuals count once; a crew is its owner plus employees. */
|
|
23
|
+
export const agentCount = (kind: "individual" | "business", size: RunSize) =>
|
|
24
|
+
kind === "individual" ? size.units : size.units * (1 + size.employees);
|
|
25
|
+
|
|
26
|
+
/** Breadth evaluators per tier: Quick ~50, Standard 200–500, Deep 1,000–5,000. */
|
|
27
|
+
export const EVALUATORS: Record<Tier, number> = { quick: 50, standard: 300, deep: 1500 };
|
package/src/costs.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Cost meter: what Debutly is spending, in one place.
|
|
2
|
+
//
|
|
3
|
+
// npm run debutly:costs -- [--repo owner/name ...]
|
|
4
|
+
//
|
|
5
|
+
// Shows the OpenRouter key's spend (today, this week, this month, limit left) and, for each app
|
|
6
|
+
// repo given, what its latest nightly agent run cost (read from the GitHub run log via `gh`).
|
|
7
|
+
import "./util/legacy-env";
|
|
8
|
+
import "./util/key-defaults";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { isOpenRouter } from "./llm/openrouter";
|
|
13
|
+
|
|
14
|
+
const run = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
export interface KeySpend {
|
|
17
|
+
today: number;
|
|
18
|
+
week: number;
|
|
19
|
+
month: number;
|
|
20
|
+
total: number;
|
|
21
|
+
limit: number | null;
|
|
22
|
+
remaining: number | null;
|
|
23
|
+
resets: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function fetchKeySpend(baseUrl: string, apiKey: string, fetchFn: typeof fetch = fetch): Promise<KeySpend> {
|
|
27
|
+
const res = await fetchFn(`${baseUrl.replace(/\/+$/, "")}/key`, { headers: { authorization: `Bearer ${apiKey}` } });
|
|
28
|
+
if (!res.ok) throw new Error(`OpenRouter key info: HTTP ${res.status}`);
|
|
29
|
+
const { data: d } = (await res.json()) as { data: Record<string, unknown> };
|
|
30
|
+
const n = (k: string) => (typeof d[k] === "number" ? (d[k] as number) : 0);
|
|
31
|
+
return {
|
|
32
|
+
today: n("usage_daily"),
|
|
33
|
+
week: n("usage_weekly"),
|
|
34
|
+
month: n("usage_monthly"),
|
|
35
|
+
total: n("usage"),
|
|
36
|
+
limit: typeof d.limit === "number" ? d.limit : null,
|
|
37
|
+
remaining: typeof d.limit_remaining === "number" ? d.limit_remaining : null,
|
|
38
|
+
resets: typeof d.limit_reset === "string" ? d.limit_reset : null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The cost a Debutly run printed at the end ("Done: … cost $0.1234 …"), from a CI log. */
|
|
43
|
+
export function costFromLog(log: string): number | null {
|
|
44
|
+
const matches = [...log.matchAll(/Done: [^\n]*?cost \$(\d+(?:\.\d+)?)/g)];
|
|
45
|
+
return matches.length ? Number(matches[matches.length - 1][1]) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface NightlyCost {
|
|
49
|
+
repo: string;
|
|
50
|
+
when: string;
|
|
51
|
+
costUsd: number | null;
|
|
52
|
+
url: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function latestNightly(repo: string): Promise<NightlyCost | null> {
|
|
56
|
+
const { stdout } = await run("gh", ["run", "list", "--repo", repo, "--workflow", "throwaway-env.yml", "--status", "success", "--limit", "5", "--json", "databaseId,createdAt,url,event"]);
|
|
57
|
+
const runs = JSON.parse(stdout) as { databaseId: number; createdAt: string; url: string; event: string }[];
|
|
58
|
+
for (const r of runs) {
|
|
59
|
+
const { stdout: log } = await run("gh", ["run", "view", String(r.databaseId), "--repo", repo, "--log"], { maxBuffer: 64 * 1024 * 1024 });
|
|
60
|
+
const costUsd = costFromLog(log);
|
|
61
|
+
if (costUsd !== null) return { repo, when: r.createdAt, costUsd, url: r.url };
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const usd = (x: number) => `$${x.toFixed(2)}`;
|
|
67
|
+
|
|
68
|
+
if (process.argv[1]?.endsWith("costs.ts")) {
|
|
69
|
+
const { values } = parseArgs({ options: { repo: { type: "string", multiple: true } } });
|
|
70
|
+
const { DEBUTLY_BASE_URL, DEBUTLY_API_KEY } = process.env;
|
|
71
|
+
if (DEBUTLY_BASE_URL && isOpenRouter(DEBUTLY_BASE_URL) && DEBUTLY_API_KEY) {
|
|
72
|
+
const s = await fetchKeySpend(DEBUTLY_BASE_URL, DEBUTLY_API_KEY);
|
|
73
|
+
console.log("OpenRouter key");
|
|
74
|
+
console.log(` Today ${usd(s.today)} · this week ${usd(s.week)} · this month ${usd(s.month)} · all time ${usd(s.total)}`);
|
|
75
|
+
if (s.limit !== null) console.log(` Limit ${usd(s.limit)}${s.resets ? ` (resets ${s.resets})` : ""} · left ${usd(s.remaining ?? 0)}`);
|
|
76
|
+
} else {
|
|
77
|
+
console.log("OpenRouter key: not configured (DEBUTLY_BASE_URL / DEBUTLY_API_KEY)");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const nightly: NightlyCost[] = [];
|
|
81
|
+
for (const repo of values.repo ?? []) {
|
|
82
|
+
try {
|
|
83
|
+
const n = await latestNightly(repo);
|
|
84
|
+
if (n) nightly.push(n);
|
|
85
|
+
else console.log(` ${repo}: no costed agent run found yet`);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.log(` ${repo}: could not read runs (${err instanceof Error ? err.message.split("\n")[0] : err})`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (nightly.length) {
|
|
91
|
+
console.log("\nLatest agent run per app");
|
|
92
|
+
for (const n of nightly) console.log(` ${n.repo.padEnd(40)} ${usd(n.costUsd ?? 0)} (${n.when.slice(0, 10)})`);
|
|
93
|
+
const perNight = nightly.reduce((a, n) => a + (n.costUsd ?? 0), 0);
|
|
94
|
+
console.log(` ${"Per night, all apps".padEnd(40)} ${usd(perNight)} → about ${usd(perNight * 30)} a month`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Inbox } from "../inbox/types";
|
|
2
|
+
import type { Business, CustomerUnit, Persona, Session } from "../types";
|
|
3
|
+
|
|
4
|
+
/** Runs one agent in its own browser context; `brief` joins its system prompt. */
|
|
5
|
+
export type RunAgent = (persona: Persona, goal: string, brief: string | undefined) => Promise<Session>;
|
|
6
|
+
|
|
7
|
+
export interface UnitDeps {
|
|
8
|
+
runAgent: RunAgent;
|
|
9
|
+
emailOf: (persona: Persona) => string;
|
|
10
|
+
inbox?: Inbox;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const article = (word: string) => (/^[aeiou]/i.test(word) ? "an" : "a");
|
|
14
|
+
|
|
15
|
+
const companyBrief = (b: Business) =>
|
|
16
|
+
`You work at ${b.name}, ${article(b.archetype)} ${b.archetype} of about ${b.size} people. Its budget for tools like this is $${b.monthlyBudget} a month. Company goal: ${b.goal}.`;
|
|
17
|
+
|
|
18
|
+
export function ownerGoal(goal: string, team: { name: string; role?: string; email: string }[]): string {
|
|
19
|
+
if (!team.length) return goal;
|
|
20
|
+
const who = team.map((t) => `${t.name} (${t.role ?? "Teammate"}) at ${t.email}`).join("; ");
|
|
21
|
+
return `${goal}\nOnce you are set up, invite your teammates by email so they can use it too: ${who}.`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A teammate who lands in an empty workspace of their own has not joined anything, even if the
|
|
25
|
+
// invite page said "accepted"; that must not count as success.
|
|
26
|
+
export const employeeGoal = (role: string, owner: string, company: string) =>
|
|
27
|
+
`${owner} has invited you to use this product at ${company}. Check your email for the invite, accept it and set up your account. Then check that you are in ${company}'s shared workspace: you should see what ${owner} set up there, such as the company details or the items they added. If you only see an empty workspace of your own, joining did not work: report that as a failure, not success. If you are in, do one task a ${role} would typically do here. Finish when you have done that or when you would give up.`;
|
|
28
|
+
|
|
29
|
+
const tag = (s: Session, unitId: string, p: Persona): Session => ({ ...s, unitId, role: p.role, inclusion: p.inclusion, language: p.language });
|
|
30
|
+
const notInvited = (unitId: string, p: Persona, why: string): Session =>
|
|
31
|
+
tag({ agentId: p.id, personaName: p.name, goal: "", steps: [], outcome: "not_invited", outcomeNote: why }, unitId, p);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One customer unit. An individual runs once. A business crew runs its owner first (who is told
|
|
35
|
+
* whom to invite), then, in parallel and each in its own browser, only the employees whose invite
|
|
36
|
+
* actually reached their inbox, sharing the company brief and what the owner reported.
|
|
37
|
+
*/
|
|
38
|
+
export async function runUnit(unit: CustomerUnit, goal: string, deps: UnitDeps): Promise<Session[]> {
|
|
39
|
+
if (unit.kind === "individual") return [tag(await deps.runAgent(unit.person, goal, undefined), unit.id, unit.person)];
|
|
40
|
+
|
|
41
|
+
const { business, owner, employees } = unit;
|
|
42
|
+
const brief = companyBrief(business);
|
|
43
|
+
const team = employees.map((e) => ({ name: e.name, role: e.role, email: deps.emailOf(e) }));
|
|
44
|
+
const ownerSession = tag(
|
|
45
|
+
await deps.runAgent(owner, ownerGoal(goal, team), `${brief}\nYou run ${business.name} and decide which plan and how many seats to buy.`),
|
|
46
|
+
unit.id,
|
|
47
|
+
owner,
|
|
48
|
+
);
|
|
49
|
+
if (!employees.length) return [ownerSession];
|
|
50
|
+
const { inbox } = deps;
|
|
51
|
+
if (!inbox) return [ownerSession, ...employees.map((e) => notInvited(unit.id, e, "No test inbox, so teammates cannot receive invites."))];
|
|
52
|
+
|
|
53
|
+
const shared = `${brief}\n${owner.name} (the owner) reported: "${ownerSession.outcomeNote}"`;
|
|
54
|
+
const rest = await Promise.all(
|
|
55
|
+
employees.map(async (e) => {
|
|
56
|
+
const mail = await inbox.latest(deps.emailOf(e)).catch(() => []);
|
|
57
|
+
if (!mail.length) return notInvited(unit.id, e, `No invite email arrived after ${owner.name} finished.`);
|
|
58
|
+
return tag(await deps.runAgent(e, employeeGoal(e.role ?? "teammate", owner.name, business.name), shared), unit.id, e);
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
return [ownerSession, ...rest];
|
|
62
|
+
}
|