niceeval 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.
Files changed (94) hide show
  1. package/README.md +164 -0
  2. package/README.zh.md +160 -0
  3. package/bin/niceeval.js +11 -0
  4. package/package.json +96 -0
  5. package/src/agents/bub.ts +223 -0
  6. package/src/agents/builtin.ts +6 -0
  7. package/src/agents/claude-code.ts +96 -0
  8. package/src/agents/codex.ts +122 -0
  9. package/src/agents/index.ts +25 -0
  10. package/src/agents/shared.ts +145 -0
  11. package/src/cli.ts +421 -0
  12. package/src/context/context.test.ts +96 -0
  13. package/src/context/context.ts +427 -0
  14. package/src/context/control-flow.ts +27 -0
  15. package/src/context/session.ts +124 -0
  16. package/src/define.ts +112 -0
  17. package/src/expect/index.ts +243 -0
  18. package/src/i18n/en.ts +131 -0
  19. package/src/i18n/index.ts +39 -0
  20. package/src/i18n/zh-CN.ts +132 -0
  21. package/src/index.ts +39 -0
  22. package/src/loaders/index.ts +56 -0
  23. package/src/o11y/cost.ts +57 -0
  24. package/src/o11y/derive.ts +304 -0
  25. package/src/o11y/otlp/canonical.ts +112 -0
  26. package/src/o11y/otlp/mappers/bub.ts +30 -0
  27. package/src/o11y/otlp/mappers/codex.ts +38 -0
  28. package/src/o11y/otlp/mappers/index.ts +25 -0
  29. package/src/o11y/otlp/parse.ts +330 -0
  30. package/src/o11y/otlp/receiver.ts +100 -0
  31. package/src/o11y/otlp/sandbox-receiver.ts +113 -0
  32. package/src/o11y/otlp/select.ts +82 -0
  33. package/src/o11y/parsers/bub.ts +240 -0
  34. package/src/o11y/parsers/claude-code.ts +270 -0
  35. package/src/o11y/parsers/codex.ts +480 -0
  36. package/src/o11y/parsers/index.ts +37 -0
  37. package/src/o11y/prices.json +9873 -0
  38. package/src/runner/discover.ts +77 -0
  39. package/src/runner/reporters/artifacts.ts +81 -0
  40. package/src/runner/reporters/console.ts +76 -0
  41. package/src/runner/reporters/index.ts +5 -0
  42. package/src/runner/reporters/json.ts +52 -0
  43. package/src/runner/reporters/live.ts +178 -0
  44. package/src/runner/reporters/table.ts +328 -0
  45. package/src/runner/run.ts +860 -0
  46. package/src/runner/sandbox-prep.ts +91 -0
  47. package/src/sandbox/checkpoint.ts +39 -0
  48. package/src/sandbox/docker.ts +539 -0
  49. package/src/sandbox/e2b.ts +203 -0
  50. package/src/sandbox/index.ts +25 -0
  51. package/src/sandbox/registry.ts +60 -0
  52. package/src/sandbox/resolve.ts +131 -0
  53. package/src/sandbox/source-files.ts +30 -0
  54. package/src/sandbox/vercel.ts +236 -0
  55. package/src/scoring/collector.ts +113 -0
  56. package/src/scoring/judge.ts +236 -0
  57. package/src/scoring/scoped.ts +289 -0
  58. package/src/scoring/verdict.ts +20 -0
  59. package/src/source-loc.ts +53 -0
  60. package/src/types.ts +913 -0
  61. package/src/util.test.ts +31 -0
  62. package/src/util.ts +50 -0
  63. package/src/view/app/App.tsx +189 -0
  64. package/src/view/app/components/AttemptModal.tsx +66 -0
  65. package/src/view/app/components/CodeView.tsx +272 -0
  66. package/src/view/app/components/CopyControls.tsx +89 -0
  67. package/src/view/app/components/ExperimentTable.tsx +266 -0
  68. package/src/view/app/components/GroupSelector.tsx +61 -0
  69. package/src/view/app/components/LazyArtifact.tsx +50 -0
  70. package/src/view/app/components/Trace.tsx +100 -0
  71. package/src/view/app/components/Transcript.tsx +130 -0
  72. package/src/view/app/components/primitives.tsx +43 -0
  73. package/src/view/app/components/ui/badge.tsx +21 -0
  74. package/src/view/app/components/ui/dialog.tsx +34 -0
  75. package/src/view/app/components/ui/tabs.tsx +30 -0
  76. package/src/view/app/i18n.ts +341 -0
  77. package/src/view/app/index.html +13 -0
  78. package/src/view/app/lib/cn.ts +7 -0
  79. package/src/view/app/lib/format.ts +73 -0
  80. package/src/view/app/lib/guards.ts +61 -0
  81. package/src/view/app/lib/outcome.ts +96 -0
  82. package/src/view/app/lib/rows.ts +63 -0
  83. package/src/view/app/lib/transcript-data.tsx +121 -0
  84. package/src/view/app/main.tsx +17 -0
  85. package/src/view/app/pages/RunsPage.tsx +83 -0
  86. package/src/view/app/pages/TracesPage.tsx +40 -0
  87. package/src/view/app/shared.ts +10 -0
  88. package/src/view/app/types.ts +114 -0
  89. package/src/view/app/vite.config.ts +26 -0
  90. package/src/view/client-dist/app.css +2 -0
  91. package/src/view/client-dist/app.js +56 -0
  92. package/src/view/index.ts +406 -0
  93. package/src/view/styles.css +1074 -0
  94. package/src/view/template.html +15 -0
