@hackerrank/astra-cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -3,19 +3,42 @@
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
+ * -t, --task <text> Task text -> bench mode (run to completion)
36
+ * -f, --task-file <path> Read task text from a file -> bench mode
37
+ * -p, --path <dir> Task directory (copied into an isolated bench
38
+ * workspace) -> bench mode
39
+ * --bench-root <dir> Root folder for bench runs (default: ./bench)
17
40
  * -C, --cwd <path> Working directory for commands (default: cwd)
18
- * -o, --output <path> Also write trajectory JSON here (autonomous mode)
41
+ * -o, --output <path> Also write trajectory JSON here (bench mode)
19
42
  * -s, --steps <n> Step limit (default: 40)
20
43
  * -w, --wall <seconds> Wall-clock limit (default: 0 = none)
21
44
  * --timeout <seconds> Per-command timeout (default: 60)
@@ -25,8 +48,8 @@
25
48
  * --resume <id> Resume a saved session by id
26
49
  * --sessions List saved sessions and exit
27
50
  * -y, --yolo Auto-run commands without confirmation
28
- * (always on in autonomous mode)
29
- * -q, --quiet Do not stream steps (autonomous mode)
51
+ * (always on in bench mode)
52
+ * -q, --quiet Do not stream steps (bench mode)
30
53
  * -h, --help Show this help
31
54
  *
32
55
  * API key resolution (first hit wins):
@@ -34,6 +57,13 @@
34
57
  * 2. ASTRA_GATEWAY_API_KEY env var
35
58
  * 3. ~/.astra/config.json (dedicated astra config)
36
59
  * 4. interactive prompt (when run in a terminal); offers to save to (3)
60
+ *
61
+ * Agent model resolution (agent mode, first hit wins):
62
+ * 1. --model / --reasoning flags
63
+ * 2. resumed session
64
+ * 3. cached agent prefs in ~/.astra/config.json (last model used)
65
+ * 4. first-run interactive setup (list pickers for model + reasoning)
66
+ * 5. fallback: glm-5.2 with medium reasoning
37
67
  */
38
68
 
39
69
  import fs from "node:fs";
@@ -41,7 +71,8 @@ import path from "node:path";
41
71
  import { GatewayModel } from "./model.js";
42
72
  import { LocalEnvironment } from "./environment.js";
43
73
  import { Agent } from "./agent.js";
