@hackerrank/astra-cli 0.1.23 → 0.1.25

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
@@ -80,7 +80,7 @@ astra -m claude-sonnet-5 # start chatting
80
80
  astra -m claude-sonnet-5 -y # auto-run commands (no prompts)
81
81
  ```
82
82
 
83
- In-REPL commands: `/help /exit /clear /history /tokens /yolo`.
83
+ In-REPL commands: `/help /plan <task> /exit /clear /history /tokens /yolo`.
84
84
 
85
85
  ### Autonomous task run
86
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackerrank/astra-cli",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Minimal zero-dependency AI coding agent for the HackerRank AI Gateway.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/model.js CHANGED
@@ -42,7 +42,7 @@ export class GatewayModel {
42
42
  * @param {number} [opts.maxRetries]
43
43
  * @param {(info:object)=>void} [opts.onRetry] called before each retry sleep
44
44
  */
45
- constructor({ model, baseUrl, apiKey, modelKwargs = {}, maxRetries = 5, maxTokens = 8192, requestTimeoutMs = 0, onRetry } = {}) {
45
+ constructor({ model, baseUrl, apiKey, modelKwargs = {}, maxRetries = 8, maxTokens = 8192, requestTimeoutMs = 0, onRetry } = {}) {
46
46
  if (!model) throw new Error("GatewayModel: `model` is required");
47
47
  this.model = model;
48
48
  this.maxTokens = maxTokens;
@@ -124,14 +124,15 @@ export class GatewayModel {
124
124
  // Retry on transient server / rate-limit errors with exponential
125
125
  // backoff (honoring Retry-After when the server provides it).
126
126
  if ((res.status === 429 || res.status >= 500) && attempt < this.maxRetries) {
127
- const wait = retryAfterMs(res.headers) ?? backoffMs(attempt);
127
+ const serverWait = retryAfterMs(res.headers, text);
128
+ const wait = serverWait != null ? Math.max(serverWait + 250, 1000) : backoffMs(attempt);
128
129
  this.nRetries++;
129
130
  this.onRetry({
130
131
  attempt: attempt + 1,
131
132
  maxRetries: this.maxRetries,
132
133
  status: res.status,
133
134
  waitMs: wait,
134
- reason: `HTTP ${res.status}`,
135
+ reason: res.status === 429 ? "HTTP 429 (rate limited)" : `HTTP ${res.status}`,
135
136
  });
136
137
  await sleep(wait);
137
138
  continue;
@@ -282,14 +283,180 @@ function hintForStatus(status) {
282
283
  }
283
284
  }
284
285
 
285
- /** Parse a Retry-After header (seconds or HTTP-date) into milliseconds. */
286
- function retryAfterMs(headers) {
287
- const v = headers?.get?.("retry-after");
288
- if (!v) return null;
289
- const secs = Number(v);
290
- if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
291
- const when = Date.parse(v);
292
- if (Number.isFinite(when)) return Math.max(0, when - Date.now());
286
+ /**
287
+ * Parse a duration string, number, or date into milliseconds.
288
+ * Supports numbers (seconds or timestamps), units (ms, s, m, h, d),
289
+ * composite durations ("1m 30s"), and HTTP/ISO dates.
290
+ */
291
+ export function parseDurationMs(val) {
292
+ if (typeof val === "number" && Number.isFinite(val) && val >= 0) {
293
+ if (val > 1e11) return Math.max(0, val - Date.now());
294
+ if (val > 1e9) return Math.max(0, val * 1000 - Date.now());
295
+ return Math.max(0, Math.round(val * 1000));
296
+ }
297
+ if (!val || typeof val !== "string") return null;
298
+ const s = val.trim();
299
+
300
+ const rawNum = Number(s);
301
+ if (Number.isFinite(rawNum) && rawNum >= 0) {
302
+ if (rawNum > 1e11) return Math.max(0, rawNum - Date.now());
303
+ if (rawNum > 1e9) return Math.max(0, rawNum * 1000 - Date.now());
304
+ return Math.max(0, Math.round(rawNum * 1000));
305
+ }
306
+
307
+ const when = Date.parse(s);
308
+ if (Number.isFinite(when) && when > Date.now()) {
309
+ return Math.max(0, when - Date.now());
310
+ }
311
+
312
+ const pattern = /([0-9]+(?:\.[0-9]+)?)\s*(milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b/gi;
313
+ let match;
314
+ let totalMs = 0;
315
+ let matched = false;
316
+ while ((match = pattern.exec(s)) !== null) {
317
+ matched = true;
318
+ const n = parseFloat(match[1]);
319
+ const unit = match[2].toLowerCase();
320
+ if (!Number.isFinite(n)) continue;
321
+ if (unit.startsWith("ms") || unit.startsWith("milli")) {
322
+ totalMs += n;
323
+ } else if (unit.startsWith("s")) {
324
+ totalMs += n * 1000;
325
+ } else if (unit.startsWith("m")) {
326
+ totalMs += n * 60 * 1000;
327
+ } else if (unit.startsWith("h")) {
328
+ totalMs += n * 3600 * 1000;
329
+ } else if (unit.startsWith("d")) {
330
+ totalMs += n * 86400 * 1000;
331
+ }
332
+ }
333
+ return matched ? Math.round(totalMs) : null;
334
+ }
335
+
336
+ /**
337
+ * Extract retry duration from unstructured error message text.
338
+ * Matches phrases like "retry after 12.5s", "try again in 5 seconds", "resets in 30s", "wait 10s".
339
+ */
340
+ export function extractDurationFromText(text) {
341
+ if (!text || typeof text !== "string") return null;
342
+
343
+ const p1 = /(?:retry(?:ing)?|try\s+again|wait(?:ing)?|resets?|available|back\s*off)\s+(?:again\s+)?(?:after|in|for)\s+([0-9]+(?:\.[0-9]+)?\s*(?:milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b(?:\s+[0-9]+(?:\.[0-9]+)?\s*(?:milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b)*|[0-9]+(?:\.[0-9]+)?)/i;
344
+ const m1 = text.match(p1);
345
+ if (m1) {
346
+ const parsed = parseDurationMs(m1[1]);
347
+ if (parsed != null) return parsed;
348
+ }
349
+
350
+ const p2 = /(?:wait(?:ing)?)\s+([0-9]+(?:\.[0-9]+)?\s*(?:milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b)/i;
351
+ const m2 = text.match(p2);
352
+ if (m2) {
353
+ const parsed = parseDurationMs(m2[1]);
354
+ if (parsed != null) return parsed;
355
+ }
356
+
357
+ const p3 = /in\s+([0-9]+(?:\.[0-9]+)?\s*(?:milliseconds?|millis?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b)\s*[,.]?\s*(?:please\s+)?(?:retry|try)/i;
358
+ const m3 = text.match(p3);
359
+ if (m3) {
360
+ const parsed = parseDurationMs(m3[1]);
361
+ if (parsed != null) return parsed;
362
+ }
363
+
364
+ return null;
365
+ }
366
+
367
+ /**
368
+ * Parse retry delay from response headers or error response body into milliseconds.
369
+ * Checks standard and provider-specific rate-limit headers as well as JSON fields
370
+ * and error messages returned by AI gateways (HackerRank, OpenAI, Anthropic, LiteLLM, Gemini, etc.).
371
+ */
372
+ export function retryAfterMs(headers, bodyText) {
373
+ const msHeader = headers?.get?.("retry-after-ms");
374
+ if (msHeader) {
375
+ const ms = Number(msHeader);
376
+ if (Number.isFinite(ms) && ms >= 0) return Math.round(ms);
377
+ }
378
+
379
+ const headerKeys = [
380
+ "retry-after",
381
+ "x-retry-after",
382
+ "x-ratelimit-reset-requests",
383
+ "x-ratelimit-reset-tokens",
384
+ "x-ratelimit-reset",
385
+ ];
386
+ for (const k of headerKeys) {
387
+ const val = headers?.get?.(k);
388
+ if (val) {
389
+ const parsed = parseDurationMs(val);
390
+ if (parsed != null) return parsed;
391
+ }
392
+ }
393
+
394
+ if (bodyText) {
395
+ let json = null;
396
+ if (typeof bodyText === "object") {
397
+ json = bodyText;
398
+ } else if (typeof bodyText === "string") {
399
+ try {
400
+ json = JSON.parse(bodyText);
401
+ } catch {}
402
+ }
403
+
404
+ if (json && typeof json === "object") {
405
+ const msCandidates = [json.retry_after_ms, json.error?.retry_after_ms];
406
+ for (const c of msCandidates) {
407
+ if (Number.isFinite(Number(c)) && Number(c) >= 0) {
408
+ return Math.round(Number(c));
409
+ }
410
+ }
411
+
412
+ const durationCandidates = [
413
+ json.retry_after,
414
+ json.retryAfter,
415
+ json.retry_after_seconds,
416
+ json.retry_after_sec,
417
+ json.reset_in,
418
+ json.reset_after,
419
+ json.reset_at,
420
+ json.wait_seconds,
421
+ json.wait_time,
422
+ json.error?.retry_after,
423
+ json.error?.retryAfter,
424
+ json.error?.retry_after_seconds,
425
+ json.error?.retry_after_sec,
426
+ json.error?.reset_in,
427
+ json.error?.reset_after,
428
+ json.error?.reset_at,
429
+ json.error?.wait_seconds,
430
+ json.error?.wait_time,
431
+ ];
432
+ for (const c of durationCandidates) {
433
+ if (c != null) {
434
+ const parsed = parseDurationMs(c);
435
+ if (parsed != null) return parsed;
436
+ }
437
+ }
438
+
439
+ const msgCandidates = [
440
+ json.error?.message,
441
+ typeof json.error === "string" ? json.error : null,
442
+ json.message,
443
+ json.detail,
444
+ typeof json.details === "string" ? json.details : null,
445
+ ];
446
+ for (const msg of msgCandidates) {
447
+ if (msg) {
448
+ const extracted = extractDurationFromText(msg);
449
+ if (extracted != null) return extracted;
450
+ }
451
+ }
452
+ }
453
+
454
+ if (typeof bodyText === "string") {
455
+ const extracted = extractDurationFromText(bodyText);
456
+ if (extracted != null) return extracted;
457
+ }
458
+ }
459
+
293
460
  return null;
294
461
  }
295
462
 
package/src/prompts.js CHANGED
@@ -102,3 +102,27 @@ export function render(template, vars) {
102
102
  key in vars && vars[key] != null ? String(vars[key]) : ""
103
103
  );
104
104
  }
105
+
106
+ export const PLAN_PROMPT_TEMPLATE = `Please explore the workspace and create a clear, actionable plan for this task:
107
+
108
+ Task:
109
+ {{task}}
110
+
111
+ Instructions for planning:
112
+ 1. Run read-only commands (e.g. ls, find, grep, cat) to explore and understand the relevant files.
113
+ 2. Present a structured plan with:
114
+ - **Objective & Scope**: Summary of what needs to be done.
115
+ - **Files to Modify / Create**: List of target files.
116
+ - **Self-Testing & Verification Strategy**: Exact test commands or verification scripts to write/run.
117
+ - **Self-Review Checklist**: Code quality, edge cases, and cleanliness checks.
118
+ 3. Finish with a chat response presenting the complete plan so the user can review it before execution.`;
119
+
120
+ export const AUTONOMOUS_EXECUTION_PROMPT = `The plan has been approved. Execute the plan end-to-end now in autonomous mode without asking further questions.
121
+
122
+ Workflow requirements:
123
+ 1. Implement the changes with clean, focused edits.
124
+ 2. Self-verify by writing or running test scripts to prove the solution works.
125
+ 3. Review your changes with \`git diff\` (or inspect modified files) to ensure no regressions, unintended files, or debug prints remain.
126
+ 4. If any tests or checks fail, diagnose and fix the errors.
127
+ 5. When completely finished, verified, and reviewed, run:
128
+ \`echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT\``;
package/src/repl.js CHANGED
@@ -17,6 +17,7 @@ import readline from "node:readline";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { ask, saveAgentPrefs } from "./config.js";
19
19
  import { AVAILABLE_MODELS, REASONING_LEVELS } from "./models.js";