@@ -0,0 +1,406 @@
1
+ // 本地结果查看器:读 summary.json,按 experiment 聚合,注入 HTML 模板。
2
+
3
+ import { existsSync, statSync } from "node:fs";
4
+ import { createServer, type Server } from "node:http";
5
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
6
+ import { dirname, join, relative, resolve } from "node:path";
7
+ import type { EvalResult, RunSummary, Usage } from "../types.ts";
8
+
9
+ export interface ViewOptions {
10
+ input?: string;
11
+ out?: string;
12
+ port?: number;
13
+ }
14
+
15
+ export interface ViewServer {
16
+ url: string;
17
+ close(): Promise<void>;
18
+ }
19
+
20
+ interface LoadedSummary {
21
+ path: string;
22
+ summary: RunSummary;
23
+ }
24
+
25
+ interface LeaderboardRow {
26
+ key: string;
27
+ experimentId?: string;
28
+ experiment?: EvalResult["experiment"];
29
+ group?: string;
30
+ label: string;
31
+ agent: string;
32
+ model?: string;
33
+ runs: number;
34
+ evals: number;
35
+ passed: number;
36
+ failed: number;
37
+ errored: number;
38
+ skipped: number;
39
+ passRate: number;
40
+ avgDurationMs: number;
41
+ usage: Usage;
42
+ estimatedCostUSD?: number;
43
+ /** 该实验组里最新一次 run 的 startedAt(ISO);详情展示「运行时间」。 */
44
+ lastRunAt?: string;
45
+ results: EvalResult[];
46
+ }
47
+
48
+ const OUTCOME_ORDER: Record<EvalResult["outcome"], number> = {
49
+ errored: 0,
50
+ failed: 0,
51
+ skipped: 1,
52
+ passed: 2,
53
+ };
54
+
55
+ const TEMPLATE_PLACEHOLDERS = {
56
+ styles: "<!-- __NICEEVAL_STYLES__ -->",
57
+ appCode: "__NICEEVAL_APP_CODE__",
58
+ viewData: "__NICEEVAL_VIEW_DATA_JSON__",
59
+ } as const;
60
+
61
+ /** 读最近一次运行的所有 EvalResult,供 --resume 跳过已通过的 eval。 */
62
+ export async function loadMostRecentResults(root = ".niceeval"): Promise<EvalResult[]> {
63
+ const summaries = await loadSummaries(root);
64
+ // loadSummaries 已按 startedAt 降序,第一个是最新的
65
+ return summaries[0]?.summary.results ?? [];
66
+ }
67
+
68
+ export async function buildView(opts: ViewOptions = {}): Promise<string> {
69
+ const summaries = await loadSummaries(opts.input);
70
+ const out = resolve(opts.out ?? ".niceeval/report.html");
71
+ await mkdir(dirname(out), { recursive: true });
72
+ await writeFile(out, await renderHtml(summaries), "utf-8");
73
+ return out;
74
+ }
75
+
76
+ export async function startViewServer(opts: ViewOptions = {}): Promise<ViewServer> {
77
+ const input = opts.input;
78
+ const root = viewRoot(input);
79
+ const server = createServer(async (req, res) => {
80
+ try {
81
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
82
+ if (url.pathname === "/healthz") {
83
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
84
+ res.end("ok");
85
+ return;
86
+ }
87
+ // 按需提供拆分工件(trace.json / events.json / …),前端展开时 fetch。
88
+ if (url.pathname === "/artifact") {
89
+ await serveArtifact(root, url.searchParams.get("p") ?? "", res);
90
+ return;
91
+ }
92
+ if (url.pathname !== "/") {
93
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
94
+ res.end("not found");
95
+ return;
96
+ }
97
+ res.writeHead(200, {
98
+ "content-type": "text/html; charset=utf-8",
99
+ "cache-control": "no-store",
100
+ });
101
+ res.end(await renderHtml(await loadSummaries(input)));
102
+ } catch (e) {
103
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
104
+ res.end(e instanceof Error ? e.stack ?? e.message : String(e));
105
+ }
106
+ });
107
+
108
+ const port = await listen(server, opts.port ?? 0);
109
+ return {
110
+ url: `http://127.0.0.1:${port}/`,
111
+ close: () =>
112
+ new Promise((resolveClose, reject) => {
113
+ server.close((err) => (err ? reject(err) : resolveClose()));
114
+ }),
115
+ };
116
+ }
117
+
118
+ /** 安全地把 root 下的工件文件吐回去(限定 .json,且解析后必须仍在 root 内)。 */
119
+ async function serveArtifact(
120
+ root: string,
121
+ rel: string,
122
+ res: import("node:http").ServerResponse,
123
+ ): Promise<void> {
124
+ const abs = resolve(root, rel);
125
+ const within = abs === root || abs.startsWith(root + "/");
126
+ if (!within || !rel.endsWith(".json")) {
127
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
128
+ res.end("bad artifact path");
129
+ return;
130
+ }
131
+ try {
132
+ const body = await readFile(abs, "utf-8");
133
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
134
+ res.end(body);
135
+ } catch {
136
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
137
+ res.end("artifact not found");
138
+ }
139
+ }
140
+
141
+ async function renderHtml(loaded: LoadedSummary[]): Promise<string> {
142
+ const latest = loaded[0]?.summary;
143
+ const rows = aggregateRows(loaded);
144
+ const totals = summarizeAll(loaded);
145
+ const template = await readViewAsset("template.html");
146
+ const styles = await readViewAsset("client-dist/app.css");
147
+ const app = await readViewAsset("client-dist/app.js");
148
+ const viewData = {
149
+ rows,
150
+ name: latest?.name,
151
+ lastRun: latest ? formatDate(latest.startedAt) : "No runs yet",
152
+ passRate: formatPercent(totals.passRate),
153
+ resultCount: String(totals.results),
154
+ duration: formatDuration(totals.durationMs),
155
+ cost: formatCost(totals.cost),
156
+ };
157
+
158
+ return template
159
+ .replace(TEMPLATE_PLACEHOLDERS.styles, () => `<style>\n${styles}\n</style>`)
160
+ .replace(TEMPLATE_PLACEHOLDERS.viewData, () => JSON.stringify(viewData).replace(/</g, "\\u003c"))
161
+ .replace(TEMPLATE_PLACEHOLDERS.appCode, () => JSON.stringify(app).replace(/</g, "\\u003c"));
162
+ }
163
+
164
+ async function readViewAsset(name: string): Promise<string> {
165
+ return readFile(new URL(name, import.meta.url), "utf-8");
166
+ }
167
+
168
+ async function listen(server: Server, preferredPort: number): Promise<number> {
169
+ const tryListen = (port: number): Promise<number> =>
170
+ new Promise((resolveListen, reject) => {
171
+ const onError = (err: NodeJS.ErrnoException) => {
172
+ server.off("listening", onListening);
173
+ reject(err);
174
+ };
175
+ const onListening = () => {
176
+ server.off("error", onError);
177
+ const address = server.address();
178
+ resolveListen(typeof address === "object" && address ? address.port : port);
179
+ };
180
+ server.once("error", onError);
181
+ server.once("listening", onListening);
182
+ server.listen(port, "127.0.0.1");
183
+ });
184
+
185
+ if (preferredPort === 0) return tryListen(0);
186
+ for (let port = preferredPort; port < preferredPort + 20; port++) {
187
+ try {
188
+ return await tryListen(port);
189
+ } catch (e) {
190
+ if ((e as NodeJS.ErrnoException).code !== "EADDRINUSE") throw e;
191
+ }
192
+ }
193
+ throw new Error(`No available port near ${preferredPort}`);
194
+ }
195
+
196
+ /** 服务/解析工件的根目录:输入是目录就用它,是文件就用其所在目录。 */
197
+ function viewRoot(input?: string): string {
198
+ const t = resolve(input ?? ".niceeval");
199
+ try {
200
+ return statSync(t).isFile() ? dirname(t) : t;
201
+ } catch {
202
+ return t;
203
+ }
204
+ }
205
+
206
+ async function loadSummaries(input?: string): Promise<LoadedSummary[]> {
207
+ const target = resolve(input ?? ".niceeval");
208
+ if (!existsSync(target)) return [];
209
+ const root = viewRoot(input);
210
+ const s = await stat(target);
211
+ if (s.isFile()) {
212
+ const summary = await readSummary(target);
213
+ attachArtifactBase(summary, target, root);
214
+ return [{ path: target, summary }];
215
+ }
216
+
217
+ const candidates = await findSummaryFiles(target);
218
+ const loaded: LoadedSummary[] = [];
219
+ for (const path of candidates) {
220
+ try {
221
+ const summary = await readSummary(path);
222
+ attachArtifactBase(summary, path, root);
223
+ loaded.push({ path, summary });
224
+ } catch {
225
+ // Ignore unrelated JSON files under .niceeval.
226
+ }
227
+ }
228
+ loaded.sort((a, b) => b.summary.startedAt.localeCompare(a.summary.startedAt));
229
+ return loaded;
230
+ }
231
+
232
+ /** 给每条 result 拼出相对 view 根的工件目录(前端据此 fetch trace.json 等)。 */
233
+ function attachArtifactBase(summary: RunSummary, summaryPath: string, root: string): void {
234
+ const runDir = dirname(summaryPath);
235
+ for (const r of summary.results) {
236
+ if (!r.artifactsDir) continue;
237
+ const abs = join(runDir, r.artifactsDir);
238
+ r.artifactBase = relative(root, abs).split(/[\\/]/).join("/");
239
+ r.artifactAbsBase = abs;
240
+ }
241
+ }
242
+
243
+ async function findSummaryFiles(dir: string): Promise<string[]> {
244
+ const entries = await readdir(dir, { withFileTypes: true });
245
+ const direct = entries.filter((e) => e.isFile() && e.name === "summary.json").map((e) => join(dir, e.name));
246
+ const nested = await Promise.all(entries.filter((e) => e.isDirectory()).map((e) => findSummaryFiles(join(dir, e.name))));
247
+ return [...direct, ...nested.flat()];
248
+ }
249
+
250
+ async function readSummary(path: string): Promise<RunSummary> {
251
+ const data = JSON.parse(await readFile(path, "utf-8")) as RunSummary;
252
+ if (!Array.isArray(data.results) || typeof data.startedAt !== "string") {
253
+ throw new Error(`${path} is not a niceeval summary`);
254
+ }
255
+ return data;
256
+ }
257
+
258
+ function aggregateRows(loaded: LoadedSummary[]): LeaderboardRow[] {
259
+ const groups = new Map<string, EvalResult[]>();
260
+ const lastRunAt = new Map<string, string>();
261
+ for (const item of loaded) {
262
+ for (const result of item.summary.results) {
263
+ const key = result.experimentId ? `exp|||${result.experimentId}` : `legacy|||${result.agent}|||${result.model ?? ""}`;
264
+ groups.set(key, [...(groups.get(key) ?? []), result]);
265
+ const prev = lastRunAt.get(key);
266
+ if (!prev || item.summary.startedAt > prev) lastRunAt.set(key, item.summary.startedAt);
267
+ }
268
+ }
269
+
270
+ return Array.from(groups.entries()).map(([key, results]) => {
271
+ const first = results[0]!;
272
+ const experimentId = first.experimentId;
273
+ const cost = sumMaybe(results.map((r) => r.estimatedCostUSD));
274
+ // 一行 = 一个实验,results 内按 eval id 折叠计票(passed/failed/通过率都是 eval 级)。
275
+ const stats = evalLevelStats(results, (r) => r.id);
276
+ return {
277
+ key,
278
+ experimentId,
279
+ experiment: first.experiment,
280
+ group: experimentGroup(experimentId),
281
+ label: displayExperimentName(experimentId) ?? fallbackExperimentLabel(first),
282
+ agent: first.agent,
283
+ model: first.model,
284
+ lastRunAt: lastRunAt.get(key),
285
+ runs: results.length, // 总 attempt 数(详情里作次要信息)
286
+ evals: stats.evals, // 去重后的 eval 数(成功率分母的口径)
287
+ passed: stats.passed,
288
+ failed: stats.failed,
289
+ errored: stats.errored,
290
+ skipped: stats.skipped,
291
+ passRate: stats.passRate,
292
+ avgDurationMs: avg(results.map((r) => r.durationMs)),
293
+ usage: sumUsage(results.map((r) => r.usage)),
294
+ estimatedCostUSD: cost,
295
+ results: results
296
+ .slice()
297
+ .sort((a, b) => OUTCOME_ORDER[a.outcome] - OUTCOME_ORDER[b.outcome] || a.id.localeCompare(b.id)),
298
+ };
299
+ });
300
+ }
301
+
302
+ function resultOutcome(result: EvalResult): EvalResult["outcome"] {
303
+ return result.outcome;
304
+ }
305
+
306
+ /**
307
+ * 把同一个 eval 的多轮 attempt 折叠成单一判决:任一轮通过 → 该 eval 通过(对齐 earlyExit
308
+ * 「先过一次即停」语义),否则按 failed > errored > skipped 取最严重的一个。
309
+ */
310
+ function foldEvalOutcome(results: EvalResult[]): EvalResult["outcome"] {
311
+ const outcomes = results.map(resultOutcome);
312
+ if (outcomes.some((o) => o === "passed")) return "passed";
313
+ if (outcomes.some((o) => o === "failed")) return "failed";
314
+ if (outcomes.some((o) => o === "errored")) return "errored";
315
+ return "skipped";
316
+ }
317
+
318
+ /**
319
+ * 通过率与 passed/failed 一律按 eval 计票,不按 attempt:每个 eval 不管跑几轮都只占一票,先把它
320
+ * 的多轮折叠成单一判决再计数。否则 runs>1 时同一 eval 的 N 次 attempt 各算一票 —— 尤其 earlyExit
321
+ * 开时通过的 eval 只留 1 次、失败的 eval 跑满 N 次,失败 eval 被重复计入分母,把通过率拉低
322
+ * (见 docs/runner.md、docs/scoring.md)。keyOf 决定「一个 eval」的粒度:单实验按 eval id,
323
+ * 跨实验组按 experimentId|eval id。
324
+ */
325
+ function evalLevelStats(results: EvalResult[], keyOf: (r: EvalResult) => string) {
326
+ const byEval = new Map<string, EvalResult[]>();
327
+ for (const r of results) byEval.set(keyOf(r), [...(byEval.get(keyOf(r)) ?? []), r]);
328
+ const counts = { passed: 0, failed: 0, errored: 0, skipped: 0 };
329
+ for (const group of byEval.values()) counts[foldEvalOutcome(group)] += 1;
330
+ const ran = counts.passed + counts.failed + counts.errored; // skipped 不进分母
331
+ return { evals: byEval.size, ...counts, passRate: ran ? counts.passed / ran : 0 };
332
+ }
333
+
334
+ function summarizeAll(loaded: LoadedSummary[]) {
335
+ const results = loaded.flatMap((s) => s.summary.results);
336
+ // 顶部总览同样按 eval 计票:每个(实验, eval)只算一份,跨实验/跨 run 不被 runs 灌票。
337
+ const groupKey = (r: EvalResult) => (r.experimentId ? `exp|||${r.experimentId}` : `legacy|||${r.agent}|||${r.model ?? ""}`);
338
+ const stats = evalLevelStats(results, (r) => `${groupKey(r)}|||${r.id}`);
339
+ return {
340
+ results: stats.evals,
341
+ passRate: stats.passRate,
342
+ durationMs: loaded.reduce((sum, s) => sum + (s.summary.durationMs ?? 0), 0),
343
+ cost: sumMaybe(loaded.map((s) => s.summary.estimatedCostUSD)),
344
+ };
345
+ }
346
+
347
+ function sumUsage(items: Array<Usage | undefined>): Usage {
348
+ return {
349
+ inputTokens: items.reduce((n, u) => n + (u?.inputTokens ?? 0), 0),
350
+ outputTokens: items.reduce((n, u) => n + (u?.outputTokens ?? 0), 0),
351
+ cacheReadTokens: items.reduce((n, u) => n + (u?.cacheReadTokens ?? 0), 0),
352
+ cacheWriteTokens: items.reduce((n, u) => n + (u?.cacheWriteTokens ?? 0), 0),
353
+ requests: items.reduce((n, u) => n + (u?.requests ?? 0), 0),
354
+ };
355
+ }
356
+
357
+ function sumMaybe(items: Array<number | undefined>): number | undefined {
358
+ const known = items.filter((n): n is number => n !== undefined);
359
+ return known.length ? known.reduce((sum, n) => sum + n, 0) : undefined;
360
+ }
361
+
362
+ function avg(items: number[]): number {
363
+ return items.length ? items.reduce((sum, n) => sum + n, 0) / items.length : 0;
364
+ }
365
+
366
+ function displayExperimentName(id: string | undefined): string | undefined {
367
+ if (!id) return undefined;
368
+ return id.split("/").filter(Boolean).at(-1) ?? id;
369
+ }
370
+
371
+ function experimentGroup(id: string | undefined): string | undefined {
372
+ if (!id || !id.includes("/")) return undefined;
373
+ return id.split("/").slice(0, -1).join("/");
374
+ }
375
+
376
+ function fallbackExperimentLabel(result: EvalResult): string {
377
+ if (result.experiment?.id) return displayExperimentName(result.experiment.id) ?? result.experiment.id;
378
+ if (result.model) return `${result.agent}/${result.model}`;
379
+ return result.agent || "ad hoc run";
380
+ }
381
+
382
+ function formatDate(iso: string): string {
383
+ return new Intl.DateTimeFormat("en", {
384
+ year: "numeric",
385
+ month: "short",
386
+ day: "numeric",
387
+ hour: "2-digit",
388
+ minute: "2-digit",
389
+ }).format(new Date(iso));
390
+ }
391
+
392
+ function formatPercent(v: number): string {
393
+ return `${Math.round(v * 100)}%`;
394
+ }
395
+
396
+ function formatDuration(ms: number): string {
397
+ if (!Number.isFinite(ms) || ms <= 0) return "0ms";
398
+ if (ms >= 60_000) return `${(ms / 60_000).toFixed(1)}m`;
399
+ if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
400
+ return `${Math.round(ms)}ms`;
401
+ }
402
+
403
+ function formatCost(n: number | undefined): string {
404
+ if (n === undefined || n <= 0) return "$0";
405
+ return `$${n.toFixed(n < 1 ? 3 : 2)}`;
406
+ }