44
- import { resolveCredentials, promptForCredentials, canPrompt } from "./config.js";
74
+ import { resolveCredentials, promptForCredentials, canPrompt, readAgentPrefs, saveAgentPrefs, promptForAgentPrefs } from "./config.js";
75
+ import { AVAILABLE_MODELS, REASONING_LEVELS, DEFAULT_MODEL, DEFAULT_REASONING } from "./models.js";
45
76
  import {
46
77
  newSessionId,
47
78
  saveSession,
@@ -51,6 +82,26 @@ import {
51
82
  deriveTitle,
52
83
  } from "./session.js";
53
84
  import { runRepl } from "./repl.js";
85
+ import {
86
+ allocateRun,
87
+ seedWorkspace,
88
+ collectMetrics,
89
+ writeMetrics,
90
+ runMatrix,
91
+ aggregate,
92
+ packBench,
93
+ pushToS3,
94
+ defaultPushUri,
95
+ benchRoot,
96
+ } from "./bench.js";
97
+
98
+ /** Map a reasoning level to gateway modelKwargs. Empty for off/none/unset. */
99
+ function reasoningKwargs(level) {
100
+ if (!level) return {};
101
+ const l = String(level).toLowerCase();
102
+ if (l === "off" || l === "none" || l === "disabled") return {};
103
+ return { reasoning_effort: l };
104
+ }
54
105
 
55
106
  function parseArgs(argv) {
56
107
  const args = { steps: 40, wall: 0, timeout: 60, quiet: false, "max-output": 16000 };
@@ -58,6 +109,13 @@ function parseArgs(argv) {
58
109
  "-m": "model", "--model": "model",
59
110
  "-t": "task", "--task": "task",
60
111
  "-f": "task-file", "--task-file": "task-file",
112
+ "-p": "path", "--path": "path",
113
+ "-r": "reasoning", "--reasoning": "reasoning",
114
+ "--repeat": "repeat",
115
+ "--push": "push",
116
+ "--push-uri": "push-uri",
117
+ "--tar": "tar",
118
+ "--bench-root": "bench-root",
61
119
  "-C": "cwd", "--cwd": "cwd",
62
120
  "-o": "output", "--output": "output",
63
121
  "-s": "steps", "--steps": "steps",
@@ -72,7 +130,7 @@ function parseArgs(argv) {
72
130
  "-y": "yolo", "--yolo": "yolo",
73
131
  "-h": "help", "--help": "help",
74
132
  };
75
- const flags = new Set(["quiet", "yolo", "help", "sessions"]);
133
+ const flags = new Set(["quiet", "yolo", "help", "sessions", "tar", "push"]);
76
134
  for (let i = 2; i < argv.length; i++) {
77
135
  const key = alias[argv[i]];
78
136
  if (!key) { console.error(`Unknown option: ${argv[i]}`); process.exit(2); }
@@ -107,17 +165,12 @@ async function main() {
107
165
  resumeDoc = loadSession(args.resume);
108
166
  }
109
167
 
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.
168
+ // A task (-t/-f) or task path (-p) selects bench mode; otherwise agent mode.
169
+ // Bench mode uses the autonomous engine; agent mode uses the interactive one.
118
170
  let task = args.task;
119
171
  if (args["task-file"]) task = fs.readFileSync(args["task-file"], "utf8");
120
- const mode = task
172
+ const wantsBench = !!(task || args.path);
173
+ const mode = wantsBench
121
174
  ? "autonomous"
122
175
  : resumeDoc?.info?.mode === "autonomous"
123
176
  ? "autonomous"
@@ -125,12 +178,13 @@ async function main() {
125
178
 
126
179
  if (mode === "interactive" && !canPrompt()) {
127
180
  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"
181
+ "\x1b[31m[astra] agent mode needs a terminal. Provide a task with -t/-f/-p\n" +
182
+ "to run a bench, or run in a TTY.\x1b[0m"
130
183
  );
131
184
  process.exit(2);
132
185
  }
133
186
 
187
+ // -------------------- API KEY RESOLUTION --------------------
134
188
  let apiKey = resolveApiKey(args["api-key"], args["base-url"]);
135
189
  if (!apiKey) {
136
190
  if (canPrompt()) {
@@ -149,6 +203,69 @@ async function main() {
149
203
  }
150
204
  }
151
205
 
206
+ // -------------------- MODEL + REASONING RESOLUTION --------------------
207
+ // Bench (autonomous) mode: an explicit --model is required so runs are
208
+ // reproducible and never silently pick a cached/default model.
209
+ //
210
+ // Agent (interactive) mode: resolve in priority order
211
+ // 1. --model flag / --reasoning flag
212
+ // 2. resumed session
213
+ // 3. cached agent prefs in ~/.astra/config.json ("remember last model")
214
+ // 4. first-time interactive setup (list pickers for model + reasoning)
215
+ // 5. hard fallback: glm-5.2 with medium reasoning
216
+ // The chosen values are cached so the next `astra` needs no flags.
217
+ let modelId = args.model || resumeDoc?.info?.model;
218
+ let reasoning = args.reasoning || "";
219
+
220
+ if (mode === "autonomous") {
221
+ if (!modelId) {
222
+ console.error("\x1b[31m[astra] --model is required for bench mode.\x1b[0m\n");
223
+ console.log(HELP);
224
+ process.exit(2);
225
+ }
226
+
227
+ // Multi-model / multi-reasoning / repeated matrix. Triggered by a
228
+ // comma-separated model or reasoning list, or --repeat > 1. Runs each cell
229
+ // sequentially in its own isolated workspace, then prints a leaderboard and
230
+ // optionally tars + uploads the whole bench/ folder.
231
+ const models = String(modelId).split(",").map((s) => s.trim()).filter(Boolean);
232
+ const reasonings = reasoning
233
+ ? String(reasoning).split(",").map((s) => s.trim()).filter(Boolean)
234
+ : [""];
235
+ const repeat = Math.max(1, Number(args.repeat) || 1);
236
+ const isMatrix = models.length > 1 || reasonings.length > 1 || repeat > 1;
237
+
238
+ if (isMatrix && !resumeDoc) {
239
+ await runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, quiet: !!args.quiet });
240
+ return; // runBenchMatrix exits the process
241
+ }
242
+ } else {
243
+ const cached = readAgentPrefs();
244
+ if (!modelId) modelId = cached.model;
245
+ if (!reasoning) reasoning = cached.reasoning;
246
+
247
+ if (!modelId) {
248
+ // No flag, no resume, no cache — run first-time setup (list pickers).
249
+ if (canPrompt()) {
250
+ const prefs = await promptForAgentPrefs({
251
+ defaultModel: DEFAULT_MODEL,
252
+ defaultReasoning: DEFAULT_REASONING,
253
+ });
254
+ modelId = prefs.model;
255
+ reasoning = reasoning || prefs.reasoning;
256
+ }
257
+ }
258
+
259
+ // Hard fallback if we still have nothing.
260
+ if (!modelId) {
261
+ modelId = DEFAULT_MODEL;
262
+ reasoning = reasoning || DEFAULT_REASONING;
263
+ }
264
+
265
+ // Remember the last model/reasoning used in agent mode.
266
+ saveAgentPrefs({ model: modelId, reasoning: reasoning || DEFAULT_REASONING });
267
+ }
268
+
152
269
  const cwd = args.cwd ? path.resolve(args.cwd) : process.cwd();
153
270
  const quiet = !!args.quiet;
154
271
 
@@ -156,10 +273,30 @@ async function main() {
156
273
  model: modelId,
157
274
  baseUrl: args["base-url"],
158
275
  apiKey,
276
+ modelKwargs: reasoningKwargs(reasoning),
159
277
  onRetry: quiet ? () => {} : (r) => printRetry(r),
160
278
  });
279
+
280
+ // -------------------- BENCH SETUP --------------------
281
+ // A fresh bench run (task text or task path, not a resume) gets an isolated
282
+ // workspace under bench/<model-name-reasoning>/run-NN/. The agent's commands
283
+ // run inside that workspace and metrics are recorded when it finishes.
284
+ let benchRun = null;
285
+ if (mode === "autonomous" && !resumeDoc) {
286
+ benchRun = allocateRun({ model: modelId, reasoning, root: args["bench-root"] });
287
+ const seeded = seedWorkspace(benchRun.workspace, {
288
+ taskPath: args.path,
289
+ taskFile: args["task-file"],
290
+ taskText: task,
291
+ });
292
+ task = seeded.taskText || task || "";
293
+ // Persist the task text alongside the run for reproducibility.
294
+ fs.writeFileSync(path.join(benchRun.dir, "task.md"), task || "");
295
+ }
296
+
297
+ const cmdCwd = benchRun ? benchRun.workspace : cwd;
161
298
  const env = new LocalEnvironment({
162
- cwd,
299
+ cwd: cmdCwd,
163
300
  timeout: Number(args.timeout),
164
301
  maxOutputChars: Number(args["max-output"]),
165
302
  });
@@ -169,7 +306,11 @@ async function main() {
169
306
  mode,
170
307
  stepLimit: Number(args.steps),
171
308
  wallTimeLimitSeconds: Number(args.wall),
172
- outputPath: args.output ? path.resolve(args.output) : null,
309
+ outputPath: benchRun
310
+ ? path.join(benchRun.dir, "trajectory.json")
311
+ : args.output
312
+ ? path.resolve(args.output)
313
+ : null,
173
314
  sessionId,
174
315
  saveSession,
175
316
  onEvent: quiet || mode === "interactive" ? () => {} : (msg) => printEvent(msg),
@@ -184,26 +325,67 @@ async function main() {
184
325
  // -------------------- INTERACTIVE MODE --------------------
185
326
  if (mode === "interactive") {
186
327
  if (!resumeDoc) agent.start();
187
- agent.title = agent.title || "interactive";
188
- await runRepl(agent, { model, autoRun: !!args.yolo, fresh: !resumeDoc });
328
+ agent.title = agent.title || "agent";
329
+ // Bench runner for the in-REPL Alt+Tab toggle: runs a task autonomously in
330
+ // an isolated workspace under bench/<model-name-reasoning>/run-NN/.
331
+ const runBench = async (taskText, log) => {
332
+ const alloc = allocateRun({ model: modelId, reasoning, root: args["bench-root"] });
333
+ seedWorkspace(alloc.workspace, { taskText });
334
+ fs.writeFileSync(path.join(alloc.dir, "task.md"), taskText || "");
335
+ log(`[astra] bench ${alloc.slug}/${alloc.runId} → ${alloc.workspace}`);
336
+ const benchEnv = new LocalEnvironment({
337
+ cwd: alloc.workspace,
338
+ timeout: Number(args.timeout),
339
+ maxOutputChars: Number(args["max-output"]),
340
+ });
341
+ const benchAgent = new Agent(model, benchEnv, {
342
+ mode: "autonomous",
343
+ stepLimit: Number(args.steps),
344
+ wallTimeLimitSeconds: Number(args.wall),
345
+ outputPath: path.join(alloc.dir, "trajectory.json"),
346
+ });
347
+ const res = await benchAgent.run(taskText);
348
+ const metrics = collectMetrics({ agent: benchAgent, model, reasoning });
349
+ const paths = writeMetrics({ runDir: alloc.dir, root: alloc.root, metrics });
350
+ log(`[astra] bench done: exit=${res.exit_status} · metrics → ${paths.runMetrics}`);
351
+ };
352
+ await runRepl(agent, { model, autoRun: !!args.yolo, fresh: !resumeDoc, reasoning, runBench });
189
353
  process.exit(0);
190
354
  }
191
355
 
192
- // -------------------- AUTONOMOUS MODE --------------------
356
+ // -------------------- BENCH MODE (autonomous engine) --------------------
193
357
  if (!task && !resumeDoc) {
194
- console.error("\x1b[31m[astra] provide a task with -t or -f.\x1b[0m");
358
+ console.error("\x1b[31m[astra] provide a task with -t, -f, or -p.\x1b[0m");
195
359
  process.exit(2);
196
360
  }
197
361
  if (task) agent.title = deriveTitle({ info: { task }, messages: [] });
198
362
 
199
363
  if (!quiet) {
200
- console.error(`\x1b[2m[astra] autonomous · model=${modelId} cwd=${cwd} steps<=${args.steps}\x1b[0m`);
364
+ if (benchRun) {
365
+ console.error(
366
+ `\x1b[2m[astra] bench · ${benchRun.slug}/${benchRun.runId} ` +
367
+ `· model=${modelId}${reasoning ? ` reasoning=${reasoning}` : ""} steps<=${args.steps}\x1b[0m`
368
+ );
369
+ console.error(`\x1b[2m[astra] workspace -> ${benchRun.workspace}\x1b[0m`);
370
+ } else {
371
+ console.error(`\x1b[2m[astra] bench · model=${modelId} cwd=${cmdCwd} steps<=${args.steps}\x1b[0m`);
372
+ }
201
373
  }
202
374
 
203
375
  const result =
204
376
  resumeDoc && !task ? await continueAutonomous(agent) : await agent.run(task);
205
377
  agent.save();
206
378
 
379
+ // Record benchmark metrics (per-run CSV + rolled-up index).
380
+ if (benchRun) {
381
+ const metrics = collectMetrics({ agent, model, reasoning });
382
+ const paths = writeMetrics({ runDir: benchRun.dir, root: benchRun.root, metrics });
383
+ if (!quiet) {
384
+ console.error(`\x1b[2m[astra] metrics -> ${paths.runMetrics}\x1b[0m`);
385
+ console.error(`\x1b[2m[astra] index -> ${paths.index}\x1b[0m`);
386
+ }
387
+ }
388
+
207
389
  if (!quiet) {
208
390
  const pt = model.totalPromptTokens;
209
391
  const ct = model.totalCompletionTokens;
@@ -229,6 +411,113 @@ async function main() {
229
411
  process.exit(result.exit_status === "Submitted" ? 0 : 1);
230
412
  }
231
413
 
414
+ /**
415
+ * Run a multi-model / multi-reasoning / repeated bench matrix, print a
416
+ * leaderboard, optionally tar + upload the bench/ folder, then exit.
417
+ */
418
+ async function runBenchMatrix({ args, apiKey, models, reasonings, repeat, task, quiet }) {
419
+ const root = args["bench-root"];
420
+ const total = models.length * reasonings.length * repeat;
421
+ console.error(
422
+ `\x1b[1m[astra] bench matrix · ${models.length} model(s) × ` +
423
+ `${reasonings.length} reasoning × ${repeat} repeat = ${total} run(s)\x1b[0m`
424
+ );
425
+
426
+ let n = 0;
427
+ const rows = await runMatrix({
428
+ models,
429
+ reasonings,
430
+ repeat,
431
+ apiKey,
432
+ baseUrl: args["base-url"],
433
+ task,
434
+ taskPath: args.path,
435
+ taskFile: args["task-file"],
436
+ root,
437
+ steps: Number(args.steps),
438
+ wall: Number(args.wall),
439
+ timeout: Number(args.timeout),
440
+ maxOutputChars: Number(args["max-output"]),
441
+ onStart: (c) => {
442
+ n++;
443
+ if (!quiet) {
444
+ console.error(`\x1b[36m[astra] (${n}/${total}) ${c.slug}/${c.runId} …\x1b[0m`);
445
+ }
446
+ },
447
+ onDone: (r) => {
448
+ const mark = r.resolved ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
449
+ const status = r.error ? `Error: ${r.error.split("\n")[0]}` : r.exit_status;
450
+ console.error(
451
+ ` ${mark} ${String(r.model).padEnd(20)} ${String(r.reasoning).padEnd(8)} ` +
452
+ `${String(status).padEnd(16)} ${String(r.steps).padStart(3)} steps · ` +
453
+ `${fmt(r.total_tokens)} tok · ${fmtUsd(r.cost_usd)}` +
454
+ `${r.cost_source === "estimated" ? "~" : ""}`
455
+ );
456
+ },
457
+ });
458
+
459
+ printLeaderboard(aggregate(rows));
460
+
461
+ const indexPath = path.join(benchRoot(root), "metrics.csv");
462
+
463
+ // Collect the important artifact locations to print together at the end.
464
+ const links = [["metrics", indexPath]];
465
+
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
+ }
487
+ }
488
+
489
+ const solved = rows.filter((r) => r.resolved).length;
490
+ console.error(`\n\x1b[1m[astra] matrix done · ${solved}/${rows.length} resolved\x1b[0m`);
491
+
492
+ // Print the important URLs / paths, aligned, at the very end.
493
+ const w = Math.max(...links.map(([k]) => k.length));
494
+ console.error("");
495
+ for (const [k, v] of links) {
496
+ console.error(`\x1b[1m ${k.padEnd(w)}\x1b[0m ${v}`);
497
+ }
498
+
499
+ process.exit(solved === rows.length ? 0 : 1);
500
+ }
501
+
502
+ /** Print the per-(model,reasoning) leaderboard table. */
503
+ function printLeaderboard(board) {
504
+ board.sort((a, b) => b.solved_rate - a.solved_rate || a.avg_steps - b.avg_steps);
505
+ console.error(
506
+ `\n\x1b[1m${"model".padEnd(20)} ${"reason".padEnd(8)} ${"solved".padEnd(8)} ` +
507
+ `${"steps".padStart(6)} ${"tokens".padStart(9)} ${"cost".padStart(9)} source\x1b[0m`
508
+ );
509
+ for (const g of board) {
510
+ const solved = `${g.solved}/${g.runs}`;
511
+ const rate = `${Math.round(g.solved_rate * 100)}%`;
512
+ const cost = g.cost_usd == null ? "n/a" : fmtUsd(g.cost_usd);
513
+ console.error(
514
+ `${String(g.model).padEnd(20)} ${String(g.reasoning).padEnd(8)} ` +
515
+ `${(solved + " " + rate).padEnd(8)} ${g.avg_steps.toFixed(1).padStart(6)} ` +
516
+ `${fmt(Math.round(g.avg_tokens)).padStart(9)} ${cost.padStart(9)} ${g.cost_source}`
517
+ );
518
+ }
519
+ }
520
+
232
521
  /** Drive an already-seeded autonomous agent to completion (used on resume). */
233
522
  async function continueAutonomous(agent) {
234
523
  while (true) {
@@ -259,11 +548,11 @@ function printSessions() {
259
548
  }
260
549
 
261
550
  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`);
551
+ // Bench mode keeps output compact: skip the verbose system/user/assistant
552
+ // message bodies (per-step progress is shown by printStep). Only surface a
553
+ // non-empty terminal exit event so the run's submission is still visible.
554
+ if (msg.role !== "exit" || !msg.content?.trim()) return;
555
+ console.error(`\x1b[35m--- SUBMISSION ---\x1b[0m\n${msg.content}\n`);
267
556
  }
268
557
 
269
558
  /** 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
+ }
package/src/model.js CHANGED
@@ -55,6 +55,10 @@ export class GatewayModel {
55
55
  // Cumulative token usage across all calls (exact, from the API).
56
56
  this.totalPromptTokens = 0;
57
57
  this.totalCompletionTokens = 0;
58
+ this.totalReasoningTokens = 0;
59
+ this.totalCachedTokens = 0;
60
+ this.totalCacheWriteTokens = 0;
61
+ this.nRetries = 0;
58
62
  // Cumulative USD cost. `reported` = summed from the gateway's usage.cost;
59
63
  // `estimated` = summed from the public price table (prices.js).
60
64
  this.totalCostUsd = 0;
@@ -113,6 +117,7 @@ export class GatewayModel {
113
117
  // backoff (honoring Retry-After when the server provides it).
114
118
  if ((res.status === 429 || res.status >= 500) && attempt < this.maxRetries) {
115
119
  const wait = retryAfterMs(res.headers) ?? backoffMs(attempt);
120
+ this.nRetries++;
116
121
  this.onRetry({
117
122
  attempt: attempt + 1,
118
123
  maxRetries: this.maxRetries,
@@ -133,6 +138,9 @@ export class GatewayModel {
133
138
  const usage = normalizeUsage(data?.usage);
134
139
  this.totalPromptTokens += usage.prompt_tokens;
135
140
  this.totalCompletionTokens += usage.completion_tokens;
141
+ this.totalReasoningTokens += usage.reasoning_tokens || 0;
142
+ this.totalCachedTokens += usage.cached_tokens || 0;
143
+ this.totalCacheWriteTokens += usage.cache_write_tokens || 0;
136
144
  // Cost: prefer the gateway's exact number; otherwise estimate from the
137
145
  // public price table. Attach per-call cost + source to usage.
138
146
  const cost = this._accountCost(usage);
@@ -145,6 +153,7 @@ export class GatewayModel {
145
153
  // (auth, quota, context window) are re-thrown immediately.
146
154
  if (attempt < this.maxRetries && isRetryable(err)) {
147
155
  const wait = backoffMs(attempt);
156
+ this.nRetries++;
148
157
  this.onRetry({
149
158
  attempt: attempt + 1,
150
159
  maxRetries: this.maxRetries,
package/src/models.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Central catalog of gateway models and reasoning levels used by astra.
3
+ *
4
+ * Kept in one place so the CLI, interactive setup, and any list-based pickers
5
+ * stay in sync. The list mirrors the context-window table in repl.js.
6
+ */
7
+
8
+ /** Selectable model ids on the gateway. */
9
+ export const AVAILABLE_MODELS = [
10
+ "claude-opus-5",
11
+ "claude-sonnet-5",
12
+ "gemini-3.7-flash",
13
+ "gpt-5.6-luna",
14
+ "gpt-5.6-sol",
15
+ "gpt-5.6-terra",
16
+ "grok-4.6",
17
+ "deepseek-v4-pro",
18
+ "glm-5.2",
19
+ "kimi-k3",
20
+ "qwen-3.8",
21
+ ];
22
+
23
+ /** Selectable reasoning-effort levels. */
24
+ export const REASONING_LEVELS = ["off", "low", "medium", "high"];
25
+
26
+ /**
27
+ * Ultimate fallback when no model is configured, cached, or chosen.
28
+ * (Requested behavior: fall back to glm-5.2 with medium reasoning.)
29
+ */
30
+ export const DEFAULT_MODEL = "glm-5.2";
31
+ export const DEFAULT_REASONING = "medium";
package/src/prompts.js CHANGED
@@ -42,13 +42,17 @@ Autonomous mode:
42
42
  After that command you cannot continue.`;
43
43
 
44
44
  export const INTERACTIVE_RULES = `
45
- Interactive mode:
46
- - You are a helpful pair-programming assistant. Work with the user turn by turn.
45
+ Agent mode (interactive SWE assistant):
46
+ - You are astra, a sharp, pragmatic senior software engineer pair-programming
47
+ with the user in their shell. Be concise, direct, and technically precise.
48
+ - Work turn by turn. Prefer small, verifiable steps over large speculative ones.
47
49
  - To run a shell command, use the bash block as described above.
48
50
  - To talk to the user (answer a question, ask for clarification, summarize, or
49
51
  report you are done) respond with plain text and NO bash block. That hands
50
52
  the turn back to the user.
51
- - Prefer small, verifiable steps. Do not fabricate command output.`;
53
+ - Explain what you are about to do in one or two lines before acting.
54
+ - Never fabricate command output; always run the command and react to what you
55
+ actually observe.`;
52
56
 
53
57
  export const INSTANCE_TEMPLATE = `Please complete this task:
54
58