@hackerrank/astra-cli 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -115,7 +115,7 @@ astra \
115
115
  --repeat 3 \
116
116
  --tar # write a bench/ tarball locally
117
117
 
118
- # push results to S3 (tars bench/ then `aws s3 cp` to the URI)
118
+ # push results to S3 (tars bench/ and uploads it + report.html to the URI)
119
119
  astra -m m1,m2 -p tasks/dummy-slugify --repeat 3 \
120
120
  --push s3://hackerrank-astra-bench-results-dev/runs/2025-09-02/
121
121
  ```
@@ -126,6 +126,35 @@ price entry shows **`n/a`** (never `$0`), and estimated costs (native routes) ar
126
126
  marked `~`. `--push` requires an `s3://` URI and the `aws` CLI on PATH; if the
127
127
  upload fails the local tarball is kept.
128
128
 
129
+ Next to the tarball, `--push` also uploads the self-contained `report.html`
130
+ dashboard with `Content-Type: text/html` and prints a **7-day presigned
131
+ "view report" link** at the end of the run. The bucket is private (no public
132
+ read), so that presigned URL is the only way to open the dashboard — e.g.
133
+ clickable straight from a Jenkins console log.
134
+
135
+ ### HTML report
136
+
137
+ Every matrix run (and every single `-p`/`-t`/`-f` bench run) rebuilds
138
+ `bench/summary.json` and a self-contained `bench/report.html` dashboard —
139
+ no server, no build step, no external JS: open it straight from `file://` or
140
+ from inside the `bench-*.tgz` tarball. It has a leaderboard, a model × task
141
+ pass-rate heatmap, cost/token charts, per-step trend lines, and an
142
+ expandable command timeline for every run.
143
+
144
+ Regenerate it on demand (e.g. after manually editing/pruning `bench/`) with:
145
+
146
+ ```bash
147
+ astra --report # rebuild ./bench/{summary.json,report.html}
148
+ astra --report --bench-root ./other-bench
149
+ ```
150
+
151
+ `summary.json` follows the `astra-bench-1` schema: `kpis` (totals), a tidy
152
+ `leaderboard[]` (one row per model×reasoning), `matrix[]` (model×reasoning×task
153
+ pass@k), `runs[]` (every attempt, with a compact per-step `timeline`), and
154
+ `step_series[]` (token/cost distributions by step, for the trend charts). Each
155
+ run folder also gets a standalone `run.json` for drill-down without loading
156
+ the full `trajectory.json`.
157
+
129
158
  ### Sessions
130
159
 
131
160
  Every run (either mode) is saved under `~/.astra/sessions/<id>.json`.
@@ -152,7 +181,11 @@ credits — that's a quota issue, not a bug.
152
181
  --repeat <n> Attempts per (model,reasoning) in a matrix (default: 1)
153
182
  --bench-root <dir> Root folder for bench runs (default: ./bench)
154
183
  --tar After a matrix, write a bench/ tarball locally
155
- --push <s3-uri> After a matrix, tar bench/ and `aws s3 cp` to the URI
184
+ --push <s3-uri> After a matrix, tar bench/ and `aws s3 cp` it (plus
185
+ report.html) to the URI; prints presigned
186
+ download/view links
187
+ --report Rebuild bench/summary.json + bench/report.html from
188
+ whatever runs already exist on disk, then exit
156
189
  -C, --cwd <path> Working directory for commands (default: cwd)
157
190
  -o, --output <path> Also write trajectory JSON here (autonomous mode)
158
191
  -s, --steps <n> Step limit (default: 40)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackerrank/astra-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Minimal zero-dependency AI coding agent for the HackerRank AI Gateway.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/bench.js CHANGED
@@ -455,17 +455,38 @@ function s3ConsoleUrl(s3Uri) {
455
455
  return `https://s3.console.aws.amazon.com/s3/object/${bucket}?prefix=${encodeURIComponent(key)}`;
456
456
  }
457
457
 
458
+ /**
459
+ * Generate a time-limited presigned URL for an S3 object via the AWS CLI
460
+ * (`aws s3 presign`). `expiresIn` defaults to 7 days (604800s). Returns null
461
+ * (rather than throwing) if the AWS CLI is missing or presigning fails, so
462
+ * a presign failure never blocks the run.
463
+ */
464
+ export function presignS3Url(s3Uri, expiresIn = 604800) {
465
+ const res = spawnSync(
466
+ "aws",
467
+ ["s3", "presign", s3Uri, "--expires-in", String(expiresIn)],
468
+ { stdio: ["ignore", "pipe", "pipe"] }
469
+ );
470
+ if (res.error && res.error.code === "ENOENT") return null;
471
+ if (res.status !== 0) return null;
472
+ return (res.stdout?.toString() || "").trim() || null;
473
+ }
474
+
458
475
  /**
459
476
  * Upload a file to S3 via the AWS CLI. Returns { uri, url } where `uri` is the
460
477
  * final s3:// destination and `url` is a browsable https console link.
461
- * Fails gracefully (throws a descriptive error) when the AWS CLI is missing or
462
- * the copy fails; callers should keep the local tarball on failure.
478
+ * Optionally sets the object's Content-Type (e.g. "text/html" so presigned
479
+ * URLs open in a browser instead of downloading). Fails gracefully (throws a
480
+ * descriptive error) when the AWS CLI is missing or the copy fails; callers
481
+ * should keep the local tarball on failure.
463
482
  */
