@hackerrank/astra-cli 0.1.0 → 0.1.2

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/src/cli.js CHANGED
@@ -3,19 +3,45 @@
3
3
  * astra CLI — a minimal, zero-dependency AI coding agent for the
4
4
  * HackerRank AI Gateway.
5
5
  *
6
+ * Two modes:
7
+ * agent (default) interactive SWE assistant — a chat REPL. Toggle to bench
8
+ * mode at any time with Alt+Tab.
9
+ * bench benchmark runner — runs a task autonomously in an isolated
10
+ * workspace and records metrics. Entered automatically when
11
+ * a task (-t/-f) or task path (-p) is provided.
12
+ *
6
13
  * Usage:
7
- * astra -m <model> interactive assistant (chat REPL)
8
- * astra -m <model> -t "<task text>" autonomous task run
14
+ * astra agent mode (uses cached / prompted model)
15
+ * astra -m <model> agent mode (interactive assistant)
16
+ * astra -m <model> -t "<task text>" bench run from task text
9
17
  * astra -m <model> -f path/to/instruction.md
18
+ * astra -m <model> -p tasks/dummy-slugify bench run from a task directory
10
19
  * astra --resume <session-id> resume a saved session
11
20
  * astra --sessions list saved sessions
12
21
  *
13
22
  * Options:
14
- * -m, --model <id> Model id on the gateway (required unless --sessions)
15
- * -t, --task <text> Task text -> autonomous mode (run to completion)
16
- * -f, --task-file <path> Read task text from a file -> autonomous mode
23
+ * -m, --model <id> Model id on the gateway (bench mode: required;
24
+ * comma-separated for a multi-model matrix, e.g.
25
+ * m1,m2,m3; agent mode: falls back to cached prefs,
26
+ * then a first-run picker, then glm-5.2)
27
+ * -r, --reasoning <lvl> Reasoning effort (e.g. off|low|medium|high;
28
+ * comma-separated to sweep levels in bench mode).
29
+ * Sent to the model and recorded in the bench name.
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>/
33
+ * --push-uri <s3-uri> Override the S3 push destination
34
+ * --tar After a matrix, write a bench/ tarball locally
35
+ * --report Rebuild bench/summary.json + bench/report.html
36
+ * from whatever runs already exist on disk, then
37
+ * exit (no model/API key needed).
38
+ * -t, --task <text> Task text -> bench mode (run to completion)
39
+ * -f, --task-file <path> Read task text from a file -> bench mode
40
+ * -p, --path <dir> Task directory (copied into an isolated bench
41
+ * workspace) -> bench mode
42
+ * --bench-root <dir> Root folder for bench runs (default: ./bench)
17
43
  * -C, --cwd <path> Working directory for commands (default: cwd)
18
- * -o, --output <path> Also write trajectory JSON here (autonomous mode)
44
+ * -o, --output <path> Also write trajectory JSON here (bench mode)
19
45
  * -s, --steps <n> Step limit (default: 40)
20
46
  * -w, --wall <seconds> Wall-clock limit (default: 0 = none)
21
47
  * --timeout <seconds> Per-command timeout (default: 60)
@@ -25,8 +51,8 @@
25
51
  * --resume <id> Resume a saved session by id
26
52
  * --sessions List saved sessions and exit
27
53
  * -y, --yolo Auto-run commands without confirmation
28
- * (always on in autonomous mode)
29
- * -q, --quiet Do not stream steps (autonomous mode)
54
+ * (always on in bench mode)
55
+ * -q, --quiet Do not stream steps (bench mode)
30
56
  * -h, --help Show this help
31
57
  *
32
58
  * API key resolution (first hit wins):
@@ -34,6 +60,13 @@
34
60
  * 2. ASTRA_GATEWAY_API_KEY env var
35
61
  * 3. ~/.astra/config.json (dedicated astra config)
36
62
  * 4. interactive prompt (when run in a terminal); offers to save to (3)
63
+ *
64
+ * Agent model resolution (agent mode, first hit wins):
65
+ * 1. --model / --reasoning flags
66
+ * 2. resumed session
67
+ * 3. cached agent prefs in ~/.astra/config.json (last model used)
68
+ * 4. first-run interactive setup (list pickers for model + reasoning)
69
+ * 5. fallback: glm-5.2 with medium reasoning
37
70
  */
