@workos/quickstudy 0.0.1
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 +21 -0
- package/README.md +270 -0
- package/examples/harbor-notes/README.md +40 -0
- package/examples/harbor-notes/evals/create-note/EVAL.ts +14 -0
- package/examples/harbor-notes/evals/create-note/PROMPT.md +9 -0
- package/examples/harbor-notes/evals/create-note/local/README.txt +1 -0
- package/examples/harbor-notes/experiments/scripted.ts +6 -0
- package/examples/harbor-notes/package.json +6 -0
- package/examples/harbor-notes/quickstudy.identity.json +1 -0
- package/examples/harbor-notes/runtime.ts +48 -0
- package/examples/harbor-notes/semantic-example.ts +21 -0
- package/images/agent-runtime/Dockerfile +58 -0
- package/images/egress-proxy/Dockerfile +28 -0
- package/images/mcp-proxy/Dockerfile +30 -0
- package/package.json +53 -0
- package/src/adapters/claude.ts +107 -0
- package/src/adapters/codex.ts +107 -0
- package/src/adapters/echo.ts +57 -0
- package/src/adapters/parse.ts +117 -0
- package/src/adapters/types.ts +152 -0
- package/src/build-info.generated.ts +12 -0
- package/src/cli.ts +787 -0
- package/src/completeness.ts +104 -0
- package/src/diagnose/excerpt.ts +106 -0
- package/src/diagnose/prompt.ts +175 -0
- package/src/diagnose/render.ts +55 -0
- package/src/diagnose/run.ts +290 -0
- package/src/diagnose/select.ts +110 -0
- package/src/diagnose/types.ts +88 -0
- package/src/evals/discovery.ts +173 -0
- package/src/evals/prompt.ts +190 -0
- package/src/evals/result.ts +10 -0
- package/src/evals/types.ts +115 -0
- package/src/execution-policy.ts +71 -0
- package/src/experiments/discovery.ts +76 -0
- package/src/experiments/groups.ts +119 -0
- package/src/experiments/types.ts +116 -0
- package/src/export-types.ts +127 -0
- package/src/export.ts +381 -0
- package/src/hash.ts +74 -0
- package/src/identity-diff.ts +30 -0
- package/src/ids.ts +30 -0
- package/src/index.ts +58 -0
- package/src/isolation/docker.ts +639 -0
- package/src/isolation/image-contexts.generated.ts +927 -0
- package/src/isolation/images.ts +138 -0
- package/src/isolation/mcp-proxy/server.ts +260 -0
- package/src/isolation/mcp.ts +144 -0
- package/src/isolation/proxy/allowlist.ts +148 -0
- package/src/isolation/proxy/server.ts +382 -0
- package/src/llm.ts +132 -0
- package/src/manifest.ts +228 -0
- package/src/model-identity.ts +12 -0
- package/src/plan.ts +55 -0
- package/src/probe.ts +426 -0
- package/src/report/pass-at-k.ts +76 -0
- package/src/report/report.ts +731 -0
- package/src/runner/context.ts +96 -0
- package/src/runner/deadline.ts +37 -0
- package/src/runner/execute.ts +992 -0
- package/src/runner/run-lock.ts +32 -0
- package/src/runner/scheduler.ts +62 -0
- package/src/runner/score-worker.ts +107 -0
- package/src/runner/scorer-worker.ts +61 -0
- package/src/runtime/types.ts +89 -0
- package/src/secrets.ts +151 -0
- package/src/semantic.ts +185 -0
- package/src/serve.ts +52 -0
- package/src/source-identity.ts +76 -0
- package/src/store/artifacts.ts +146 -0
- package/src/store/db.ts +318 -0
- package/src/store/schema.ts +39 -0
- package/src/surface-usage.ts +297 -0
- package/src/ui-bundle.generated.ts +12 -0
- package/ui/dist/index.html +32 -0
package/src/store/db.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bun:sqlite results store for the eval × experiment schema (v3, THE schema —
|
|
3
|
+
* the legacy five-axis store was deleted with the authoring stack).
|
|
4
|
+
* Concurrency contract: single writer, WAL so readers never block on the
|
|
5
|
+
* writer.
|
|
6
|
+
*
|
|
7
|
+
* Schema versioning is enforced by refusal: a database whose tables exist at
|
|
8
|
+
* any other user_version is refused with archive/export instructions, never
|
|
9
|
+
* migrated.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
import type { EvalResult, EvalSuite } from "../evals/types.ts";
|
|
14
|
+
import type { ComparisonGroup } from "../experiments/groups.ts";
|
|
15
|
+
import type { RunManifest } from "../manifest.ts";
|
|
16
|
+
import type { SurfaceUsage, SurfaceUsageConfig } from "../surface-usage.ts";
|
|
17
|
+
import { SCHEMA_SQL } from "./schema.ts";
|
|
18
|
+
|
|
19
|
+
export type AttemptStatus = "completed" | "incomplete" | "error";
|
|
20
|
+
|
|
21
|
+
/** The run-level configuration recorded in `runs.config_json`. */
|
|
22
|
+
export interface RunConfig {
|
|
23
|
+
scheduler?: { concurrency: number; seed: string; policy: "seeded-hash-v1"; order: import("../plan.ts").AttemptPlan[] };
|
|
24
|
+
trials: number;
|
|
25
|
+
evalIds: string[];
|
|
26
|
+
experimentIds: string[];
|
|
27
|
+
evalsRoot?: string;
|
|
28
|
+
experimentsRoot?: string;
|
|
29
|
+
/** Egress policy the run enforced — what it could reach is part of its record. */
|
|
30
|
+
egress?: { proxy: boolean; allowlist: string[] };
|
|
31
|
+
/**
|
|
32
|
+
* The comparison groups computed at run time (withhold-and-diff state
|
|
33
|
+
* included). Recorded here — NOT in the manifest — so reading them back
|
|
34
|
+
* never perturbs run identity; the report and the Compare view render
|
|
35
|
+
* exactly what the run saw.
|
|
36
|
+
*/
|
|
37
|
+
groups?: ComparisonGroup[];
|
|
38
|
+
/**
|
|
39
|
+
* Offered surfaces per experiment id (docs hosts, CLI commands, MCP server
|
|
40
|
+
* names), recorded at run time like `egress` — what a run offered is part
|
|
41
|
+
* of its record. Absent on older runs and when nothing was declared
|
|
42
|
+
* (absent ≠ empty-offered).
|
|
43
|
+
*/
|
|
44
|
+
surfaces?: Record<string, SurfaceUsageConfig>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RunRecord {
|
|
48
|
+
id: string;
|
|
49
|
+
/** Epoch milliseconds. */
|
|
50
|
+
startedAt: number;
|
|
51
|
+
/** NULL means the run is incomplete; reporting warns loudly. */
|
|
52
|
+
finishedAt: number | null;
|
|
53
|
+
config: RunConfig;
|
|
54
|
+
manifest: RunManifest;
|
|
55
|
+
manifestHash: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Everything the runner persists for one attempt. */
|
|
59
|
+
export interface NewAttempt {
|
|
60
|
+
/** Explicit retry of a non-completed trial; original evidence is retained. */
|
|
61
|
+
retryOf?: string | null;
|
|
62
|
+
judgeRecords?: import("../evals/types.ts").JudgeRecord[];
|
|
63
|
+
id: string;
|
|
64
|
+
runId: string;
|
|
65
|
+
evalId: string;
|
|
66
|
+
experimentId: string;
|
|
67
|
+
suite: EvalSuite;
|
|
68
|
+
trialIndex: number;
|
|
69
|
+
attemptIdentityHash: string;
|
|
70
|
+
status: AttemptStatus;
|
|
71
|
+
result?: EvalResult | null;
|
|
72
|
+
/** Omitted or null = no supported raw stream — stored as NULL, never as an all-zero object. */
|
|
73
|
+
surfaceUsage?: SurfaceUsage | null;
|
|
74
|
+
transcriptRef?: string | null;
|
|
75
|
+
diffRef?: string | null;
|
|
76
|
+
/** Omitted or null = telemetry unavailable — stored as NULL, never as 0. */
|
|
77
|
+
costUsd?: number | null;
|
|
78
|
+
tokensIn?: number | null;
|
|
79
|
+
tokensOut?: number | null;
|
|
80
|
+
/** Agent steps (adapter-counted); omitted or null = no parseable stream. */
|
|
81
|
+
turns?: number | null;
|
|
82
|
+
startedAt?: number | null;
|
|
83
|
+
finishedAt?: number | null;
|
|
84
|
+
error?: string | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AttemptRecord {
|
|
88
|
+
retryOf?: string | null;
|
|
89
|
+
judgeRecords?: import("../evals/types.ts").JudgeRecord[];
|
|
90
|
+
id: string;
|
|
91
|
+
runId: string;
|
|
92
|
+
evalId: string;
|
|
93
|
+
experimentId: string;
|
|
94
|
+
suite: EvalSuite;
|
|
95
|
+
trialIndex: number;
|
|
96
|
+
attemptIdentityHash: string;
|
|
97
|
+
status: AttemptStatus;
|
|
98
|
+
result: EvalResult | null;
|
|
99
|
+
surfaceUsage: SurfaceUsage | null;
|
|
100
|
+
transcriptRef: string | null;
|
|
101
|
+
diffRef: string | null;
|
|
102
|
+
costUsd: number | null;
|
|
103
|
+
tokensIn: number | null;
|
|
104
|
+
tokensOut: number | null;
|
|
105
|
+
turns: number | null;
|
|
106
|
+
startedAt: number | null;
|
|
107
|
+
finishedAt: number | null;
|
|
108
|
+
error: string | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface RunRow {
|
|
112
|
+
id: string;
|
|
113
|
+
started_at: number;
|
|
114
|
+
finished_at: number | null;
|
|
115
|
+
config_json: string;
|
|
116
|
+
manifest_json: string;
|
|
117
|
+
manifest_hash: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface AttemptRow {
|
|
121
|
+
retry_of: string | null;
|
|
122
|
+
judge_usage_json: string | null;
|
|
123
|
+
id: string;
|
|
124
|
+
run_id: string;
|
|
125
|
+
eval_id: string;
|
|
126
|
+
experiment_id: string;
|
|
127
|
+
suite: string;
|
|
128
|
+
trial_index: number;
|
|
129
|
+
attempt_identity_hash: string;
|
|
130
|
+
status: string;
|
|
131
|
+
result_json: string | null;
|
|
132
|
+
surface_usage_json: string | null;
|
|
133
|
+
transcript_ref: string | null;
|
|
134
|
+
diff_ref: string | null;
|
|
135
|
+
cost_usd: number | null;
|
|
136
|
+
tokens_in: number | null;
|
|
137
|
+
tokens_out: number | null;
|
|
138
|
+
turns: number | null;
|
|
139
|
+
started_at: number | null;
|
|
140
|
+
finished_at: number | null;
|
|
141
|
+
error: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function toRunRecord(row: RunRow): RunRecord {
|
|
145
|
+
return {
|
|
146
|
+
id: row.id,
|
|
147
|
+
startedAt: row.started_at,
|
|
148
|
+
finishedAt: row.finished_at,
|
|
149
|
+
config: JSON.parse(row.config_json) as RunConfig,
|
|
150
|
+
manifest: JSON.parse(row.manifest_json) as RunManifest,
|
|
151
|
+
manifestHash: row.manifest_hash,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toAttemptRecord(row: AttemptRow): AttemptRecord {
|
|
156
|
+
return {
|
|
157
|
+
id: row.id,
|
|
158
|
+
...(row.judge_usage_json ? { judgeRecords: JSON.parse(row.judge_usage_json) } : {}),
|
|
159
|
+
...(row.retry_of ? { retryOf: row.retry_of } : {}),
|
|
160
|
+
runId: row.run_id,
|
|
161
|
+
evalId: row.eval_id,
|
|
162
|
+
experimentId: row.experiment_id,
|
|
163
|
+
suite: row.suite as EvalSuite,
|
|
164
|
+
trialIndex: row.trial_index,
|
|
165
|
+
attemptIdentityHash: row.attempt_identity_hash,
|
|
166
|
+
status: row.status as AttemptStatus,
|
|
167
|
+
result: row.result_json === null ? null : (JSON.parse(row.result_json) as EvalResult),
|
|
168
|
+
surfaceUsage: row.surface_usage_json === null ? null : (JSON.parse(row.surface_usage_json) as SurfaceUsage),
|
|
169
|
+
transcriptRef: row.transcript_ref,
|
|
170
|
+
diffRef: row.diff_ref,
|
|
171
|
+
costUsd: row.cost_usd,
|
|
172
|
+
tokensIn: row.tokens_in,
|
|
173
|
+
tokensOut: row.tokens_out,
|
|
174
|
+
turns: row.turns,
|
|
175
|
+
startedAt: row.started_at,
|
|
176
|
+
finishedAt: row.finished_at,
|
|
177
|
+
error: row.error,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Read a database file's schema version without creating or migrating it. */
|
|
182
|
+
export function storeSchemaVersion(path: string): number | null {
|
|
183
|
+
try {
|
|
184
|
+
const db = new Database(path, { readonly: true });
|
|
185
|
+
try {
|
|
186
|
+
return db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0;
|
|
187
|
+
} finally {
|
|
188
|
+
db.close();
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export class ResultsStore {
|
|
196
|
+
private readonly db: Database;
|
|
197
|
+
|
|
198
|
+
constructor(path: string) {
|
|
199
|
+
this.db = new Database(path, { create: true });
|
|
200
|
+
const existing = this.db
|
|
201
|
+
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('runs', 'attempts')")
|
|
202
|
+
.all();
|
|
203
|
+
const version = this.db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0;
|
|
204
|
+
if (existing.length > 0 && version !== 3) {
|
|
205
|
+
this.db.close();
|
|
206
|
+
throw new Error(
|
|
207
|
+
`results database at ${path} uses schema v${version || "legacy"}; this command requires schema v3.\n` +
|
|
208
|
+
`Old results are never migrated. Either archive the database and keep your previous quickstudy\n` +
|
|
209
|
+
`binary to read it, or run \`quickstudy export\` with that binary first — then point --db at a\n` +
|
|
210
|
+
`fresh file for v3 runs.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
214
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
215
|
+
this.db.exec(SCHEMA_SQL);
|
|
216
|
+
// Additive column repair within v3: `turns` joined the schema after v3
|
|
217
|
+
// shipped. Adding a nullable column rewrites nothing — existing rows read
|
|
218
|
+
// NULL, the standard "telemetry unavailable" value — so this is not the
|
|
219
|
+
// cross-version migration the refusal above exists to prevent.
|
|
220
|
+
const attemptColumns = this.db
|
|
221
|
+
.query<{ name: string }, []>("SELECT name FROM pragma_table_info('attempts')")
|
|
222
|
+
.all()
|
|
223
|
+
.map((column) => column.name);
|
|
224
|
+
if (!attemptColumns.includes("turns")) {
|
|
225
|
+
this.db.exec("ALTER TABLE attempts ADD COLUMN turns INTEGER;");
|
|
226
|
+
}
|
|
227
|
+
if (!attemptColumns.includes("judge_usage_json")) this.db.exec("ALTER TABLE attempts ADD COLUMN judge_usage_json TEXT;");
|
|
228
|
+
if (!attemptColumns.includes("retry_of")) this.db.exec("ALTER TABLE attempts ADD COLUMN retry_of TEXT;");
|
|
229
|
+
this.db.exec("PRAGMA user_version = 3;");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
insertRun(run: { id: string; startedAt: number; config: RunConfig; manifest: RunManifest }): void {
|
|
233
|
+
this.db
|
|
234
|
+
.query("INSERT INTO runs (id, started_at, finished_at, config_json, manifest_json, manifest_hash) VALUES (?, ?, NULL, ?, ?, ?)")
|
|
235
|
+
.run(run.id, run.startedAt, JSON.stringify(run.config), JSON.stringify(run.manifest), run.manifest.hash);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
reopenRun(id: string): void { this.db.query("UPDATE runs SET finished_at = NULL WHERE id = ?").run(id); }
|
|
239
|
+
|
|
240
|
+
finishRun(id: string, finishedAt: number): void {
|
|
241
|
+
const changed = this.db.query("UPDATE runs SET finished_at = ? WHERE id = ?").run(finishedAt, id);
|
|
242
|
+
if (changed.changes === 0) throw new Error(`cannot finish run "${id}": no such run`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
getRun(id: string): RunRecord | null {
|
|
246
|
+
const row = this.db.query<RunRow, [string]>("SELECT * FROM runs WHERE id = ?").get(id);
|
|
247
|
+
return row ? toRunRecord(row) : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** All runs, newest first. */
|
|
251
|
+
listRuns(): RunRecord[] {
|
|
252
|
+
return this.db.query<RunRow, []>("SELECT * FROM runs ORDER BY started_at DESC, id DESC").all().map(toRunRecord);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Most recent run, or null on an empty database (`report --latest`). */
|
|
256
|
+
latestRun(): RunRecord | null {
|
|
257
|
+
const row = this.db.query<RunRow, []>("SELECT * FROM runs ORDER BY started_at DESC, id DESC LIMIT 1").get();
|
|
258
|
+
return row ? toRunRecord(row) : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
insertAttempt(attempt: NewAttempt): void {
|
|
262
|
+
this.db
|
|
263
|
+
.query(
|
|
264
|
+
`INSERT INTO attempts (
|
|
265
|
+
id, run_id, eval_id, experiment_id, suite, trial_index, attempt_identity_hash, status,
|
|
266
|
+
result_json, surface_usage_json, transcript_ref, diff_ref, cost_usd, tokens_in, tokens_out,
|
|
267
|
+
turns, started_at, finished_at, error, retry_of, judge_usage_json
|
|
268
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
269
|
+
)
|
|
270
|
+
.run(
|
|
271
|
+
attempt.id,
|
|
272
|
+
attempt.runId,
|
|
273
|
+
attempt.evalId,
|
|
274
|
+
attempt.experimentId,
|
|
275
|
+
attempt.suite,
|
|
276
|
+
attempt.trialIndex,
|
|
277
|
+
attempt.attemptIdentityHash,
|
|
278
|
+
attempt.status,
|
|
279
|
+
attempt.result === undefined || attempt.result === null ? null : JSON.stringify(attempt.result),
|
|
280
|
+
attempt.surfaceUsage === undefined || attempt.surfaceUsage === null ? null : JSON.stringify(attempt.surfaceUsage),
|
|
281
|
+
attempt.transcriptRef ?? null,
|
|
282
|
+
attempt.diffRef ?? null,
|
|
283
|
+
attempt.costUsd ?? null,
|
|
284
|
+
attempt.tokensIn ?? null,
|
|
285
|
+
attempt.tokensOut ?? null,
|
|
286
|
+
attempt.turns ?? null,
|
|
287
|
+
attempt.startedAt ?? null,
|
|
288
|
+
attempt.finishedAt ?? null,
|
|
289
|
+
attempt.error ?? null,
|
|
290
|
+
attempt.retryOf ?? null,
|
|
291
|
+
attempt.judgeRecords ? JSON.stringify(attempt.judgeRecords) : null,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
listAttempts(runId: string): AttemptRecord[] {
|
|
296
|
+
return this.db
|
|
297
|
+
.query<AttemptRow, [string]>("SELECT * FROM attempts WHERE run_id = ? ORDER BY id")
|
|
298
|
+
.all(runId)
|
|
299
|
+
.map(toAttemptRecord);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
close(): void {
|
|
303
|
+
this.db.close();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Only explicit, valid retry links supersede a row. Accidental duplicates remain visible. */
|
|
308
|
+
export function activeAttempts(attempts: AttemptRecord[]): AttemptRecord[] {
|
|
309
|
+
const superseded = new Set<string>();
|
|
310
|
+
for (const row of attempts) if (row.retryOf) {
|
|
311
|
+
const previous = attempts.find((a) => a.id === row.retryOf);
|
|
312
|
+
if (!previous || previous.status === "completed" || previous.runId !== row.runId || previous.evalId !== row.evalId || previous.experimentId !== row.experimentId || previous.trialIndex !== row.trialIndex || previous.attemptIdentityHash !== row.attemptIdentityHash || superseded.has(previous.id)) {
|
|
313
|
+
throw new Error(`invalid retry lineage for attempt ${row.id}`);
|
|
314
|
+
}
|
|
315
|
+
superseded.add(previous.id);
|
|
316
|
+
}
|
|
317
|
+
return attempts.filter((a) => !superseded.has(a.id));
|
|
318
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The results-store schema (v3: eval × experiment), as code.
|
|
3
|
+
*
|
|
4
|
+
* Inlined here (rather than read from a `schema.sql` file at runtime) so the
|
|
5
|
+
* compiled single-file binary carries it in its bundle — a `readFileSync`
|
|
6
|
+
* against `import.meta.dir` resolves to a path that does not exist inside the
|
|
7
|
+
* binary's `$bunfs` virtual filesystem. It's small and changes rarely, so a
|
|
8
|
+
* TypeScript string is the simplest portable home for it.
|
|
9
|
+
*/
|
|
10
|
+
export const SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS runs (
|
|
11
|
+
id TEXT PRIMARY KEY, -- ulid
|
|
12
|
+
started_at INTEGER NOT NULL, -- epoch milliseconds
|
|
13
|
+
finished_at INTEGER, -- NULL = run incomplete; reporting warns loudly
|
|
14
|
+
config_json TEXT NOT NULL, -- selectors, trials, roots, comparison groups
|
|
15
|
+
manifest_json TEXT NOT NULL,
|
|
16
|
+
manifest_hash TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS attempts (
|
|
20
|
+
id TEXT PRIMARY KEY, -- ulid
|
|
21
|
+
run_id TEXT NOT NULL REFERENCES runs(id),
|
|
22
|
+
eval_id TEXT NOT NULL,
|
|
23
|
+
experiment_id TEXT NOT NULL,
|
|
24
|
+
suite TEXT NOT NULL DEFAULT 'benchmark',
|
|
25
|
+
trial_index INTEGER NOT NULL,
|
|
26
|
+
attempt_identity_hash TEXT NOT NULL,
|
|
27
|
+
status TEXT NOT NULL, -- 'completed' | 'incomplete' | 'error'
|
|
28
|
+
result_json TEXT, -- the scorer's EvalResult (completed attempts)
|
|
29
|
+
surface_usage_json TEXT, -- NULL = telemetry unavailable (never zero-filled)
|
|
30
|
+
transcript_ref TEXT, diff_ref TEXT,
|
|
31
|
+
cost_usd REAL, -- NULL = telemetry unavailable (never zero-filled)
|
|
32
|
+
tokens_in INTEGER, tokens_out INTEGER,
|
|
33
|
+
turns INTEGER, -- agent steps; NULL = no adapter stream (scripted commands)
|
|
34
|
+
started_at INTEGER, finished_at INTEGER,
|
|
35
|
+
error TEXT -- only when status = 'error'
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_attempts_run_pair ON attempts(run_id, eval_id, experiment_id);
|
|
39
|
+
`;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observable use of the developer surfaces offered to an attempt.
|
|
3
|
+
*
|
|
4
|
+
* Availability is recorded in the experiment manifest; this module answers a
|
|
5
|
+
* different question: did the agent actually call an MCP tool, invoke the
|
|
6
|
+
* product CLI, or open a product documentation URL? Extraction is deliberately
|
|
7
|
+
* conservative and stores only redacted identifiers. Full shell commands and
|
|
8
|
+
* URL query strings can contain credentials and must remain in the protected
|
|
9
|
+
* raw transcript, never in aggregate telemetry.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { parseJsonlLines } from "./adapters/parse.ts";
|
|
13
|
+
|
|
14
|
+
export type SurfaceUsageVendor = "claude" | "codex" | "cursor" | "gemini";
|
|
15
|
+
|
|
16
|
+
export interface SurfaceUsage {
|
|
17
|
+
docs: { count: number; urls: string[] };
|
|
18
|
+
mcp: { count: number; tools: string[] };
|
|
19
|
+
cli: { count: number; commands: string[] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SurfaceUsageConfig {
|
|
23
|
+
/** Exact host or parent domain; subdomains also match. */
|
|
24
|
+
docsHosts?: readonly string[];
|
|
25
|
+
/** Product CLI executable names, without arguments. */
|
|
26
|
+
cliCommands?: readonly string[];
|
|
27
|
+
/** MCP server names as configured for the attempt. */
|
|
28
|
+
mcpServers?: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ToolInvocation {
|
|
32
|
+
id?: string;
|
|
33
|
+
name: string;
|
|
34
|
+
input: unknown;
|
|
35
|
+
kind?: "command" | "mcp" | "web";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function record(value: unknown): Record<string, unknown> {
|
|
39
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
40
|
+
? (value as Record<string, unknown>)
|
|
41
|
+
: {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pushUnique(signal: { count: number }, values: string[], value: string): void {
|
|
45
|
+
signal.count += 1;
|
|
46
|
+
if (!values.includes(value)) values.push(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function claudeInvocations(event: Record<string, unknown>): ToolInvocation[] {
|
|
50
|
+
if (event["type"] !== "assistant") return [];
|
|
51
|
+
const message = record(event["message"]);
|
|
52
|
+
const content = Array.isArray(message["content"]) ? message["content"] : [];
|
|
53
|
+
return content.flatMap((part): ToolInvocation[] => {
|
|
54
|
+
const item = record(part);
|
|
55
|
+
if (item["type"] !== "tool_use" || typeof item["name"] !== "string") return [];
|
|
56
|
+
return [{
|
|
57
|
+
...(typeof item["id"] === "string" ? { id: item["id"] } : {}),
|
|
58
|
+
name: item["name"],
|
|
59
|
+
input: item["input"],
|
|
60
|
+
}];
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function codexInvocations(event: Record<string, unknown>): ToolInvocation[] {
|
|
65
|
+
if (event["type"] !== "item.started" && event["type"] !== "item.completed") return [];
|
|
66
|
+
const item = record(event["item"]);
|
|
67
|
+
const identity = typeof item["id"] === "string" ? { id: item["id"] } : {};
|
|
68
|
+
const type = item["type"];
|
|
69
|
+
if (type === "command_execution" && typeof item["command"] === "string") {
|
|
70
|
+
return [{ ...identity, name: "command_execution", input: item["command"], kind: "command" }];
|
|
71
|
+
}
|
|
72
|
+
if (type === "mcp_tool_call") {
|
|
73
|
+
const server = typeof item["server"] === "string" ? item["server"] : "";
|
|
74
|
+
const tool = typeof item["tool"] === "string" ? item["tool"] : typeof item["name"] === "string" ? item["name"] : "tool";
|
|
75
|
+
return [{ ...identity, name: server === "" ? tool : `${server}__${tool}`, input: item["arguments"] ?? item["input"], kind: "mcp" }];
|
|
76
|
+
}
|
|
77
|
+
if (type === "web_search") {
|
|
78
|
+
return [{ ...identity, name: "web_search", input: item, kind: "web" }];
|
|
79
|
+
}
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cursorInvocations(event: Record<string, unknown>): ToolInvocation[] {
|
|
84
|
+
if (event["type"] !== "tool_call") return [];
|
|
85
|
+
const call = record(event["tool_call"]);
|
|
86
|
+
const entry = Object.entries(call)[0];
|
|
87
|
+
if (!entry) return [];
|
|
88
|
+
const [name, body] = entry;
|
|
89
|
+
return [{
|
|
90
|
+
...(typeof event["call_id"] === "string" ? { id: event["call_id"] } : {}),
|
|
91
|
+
name,
|
|
92
|
+
input: record(body)["args"] ?? body,
|
|
93
|
+
}];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function geminiInvocations(event: Record<string, unknown>): ToolInvocation[] {
|
|
97
|
+
if (event["type"] !== "tool_use" || typeof event["tool_name"] !== "string") return [];
|
|
98
|
+
return [{
|
|
99
|
+
...(typeof event["tool_id"] === "string" ? { id: event["tool_id"] } : {}),
|
|
100
|
+
name: event["tool_name"],
|
|
101
|
+
input: event["parameters"],
|
|
102
|
+
}];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function invocationsFor(vendor: SurfaceUsageVendor, event: Record<string, unknown>): ToolInvocation[] {
|
|
106
|
+
if (vendor === "claude") return claudeInvocations(event);
|
|
107
|
+
if (vendor === "codex") return codexInvocations(event);
|
|
108
|
+
if (vendor === "cursor") return cursorInvocations(event);
|
|
109
|
+
return geminiInvocations(event);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function allStrings(value: unknown, out: string[] = []): string[] {
|
|
113
|
+
if (typeof value === "string") {
|
|
114
|
+
out.push(value);
|
|
115
|
+
} else if (Array.isArray(value)) {
|
|
116
|
+
for (const item of value) allStrings(item, out);
|
|
117
|
+
} else if (value !== null && typeof value === "object") {
|
|
118
|
+
for (const item of Object.values(value as Record<string, unknown>)) allStrings(item, out);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const URL_PATTERN = /https?:\/\/[^\s"'<>)}\]]+/giu;
|
|
124
|
+
|
|
125
|
+
function sanitizedDocsUrls(input: unknown, hosts: readonly string[]): string[] {
|
|
126
|
+
if (hosts.length === 0) return [];
|
|
127
|
+
const configured = hosts.map((host) => host.trim().toLowerCase().replace(/^\.+|\.+$/g, "")).filter(Boolean);
|
|
128
|
+
const urls: string[] = [];
|
|
129
|
+
for (const text of allStrings(input)) {
|
|
130
|
+
for (const raw of text.match(URL_PATTERN) ?? []) {
|
|
131
|
+
try {
|
|
132
|
+
const url = new URL(raw);
|
|
133
|
+
const host = url.hostname.toLowerCase();
|
|
134
|
+
if (!configured.some((allowed) => host === allowed || host.endsWith(`.${allowed}`))) continue;
|
|
135
|
+
url.username = "";
|
|
136
|
+
url.password = "";
|
|
137
|
+
url.search = "";
|
|
138
|
+
url.hash = "";
|
|
139
|
+
const safe = url.toString();
|
|
140
|
+
if (!urls.includes(safe)) urls.push(safe);
|
|
141
|
+
} catch {
|
|
142
|
+
// A transcript fragment that merely resembles a URL is not evidence.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return urls;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function shellSegments(command: string): string[] {
|
|
150
|
+
// Unwrap the common structured-stream representation `bash -lc '<body>'`.
|
|
151
|
+
// This is intentionally not a full shell parser; it only finds the leading
|
|
152
|
+
// executable of each simple pipeline/list segment and never executes text.
|
|
153
|
+
const wrapped = /^\s*(?:\/bin\/)?(?:ba|z|da)?sh\s+-[a-z]*c\s+(['"])([\s\S]*)\1\s*$/u.exec(command);
|
|
154
|
+
const body = wrapped?.[2] ?? command;
|
|
155
|
+
return body.split(/(?:&&|\|\||[;|\n])/u).map((segment) => segment.trim().replace(/^['"(]+|['")]+$/g, ""));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Return a non-secret command label such as `product widgets`, or null. */
|
|
159
|
+
function cliLabel(input: unknown, executables: readonly string[]): string | null {
|
|
160
|
+
const command = typeof input === "string" ? input : allStrings(input).join("\n");
|
|
161
|
+
for (const segment of shellSegments(command)) {
|
|
162
|
+
// Remove command prefixes without retaining their values. Environment
|
|
163
|
+
// assignments, `env`, and `sudo` commonly precede the real executable.
|
|
164
|
+
const tokens = segment.split(/\s+/u).filter(Boolean);
|
|
165
|
+
let index = 0;
|
|
166
|
+
while (tokens[index] === "sudo" || tokens[index] === "env" || /^[A-Za-z_][A-Za-z0-9_]*=.*/u.test(tokens[index] ?? "")) {
|
|
167
|
+
index += 1;
|
|
168
|
+
}
|
|
169
|
+
const invoked = (tokens[index] ?? "").replace(/^['"]|['"]$/g, "");
|
|
170
|
+
for (const executable of executables) {
|
|
171
|
+
const trimmed = executable.trim();
|
|
172
|
+
if (trimmed === "") continue;
|
|
173
|
+
const basename = invoked.split("/").at(-1);
|
|
174
|
+
if (invoked !== trimmed && basename !== trimmed) continue;
|
|
175
|
+
const rawSubcommand = tokens[index + 1]?.replace(/^['"]|['"]$/g, "");
|
|
176
|
+
const subcommand = rawSubcommand && !rawSubcommand.startsWith("-") ? rawSubcommand : undefined;
|
|
177
|
+
return subcommand ? `${trimmed} ${subcommand}` : trimmed;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isMcpInvocation(invocation: ToolInvocation, serverNames: readonly string[]): boolean {
|
|
184
|
+
if (invocation.kind === "mcp") return true;
|
|
185
|
+
const name = invocation.name.toLowerCase();
|
|
186
|
+
if (name.startsWith("mcp__") || name.includes("mcp_tool")) return true;
|
|
187
|
+
return serverNames.some((server) => {
|
|
188
|
+
const normalized = server.toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
189
|
+
return normalized !== "" && (name.startsWith(`${normalized}__`) || name.includes(`__${normalized}__`));
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function mcpLabel(name: string): string {
|
|
194
|
+
// Tool names are identifiers, but cap them so a malformed stream cannot
|
|
195
|
+
// bloat report JSON. No tool inputs are ever retained here.
|
|
196
|
+
return name.replace(/[^a-zA-Z0-9_.:/-]+/g, "_").slice(0, 160) || "mcp_tool";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The adapter names whose raw streams extraction understands; null = store NULL usage. */
|
|
200
|
+
export function surfaceUsageVendor(adapter: string): SurfaceUsageVendor | null {
|
|
201
|
+
return adapter === "claude" || adapter === "codex" || adapter === "cursor" || adapter === "gemini"
|
|
202
|
+
? adapter
|
|
203
|
+
: null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Derive the OFFERED surface configuration from an experiment's runtime
|
|
208
|
+
* config. By convention the treatment's identity-bearing settings live at
|
|
209
|
+
* `runtime.config[treatment]`; rather than hard-coding treatment names, the
|
|
210
|
+
* derivation scans every config subtree for the well-known keys `hosts`
|
|
211
|
+
* (documentation hosts) and `commands` (product CLI executables). MCP server
|
|
212
|
+
* names come from the runner's already-resolved server map — calling
|
|
213
|
+
* `runtime.mcpServers()` here could re-trigger credential checks.
|
|
214
|
+
*/
|
|
215
|
+
export function surfaceUsageConfigFromRuntime(
|
|
216
|
+
config: Record<string, unknown> | undefined,
|
|
217
|
+
mcpServerNames: readonly string[],
|
|
218
|
+
): SurfaceUsageConfig {
|
|
219
|
+
const docsHosts: string[] = [];
|
|
220
|
+
const cliCommands: string[] = [];
|
|
221
|
+
const visit = (value: unknown): void => {
|
|
222
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return;
|
|
223
|
+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
224
|
+
if ((key === "hosts" || key === "docsHosts") && Array.isArray(item)) {
|
|
225
|
+
// Wildcard entries ("*.example.com") reduce to their parent domain —
|
|
226
|
+
// the matcher already covers subdomains of a configured parent.
|
|
227
|
+
for (const host of item) if (typeof host === "string") docsHosts.push(host.replace(/^\*\./u, ""));
|
|
228
|
+
} else if ((key === "commands" || key === "cliCommands") && Array.isArray(item)) {
|
|
229
|
+
for (const command of item) if (typeof command === "string") cliCommands.push(command);
|
|
230
|
+
} else {
|
|
231
|
+
visit(item);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
visit(config);
|
|
236
|
+
return {
|
|
237
|
+
docsHosts: [...new Set(docsHosts)],
|
|
238
|
+
cliCommands: [...new Set(cliCommands)],
|
|
239
|
+
mcpServers: [...new Set(mcpServerNames)],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Extract aggregate, redacted usage from a vendor's raw JSONL stream.
|
|
245
|
+
*
|
|
246
|
+
* A returned all-zero object means the supported stream was inspected and no
|
|
247
|
+
* configured product surface was observed. Callers must store `null`, rather
|
|
248
|
+
* than an all-zero object, when no supported raw stream was available.
|
|
249
|
+
*/
|
|
250
|
+
export function extractSurfaceUsage(
|
|
251
|
+
vendor: SurfaceUsageVendor,
|
|
252
|
+
raw: string,
|
|
253
|
+
config: SurfaceUsageConfig,
|
|
254
|
+
): SurfaceUsage {
|
|
255
|
+
const usage: SurfaceUsage = {
|
|
256
|
+
docs: { count: 0, urls: [] },
|
|
257
|
+
mcp: { count: 0, tools: [] },
|
|
258
|
+
cli: { count: 0, commands: [] },
|
|
259
|
+
};
|
|
260
|
+
const docsHosts = config.docsHosts ?? [];
|
|
261
|
+
const cliCommands = config.cliCommands ?? [];
|
|
262
|
+
const mcpServers = config.mcpServers ?? [];
|
|
263
|
+
const seenInvocations = new Set<string>();
|
|
264
|
+
|
|
265
|
+
for (const rawEvent of parseJsonlLines(raw)) {
|
|
266
|
+
const event = record(rawEvent);
|
|
267
|
+
for (const invocation of invocationsFor(vendor, event)) {
|
|
268
|
+
if (invocation.id !== undefined) {
|
|
269
|
+
if (seenInvocations.has(invocation.id)) continue;
|
|
270
|
+
seenInvocations.add(invocation.id);
|
|
271
|
+
}
|
|
272
|
+
if (isMcpInvocation(invocation, mcpServers)) {
|
|
273
|
+
pushUnique(usage.mcp, usage.mcp.tools, mcpLabel(invocation.name));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const command = invocation.kind === "command" || /bash|shell|terminal|command/i.test(invocation.name)
|
|
277
|
+
? cliLabel(invocation.input, cliCommands)
|
|
278
|
+
: null;
|
|
279
|
+
if (command) pushUnique(usage.cli, usage.cli.commands, command);
|
|
280
|
+
|
|
281
|
+
for (const url of sanitizedDocsUrls(invocation.input, docsHosts)) {
|
|
282
|
+
pushUnique(usage.docs, usage.docs.urls, url);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return usage;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Observe offered surfaces plus explicitly shared targets, without changing offered state. */
|
|
291
|
+
export function observationConfig(offered: SurfaceUsageConfig, targets?: SurfaceUsageConfig): SurfaceUsageConfig {
|
|
292
|
+
return {
|
|
293
|
+
docsHosts: [...new Set([...(offered.docsHosts ?? []), ...(targets?.docsHosts ?? [])])].sort(),
|
|
294
|
+
cliCommands: [...new Set([...(offered.cliCommands ?? []), ...(targets?.cliCommands ?? [])])].sort(),
|
|
295
|
+
mcpServers: [...new Set([...(offered.mcpServers ?? []), ...(targets?.mcpServers ?? [])])].sort(),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AUTO-GENERATED — do not edit by hand.
|
|
3
|
+
*
|
|
4
|
+
* Holds the built UI shell (`ui/dist/index.html`, a single self-contained file
|
|
5
|
+
* with JS/CSS inlined), base64-encoded, so the compiled single-file binary can
|
|
6
|
+
* serve `quickstudy ui` / `quickstudy export` with no `ui/dist` on disk.
|
|
7
|
+
*
|
|
8
|
+
* Empty in a source checkout (where the CLI reads `ui/dist` directly).
|
|
9
|
+
* `bun run build` repopulates this for the duration of the `--compile` step,
|
|
10
|
+
* then restores this placeholder so the working tree stays clean.
|
|
11
|
+
*/
|
|
12
|
+
export const UI_INDEX_HTML_BASE64 = "";
|