464
- export function pushToS3(file, s3Uri) {
483
+ export function pushToS3(file, s3Uri, { contentType } = {}) {
465
484
  if (!/^s3:\/\//.test(s3Uri)) throw new Error(`--push destination must be an s3:// URI, got: ${s3Uri}`);
466
485
  // If the URI ends with "/", upload into it under the file's basename.
467
486
  const dest = s3Uri.endsWith("/") ? s3Uri + path.basename(file) : s3Uri;
468
- const res = spawnSync("aws", ["s3", "cp", file, dest], {
487
+ const cmd = ["s3", "cp", file, dest];
488
+ if (contentType) cmd.push("--content-type", contentType);
489
+ const res = spawnSync("aws", cmd, {
469
490
  stdio: ["ignore", "pipe", "pipe"],
470
491
  });
471
492
  if (res.error && res.error.code === "ENOENT") {
@@ -474,5 +495,5 @@ export function pushToS3(file, s3Uri) {
474
495
  if (res.status !== 0) {
475
496
  throw new Error(`aws s3 cp failed: ${res.stderr?.toString() || "unknown"}`);
476
497
  }
477
- return { uri: dest, url: s3ConsoleUrl(dest) };
498
+ return { uri: dest, url: s3ConsoleUrl(dest), presigned: presignS3Url(dest) };
478
499
  }
package/src/cli.js CHANGED
@@ -28,10 +28,14 @@
28
28
  * comma-separated to sweep levels in bench mode).
29
29
  * Sent to the model and recorded in the bench name.
30
30
  * --repeat <n> Attempts per (model,reasoning) in a matrix (1)
31
- * --push After a matrix, tar bench/ and upload to
32
- * s3://astra-bench-results/<timestamp>/
31
+ * --push After a matrix, tar bench/ and upload it (plus
32
+ * report.html) to s3://astra-bench-results/<timestamp>/;
33
+ * prints presigned download/view links
33
34
  * --push-uri <s3-uri> Override the S3 push destination
34
35
  * --tar After a matrix, write a bench/ tarball locally
36
+ * --report Rebuild bench/summary.json + bench/report.html
37
+ * from whatever runs already exist on disk, then
38
+ * exit (no model/API key needed).
35
39
  * -t, --task <text> Task text -> bench mode (run to completion)
36
40
  * -f, --task-file <path> Read task text from a file -> bench mode
37
41
  * -p, --path <dir> Task directory (copied into an isolated bench
@@ -94,6 +98,7 @@ import {
94
98
  defaultPushUri,
95
99
  benchRoot,
96
100
  } from "./bench.js";
101
+ import { refreshReport } from "./report.js";
97
102
 
98
103
  /** Map a reasoning level to gateway modelKwargs. Empty for off/none/unset. */
99
104
  function reasoningKwargs(level) {
@@ -115,6 +120,7 @@ function parseArgs(argv) {
115
120
  "--push": "push",
116
121
  "--push-uri": "push-uri",
117
122
  "--tar": "tar",
123
+ "--report": "report",
118
124
  "--bench-root": "bench-root",
119
125
  "-C": "cwd", "--cwd": "cwd",
120
126
  "-o": "output", "--output": "output",
@@ -130,7 +136,7 @@ function parseArgs(argv) {
130
136
  "-y": "yolo", "--yolo": "yolo",
131
137
  "-h": "help", "--help": "help",
132
138
  };
133
- const flags = new Set(["quiet", "yolo", "help", "sessions", "tar", "push"]);
139
+ const flags = new Set(["quiet", "yolo", "help", "sessions", "tar", "push", "report"]);
134
140
  for (let i = 2; i < argv.length; i++) {
135
141
  const key = alias[argv[i]];
136
142
  if (!key) { console.error(`Unknown option: ${argv[i]}`); process.exit(2); }
@@ -154,6 +160,18 @@ async function main() {
154
160
 
155
161
  if (args.sessions) { printSessions(); process.exit(0); }
156
162
  if (args.help) { console.log(HELP); process.exit(0); }
163
+ if (args.report) {
164
+ try {
165
+ const res = refreshReport(args["bench-root"]);
166
+ console.error(`\x1b[1m[astra] report · ${res.runs} run(s) · ${res.models} model(s) · ${res.tasks} task(s)\x1b[0m`);
167
+ console.error(`\x1b[1m summary\x1b[0m ${res.summary}`);
168
+ console.error(`\x1b[1m html \x1b[0m ${res.html}`);
169
+ } catch (err) {
170
+ console.error(`\x1b[31m[astra] report failed: ${err.message}\x1b[0m`);
171
+ process.exit(1);
172
+ }
173
+ process.exit(0);
174
+ }
157
175
 
158
176
  // Load a session to resume (if any) to infer defaults.
159
177
  let resumeDoc = null;
@@ -348,6 +366,12 @@ async function main() {
348
366
  const metrics = collectMetrics({ agent: benchAgent, model, reasoning });
349
367
  const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
350
368
  log(`[astra] bench done: exit=${res.exit_status} · metrics → ${paths.runMetrics}`);
369
+ try {
370
+ const report = refreshReport(alloc.root);
371
+ log(`[astra] report → ${report.html}`);
372
+ } catch (err) {
373
+ log(`[astra] report generation skipped: ${err.message}`);
374
+ }
351
375
  };
352
376
  await runRepl(agent, { model, autoRun: !!args.yolo, fresh: !resumeDoc, reasoning, runBench });
353
377
  process.exit(0);
@@ -384,6 +408,12 @@ async function main() {
384
408
  console.error(`\x1b[2m[astra] metrics -> ${paths.runMetrics}\x1b[0m`);
385
409
  console.error(`\x1b[2m[astra] index -> ${paths.index}\x1b[0m`);
386
410
  }
411
+ try {
412
+ const report = refreshReport(benchRun.root);
413
+ if (!quiet) console.error(`\x1b[2m[astra] report -> ${report.html}\x1b[0m`);
414
+ } catch {
415
+ // Non-fatal: report regeneration is best-effort for single runs.
416
+ }
387
417
  }
388
418
 
389
419
  if (!quiet) {
@@ -408,9 +438,74 @@ async function main() {
408
438
  console.error(`\x1b[2m[astra] session -> ${sessionId}\x1b[0m`);
409
439
  }
410
440
  if (result.submission) console.log(result.submission);
441
+
442
+ // For a single-run bench, tar + push the bench/ folder if requested.
443
+ // (Matrix runs handle this inside runBenchMatrix.)
444
+ if (benchRun) {
445
+ const links = [];
446
+ archiveAndPush(args, benchRun.root, links);
447
+ if (links.length) {
448
+ const w = Math.max(...links.map(([k]) => k.length));
449
+ console.error("");
450
+ for (const [k, v] of links) console.error(`\x1b[1m ${k.padEnd(w)}\x1b[0m ${v}`);
451
+ }
452
+ }
453
+
411
454
  process.exit(result.exit_status === "Submitted" ? 0 : 1);
412
455
  }
413
456
 
457
+ /**
458
+ * Tar the bench/ folder and (optionally) upload it to S3. Mutates `links`
459
+ * with the resulting artifact paths/URLs and prints any failures.
460
+ * Works for both single-run and matrix bench runs.
461
+ */
462
+ function archiveAndPush(args, root, links) {
463
+ if (!(args.tar || args.push)) return;
464
+ try {
465
+ const tarball = packBench({ root });
466
+ links.push(["tarball", tarball]);
467
+ if (args.push) {
468
+ // Bare --push uses the default bucket + timestamp prefix; --push-uri
469
+ // overrides the destination.
470
+ const dest = args["push-uri"] || defaultPushUri();
471
+ try {
472
+ const pushed = pushToS3(tarball, dest);
473
+ links.push(["s3 uri", pushed.uri]);
474
+ links.push(["s3 url", pushed.url]);
475
+ if (pushed.presigned) {
476
+ links.push(["download (presigned, 7d)", pushed.presigned]);
477
+ }
478
+ // Also push the self-contained HTML dashboard next to the tarball and
479
+ // emit a presigned view link: the bucket is private (no public read),
480
+ // so the presigned URL is the only way to open the report — e.g. by
481
+ // clicking it straight from the Jenkins console output. Uploaded with
482
+ // Content-Type: text/html so browsers render it instead of downloading.
483
+ const reportHtml = path.join(benchRoot(root), "report.html");
484
+ if (fs.existsSync(reportHtml)) {
485
+ const htmlDest = dest.endsWith("/")
486
+ ? dest + "report.html"
487
+ : dest.slice(0, dest.lastIndexOf("/") + 1) + "report.html";
488
+ try {
489
+ const html = pushToS3(reportHtml, htmlDest, { contentType: "text/html" });
490
+ if (html.presigned) {
491
+ links.push(["view report (presigned, 7d)", html.presigned]);
492
+ } else {
493
+ links.push(["report (s3 url)", html.url]);
494
+ }
495
+ } catch (err) {
496
+ console.error(`\x1b[31m[astra] S3 report push failed: ${err.message}\x1b[0m`);
497
+ }
498
+ }
499
+ } catch (err) {
500
+ console.error(`\x1b[31m[astra] S3 push failed: ${err.message}\x1b[0m`);
501
+ console.error(`\x1b[33m[astra] tarball kept locally: ${tarball}\x1b[0m`);
502
+ }
503
+ }
504
+ } catch (err) {
505
+ console.error(`\x1b[31m[astra] tar failed: ${err.message}\x1b[0m`);
506
+ }
507
+ }
508
+
414
509
  /**
415
510
  * Run a multi-model / multi-reasoning / repeated bench matrix, print a
416
511
  * leaderboard, optionally tar + upload the bench/ folder, then exit.
@@ -463,29 +558,18 @@ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task,
463
558
  // Collect the important artifact locations to print together at the end.
464
559
  const links = [["metrics", indexPath]];
465
560
 
466
- // Tar + optional S3 push of the whole bench/ folder.
467
- if (args.tar || args.push) {
468
- try {
469
- const tarball = packBench({ root });
470
- links.push(["tarball", tarball]);
471
- if (args.push) {
472
- // Bare --push uses the default bucket + timestamp prefix; --push-uri
473
- // overrides the destination.
474
- const dest = args["push-uri"] || defaultPushUri();
475
- try {
476
- const pushed = pushToS3(tarball, dest);
477
- links.push(["s3 uri", pushed.uri]);
478
- links.push(["s3 url", pushed.url]);
479
- } catch (err) {
480
- console.error(`\x1b[31m[astra] S3 push failed: ${err.message}\x1b[0m`);
481
- console.error(`\x1b[33m[astra] tarball kept locally: ${tarball}\x1b[0m`);
482
- }
483
- }
484
- } catch (err) {
485
- console.error(`\x1b[31m[astra] tar failed: ${err.message}\x1b[0m`);
486
- }
561
+ // Rebuild the dashboard (summary.json + report.html) from every run on
562
+ // disk under this root, including runs from earlier matrix invocations.
563
+ try {
564
+ const report = refreshReport(root);
565
+ links.push(["report", report.html]);
566
+ } catch (err) {
567
+ console.error(`\x1b[33m[astra] report generation skipped: ${err.message}\x1b[0m`);
487
568
  }
488
569
 
570
+ // Tar + optional S3 push of the whole bench/ folder.
571
+ archiveAndPush(args, root, links);
572
+
489
573
  const solved = rows.filter((r) => r.resolved).length;
490
574
  console.error(`\n\x1b[1m[astra] matrix done · ${solved}/${rows.length} resolved\x1b[0m`);
491
575
 
package/src/model.js CHANGED
@@ -67,6 +67,9 @@ export class GatewayModel {
67
67
  this.costSource = null; // "reported" | "estimated" | "mixed" | null
68
68
  // Preferred token-cap parameter; swapped automatically on 400 if needed.
69
69
  this._tokenParam = "max_tokens";
70
+ // Optional AbortSignal; when set and aborted, in-flight requests are
71
+ // cancelled (used to force-quit a long model call on a second Ctrl+C).
72
+ this.signal = null;
70
73
  if (!this.apiKey) {
71
74
  throw new Error(
72
75
  "GatewayModel: no API key. Set ASTRA_GATEWAY_API_KEY or configure ~/.astra/config.json."
@@ -100,6 +103,7 @@ export class GatewayModel {
100
103
  Authorization: `Bearer ${this.apiKey}`,
101
104
  },
102
105
  body: JSON.stringify(body),
106
+ signal: this.signal || undefined,
103
107
  });
104
108
 
105
109
  if (!res.ok) {
@@ -228,8 +232,10 @@ export class GatewayModel {
228
232
 
229
233
  function isRetryable(err) {
230
234
  // Never retry classified terminal errors (auth/quota/bad-request) or context
231
- // overflow — those won't succeed on retry.
235
+ // overflow — those won't succeed on retry. Also never retry an aborted
236
+ // request (user force-quit via Ctrl+C).
232
237
  if (err instanceof GatewayError || err instanceof ContextWindowError) return false;
238
+ if (err?.name === "AbortError") return false;
233
239
  const msg = String(err?.message || err);
234
240
  return /HTTP 5\d\d|HTTP 429|ECONNRESET|ETIMEDOUT|fetch failed|network/i.test(msg);
235
241
  }
package/src/repl.js CHANGED
@@ -134,7 +134,9 @@ function turnSummary({ turn, upTok, downTok, costUsd, costKind, seconds }) {
134
134
  */
135
135
  function footerLines(agent, model, yolo, mode = "agent") {
136
136
  const cols = process.stdout.columns || 100;
137
- const rule = "─".repeat(cols);
137
+ // Leave one cell of slack so a full-width rule never wraps to a new row
138
+ // (which pushes the whole footer up and looks broken on some terminals).
139
+ const rule = "─".repeat(Math.max(1, cols - 1));
138
140
 
139
141
  const reasoning = model.modelKwargs?.reasoning_effort || model.modelKwargs?.reasoning?.effort || "off";
140
142
  const modeTag = mode === "bench" ? C.yellow("◆ bench") : C.cyan("◆ agent");
@@ -154,12 +156,13 @@ function footerLines(agent, model, yolo, mode = "agent") {
154
156
 
155
157
  return [
156
158
  C.dim(rule),
157
- modeTag + " " + C.cyan(repoLabel()) + " " + C.dim(modelBits.join(" ")) + " " + C.dim("opt+←→ model · opt+↑↓ reason · tab: mode"),
159
+ modeTag + " " + C.cyan(repoLabel()) + " " + C.dim(modelBits.join(" ")),
158
160
  C.dim(usage),
161
+ C.dim("opt+left/right: model · opt+up/down: reasoning · shift+tab: mode"),
159
162
  ];
160
163
  }
161
164
 
162
- const FOOTER_LINES = 3; // divider + repo/model + usage
165
+ const FOOTER_LINES = 4; // divider + repo/model + usage + shortcuts
163
166
  const PROMPT_PREFIX = C.green("› "); // live input row (minimal)
164
167
  const HISTORY_PREFIX = C.green("you › "); // echoed into transcript/history
165
168
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -336,6 +339,11 @@ class Screen {
336
339
  this.drawPrompt();
337
340
  }
338
341
 
342
+ /** True while the agent is working (a turn is in progress). */
343
+ isBusy() {
344
+ return this._busy || this._busyResume;
345
+ }
346
+
339
347
  /**
340
348
  * Resolve with the next full line the user types. An optional prompt label
341
349
  * replaces the default "you › " (used by the approval gate so the question
@@ -486,8 +494,12 @@ class Screen {
486
494
  this.buf = this.buf.slice(0, -1);
487
495
  this.drawPrompt();
488
496
  } else if (ch === "\x03") { // Ctrl+C
497
+ // If a prompt is pending (idle), resolve it so the loop can react.
498
+ // Also always notify the interrupt hook so a *running* agent turn can
499
+ // be aborted even when no readLine is pending.
489
500
  const r = this.resolve; this.resolve = null;
490
501
  if (r) r("__SIGINT__");
502
+ if (this.onInterrupt) this.onInterrupt();
491
503
  } else if (ch === "\x04") { // Ctrl+D
492
504
  const r = this.resolve; this.resolve = null;
493
505
  if (r) r("__EOF__");
@@ -576,6 +588,31 @@ async function runStickyRepl(agent, model, yolo, intro = [], { runBench = null }
576
588
  // extra needed here, but expose a hook for symmetry / future use).
577
589
  screen.onClearInput = () => {};
578
590
 
591
+ // Ctrl+C handling. In raw mode Ctrl+C does NOT raise SIGINT — it arrives as
592
+ // a byte, so we handle it explicitly. The screen's key handler already
593
+ // resolves any pending readLine with "__SIGINT__" (idle case); here we only
594
+ // deal with the *busy* case where no readLine is pending:
595
+ // - first Ctrl+C while working -> request an abort after the current step
596
+ // - second Ctrl+C while working -> force an immediate quit
597
+ const interrupt = { aborted: false, quit: false };
598
+ screen.onInterrupt = () => {
599
+ if (!screen.isBusy()) {
600
+ // Idle: the readLine resolver was already fired with "__SIGINT__", which
601
+ // breaks the loop and quits gracefully. Nothing more to do here.
602
+ return;
603
+ }
604
+ if (interrupt.aborted) {
605
+ // Second Ctrl+C while working -> force quit: abort the in-flight model
606
+ // request so a long call unblocks immediately.
607
+ interrupt.quit = true;
608
+ log(C.red("[astra] force quitting…"));
609
+ if (model._abort) { try { model._abort.abort(); } catch {} }
610
+ return;
611
+ }
612
+ interrupt.aborted = true;
613
+ log(C.yellow("[astra] interrupting… (press Ctrl+C again to force quit)"));
614
+ };
615
+
579
616
  // Approval gate: show the command, then ask for y/n/a on the prompt row.
580
617
  agent.confirm = async (command) => {
581
618
  log(C.yellow("$ " + command));
@@ -621,6 +658,7 @@ async function runStickyRepl(agent, model, yolo, intro = [], { runBench = null }
621
658
  mode = "agent"; refresh(); continue;
622
659
  }
623
660
  screen.startBusy("benching");
661
+ interrupt.aborted = false;
624
662
  try {
625
663
  await runBench(line, log);
626
664
  } catch (err) {
@@ -628,18 +666,33 @@ async function runStickyRepl(agent, model, yolo, intro = [], { runBench = null }
628
666
  } finally {
629
667
  screen.stopBusy();
630
668
  }
669
+ if (interrupt.quit) break;
631
670
  mode = "agent";
632
671
  refresh();
633
672
  continue;
634
673
  }
635
674
 
636
675
  agent.addUserMessage(line);
676
+ interrupt.aborted = false; // reset per turn
677
+ // Fresh AbortController for this turn so a force-quit can cancel an
678
+ // in-flight model request. Cleared in `finally`.
679
+ model._abort = new AbortController();
680
+ model.signal = model._abort.signal;
637
681
  screen.startBusy("working");
638
682
  try {
639
- await driveUntilChat(agent, log);
683
+ await driveUntilChat(agent, log, interrupt);
684
+ } catch (err) {
685
+ if (err?.name === "AbortError" || interrupt.quit) {
686
+ // Force-quit path: swallow the abort and fall through to break.
687
+ } else {
688
+ throw err;
689
+ }
640
690
  } finally {
641
691
  screen.stopBusy();
692
+ model.signal = null;
693
+ model._abort = null;
642
694
  }
695
+ if (interrupt.quit) break; // force-quit requested via a second Ctrl+C
643
696
  const after = usageSnapshot(model);
644
697
  log(
645
698
  turnSummary({
@@ -733,10 +786,16 @@ function usageSnapshot(model) {
733
786
 
734
787
  /**
735
788
  * Take turns until the agent produces a chat reply (or terminates). Commands
736
- * run in between; their output goes back to the model automatically.
789
+ * run in between; their output goes back to the model automatically. If an
790
+ * `interrupt` signal is provided, the loop stops cleanly between turns when
791
+ * `interrupt.aborted` becomes true (Ctrl+C while working).
737
792
  */
738
- async function driveUntilChat(agent, log) {
793
+ async function driveUntilChat(agent, log, interrupt = null) {
739
794
  while (true) {
795
+ if (interrupt && interrupt.aborted) {
796
+ log(C.yellow("[astra] interrupted — returning to prompt."));
797
+ return;
798
+ }
740
799
  let turn;
741
800
  try {
742
801
  turn = await agent.runTurn();
@@ -748,6 +807,10 @@ async function driveUntilChat(agent, log) {
748
807
  throw err;
749
808
  }
750
809
 
810
+ if (interrupt && interrupt.aborted) {
811
+ log(C.yellow("[astra] interrupted — returning to prompt."));
812
+ return;
813
+ }
751
814
  if (turn.kind === "chat") {
752
815
  log("\n" + C.cyan("astra › ") + turn.content.trim());
753
816
  return;
@@ -0,0 +1,580 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>astra bench report</title>
7
+ <link rel="stylesheet" href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700&display=swap" />
8
+ <style>
9
+ :root { --ink:#11131a; --muted:#69707d; --wash:#f5f6f8; --line:#e4e7eb; --lime:#a7ff63; --blue:#5285f7; --purple:#9160d8; --green:#39ae76; --orange:#f3a021; --red:#d85668; font-family:Satoshi,"Avenir Next",Arial,sans-serif; }
10
+ * { box-sizing:border-box; } body { margin:0; background:#fff; color:var(--ink); font-size:14px; line-height:1.45; } .wrap { max-width:1240px; margin:auto; padding:28px 32px 68px; }
11
+ header.top { display:flex; justify-content:space-between; gap:20px; align-items:flex-start; padding-bottom:28px; border-bottom:1px solid var(--line); } header.top h1 { font-size:32px; letter-spacing:-.045em; line-height:1; margin:8px 0 0; font-weight:700; } .eyebrow { display:inline-block; padding:5px 14px; border-radius:99px; background:var(--lime); font-size:12px; font-weight:700; } header.top .meta { color:var(--muted); font-size:12px; text-align:right; }
12
+ .kpis { display:grid; grid-template-columns:repeat(4,1fr); margin:24px 0 52px; } .kpi { min-height:96px; padding:8px 18px; border-left:1px solid var(--line); } .kpi:first-child { border:0; padding-left:0; } .kpi .v { font-size:28px; letter-spacing:-.045em; font-weight:700; } .kpi .l { color:var(--muted); font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; margin-top:8px; }
13
+ section { margin:56px 0; } section > h2 { font-size:20px; letter-spacing:-.035em; margin:0 0 22px; } section > h2 .sub { color:var(--muted); font-size:13px; font-weight:400; letter-spacing:0; } .grid2 { display:grid; grid-template-columns:1.1fr 1fr; gap:44px; } .panel { padding:0; } .panel h3 { color:var(--muted); letter-spacing:.07em; text-transform:uppercase; font-size:12px; margin:0 0 16px; }
14
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
15
+ th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; }
16
+ th { color: var(--muted); font-weight: 600; font-size: 11.5px; text-transform: uppercase; letter-spacing: .03em; cursor: pointer; user-select: none; }
17
+ th:hover { color: var(--text); }
18
+ th .arrow { opacity: .5; font-size: 10px; margin-left: 3px; }
19
+ tbody tr:hover { background: rgba(255,255,255,0.02); }
20
+ tbody tr.run-row { cursor: pointer; }
21
+ .pill { display: inline-block; padding: 1px 8px; border-radius: 100px; font-size: 11px; font-weight: 600; }
22
+ .pill.ok { background: rgba(62,207,142,.15); color: var(--good); }
23
+ .pill.bad { background: rgba(239,90,111,.15); color: var(--bad); }
24
+ .pill.warn { background: rgba(232,179,57,.15); color: var(--warn); }
25
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
26
+ .muted { color: var(--muted); }
27
+ .bar-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
28
+ .bar-row .lbl { width: 150px; flex: 0 0 150px; font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
29
+ .bar-track { flex: 1; height: 9px; background: var(--wash); border-radius: 99px; overflow: hidden; display: flex; }
30
+ .bar-seg { height: 100%; }
31
+ .bar-val { width: 64px; flex: 0 0 64px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
32
+ .legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 10px; font-size: 11.5px; color: var(--muted); }
33
+ .legend .sw { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
34
+ .heat-table td { text-align: center; font-variant-numeric: tabular-nums; }
35
+ .heat-cell { display: inline-block; min-width: 44px; padding: 3px 6px; border-radius: 5px; font-size: 12px; }
36
+ details.run-detail { margin: 0; }
37
+ details.run-detail > summary { display: none; }
38
+ .timeline { margin: 0 0 18px; background:var(--wash); padding:0 14px; overflow: hidden; }
39
+ .timeline .step { padding: 10px 14px; border-bottom: 1px solid var(--border); }
40
+ .timeline .step:last-child { border-bottom: none; }
41
+ .timeline .step .head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; margin-bottom: 4px; }
42
+ .timeline .step .n { color: var(--accent); font-weight: 700; font-size: 12px; }
43
+ .timeline .step .stats { color: var(--muted); font-size: 11.5px; }
44
+ .timeline .step .thought { color: var(--muted); font-size: 12.5px; margin-bottom: 6px; }
45
+ .timeline .step pre { background: var(--panel-2); border-radius: 6px; padding: 8px 10px; margin: 4px 0; font-size: 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
46
+ .rc0 { color: var(--good); } .rcN { color: var(--bad); }
47
+ .toolbar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; align-items: center; }
48
+ .toolbar select, .toolbar input { background: var(--wash); border:0; color:var(--ink); border-radius:4px; padding:7px 9px; font-size:12px; } .model-filter { align-items:end; } .model-filter > span { color:var(--muted); font-size:11px; } .model-menu { position:relative; } .model-menu summary { cursor:pointer; list-style:none; min-width:220px; padding:9px 32px 9px 11px; background:var(--wash); border-radius:5px; font-size:12px; position:relative; } .model-menu summary::-webkit-details-marker { display:none; } .model-menu summary::after { content:"⌄"; position:absolute; right:11px; color:var(--muted); } .model-options { position:absolute; z-index:2; top:calc(100% + 6px); left:0; width:260px; max-height:240px; overflow:auto; padding:6px; background:#fff; border:1px solid var(--line); box-shadow:0 10px 24px rgba(17,19,26,.1); } .model-option { display:flex; gap:9px; align-items:center; padding:8px; font-size:12px; cursor:pointer; } .model-option:hover { background:var(--wash); } .model-option input { accent-color:var(--blue); }
49
+ .toolbar label { color: var(--muted); font-size: 12px; }
50
+ footer { color: var(--muted); font-size: 11.5px; margin-top: 40px; text-align: center; }
51
+ svg text { fill: var(--muted); font-size: 10.5px; }
52
+ .axis line, .axis path { stroke: var(--line); }
53
+ .n-a { color: var(--muted); }
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <div class="wrap">
58
+ <header class="top">
59
+ <div><h1>Benchmark report</h1><div class="meta" id="generated"></div></div>
60
+ </header>
61
+
62
+ <div class="kpis" id="kpis"></div>
63
+
64
+ <section>
65
+ <h2>Performance <span class="sub">run volume and reliability by model</span></h2>
66
+ <div class="toolbar model-filter">
67
+ <span>Models</span>
68
+ <details class="model-menu">
69
+ <summary id="model-filter-label">All models</summary>
70
+ <div id="leaderboard-model-options" class="model-options"></div>
71
+ </details>
72
+ </div>
73
+ <div class="panel" style="margin-top:16px;">
74
+ <table id="tbl-leaderboard"></table>
75
+ </div>
76
+ </section>
77
+
78
+ <section>
79
+ <h2>Efficiency <span class="sub">spend and token composition</span></h2>
80
+ <div class="grid2">
81
+ <div class="panel">
82
+ <h3>Cost vs. average duration</h3>
83
+ <div id="chart-scatter"></div>
84
+ </div>
85
+ <div class="panel">
86
+ <h3>Cost vs. average steps</h3>
87
+ <div id="chart-scatter-steps"></div>
88
+ </div>
89
+ </div>
90
+ </section>
91
+
92
+ <section>
93
+ <h2>Token mix <span class="sub">prompt, completion, reasoning, and cached tokens</span></h2>
94
+ <div class="panel">
95
+ <div id="chart-tokens"></div>
96
+ <div class="legend" id="legend-tokens"></div>
97
+ </div>
98
+ </section>
99
+
100
+ <section id="section-series">
101
+ <h2>Trajectory <span class="sub">where context and cost accumulate</span></h2>
102
+ <div class="grid2">
103
+ <div class="panel">
104
+ <h3>Prompt tokens (context) vs. step</h3>
105
+ <div id="chart-line-tokens"></div>
106
+ </div>
107
+ <div class="panel">
108
+ <h3>Cumulative cost vs. step</h3>
109
+ <div id="chart-line-cost"></div>
110
+ </div>
111
+ </div>
112
+ <div class="legend" id="legend-series"></div>
113
+ </section>
114
+
115
+ <footer>generated by <span class="mono">astra bench --report</span> · schema <span id="schema"></span></footer>
116
+ </div>
117
+
118
+ <script>
119
+ /*__ASTRA_DATA__*/
120
+ </script>
121
+ <script>
122
+ (function () {
123
+ function loadReportData() {
124
+ try {
125
+ var request = new XMLHttpRequest();
126
+ request.open("GET", "summary.json", false);
127
+ request.send(null);
128
+ if ((request.status >= 200 && request.status < 300) || request.status === 0) return JSON.parse(request.responseText);
129
+ } catch (_) {
130
+ // Direct file access can block JSON reads; use the embedded snapshot then.
131
+ }
132
+ return window.__ASTRA_EMBEDDED_DATA__ || { kpis: {}, leaderboard: [], matrix: [], runs: [], step_series: [] };
133
+ }
134
+ var DATA = loadReportData();
135
+ var PALETTE = ["#5285f7", "#9160d8", "#39ae76", "#f3a021", "#d85668", "#37a8ad"];
136
+ var colorCache = {};
137
+ function colorFor(key) {
138
+ if (!colorCache[key]) {
139
+ var n = Object.keys(colorCache).length;
140
+ colorCache[key] = PALETTE[n % PALETTE.length];
141
+ }
142
+ return colorCache[key];
143
+ }
144
+
145
+ function fmt(n) { return Number(n || 0).toLocaleString("en-US"); }
146
+ function fmt1(n) { return (Number(n) || 0).toFixed(1); }
147
+ function pct(n) { return Math.round((Number(n) || 0) * 100) + "%"; }
148
+ function fmtUsd(n) {
149
+ if (n == null) return '<span class="n-a">n/a</span>';
150
+ var v = Number(n) || 0;
151
+ if (v === 0) return "$0";
152
+ if (v < 0.01) return "$" + v.toFixed(5);
153
+ if (v < 1) return "$" + v.toFixed(4);
154
+ return "$" + v.toFixed(2);
155
+ }
156
+ function esc(s) {
157
+ return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
158
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
159
+ });
160
+ }
161
+ function el(tag, attrs, html) {
162
+ var e = document.createElement(tag);
163
+ for (var k in attrs || {}) e.setAttribute(k, attrs[k]);
164
+ if (html != null) e.innerHTML = html;
165
+ return e;
166
+ }
167
+ function svgEl(tag, attrs) {
168
+ var e = document.createElementNS("http://www.w3.org/2000/svg", tag);
169
+ for (var k in attrs || {}) e.setAttribute(k, attrs[k]);
170
+ return e;
171
+ }
172
+
173
+ // ---------------------------------------------------------------- header
174
+ document.getElementById("generated").textContent = DATA.generated_at
175
+ ? "Generated " + new Date(DATA.generated_at).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })
176
+ : "";
177
+ document.getElementById("schema").textContent = DATA.schema || "";
178
+
179
+ // ---------------------------------------------------------------- KPIs
180
+ (function renderKpis() {
181
+ var k = DATA.kpis || {};
182
+ var cards = [
183
+ ["Models Benchmarked", fmt(k.models)],
184
+ ["Total duration", fmt(k.elapsed_seconds) + "s"],
185
+ ["Total cost", fmtUsd(k.cost_usd) + (k.cost_source === "estimated" ? "~" : "")],
186
+ ["Tokens used", fmt(k.tokens)],
187
+ ];
188
+ var host = document.getElementById("kpis");
189
+ cards.forEach(function (c) {
190
+ host.appendChild(el("div", { class: "kpi" }, '<div class="v">' + c[1] + '</div><div class="l">' + esc(c[0]) + "</div>"));
191
+ });
192
+ })();
193
+
194
+ // ---------------------------------------------------------------- sortable table helper
195
+ function sortableTable(container, columns, rows, opts) {
196
+ opts = opts || {};
197
+ var state = { key: opts.defaultKey || columns[0].key, dir: opts.defaultDir || -1 };
198
+ function draw() {
199
+ var sorted = rows.slice().sort(function (a, b) {
200
+ var va = a[state.key], vb = b[state.key];
201
+ if (typeof va === "string" || typeof vb === "string") {
202
+ return state.dir * String(va).localeCompare(String(vb));
203
+ }
204
+ return state.dir * ((Number(va) || 0) - (Number(vb) || 0));
205
+ });
206
+ var thead = "<thead><tr>" + columns.map(function (c) {
207
+ var arrow = c.key === state.key ? (state.dir === 1 ? "\u2191" : "\u2193") : "";
208
+ return '<th data-key="' + c.key + '">' + esc(c.label) + (arrow ? ' <span class="arrow">' + arrow + "</span>" : "") + "</th>";
209
+ }).join("") + "</tr></thead>";
210
+ var tbody = "<tbody>" + sorted.map(function (row, i) {
211
+ var cells = columns.map(function (c) { return "<td>" + c.render(row) + "</td>"; }).join("");
212
+ var extra = opts.rowAttrs ? opts.rowAttrs(row, i) : "";
213
+ return "<tr " + extra + ">" + cells + "</tr>";
214
+ }).join("") + "</tbody>";
215
+ container.innerHTML = thead + tbody;
216
+ container.querySelectorAll("th").forEach(function (th) {
217
+ th.addEventListener("click", function () {
218
+ var key = th.getAttribute("data-key");
219
+ if (state.key === key) state.dir *= -1;
220
+ else { state.key = key; state.dir = -1; }
221
+ draw();
222
+ });
223
+ });
224
+ if (opts.afterDraw) opts.afterDraw(container, sorted);
225
+ }
226
+ draw();
227
+ }
228
+
229
+ // ---------------------------------------------------------------- leaderboard chart: run volume bars
230
+ (function () {
231
+ var host = document.getElementById("chart-solve");
232
+ if (!host) return;
233
+ var maxRuns = Math.max(1, Math.max.apply(null, (DATA.leaderboard || []).map(function (g) { return g.runs || 0; })));
234
+ (DATA.leaderboard || []).forEach(function (g) {
235
+ var row = el("div", { class: "bar-row" });
236
+ row.appendChild(el("div", { class: "lbl mono" }, esc(g.slug)));
237
+ var track = el("div", { class: "bar-track" });
238
+ var seg = el("div", {
239
+ class: "bar-seg",
240
+ style: "width:" + Math.round((g.runs || 0) / maxRuns * 100) + "%;background:" + colorFor(g.slug),
241
+ });
242
+ track.appendChild(seg);
243
+ row.appendChild(track);
244
+ row.appendChild(el("div", { class: "bar-val" }, fmt(g.runs) + " runs"));
245
+ host.appendChild(row);
246
+ });
247
+ })();
248
+
249
+ // ---------------------------------------------------------------- outcomes stacked bar
250
+ (function () {
251
+ var host = document.getElementById("chart-outcomes");
252
+ var legend = document.getElementById("legend-outcomes");
253
+ if (!host || !legend) return;
254
+ var outcomeColors = { Submitted: "#3ecf8e", LimitsExceeded: "#e8b339", TimeExceeded: "#e8b339", RepeatedFormatError: "#ef5a6f", ContextWindowExceeded: "#ef5a6f", Error: "#ef5a6f", Unknown: "#8b93a7" };
255
+ function colorForOutcome(k) { return outcomeColors[k] || "#8b93a7"; }
256
+ var allOutcomes = {};
257
+ (DATA.leaderboard || []).forEach(function (g) {
258
+ var row = el("div", { class: "bar-row" });
259
+ row.appendChild(el("div", { class: "lbl mono" }, esc(g.slug)));
260
+ var track = el("div", { class: "bar-track" });
261
+ var total = g.runs || 1;
262
+ Object.keys(g.outcomes || {}).forEach(function (k) {
263
+ allOutcomes[k] = true;
264
+ var w = (g.outcomes[k] / total) * 100;
265
+ track.appendChild(el("div", { class: "bar-seg", style: "width:" + w + "%;background:" + colorForOutcome(k) }));
266
+ });
267
+ row.appendChild(track);
268
+ row.appendChild(el("div", { class: "bar-val" }, g.runs + " runs"));
269
+ host.appendChild(row);
270
+ });
271
+ Object.keys(allOutcomes).forEach(function (k) {
272
+ legend.appendChild(el("span", {}, '<span class="sw" style="background:' + colorForOutcome(k) + '"></span>' + esc(k)));
273
+ });
274
+ })();
275
+
276
+ // ---------------------------------------------------------------- leaderboard table
277
+ (function () {
278
+ var container = document.getElementById("tbl-leaderboard");
279
+ var options = document.getElementById("leaderboard-model-options");
280
+ var filterLabel = document.getElementById("model-filter-label");
281
+ var columns = [
282
+ { key: "model", label: "Model", render: function (r) { return esc(r.model); } },
283
+ { key: "reasoning", label: "Reasoning", render: function (r) { return esc(r.reasoning); } },
284
+ { key: "runs", label: "Runs", render: function (r) { return fmt(r.runs); } },
285
+ { key: "sum_steps", label: "Total steps", render: function (r) { return fmt(r.sum_steps); } },
286
+ { key: "sum_elapsed_seconds", label: "Time taken", render: function (r) { return fmt(r.sum_elapsed_seconds) + "s"; } },
287
+ { key: "sum_tokens", label: "Tokens", render: function (r) { return fmt(r.sum_tokens); } },
288
+ { key: "cost_usd", label: "Cost", render: function (r) { return fmtUsd(r.cost_usd) + (r.cost_source === "estimated" ? "~" : ""); } },
289
+ ];
290
+ (DATA.leaderboard || []).forEach(function (r) {
291
+ var option = el("label", { class: "model-option" });
292
+ option.appendChild(el("input", { type: "checkbox", value: r.slug }));
293
+ option.appendChild(document.createTextNode(r.model + " · " + r.reasoning));
294
+ options.appendChild(option);
295
+ });
296
+ function draw() {
297
+ var selected = Array.prototype.slice.call(options.querySelectorAll("input:checked")).map(function (o) { return o.value; });
298
+ var rows = selected.length ? (DATA.leaderboard || []).filter(function (r) { return selected.indexOf(r.slug) !== -1; }) : DATA.leaderboard || [];
299
+ filterLabel.textContent = selected.length ? selected.length + " model" + (selected.length === 1 ? "" : "s") + " selected" : "All models";
300
+ sortableTable(container, columns, rows, { defaultKey: "sum_tokens" });
301
+ }
302
+ options.addEventListener("change", draw);
303
+ draw();
304
+ })();
305
+
306
+ // ---------------------------------------------------------------- token mix stacked bar
307
+ (function () {
308
+ var host = document.getElementById("chart-tokens");
309
+ var legend = document.getElementById("legend-tokens");
310
+ var keys = ["prompt", "completion", "reasoning", "cached"];
311
+ var colors = { prompt: "#5b9dff", completion: "#3ecf8e", reasoning: "#b57bff", cached: "#8b93a7" };
312
+ var maxTotal = Math.max(1, Math.max.apply(null, (DATA.leaderboard || []).map(function (g) {
313
+ return keys.reduce(function (s, k) { return s + (g.tokens[k] || 0); }, 0);
314
+ })));
315
+ (DATA.leaderboard || []).forEach(function (g) {
316
+ var row = el("div", { class: "bar-row" });
317
+ row.appendChild(el("div", { class: "lbl mono" }, esc(g.slug)));
318
+ var track = el("div", { class: "bar-track" });
319
+ var total = keys.reduce(function (s, k) { return s + (g.tokens[k] || 0); }, 0);
320
+ keys.forEach(function (k) {
321
+ var w = (g.tokens[k] || 0) / maxTotal * 100;
322
+ if (w > 0) track.appendChild(el("div", { class: "bar-seg", style: "width:" + w + "%;background:" + colors[k] }));
323
+ });
324
+ row.appendChild(track);
325
+ row.appendChild(el("div", { class: "bar-val" }, fmt(total)));
326
+ host.appendChild(row);
327
+ });
328
+ keys.forEach(function (k) {
329
+ legend.appendChild(el("span", {}, '<span class="sw" style="background:' + colors[k] + '"></span>' + k));
330
+ });
331
+ })();
332
+
333
+ // ---------------------------------------------------------------- scatter: cost vs duration
334
+ (function () {
335
+ var host = document.getElementById("chart-scatter");
336
+ var W = host.clientWidth || 460, H = 260, pad = { l: 42, r: 16, t: 14, b: 30 };
337
+ var svg = svgEl("svg", { width: "100%", height: H, viewBox: "0 0 " + W + " " + H });
338
+ var data = (DATA.leaderboard || []).filter(function (g) { return g.cost_usd != null; });
339
+ var maxCost = Math.max(0.001, Math.max.apply(null, data.map(function (g) { return g.cost_usd; }).concat([0.001])));
340
+ var maxDuration = Math.max(1, Math.max.apply(null, data.map(function (g) { return g.avg_elapsed_seconds || 0; }).concat([1])));
341
+ function x(v) { return pad.l + (v / maxCost) * (W - pad.l - pad.r); }
342
+ function y(v) { return H - pad.b - (v / maxDuration) * (H - pad.t - pad.b); }
343
+ // axes
344
+ svg.appendChild(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: H - pad.b, y2: H - pad.b, stroke: "#262b36" }));
345
+ svg.appendChild(svgEl("line", { x1: pad.l, x2: pad.l, y1: pad.t, y2: H - pad.b, stroke: "#262b36" }));
346
+ [0, 0.25, 0.5, 0.75, 1].forEach(function (t) {
347
+ var ty = y(maxDuration * t);
348
+ var lab = svgEl("text", { x: pad.l - 8, y: ty + 3, "text-anchor": "end" });
349
+ lab.textContent = Math.round(maxDuration * t) + "s";
350
+ svg.appendChild(lab);
351
+ });
352
+ var lab2 = svgEl("text", { x: W - pad.r, y: H - pad.b + 20, "text-anchor": "end" });
353
+ lab2.textContent = "cost \u2192 (max " + fmtUsd(maxCost) + ")";
354
+ svg.appendChild(lab2);
355
+ data.forEach(function (g) {
356
+ var cx = x(g.cost_usd), cy = y(g.avg_elapsed_seconds || 0);
357
+ var r = 6 + Math.min(10, (g.avg_tokens || 0) / 4000);
358
+ var c = svgEl("circle", { cx: cx, cy: cy, r: r, fill: colorFor(g.slug), "fill-opacity": 0.85 });
359
+ svg.appendChild(c);
360
+ var t = svgEl("text", { x: cx + r + 4, y: cy + 3 });
361
+ t.textContent = g.slug;
362
+ svg.appendChild(t);
363
+ });
364
+ host.appendChild(svg);
365
+ })();
366
+
367
+ // ---------------------------------------------------------------- scatter: cost vs average steps
368
+ (function () {
369
+ var host = document.getElementById("chart-scatter-steps");
370
+ var W = host.clientWidth || 460, H = 260, pad = { l: 42, r: 16, t: 14, b: 30 };
371
+ var svg = svgEl("svg", { width: "100%", height: H, viewBox: "0 0 " + W + " " + H });
372
+ var data = (DATA.leaderboard || []).filter(function (g) { return g.cost_usd != null; });
373
+ var maxCost = Math.max(0.001, Math.max.apply(null, data.map(function (g) { return g.cost_usd; }).concat([0.001])));
374
+ var maxSteps = Math.max(1, Math.max.apply(null, data.map(function (g) { return g.avg_steps || 0; }).concat([1])));
375
+ function x(v) { return pad.l + (v / maxCost) * (W - pad.l - pad.r); }
376
+ function y(v) { return H - pad.b - (v / maxSteps) * (H - pad.t - pad.b); }
377
+ svg.appendChild(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: H - pad.b, y2: H - pad.b, stroke: "#e4e7eb" }));
378
+ svg.appendChild(svgEl("line", { x1: pad.l, x2: pad.l, y1: pad.t, y2: H - pad.b, stroke: "#e4e7eb" }));
379
+ [0, 0.25, 0.5, 0.75, 1].forEach(function (t) {
380
+ var ty = y(maxSteps * t);
381
+ var lab = svgEl("text", { x: pad.l - 8, y: ty + 3, "text-anchor": "end" });
382
+ lab.textContent = fmt1(maxSteps * t);
383
+ svg.appendChild(lab);
384
+ });
385
+ var lab2 = svgEl("text", { x: W - pad.r, y: H - pad.b + 20, "text-anchor": "end" });
386
+ lab2.textContent = "cost \u2192 (max " + fmtUsd(maxCost) + ")";
387
+ svg.appendChild(lab2);
388
+ data.forEach(function (g) {
389
+ var cx = x(g.cost_usd), cy = y(g.avg_steps || 0);
390
+ var r = 6 + Math.min(10, (g.avg_tokens || 0) / 4000);
391
+ svg.appendChild(svgEl("circle", { cx: cx, cy: cy, r: r, fill: colorFor(g.slug), "fill-opacity": 0.85 }));
392
+ var label = svgEl("text", { x: cx + r + 4, y: cy + 3 });
393
+ label.textContent = g.slug;
394
+ svg.appendChild(label);
395
+ });
396
+ host.appendChild(svg);
397
+ })();
398
+
399
+ // ---------------------------------------------------------------- per-step line charts
400
+ function lineChart(host, series, valueFn, opts) {
401
+ opts = opts || {};
402
+ var W = host.clientWidth || 460, H = 260, pad = { l: 46, r: 16, t: 14, b: 26 };
403
+ var svg = svgEl("svg", { width: "100%", height: H, viewBox: "0 0 " + W + " " + H });
404
+ var maxStep = 1, maxVal = 0.0001;
405
+ series.forEach(function (s) {
406
+ s.points.forEach(function (p) {
407
+ if (p.step > maxStep) maxStep = p.step;
408
+ var v = valueFn(p);
409
+ if (v > maxVal) maxVal = v;
410
+ });
411
+ });
412
+ function x(v) { return pad.l + (v - 1) / Math.max(1, maxStep - 1) * (W - pad.l - pad.r); }
413
+ function y(v) { return H - pad.b - (v / maxVal) * (H - pad.t - pad.b); }
414
+ svg.appendChild(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: H - pad.b, y2: H - pad.b, stroke: "#262b36" }));
415
+ svg.appendChild(svgEl("line", { x1: pad.l, x2: pad.l, y1: pad.t, y2: H - pad.b, stroke: "#262b36" }));
416
+ [0, 0.5, 1].forEach(function (t) {
417
+ var ty = y(maxVal * t);
418
+ var lab = svgEl("text", { x: pad.l - 6, y: ty + 3, "text-anchor": "end" });
419
+ lab.textContent = opts.fmt ? opts.fmt(maxVal * t) : Math.round(maxVal * t);
420
+ svg.appendChild(lab);
421
+ });
422
+ series.forEach(function (s) {
423
+ var pts = s.points.slice().sort(function (a, b) { return a.step - b.step; });
424
+ var d = pts.map(function (p, i) { return (i === 0 ? "M" : "L") + x(p.step) + "," + y(valueFn(p)); }).join(" ");
425
+ svg.appendChild(svgEl("path", { d: d, fill: "none", stroke: colorFor(s.slug), "stroke-width": 2 }));
426
+ pts.forEach(function (p) {
427
+ svg.appendChild(svgEl("circle", { cx: x(p.step), cy: y(valueFn(p)), r: 2.5, fill: colorFor(s.slug) }));
428
+ });
429
+ });
430
+ host.appendChild(svg);
431
+ }
432
+
433
+ (function () {
434
+ var bySlug = {};
435
+ (DATA.step_series || []).forEach(function (r) {
436
+ if (!bySlug[r.slug]) bySlug[r.slug] = [];
437
+ bySlug[r.slug].push(r);
438
+ });
439
+ var series = Object.keys(bySlug).map(function (slug) { return { slug: slug, points: bySlug[slug] }; });
440
+ if (series.length === 0) {
441
+ document.getElementById("section-series").style.display = "none";
442
+ return;
443
+ }
444
+ lineChart(document.getElementById("chart-line-tokens"), series, function (p) { return p.prompt_tokens.p50; }, { fmt: fmt });
445
+ lineChart(document.getElementById("chart-line-cost"), series, function (p) { return p.cum_cost_usd.mean; }, { fmt: fmtUsd });
446
+ var legend = document.getElementById("legend-series");
447
+ series.forEach(function (s) {
448
+ legend.appendChild(el("span", {}, '<span class="sw" style="background:' + colorFor(s.slug) + '"></span>' + esc(s.slug)));
449
+ });
450
+ })();
451
+
452
+ // ---------------------------------------------------------------- model x task matrix (heatmap table)
453
+ (function () {
454
+ var matrix = DATA.matrix || [];
455
+ var container = document.getElementById("tbl-matrix");
456
+ if (!container) return;
457
+ if (matrix.length === 0) {
458
+ document.getElementById("section-matrix").style.display = "none";
459
+ return;
460
+ }
461
+ var tasks = [];
462
+ var slugs = [];
463
+ var cell = {};
464
+ matrix.forEach(function (m) {
465
+ if (tasks.indexOf(m.task_id) === -1) tasks.push(m.task_id);
466
+ if (slugs.indexOf(m.slug) === -1) slugs.push(m.slug);
467
+ cell[m.slug + "\u0000" + m.task_id] = m;
468
+ });
469
+ function heatColor(rate) {
470
+ if (rate == null) return "transparent";
471
+ var g = Math.round(60 + rate * 140);
472
+ var r = Math.round(220 - rate * 160);
473
+ return "rgba(" + r + "," + g + ",120,0.35)";
474
+ }
475
+ var thead = "<thead><tr><th>Model</th>" + tasks.map(function (t) { return "<th>" + esc(t) + "</th>"; }).join("") + "</tr></thead>";
476
+ var tbody = "<tbody>" + slugs.map(function (slug) {
477
+ var cells = tasks.map(function (t) {
478
+ var m = cell[slug + "\u0000" + t];
479
+ if (!m) return "<td>&mdash;</td>";
480
+ return '<td><span class="heat-cell" style="background:' + heatColor(m.pass_at_k) + '">' + m.passed + "/" + m.k + "<br>" + pct(m.pass_at_k) + "</span></td>";
481
+ }).join("");
482
+ return "<tr><td class=\"mono\">" + esc(slug) + "</td>" + cells + "</tr>";
483
+ }).join("") + "</tbody>";
484
+ container.innerHTML = thead + tbody;
485
+ })();
486
+
487
+ // ---------------------------------------------------------------- runs table + filters + expandable timeline
488
+ (function () {
489
+ var runs = DATA.runs || [];
490
+ var container = document.getElementById("tbl-runs");
491
+ var fModel = document.getElementById("filter-model");
492
+ var fTask = document.getElementById("filter-task");
493
+ var fOutcome = document.getElementById("filter-outcome");
494
+ if (!container || !fModel || !fTask || !fOutcome) return;
495
+
496
+ function uniq(fn) {
497
+ var seen = {}, out = [];
498
+ runs.forEach(function (r) { var v = fn(r); if (v && !seen[v]) { seen[v] = 1; out.push(v); } });
499
+ return out.sort();
500
+ }
501
+ uniq(function (r) { return r.slug; }).forEach(function (v) { fModel.appendChild(el("option", { value: v }, esc(v))); });
502
+ uniq(function (r) { return r.task_id; }).forEach(function (v) { fTask.appendChild(el("option", { value: v }, esc(v))); });
503
+ uniq(function (r) { return r.exit_status; }).forEach(function (v) { fOutcome.appendChild(el("option", { value: v }, esc(v))); });
504
+
505
+ var columns = [
506
+ { key: "slug", label: "Model", render: function (r) { return '<span class="mono">' + esc(r.slug) + "</span>"; } },
507
+ { key: "task_id", label: "Task", render: function (r) { return esc(r.task_id); } },
508
+ { key: "run_id", label: "Run", render: function (r) { return esc(r.run_id); } },
509
+ { key: "exit_status", label: "Outcome", render: function (r) {
510
+ var cls = r.resolved ? "ok" : (r.exit_status === "Error" ? "bad" : "warn");
511
+ return '<span class="pill ' + cls + '">' + esc(r.exit_status || "Unknown") + "</span>";
512
+ } },
513
+ { key: "steps", label: "Steps", render: function (r) { return fmt(r.steps); } },
514
+ { key: "elapsed_seconds", label: "Time", render: function (r) { return fmt(r.elapsed_seconds) + "s"; } },
515
+ { key: "tokens", label: "Tokens", render: function (r) { return fmt(r.tokens.total); } },
516
+ { key: "cost_usd", label: "Cost", render: function (r) { return fmtUsd(r.cost_usd) + (r.cost_source === "estimated" ? "~" : ""); } },
517
+ { key: "n_failed_commands", label: "Fails", render: function (r) { return fmt(r.n_failed_commands); } },
518
+ ];
519
+
520
+ function renderTimeline(run) {
521
+ var box = el("div", { class: "timeline" });
522
+ (run.timeline || []).forEach(function (s) {
523
+ var rcClass = s.returncode === 0 ? "rc0" : (s.returncode == null ? "muted" : "rcN");
524
+ var step = el("div", { class: "step" });
525
+ step.innerHTML =
526
+ '<div class="head"><span class="n">step ' + s.step + '</span>' +
527
+ '<span class="stats">' +
528
+ fmt(s.tokens.total) + " tok" +
529
+ (s.cost_usd != null ? " \u00b7 " + fmtUsd(s.cost_usd) : "") +
530
+ (s.returncode != null ? ' \u00b7 rc=<span class="' + rcClass + '">' + s.returncode + "</span>" : "") +
531
+ "</span></div>" +
532
+ (s.thought ? '<div class="thought">' + esc(s.thought) + "</div>" : "") +
533
+ (s.command ? "<pre>$ " + esc(s.command) + "</pre>" : "") +
534
+ (s.output_preview ? "<pre>" + esc(s.output_preview) + "</pre>" : "");
535
+ box.appendChild(step);
536
+ });
537
+ return box;
538
+ }
539
+
540
+ function draw() {
541
+ var filtered = runs.filter(function (r) {
542
+ if (fModel.value && r.slug !== fModel.value) return false;
543
+ if (fTask.value && r.task_id !== fTask.value) return false;
544
+ if (fOutcome.value && r.exit_status !== fOutcome.value) return false;
545
+ return true;
546
+ });
547
+ sortableTable(container, columns, filtered, {
548
+ defaultKey: "slug",
549
+ defaultDir: 1,
550
+ rowAttrs: function (r, i) { return 'class="run-row" data-idx="' + i + '"'; },
551
+ afterDraw: function (containerEl, sorted) {
552
+ containerEl.querySelectorAll("tbody tr").forEach(function (tr) {
553
+ var idx = Number(tr.getAttribute("data-idx"));
554
+ var run = sorted[idx];
555
+ tr.addEventListener("click", function () {
556
+ var next = tr.nextElementSibling;
557
+ if (next && next.classList.contains("detail-row")) {
558
+ next.remove();
559
+ return;
560
+ }
561
+ containerEl.querySelectorAll(".detail-row").forEach(function (d) { d.remove(); });
562
+ var detailRow = document.createElement("tr");
563
+ detailRow.className = "detail-row";
564
+ var td = document.createElement("td");
565
+ td.colSpan = columns.length;
566
+ td.appendChild(renderTimeline(run));
567
+ detailRow.appendChild(td);
568
+ tr.parentNode.insertBefore(detailRow, tr.nextSibling);
569
+ });
570
+ });
571
+ },
572
+ });
573
+ }
574
+ [fModel, fTask, fOutcome].forEach(function (elm) { elm.addEventListener("change", draw); });
575
+ draw();
576
+ })();
577
+ })();
578
+ </script>
579
+ </body>
580
+ </html>
package/src/report.js CHANGED
@@ -101,7 +101,9 @@ export function buildSummary(runs) {
101
101
  solved_rate: rs.length ? solved / rs.length : 0,
102
102
  outcomes,
103
103
  avg_steps: avg(rs, (r) => r.steps),
104
+ sum_steps: sum(rs, (r) => r.steps),
104
105
  avg_elapsed_seconds: avg(rs, (r) => r.elapsed_seconds),
106
+ sum_elapsed_seconds: sum(rs, (r) => r.elapsed_seconds),
105
107
  avg_tokens: avg(rs, (r) => r.tokens.total),
106
108
  sum_tokens: sum(rs, (r) => r.tokens.total),
107
109
  tokens: {
@@ -221,7 +223,7 @@ export function renderReportHtml(summary) {
221
223
  if (!tpl.includes("/*__ASTRA_DATA__*/")) {
222
224
  throw new Error("src/report.html is missing the /*__ASTRA_DATA__*/ injection marker");
223
225
  }
224
- return tpl.replace("/*__ASTRA_DATA__*/", `window.DATA = ${data};`);
226
+ return tpl.replace("/*__ASTRA_DATA__*/", `window.__ASTRA_EMBEDDED_DATA__ = ${data};`);
225
227
  }
226
228
 
227
229
  function toRunDoc(run) {