20
+ import { PLAN_PROMPT_TEMPLATE, AUTONOMOUS_EXECUTION_PROMPT, render } from "./prompts.js";
20
21
 
21
22
  const C = {
22
23
  dim: (s) => `\x1b[2m${s}\x1b[0m`,
@@ -517,6 +518,7 @@ Interactive commands:
517
518
  /help show this help
518
519
  /exit, /quit leave (session is saved)
519
520
  /clear start a fresh conversation (new context)
521
+ /plan <task> plan a task, review it, then execute autonomously with self-review
520
522
  /history print the message history
521
523
  /tokens show token usage so far
522
524
  /yolo toggle auto-run of commands (no confirmation)
@@ -643,6 +645,25 @@ async function runStickyRepl(agent, model, yolo, intro = [], { runBench = null }
643
645
  if (cmd === "history") { printHistory(agent, log); continue; }
644
646
  if (cmd === "tokens") { log(tokensLine(model)); continue; }
645
647
  if (cmd === "yolo") { yolo = !yolo; log(C.dim(`[astra] auto-run ${yolo ? "ON" : "OFF"}.`)); refresh(); continue; }
648
+ if (cmd === "plan") {
649
+ const taskText = line.slice(line.indexOf("plan") + 4).trim();
650
+ const prevYolo = yolo;
651
+ await handlePlanWorkflow({
652
+ taskText,
653
+ agent,
654
+ model,
655
+ log,
656
+ readInput: (promptText) => screen.readLine(promptText, { guardEnter: true, echo: true }),
657
+ startBusy: (label) => screen.startBusy(label),
658
+ stopBusy: () => screen.stopBusy(),
659
+ interrupt,
660
+ setYolo: (v) => { yolo = v; },
661
+ onProgress: () => refresh(),
662
+ });
663
+ yolo = prevYolo;
664
+ refresh();
665
+ continue;
666
+ }
646
667
  log(C.dim(`[astra] unknown command: /${cmd} (try /help)`));
647
668
  continue;
648
669
  }
@@ -742,6 +763,25 @@ async function runPlainRepl(agent, model, yolo) {
742
763
  if (cmd === "history") { printHistory(agent, log); continue; }
743
764
  if (cmd === "tokens") { console.error(tokensLine(model)); continue; }
744
765
  if (cmd === "yolo") { yolo = !yolo; console.error(C.dim(`[astra] auto-run ${yolo ? "ON" : "OFF"}.`)); continue; }
766
+ if (cmd === "plan") {
767
+ const taskText = line.slice(line.indexOf("plan") + 4).trim();
768
+ const prevYolo = yolo;
769
+ await handlePlanWorkflow({
770
+ taskText,
771
+ agent,
772
+ model,
773
+ log: (t) => console.error(t),
774
+ readInput: (promptText) => new Promise((res) => rl.question(promptText, res)),
775
+ startBusy: () => {},
776
+ stopBusy: () => {},
777
+ interrupt: { aborted: false, quit: false },
778
+ setYolo: (v) => { yolo = v; },
779
+ onProgress: () => printFooterInline(agent, model, yolo),
780
+ });
781
+ yolo = prevYolo;
782
+ printFooterInline(agent, model, yolo);
783
+ continue;
784
+ }
745
785
  console.error(C.dim(`[astra] unknown command: /${cmd} (try /help)`));
746
786
  continue;
747
787
  }
@@ -859,3 +899,161 @@ function tokensLine(model) {
859
899
  const dollars = usd < 0.01 ? "$" + usd.toFixed(5) : "$" + usd.toFixed(4);
860
900
  return C.dim(`[astra] tokens: prompt=${pt} completion=${ct} total=${pt + ct} · cost=${dollars} (${src})`);
861
901
  }
902
+
903
+
904
+ /**
905
+ * Run the plan-and-execute workflow:
906
+ * 1. Prompt model to explore workspace and draft a structured plan.
907
+ * 2. Pause and prompt user for approval / feedback.
908
+ * 3. On approval, execute autonomously to completion with auto-review and auto-fix.
909
+ */
910
+ export async function handlePlanWorkflow({
911
+ taskText,
912
+ agent,
913
+ model,
914
+ log,
915
+ readInput,
916
+ startBusy,
917
+ stopBusy,
918
+ interrupt,
919
+ setYolo = () => {},
920
+ onProgress = () => {},
921
+ }) {
922
+ let task = taskText;
923
+ if (!task) {
924
+ task = (await readInput(C.yellow("Enter task to plan: "))).trim();
925
+ }
926
+ if (!task) {
927
+ log(C.dim("[astra] plan cancelled (empty task)."));
928
+ return;
929
+ }
930
+
931
+ log(C.cyan(`[astra] exploring workspace and drafting plan for: "${task}"...`));
932
+ agent.addUserMessage(render(PLAN_PROMPT_TEMPLATE, { task }));
933
+
934
+ model._abort = new AbortController();
935
+ model.signal = model._abort.signal;
936
+ startBusy("planning");
937
+ try {
938
+ await driveUntilChat(agent, log, interrupt);
939
+ } finally {
940
+ stopBusy();
941
+ model.signal = null;
942
+ model._abort = null;
943
+ }
944
+ if (interrupt?.quit || interrupt?.aborted) return;
945
+
946
+ // Review gate loop
947
+ let approved = false;
948
+ while (!approved) {
949
+ const ans = (await readInput(
950
+ C.yellow("\nApprove plan and begin autonomous execution? [Y/n/feedback] ")
951
+ )).trim();
952
+ const lower = ans.toLowerCase();
953
+
954
+ if (lower === "n" || lower === "no" || lower === "cancel" || lower === "abort") {
955
+ log(C.dim("[astra] plan cancelled. Returning to interactive mode."));
956
+ return;
957
+ }
958
+ if (ans === "" || lower === "y" || lower === "yes") {
959
+ approved = true;
960
+ break;
961
+ }
962
+
963
+ // Feedback provided: refine plan
964
+ log(C.cyan(`[astra] updating plan with feedback: "${ans}"...`));
965
+ agent.addUserMessage(
966
+ `Here is feedback on the plan: "${ans}". Please refine the plan accordingly and present the updated plan.`
967
+ );
968
+ model._abort = new AbortController();
969
+ model.signal = model._abort.signal;
970
+ startBusy("planning");
971
+ try {
972
+ await driveUntilChat(agent, log, interrupt);
973
+ } finally {
974
+ stopBusy();
975
+ model.signal = null;
976
+ model._abort = null;
977
+ }
978
+ if (interrupt?.quit || interrupt?.aborted) return;
979
+ }
980
+
981
+ // Execute autonomously
982
+ log(C.green("\n[astra] plan approved! Starting unattended autonomous execution with auto-review & auto-fix..."));
983
+ const prevMode = agent.mode;
984
+ setYolo(true);
985
+ agent.mode = "autonomous";
986
+ agent.addUserMessage(AUTONOMOUS_EXECUTION_PROMPT);
987
+
988
+ model._abort = new AbortController();
989
+ model.signal = model._abort.signal;
990
+ startBusy("executing plan");
991
+ let status = "Completed";
992
+ try {
993
+ status = await driveAutonomous(agent, log, interrupt);
994
+ } catch (err) {
995
+ log(C.red(`[astra] error during autonomous execution: ${err?.message || err}`));
996
+ status = "Error";
997
+ } finally {
998
+ stopBusy();
999
+ model.signal = null;
1000
+ model._abort = null;
1001
+ agent.mode = prevMode;
1002
+ agent.exitStatus = null;
1003
+ onProgress();
1004
+ }
1005
+
1006
+ log(C.green(`\n[astra] plan execution finished (${status}). You are now back in interactive mode.`));
1007
+ }
1008
+
1009
+ /**
1010
+ * Drive turns autonomously until the model submits with the completion sentinel.
1011
+ */
1012
+ export async function driveAutonomous(agent, log, interrupt = null) {
1013
+ while (true) {
1014
+ if (interrupt && interrupt.aborted) {
1015
+ log(C.yellow("[astra] autonomous execution interrupted — returning to prompt."));
1016
+ return "Interrupted";
1017
+ }
1018
+ let turn;
1019
+ try {
1020
+ turn = await agent.runTurn();
1021
+ } catch (err) {
1022
+ if (err && (err.name === "GatewayError" || err.name === "ContextWindowError")) {
1023
+ log(C.red(`[astra] ${err.message}`));
1024
+ return err.name;
1025
+ }
1026
+ throw err;
1027
+ }
1028
+
1029
+ if (interrupt && interrupt.aborted) {
1030
+ log(C.yellow("[astra] autonomous execution interrupted — returning to prompt."));
1031
+ return "Interrupted";
1032
+ }
1033
+ if (turn.kind === "exit") {
1034
+ log(C.dim(`[astra] autonomous run ended: ${turn.exit_status}`));
1035
+ return turn.exit_status;
1036
+ }
1037
+ if (turn.kind === "command") {
1038
+ const rc = turn.returncode;
1039
+ const tag = rc === 0 ? C.dim(`[rc ${rc}]`) : C.red(`[rc ${rc}]`);
1040
+ log(tag + "\n" + indent(turn.output));
1041
+ continue;
1042
+ }
1043
+ if (turn.kind === "declined") {
1044
+ log(C.dim("[astra] command skipped."));
1045
+ continue;
1046
+ }
1047
+ if (turn.kind === "format_error") {
1048
+ log(C.dim(`[astra] (reprompting: ${turn.error})`));
1049
+ continue;
1050
+ }
1051
+ if (turn.kind === "chat") {
1052
+ log("\n" + C.cyan("astra › ") + turn.content.trim());
1053
+ agent.addUserMessage(
1054
+ "Continue executing the approved plan in autonomous mode. When all changes are implemented, tested, and self-reviewed, run:\necho COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT"
1055
+ );
1056
+ continue;
1057
+ }
1058
+ }
1059
+ }