38
71
 
39
72
  import fs from "node:fs";
@@ -41,7 +74,8 @@ import path from "node:path";
41
74
  import { GatewayModel } from "./model.js";
42
75
  import { LocalEnvironment } from "./environment.js";
43
76
  import { Agent } from "./agent.js";
44
- import { resolveCredentials, promptForCredentials, canPrompt } from "./config.js";
77
+ import { resolveCredentials, promptForCredentials, canPrompt, readAgentPrefs, saveAgentPrefs, promptForAgentPrefs } from "./config.js";
78
+ import { AVAILABLE_MODELS, REASONING_LEVELS, DEFAULT_MODEL, DEFAULT_REASONING } from "./models.js";
45
79
  import {
46
80
  newSessionId,
47
81
  saveSession,
@@ -51,6 +85,27 @@ import {
51
85
  deriveTitle,
52
86
  } from "./session.js";
53
87
  import { runRepl } from "./repl.js";
88
+ import {
89
+ allocateRun,
90
+ seedWorkspace,
91
+ collectMetrics,
92
+ writeMetrics,
93
+ runMatrix,
94
+ aggregate,
95
+ packBench,
96
+ pushToS3,
97
+ defaultPushUri,
98
+ benchRoot,
99
+ } from "./bench.js";
100
+ import { refreshReport } from "./report.js";
101
+
102
+ /** Map a reasoning level to gateway modelKwargs. Empty for off/none/unset. */
103
+ function reasoningKwargs(level) {
104
+ if (!level) return {};
105
+ const l = String(level).toLowerCase();
106
+ if (l === "off" || l === "none" || l === "disabled") return {};
107
+ return { reasoning_effort: l };
108
+ }
54
109
 
55
110
  function parseArgs(argv) {
56
111
  const args = { steps: 40, wall: 0, timeout: 60, quiet: false, "max-output": 16000 };
@@ -58,6 +113,14 @@ function parseArgs(argv) {
58
113
  "-m": "model", "--model": "model",
59
114
  "-t": "task", "--task": "task",
60
115
  "-f": "task-file", "--task-file": "task-file",
116
+ "-p": "path", "--path": "path",
117
+ "-r": "reasoning", "--reasoning": "reasoning",
118
+ "--repeat": "repeat",
119
+ "--push": "push",
120
+ "--push-uri": "push-uri",
121
+ "--tar": "tar",
122
+ "--report": "report",
123
+ "--bench-root": "bench-root",
61
124
  "-C": "cwd", "--cwd": "cwd",
62
125
  "-o": "output", "--output": "output",
63
126
  "-s": "steps", "--steps": "steps",
@@ -72,7 +135,7 @@ function parseArgs(argv) {
72
135
  "-y": "yolo", "--yolo": "yolo",
73
136
  "-h": "help", "--help": "help",
74
137
  };
75
- const flags = new Set(["quiet", "yolo", "help", "sessions"]);
138
+ const flags = new Set(["quiet", "yolo", "help", "sessions", "tar", "push", "report"]);
76
139
  for (let i = 2; i < argv.length; i++) {
77
140
  const key = alias[argv[i]];
78
141
  if (!key) { console.error(`Unknown option: ${argv[i]}`); process.exit(2); }
@@ -96,6 +159,18 @@ async function main() {
96
159
 
97
160
  if (args.sessions) { printSessions(); process.exit(0); }
98
161
  if (args.help) { console.log(HELP); process.exit(0); }
162
+ if (args.report) {
163
+ try {
164
+ const res = refreshReport(args["bench-root"]);
165
+ console.error(`\x1b[1m[astra] report · ${res.runs} run(s) · ${res.models} model(s) · ${res.tasks} task(s)\x1b[0m`);
166
+ console.error(`\x1b[1m summary\x1b[0m ${res.summary}`);
167
+ console.error(`\x1b[1m html \x1b[0m ${res.html}`);
168
+ } catch (err) {
169
+ console.error(`\x1b[31m[astra] report failed: ${err.message}\x1b[0m`);
170
+ process.exit(1);
171
+ }
172
+ process.exit(0);
173
+ }
99
174
 
100
175
  // Load a session to resume (if any) to infer defaults.
101
176
  let resumeDoc = null;
@@ -107,17 +182,12 @@ async function main() {
107
182
  resumeDoc = loadSession(args.resume);
108
183
  }
109
184
 
110
- const modelId = args.model || resumeDoc?.info?.model;
111
- if (!modelId) {
112
- console.error("\x1b[31m[astra] --model is required.\x1b[0m\n");
113
- console.log(HELP);
114
- process.exit(2);
115
- }
116
-
117
- // Task text (flag/file) selects autonomous mode; otherwise interactive.
185
+ // A task (-t/-f) or task path (-p) selects bench mode; otherwise agent mode.
186
+ // Bench mode uses the autonomous engine; agent mode uses the interactive one.
118
187
  let task = args.task;
119
188
  if (args["task-file"]) task = fs.readFileSync(args["task-file"], "utf8");
120
- const mode = task
189
+ const wantsBench = !!(task || args.path);
190
+ const mode = wantsBench
121
191
  ? "autonomous"
122
192
  : resumeDoc?.info?.mode === "autonomous"
123
193
  ? "autonomous"
@@ -125,12 +195,13 @@ async function main() {
125
195
 
126
196
  if (mode === "interactive" && !canPrompt()) {
127
197
  console.error(
128
- "\x1b[31m[astra] interactive mode needs a terminal. Provide a task with -t/-f\n" +
129
- "to run autonomously, or run in a TTY.\x1b[0m"
198
+ "\x1b[31m[astra] agent mode needs a terminal. Provide a task with -t/-f/-p\n" +
199
+ "to run a bench, or run in a TTY.\x1b[0m"
130
200
  );
131
201
  process.exit(2);
132
202
  }
133
203
 
204
+ // -------------------- API KEY RESOLUTION --------------------
134
205
  let apiKey = resolveApiKey(args["api-key"], args["base-url"]);
135
206
  if (!apiKey) {
136
207
  if (canPrompt()) {
@@ -149,6 +220,69 @@ async function main() {
149
220
  }
150
221
  }
151
222
 
223
+ // -------------------- MODEL + REASONING RESOLUTION --------------------
224
+ // Bench (autonomous) mode: an explicit --model is required so runs are
225
+ // reproducible and never silently pick a cached/default model.
226
+ //
227
+ // Agent (interactive) mode: resolve in priority order
228
+ // 1. --model flag / --reasoning flag
229
+ // 2. resumed session
230
+ // 3. cached agent prefs in ~/.astra/config.json ("remember last model")
231
+ // 4. first-time interactive setup (list pickers for model + reasoning)
232
+ // 5. hard fallback: glm-5.2 with medium reasoning
233
+ // The chosen values are cached so the next `astra` needs no flags.
234
+ let modelId = args.model || resumeDoc?.info?.model;
235
+ let reasoning = args.reasoning || "";
236
+
237
+ if (mode === "autonomous") {
238
+ if (!modelId) {
239
+ console.error("\x1b[31m[astra] --model is required for bench mode.\x1b[0m\n");
240
+ console.log(HELP);
241
+ process.exit(2);
242
+ }
243
+
244
+ // Multi-model / multi-reasoning / repeated matrix. Triggered by a
245
+ // comma-separated model or reasoning list, or --repeat > 1. Runs each cell
246
+ // sequentially in its own isolated workspace, then prints a leaderboard and
247
+ // optionally tars + uploads the whole bench/ folder.
248
+ const models = String(modelId).split(",").map((s) => s.trim()).filter(Boolean);
249
+ const reasonings = reasoning
250
+ ? String(reasoning).split(",").map((s) => s.trim()).filter(Boolean)
251
+ : [""];
252
+ const repeat = Math.max(1, Number(args.repeat) || 1);
253
+ const isMatrix = models.length > 1 || reasonings.length > 1 || repeat > 1;
254
+
255
+ if (isMatrix && !resumeDoc) {
256
+ await runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, quiet: !!args.quiet });
257
+ return; // runBenchMatrix exits the process
258
+ }
259
+ } else {
260
+ const cached = readAgentPrefs();
261
+ if (!modelId) modelId = cached.model;
262
+ if (!reasoning) reasoning = cached.reasoning;
263
+
264
+ if (!modelId) {
265
+ // No flag, no resume, no cache — run first-time setup (list pickers).
266
+ if (canPrompt()) {
267
+ const prefs = await promptForAgentPrefs({
268
+ defaultModel: DEFAULT_MODEL,
269
+ defaultReasoning: DEFAULT_REASONING,
270
+ });
271
+ modelId = prefs.model;
272
+ reasoning = reasoning || prefs.reasoning;
273
+ }
274
+ }
275
+
276
+ // Hard fallback if we still have nothing.
277
+ if (!modelId) {
278
+ modelId = DEFAULT_MODEL;
279
+ reasoning = reasoning || DEFAULT_REASONING;
280
+ }
281
+
282
+ // Remember the last model/reasoning used in agent mode.
283
+ saveAgentPrefs({ model: modelId, reasoning: reasoning || DEFAULT_REASONING });
284
+ }
285
+
152
286
  const cwd = args.cwd ? path.resolve(args.cwd) : process.cwd();
153
287
  const quiet = !!args.quiet;
154
288
 
@@ -156,10 +290,30 @@ async function main() {
156
290
  model: modelId,
157
291
  baseUrl: args["base-url"],
158
292
  apiKey,
293
+ modelKwargs: reasoningKwargs(reasoning),
159
294
  onRetry: quiet ? () => {} : (r) => printRetry(r),
160
295
  });
296
+
297
+ // -------------------- BENCH SETUP --------------------
298
+ // A fresh bench run (task text or task path, not a resume) gets an isolated
299
+ // workspace under bench/<model-name-reasoning>/run-NN/. The agent's commands
300
+ // run inside that workspace and metrics are recorded when it finishes.
301
+ let benchRun = null;
302
+ if (mode === "autonomous" && !resumeDoc) {
303
+ benchRun = allocateRun({ model: modelId, reasoning, root: args["bench-root"] });
304
+ const seeded = seedWorkspace(benchRun.workspace, {
305
+ taskPath: args.path,
306
+ taskFile: args["task-file"],
307
+ taskText: task,
308
+ });
309
+ task = seeded.taskText || task || "";
310
+ // Persist the task text alongside the run for reproducibility.
311
+ fs.writeFileSync(path.join(benchRun.dir, "task.md"), task || "");
312
+ }
313
+
314
+ const cmdCwd = benchRun ? benchRun.workspace : cwd;
161
315
  const env = new LocalEnvironment({
162
- cwd,
316
+ cwd: cmdCwd,
163
317
  timeout: Number(args.timeout),
164
318
  maxOutputChars: Number(args["max-output"]),
165
319
  });
@@ -169,7 +323,11 @@ async function main() {
169
323
  mode,
170
324
  stepLimit: Number(args.steps),
171
325
  wallTimeLimitSeconds: Number(args.wall),
172
- outputPath: args.output ? path.resolve(args.output) : null,
326
+ outputPath: benchRun
327
+ ? path.join(benchRun.dir, "trajectory.json")
328
+ : args.output
329
+ ? path.resolve(args.output)
330
+ : null,
173
331
  sessionId,
174
332
  saveSession,
175
333
  onEvent: quiet || mode === "interactive" ? () => {} : (msg) => printEvent(msg),
@@ -184,26 +342,73 @@ async function main() {
184
342
  // -------------------- INTERACTIVE MODE --------------------
185
343
  if (mode === "interactive") {
186
344
  if (!resumeDoc) agent.start();
187
- agent.title = agent.title || "interactive";
188
- await runRepl(agent, { model, autoRun: !!args.yolo, fresh: !resumeDoc });
345
+ agent.title = agent.title || "agent";
346
+ // Bench runner for the in-REPL Alt+Tab toggle: runs a task autonomously in
347
+ // an isolated workspace under bench/<model-name-reasoning>/run-NN/.
348
+ const runBench = async (taskText, log) => {
349
+ const alloc = allocateRun({ model: modelId, reasoning, root: args["bench-root"] });
350
+ seedWorkspace(alloc.workspace, { taskText });
351
+ fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText || "");
352
+ log(`[astra] bench ${alloc.slug}/${alloc.runId} → ${alloc.workspace}`);
353
+ const benchEnv = new LocalEnvironment({
354
+ cwd: alloc.workspace,
355
+ timeout: Number(args.timeout),
356
+ maxOutputChars: Number(args["max-output"]),
357
+ });
358
+ const benchAgent = new Agent(model, benchEnv, {
359
+ mode: "autonomous",
360
+ stepLimit: Number(args.steps),
361
+ wallTimeLimitSeconds: Number(args.wall),
362
+ outputPath: path.join(alloc.dir, "trajectory.json"),
363
+ });
364
+ const res = await benchAgent.run(taskText);
365
+ const metrics = collectMetrics({ agent: benchAgent, model, reasoning });
366
+ const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
367
+ log(`[astra] bench done: exit=${res.exit_status} · metrics → ${paths.runMetrics}`);
368
+ };
369
+ await runRepl(agent, { model, autoRun: !!args.yolo, fresh: !resumeDoc, reasoning, runBench });
189
370
  process.exit(0);
190
371
  }
191
372
 
192
- // -------------------- AUTONOMOUS MODE --------------------
373
+ // -------------------- BENCH MODE (autonomous engine) --------------------
193
374
  if (!task && !resumeDoc) {
194
- console.error("\x1b[31m[astra] provide a task with -t or -f.\x1b[0m");
375
+ console.error("\x1b[31m[astra] provide a task with -t, -f, or -p.\x1b[0m");
195
376
  process.exit(2);
196
377
  }
197
378
  if (task) agent.title = deriveTitle({ info: { task }, messages: [] });
198
379
 
199
380
  if (!quiet) {
200
- console.error(`\x1b[2m[astra] autonomous · model=${modelId} cwd=${cwd} steps<=${args.steps}\x1b[0m`);
381
+ if (benchRun) {
382
+ console.error(
383
+ `\x1b[2m[astra] bench · ${benchRun.slug}/${benchRun.runId} ` +
384
+ `· model=${modelId}${reasoning ? ` reasoning=${reasoning}` : ""} steps<=${args.steps}\x1b[0m`
385
+ );
386
+ console.error(`\x1b[2m[astra] workspace -> ${benchRun.workspace}\x1b[0m`);
387
+ } else {
388
+ console.error(`\x1b[2m[astra] bench · model=${modelId} cwd=${cmdCwd} steps<=${args.steps}\x1b[0m`);
389
+ }
201
390
  }
202
391
 
203
392
  const result =
204
393
  resumeDoc && !task ? await continueAutonomous(agent) : await agent.run(task);
205
394
  agent.save();
206
395
 
396
+ // Record benchmark metrics (per-run CSV + rolled-up index).
397
+ if (benchRun) {
398
+ const metrics = collectMetrics({ agent, model, reasoning });
399
+ const paths = writeMetrics({ runDir: benchRun.dir, root: benchRun.root, metrics });
400
+ if (!quiet) {
401
+ console.error(`\x1b[2m[astra] metrics -> ${paths.runMetrics}\x1b[0m`);
402
+ console.error(`\x1b[2m[astra] index -> ${paths.index}\x1b[0m`);
403
+ }
404
+ try {
405
+ const report = refreshReport(benchRun.root);
406
+ if (!quiet) console.error(`\x1b[2m[astra] report -> ${report.html}\x1b[0m`);
407
+ } catch {
408
+ // Non-fatal: report regeneration is best-effort for single runs.
409
+ }
410
+ }
411
+
207
412
  if (!quiet) {
208
413
  const pt = model.totalPromptTokens;
209
414
  const ct = model.totalCompletionTokens;
@@ -226,9 +431,146 @@ async function main() {
226
431
  console.error(`\x1b[2m[astra] session -> ${sessionId}\x1b[0m`);
227
432
  }
228
433
  if (result.submission) console.log(result.submission);
434
+
435
+ // For a single-run bench, tar + push the bench/ folder if requested.
436
+ // (Matrix runs handle this inside runBenchMatrix.)
437
+ if (benchRun) {
438
+ const links = [];
439
+ archiveAndPush(args, benchRun.root, links);
440
+ if (links.length) {
441
+ const w = Math.max(...links.map(([k]) => k.length));
442
+ console.error("");
443
+ for (const [k, v] of links) console.error(`\x1b[1m ${k.padEnd(w)}\x1b[0m ${v}`);
444
+ }
445
+ }
446
+
229
447
  process.exit(result.exit_status === "Submitted" ? 0 : 1);
230
448
  }
231
449
 
450
+ /**
451
+ * Tar the bench/ folder and (optionally) upload it to S3. Mutates `links`
452
+ * with the resulting artifact paths/URLs and prints any failures.
453
+ * Works for both single-run and matrix bench runs.
454
+ */
455
+ function archiveAndPush(args, root, links) {
456
+ if (!(args.tar || args.push)) return;
457
+ try {
458
+ const tarball = packBench({ root });
459
+ links.push(["tarball", tarball]);
460
+ if (args.push) {
461
+ // Bare --push uses the default bucket + timestamp prefix; --push-uri
462
+ // overrides the destination.
463
+ const dest = args["push-uri"] || defaultPushUri();
464
+ try {
465
+ const pushed = pushToS3(tarball, dest);
466
+ links.push(["s3 uri", pushed.uri]);
467
+ links.push(["s3 url", pushed.url]);
468
+ } catch (err) {
469
+ console.error(`\x1b[31m[astra] S3 push failed: ${err.message}\x1b[0m`);
470
+ console.error(`\x1b[33m[astra] tarball kept locally: ${tarball}\x1b[0m`);
471
+ }
472
+ }
473
+ } catch (err) {
474
+ console.error(`\x1b[31m[astra] tar failed: ${err.message}\x1b[0m`);
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Run a multi-model / multi-reasoning / repeated bench matrix, print a
480
+ * leaderboard, optionally tar + upload the bench/ folder, then exit.
481
+ */
482
+ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, quiet }) {
483
+ const root = args["bench-root"];
484
+ const total = models.length * reasonings.length * repeat;
485
+ console.error(
486
+ `\x1b[1m[astra] bench matrix · ${models.length} model(s) × ` +
487
+ `${reasonings.length} reasoning × ${repeat} repeat = ${total} run(s)\x1b[0m`
488
+ );
489
+
490
+ let n = 0;
491
+ const rows = await runMatrix({
492
+ models,
493
+ reasonings,
494
+ repeat,
495
+ apiKey,
496
+ baseUrl: args["base-url"],
497
+ task,
498
+ taskPath: args.path,
499
+ taskFile: args["task-file"],
500
+ root,
501
+ steps: Number(args.steps),
502
+ wall: Number(args.wall),
503
+ timeout: Number(args.timeout),
504
+ maxOutputChars: Number(args["max-output"]),
505
+ onStart: (c) => {
506
+ n++;
507
+ if (!quiet) {
508
+ console.error(`\x1b[36m[astra] (${n}/${total}) ${c.slug}/${c.runId} …\x1b[0m`);
509
+ }
510
+ },
511
+ onDone: (r) => {
512
+ const mark = r.resolved ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
513
+ const status = r.error ? `Error: ${r.error.split("\n")[0]}` : r.exit_status;
514
+ console.error(
515
+ ` ${mark} ${String(r.model).padEnd(20)} ${String(r.reasoning).padEnd(8)} ` +
516
+ `${String(status).padEnd(16)} ${String(r.steps).padStart(3)} steps · ` +
517
+ `${fmt(r.total_tokens)} tok · ${fmtUsd(r.cost_usd)}` +
518
+ `${r.cost_source === "estimated" ? "~" : ""}`
519
+ );
520
+ },
521
+ });
522
+
523
+ printLeaderboard(aggregate(rows));
524
+
525
+ const indexPath = path.join(benchRoot(root), "metrics.csv");
526
+
527
+ // Collect the important artifact locations to print together at the end.
528
+ const links = [["metrics", indexPath]];
529
+
530
+ // Rebuild the dashboard (summary.json + report.html) from every run on
531
+ // disk under this root, including runs from earlier matrix invocations.
532
+ try {
533
+ const report = refreshReport(root);
534
+ links.push(["report", report.html]);
535
+ } catch (err) {
536
+ console.error(`\x1b[33m[astra] report generation skipped: ${err.message}\x1b[0m`);
537
+ }
538
+
539
+ // Tar + optional S3 push of the whole bench/ folder.
540
+ archiveAndPush(args, root, links);
541
+
542
+ const solved = rows.filter((r) => r.resolved).length;
543
+ console.error(`\n\x1b[1m[astra] matrix done · ${solved}/${rows.length} resolved\x1b[0m`);
544
+
545
+ // Print the important URLs / paths, aligned, at the very end.
546
+ const w = Math.max(...links.map(([k]) => k.length));
547
+ console.error("");
548
+ for (const [k, v] of links) {
549
+ console.error(`\x1b[1m ${k.padEnd(w)}\x1b[0m ${v}`);
550
+ }
551
+
552
+ process.exit(solved === rows.length ? 0 : 1);
553
+ }
554
+
555
+ /** Print the per-(model,reasoning) leaderboard table. */
556
+ function printLeaderboard(board) {
557
+ board.sort((a, b) => b.solved_rate - a.solved_rate || a.avg_steps - b.avg_steps);
558
+ console.error(
559
+ `\n\x1b[1m${"model".padEnd(20)} ${"reason".padEnd(8)} ${"solved".padEnd(8)} ` +
560
+ `${"steps".padStart(6)} ${"tokens".padStart(9)} ${"cost".padStart(9)} source\x1b[0m`
561
+ );
562
+ for (const g of board) {
563
+ const solved = `${g.solved}/${g.runs}`;
564
+ const rate = `${Math.round(g.solved_rate * 100)}%`;
565
+ const cost = g.cost_usd == null ? "n/a" : fmtUsd(g.cost_usd);
566
+ console.error(
567
+ `${String(g.model).padEnd(20)} ${String(g.reasoning).padEnd(8)} ` +
568
+ `${(solved + " " + rate).padEnd(8)} ${g.avg_steps.toFixed(1).padStart(6)} ` +
569
+ `${fmt(Math.round(g.avg_tokens)).padStart(9)} ${cost.padStart(9)} ${g.cost_source}`
570
+ );
571
+ }
572
+ }
573
+
232
574
  /** Drive an already-seeded autonomous agent to completion (used on resume). */
233
575
  async function continueAutonomous(agent) {
234
576
  while (true) {
@@ -259,11 +601,11 @@ function printSessions() {
259
601
  }
260
602
 
261
603
  function printEvent(msg) {
262
- const c = { system: 90, user: 36, assistant: 33, exit: 35 }[msg.role] ?? 0;
263
- if (msg.role === "system") return; // too long, skip
264
- const label = msg.role.toUpperCase().padEnd(9);
265
- const body = msg.content.length > 1200 ? msg.content.slice(0, 1200) + " …[truncated]" : msg.content;
266
- console.error(`\x1b[${c}m--- ${label} ---\x1b[0m\n${body}\n`);
604
+ // Bench mode keeps output compact: skip the verbose system/user/assistant
605
+ // message bodies (per-step progress is shown by printStep). Only surface a
606
+ // non-empty terminal exit event so the run's submission is still visible.
607
+ if (msg.role !== "exit" || !msg.content?.trim()) return;
608
+ console.error(`\x1b[35m--- SUBMISSION ---\x1b[0m\n${msg.content}\n`);
267
609
  }
268
610
 
269
611
  /** Compact per-step status line with exact token usage from the API. */
package/src/config.js CHANGED
@@ -17,6 +17,12 @@ import fs from "node:fs";
17
17
  import os from "node:os";
18
18
  import path from "node:path";
19
19
  import readline from "node:readline";
20
+ import {
21
+ AVAILABLE_MODELS,
22
+ REASONING_LEVELS,
23
+ DEFAULT_MODEL,
24
+ DEFAULT_REASONING,
25
+ } from "./models.js";
20
26
 
21
27
  export const DEFAULT_BASE_URL = "https://gateway-central.ai.private.hackerrank.link/v1";
22
28
 
@@ -54,6 +60,32 @@ export function saveGatewayCredentials({ apiKey, baseUrl }) {
54
60
  return configPath();
55
61
  }
56
62
 
63
+ /**
64
+ * Save the last / preferred agent model + reasoning into the dedicated config
65
+ * (merging existing). Used so agent mode remembers the last model — users can
66
+ * then just run `astra` without `-m`.
67
+ */
68
+ export function saveAgentPrefs({ model, reasoning } = {}) {
69
+ const cfg = readConfig();
70
+ cfg.agent = { ...(cfg.agent || {}) };
71
+ if (model) cfg.agent.model = model;
72
+ if (reasoning != null) cfg.agent.reasoning = reasoning;
73
+ writeConfig(cfg);
74
+ return configPath();
75
+ }
76
+
77
+ /**
78
+ * Resolve the cached / preferred agent model + reasoning from the dedicated
79
+ * config. Returns { model, reasoning } (either may be empty when unset).
80
+ */
81
+ export function readAgentPrefs() {
82
+ const cfg = readConfig();
83
+ return {
84
+ model: cfg.agent?.model || "",
85
+ reasoning: cfg.agent?.reasoning || "",
86
+ };
87
+ }
88
+
57
89
  /**
58
90
  * Resolve gateway credentials in priority order (no prompting):
59
91
  * 1. explicit args (CLI flags)
@@ -133,3 +165,49 @@ export async function promptForCredentials({ baseUrl } = {}) {
133
165
  }
134
166
  return { apiKey, baseUrl: effectiveBase };
135
167
  }
168
+
169
+ /**
170
+ * Present a numbered list on the TTY and return the chosen item. Re-prompts on
171
+ * invalid input; returns the default (index 0 or `defaultValue`) on blank.
172
+ * @param {string} label
173
+ * @param {string[]} options
174
+ * @param {object} [opts]
175
+ * @param {string} [opts.defaultValue] pre-selected value (defaults to options[0])
176
+ */
177
+ export async function selectFromList(label, options, { defaultValue } = {}) {
178
+ if (!options || options.length === 0) return defaultValue || "";
179
+ const def = defaultValue && options.includes(defaultValue) ? defaultValue : options[0];
180
+ console.error(`\x1b[1m${label}\x1b[0m`);
181
+ for (let i = 0; i < options.length; i++) {
182
+ const marker = options[i] === def ? "\x1b[36m•\x1b[0m" : " ";
183
+ console.error(` ${marker} ${String(i + 1).padStart(2)}) ${options[i]}`);
184
+ }
185
+ while (true) {
186
+ const answer = (await ask(`Select [1-${options.length}] (default: ${def}): `)).trim();
187
+ if (answer === "") return def;
188
+ const n = Number(answer);
189
+ if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1];
190
+ // Allow typing the value directly too.
191
+ if (options.includes(answer)) return answer;
192
+ console.error("\x1b[31m[astra] Invalid choice, try again.\x1b[0m");
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Interactive first-time setup for the agent's preferred model + reasoning.
198
+ * Both are chosen from a list (not free-text) and saved to the dedicated
199
+ * config so agent mode remembers them. Returns { model, reasoning }.
200
+ */
201
+ export async function promptForAgentPrefs({ defaultModel, defaultReasoning } = {}) {
202
+ const model = await selectFromList("[astra] Choose your preferred model:", AVAILABLE_MODELS, {
203
+ defaultValue: defaultModel || DEFAULT_MODEL,
204
+ });
205
+ const reasoning = await selectFromList("[astra] Choose reasoning effort:", REASONING_LEVELS, {
206
+ defaultValue: defaultReasoning || DEFAULT_REASONING,
207
+ });
208
+ saveAgentPrefs({ model, reasoning });
209
+ console.error(
210
+ `\x1b[2m[astra] Saved model=${model} reasoning=${reasoning} to ${configPath()}\x1b[0m`
211
+ );
212
+ return { model, reasoning };
213
+ }