@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/README.md +36 -2
- package/package.json +1 -1
- package/src/agent.js +9 -0
- package/src/bench.js +478 -0
- package/src/cli.js +322 -33
- package/src/config.js +78 -0
- package/src/model.js +9 -0
- package/src/models.js +31 -0
- package/src/prompts.js +7 -3
- package/src/repl.js +213 -16
- package/src/report.js +459 -0
package/src/repl.js
CHANGED
|
@@ -15,7 +15,8 @@ import path from "node:path";
|
|
|
15
15
|
import { execFileSync } from "node:child_process";
|
|
16
16
|
import readline from "node:readline";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
|
-
import { ask } from "./config.js";
|
|
18
|
+
import { ask, saveAgentPrefs } from "./config.js";
|
|
19
|
+
import { AVAILABLE_MODELS, REASONING_LEVELS } from "./models.js";
|
|
19
20
|
|
|
20
21
|
const C = {
|
|
21
22
|
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
@@ -55,9 +56,9 @@ function pkgVersion() {
|
|
|
55
56
|
// ASCII wordmark (figlet "standard" style) shown when a new chat starts.
|
|
56
57
|
const BANNER = [
|
|
57
58
|
" _ ",
|
|
58
|
-
" __ _ ___ | |_
|
|
59
|
-
" / _` |/ __| | __| |
|
|
60
|
-
"| (_| |\\__ \\ | |_ | | | (_|
|
|
59
|
+
" __ _ ___ | |_ _ __ __ _ ",
|
|
60
|
+
" / _` |/ __| | __| | '__| / _` |",
|
|
61
|
+
"| (_| |\\__ \\ | |_ | | | (_| |",
|
|
61
62
|
" \\__,_||___/ \\__| |_| \\__,_|",
|
|
62
63
|
];
|
|
63
64
|
|
|
@@ -131,13 +132,14 @@ function turnSummary({ turn, upTok, downTok, costUsd, costKind, seconds }) {
|
|
|
131
132
|
* and cumulative token/context/cost usage. Returned as an array so the sticky
|
|
132
133
|
* screen can pin them to the bottom rows.
|
|
133
134
|
*/
|
|
134
|
-
function footerLines(agent, model, yolo) {
|
|
135
|
+
function footerLines(agent, model, yolo, mode = "agent") {
|
|
135
136
|
const cols = process.stdout.columns || 100;
|
|
136
137
|
const rule = "─".repeat(cols);
|
|
137
138
|
|
|
138
|
-
const reasoning = model.modelKwargs?.reasoning_effort || model.modelKwargs?.reasoning?.effort;
|
|
139
|
+
const reasoning = model.modelKwargs?.reasoning_effort || model.modelKwargs?.reasoning?.effort || "off";
|
|
140
|
+
const modeTag = mode === "bench" ? C.yellow("◆ bench") : C.cyan("◆ agent");
|
|
139
141
|
const modelBits = [`model: ${model.model}`];
|
|
140
|
-
|
|
142
|
+
modelBits.push(`reasoning: ${reasoning}`);
|
|
141
143
|
modelBits.push(yolo ? "auto-run" : "approve");
|
|
142
144
|
|
|
143
145
|
const up = model.totalPromptTokens;
|
|
@@ -152,7 +154,7 @@ function footerLines(agent, model, yolo) {
|
|
|
152
154
|
|
|
153
155
|
return [
|
|
154
156
|
C.dim(rule),
|
|
155
|
-
C.cyan(repoLabel()) + " " + C.dim(modelBits.join(" ")),
|
|
157
|
+
modeTag + " " + C.cyan(repoLabel()) + " " + C.dim(modelBits.join(" ")) + " " + C.dim("opt+←→ model · opt+↑↓ reason · tab: mode"),
|
|
156
158
|
C.dim(usage),
|
|
157
159
|
];
|
|
158
160
|
}
|
|
@@ -162,6 +164,57 @@ const PROMPT_PREFIX = C.green("› "); // live input row (minimal)
|
|
|
162
164
|
const HISTORY_PREFIX = C.green("you › "); // echoed into transcript/history
|
|
163
165
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
164
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Recognize an Option/Alt + arrow key from a terminal input string.
|
|
169
|
+
* Returns "up" | "down" | "left" | "right" | null.
|
|
170
|
+
*
|
|
171
|
+
* Terminals encode these differently:
|
|
172
|
+
* - xterm modifier form: ESC[1;3A/B/C/D (3 = Alt/Option)
|
|
173
|
+
* - meta word-move (iTerm2 default): ESC b (left word) / ESC f (right word)
|
|
174
|
+
* - some send ESC ESC[A.. (double-ESC) for Alt+arrow
|
|
175
|
+
* When `latched` is true the leading ESC may already have been consumed, so we
|
|
176
|
+
* also accept the tails without the leading ESC.
|
|
177
|
+
*/
|
|
178
|
+
function matchOptionArrow(s, latched = false) {
|
|
179
|
+
const arrows = { A: "up", B: "down", C: "right", D: "left" };
|
|
180
|
+
// xterm modifier form with Alt (mod code 3) or Meta (mod code 9).
|
|
181
|
+
let m = s.match(/^\x1b\[1;(?:3|9)([ABCD])$/);
|
|
182
|
+
if (m) return arrows[m[1]];
|
|
183
|
+
// Double-ESC form: ESC ESC [ A..
|
|
184
|
+
m = s.match(/^\x1b\x1b\[([ABCD])$/);
|
|
185
|
+
if (m) return arrows[m[1]];
|
|
186
|
+
// Meta word-move: ESC b / ESC f -> treat as left/right (model cycling).
|
|
187
|
+
if (s === "\x1bb") return "left";
|
|
188
|
+
if (s === "\x1bf") return "right";
|
|
189
|
+
if (latched) {
|
|
190
|
+
// Leading ESC already latched; match the tail we reconstructed as ESC+tail.
|
|
191
|
+
m = s.match(/^\x1b\[1;(?:3|9)([ABCD])$/); // (handled above, kept for clarity)
|
|
192
|
+
if (m) return arrows[m[1]];
|
|
193
|
+
if (s === "\x1bb") return "left";
|
|
194
|
+
if (s === "\x1bf") return "right";
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Read the model's current reasoning-effort level ("off" when unset). */
|
|
200
|
+
function currentReasoning(model) {
|
|
201
|
+
const eff = model.modelKwargs?.reasoning_effort || model.modelKwargs?.reasoning?.effort;
|
|
202
|
+
return eff || "off";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Apply a reasoning-effort level to the live model's request kwargs. */
|
|
206
|
+
function setReasoning(model, level) {
|
|
207
|
+
model.modelKwargs = { ...(model.modelKwargs || {}) };
|
|
208
|
+
const l = String(level || "").toLowerCase();
|
|
209
|
+
if (!l || l === "off" || l === "none" || l === "disabled") {
|
|
210
|
+
delete model.modelKwargs.reasoning_effort;
|
|
211
|
+
delete model.modelKwargs.reasoning;
|
|
212
|
+
} else {
|
|
213
|
+
model.modelKwargs.reasoning_effort = l;
|
|
214
|
+
delete model.modelKwargs.reasoning;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
165
218
|
/**
|
|
166
219
|
* A bottom-anchored screen: conversation output scrolls in the top region
|
|
167
220
|
* (bounded by a DECSTBM scroll margin) while the footer + input prompt stay
|
|
@@ -189,8 +242,7 @@ class Screen {
|
|
|
189
242
|
this._onResize = this._onResize.bind(this);
|
|
190
243
|
}
|
|
191
244
|
|
|
192
|
-
get rows() { return this.out.rows || 24; }
|
|
193
|
-
// Footer block (4 rows) pinned to the bottom: divider, prompt, repo/model, usage.
|
|
245
|
+
get rows() { return this.out.rows || 24; } // Footer block (4 rows) pinned to the bottom: divider, prompt, repo/model, usage.
|
|
194
246
|
get reserved() { return FOOTER_LINES + 1; } // +1 for the prompt row
|
|
195
247
|
get scrollBottom() { return Math.max(1, this.rows - this.reserved); } // last scrollable row
|
|
196
248
|
get footerTop() { return this.scrollBottom + 1; } // divider row
|
|
@@ -216,6 +268,12 @@ class Screen {
|
|
|
216
268
|
this.out.write(`\x1b[${this.rows};1H\n`); // move below footer
|
|
217
269
|
}
|
|
218
270
|
|
|
271
|
+
/** Cancel any pending lone-ESC latch/timer. */
|
|
272
|
+
_clearEscLatch() {
|
|
273
|
+
this._pendingEsc = false;
|
|
274
|
+
clearTimeout(this._escTimer);
|
|
275
|
+
}
|
|
276
|
+
|
|
219
277
|
/** Write a block of text into the scrolling region (may contain newlines). */
|
|
220
278
|
log(text) {
|
|
221
279
|
this.out.write(`\x1b[${this.scrollBottom};1H`); // park at bottom of scroll area
|
|
@@ -312,6 +370,86 @@ class Screen {
|
|
|
312
370
|
}
|
|
313
371
|
|
|
314
372
|
_onData(chunk) {
|
|
373
|
+
const s = chunk.toString();
|
|
374
|
+
// Optional key debug: set ASTRA_KEYDEBUG=1 to print the raw bytes of every
|
|
375
|
+
// keypress into the transcript. Use it to discover what your terminal
|
|
376
|
+
// actually sends for Option/Alt+Tab, then report it.
|
|
377
|
+
if (process.env.ASTRA_KEYDEBUG && this.log) {
|
|
378
|
+
const bytes = Array.from(chunk).map((b) => "0x" + b.toString(16).padStart(2, "0")).join(" ");
|
|
379
|
+
this.log(`\x1b[35m[keydebug] ${bytes}\x1b[0m`);
|
|
380
|
+
}
|
|
381
|
+
// --- Key sequences: mode toggle, model/reasoning cycling, Esc-to-clear ---
|
|
382
|
+
//
|
|
383
|
+
// Mode toggle: Alt/Option+Tab is delivered as ESC+Tab ("\x1b\t"); Shift+Tab
|
|
384
|
+
// ("\x1b[Z") is a reliable fallback. Model/reasoning cycling uses Option +
|
|
385
|
+
// arrow keys. A bare Esc (nothing following it) clears the input line.
|
|
386
|
+
//
|
|
387
|
+
// Because some terminals split escape sequences across reads (a lone ESC,
|
|
388
|
+
// then the rest), we latch a lone ESC briefly. If a Tab follows -> toggle;
|
|
389
|
+
// if nothing follows within the window -> treat it as bare Esc (clear).
|
|
390
|
+
|
|
391
|
+
// Whole-chunk fast paths first.
|
|
392
|
+
if (this.onToggleMode && (s === "\x1b\t" || s === "\x1b[Z")) {
|
|
393
|
+
this._clearEscLatch();
|
|
394
|
+
this.onToggleMode();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
{
|
|
398
|
+
const opt = matchOptionArrow(s, false);
|
|
399
|
+
if (opt) {
|
|
400
|
+
this._clearEscLatch();
|
|
401
|
+
if ((opt === "left" || opt === "right") && this.onCycleModel) {
|
|
402
|
+
this.onCycleModel(opt === "right" ? 1 : -1);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if ((opt === "up" || opt === "down") && this.onCycleReasoning) {
|
|
406
|
+
this.onCycleReasoning(opt === "up" ? 1 : -1);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
return; // recognized an option-arrow we don't act on; swallow it
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// A Tab following a latched ESC -> mode toggle (split Alt+Tab delivery).
|
|
414
|
+
if (this._pendingEsc && s === "\t" && this.onToggleMode) {
|
|
415
|
+
this._clearEscLatch();
|
|
416
|
+
this.onToggleMode();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
// A latched ESC followed by an arrow tail (e.g. "[C", "b", "f") -> option-arrow.
|
|
420
|
+
if (this._pendingEsc) {
|
|
421
|
+
const opt = matchOptionArrow("\x1b" + s, true);
|
|
422
|
+
if (opt) {
|
|
423
|
+
this._clearEscLatch();
|
|
424
|
+
if ((opt === "left" || opt === "right") && this.onCycleModel) {
|
|
425
|
+
this.onCycleModel(opt === "right" ? 1 : -1);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if ((opt === "up" || opt === "down") && this.onCycleReasoning) {
|
|
429
|
+
this.onCycleReasoning(opt === "up" ? 1 : -1);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
// Any other input right after a latched ESC cancels the latch and is
|
|
435
|
+
// processed normally below.
|
|
436
|
+
this._clearEscLatch();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// A lone ESC: latch briefly to disambiguate from split escape sequences.
|
|
440
|
+
// If nothing arrives, the timer treats it as a bare Esc and clears input.
|
|
441
|
+
if (s === "\x1b") {
|
|
442
|
+
this._pendingEsc = true;
|
|
443
|
+
clearTimeout(this._escTimer);
|
|
444
|
+
this._escTimer = setTimeout(() => {
|
|
445
|
+
this._pendingEsc = false;
|
|
446
|
+
if (this.onClearInput) this.onClearInput();
|
|
447
|
+
this.buf = "";
|
|
448
|
+
this.drawPrompt();
|
|
449
|
+
}, 60);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
315
453
|
for (const ch of chunk) {
|
|
316
454
|
if (ch === "\r" || ch === "\n") {
|
|
317
455
|
// Collapse a CRLF pair into one Enter: ignore a \n right after a \r.
|
|
@@ -370,24 +508,32 @@ Interactive commands:
|
|
|
370
508
|
/history print the message history
|
|
371
509
|
/tokens show token usage so far
|
|
372
510
|
/yolo toggle auto-run of commands (no confirmation)
|
|
511
|
+
|
|
512
|
+
Keyboard shortcuts:
|
|
513
|
+
alt/opt+tab switch agent / bench mode (shift+tab also works)
|
|
514
|
+
opt+left/right cycle the model
|
|
515
|
+
opt+up/down cycle reasoning effort (off/low/medium/high)
|
|
516
|
+
esc clear the input line
|
|
373
517
|
`;
|
|
374
518
|
|
|
375
519
|
/**
|
|
376
520
|
* Run the interactive loop against a prepared Agent.
|
|
377
521
|
* @param {Agent} agent a started (or restored) agent in interactive mode
|
|
378
|
-
* @param {object} opts { model, autoRun }
|
|
522
|
+
* @param {object} opts { model, autoRun, fresh, reasoning, runBench }
|
|
523
|
+
* runBench(taskText, log) => Promise optional; invoked when the user submits
|
|
524
|
+
* a message while the REPL is toggled to bench mode. Should run the task in
|
|
525
|
+
* an isolated bench workspace and record metrics.
|
|
379
526
|
*/
|
|
380
|
-
export async function runRepl(agent, { model, autoRun = false, fresh = true } = {}) {
|
|
527
|
+
export async function runRepl(agent, { model, autoRun = false, fresh = true, reasoning = "", runBench = null } = {}) {
|
|
381
528
|
let yolo = autoRun;
|
|
382
529
|
const tty = !!process.stdout.isTTY && !!process.stdin.isTTY;
|
|
383
530
|
|
|
384
531
|
const intro = fresh
|
|
385
532
|
? bannerLines({ session: agent.sessionId })
|
|
386
533
|
: [C.dim(`[astra] resumed session ${agent.sessionId} · model=${model.model}`)];
|
|
387
|
-
intro.push(C.dim(`Type a request, or /help. Commands ${yolo ? "auto-run" : "need approval"}.`));
|
|
388
534
|
|
|
389
535
|
if (tty) {
|
|
390
|
-
await runStickyRepl(agent, model, yolo, intro);
|
|
536
|
+
await runStickyRepl(agent, model, yolo, intro, { runBench });
|
|
391
537
|
} else {
|
|
392
538
|
for (const line of intro) console.error(line);
|
|
393
539
|
await runPlainRepl(agent, model, yolo);
|
|
@@ -395,10 +541,40 @@ export async function runRepl(agent, { model, autoRun = false, fresh = true } =
|
|
|
395
541
|
}
|
|
396
542
|
|
|
397
543
|
/** Sticky bottom-anchored REPL (real terminals). */
|
|
398
|
-
async function runStickyRepl(agent, model, yolo, intro = []) {
|
|
544
|
+
async function runStickyRepl(agent, model, yolo, intro = [], { runBench = null } = {}) {
|
|
399
545
|
const screen = new Screen();
|
|
400
546
|
const log = (text) => screen.log(text);
|
|
401
|
-
|
|
547
|
+
let mode = "agent"; // "agent" | "bench"
|
|
548
|
+
const refresh = () => screen.refresh(footerLines(agent, model, yolo, mode));
|
|
549
|
+
|
|
550
|
+
// Alt/Option+Tab (or Shift+Tab) toggles between agent and bench mode.
|
|
551
|
+
screen.onToggleMode = () => {
|
|
552
|
+
mode = mode === "agent" ? "bench" : "agent";
|
|
553
|
+
refresh();
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// Option+Left/Right cycles the model; Option+Up/Down cycles reasoning effort.
|
|
557
|
+
// Changes apply to the live model and are cached as the preferred agent
|
|
558
|
+
// prefs so the next `astra` launch reuses them.
|
|
559
|
+
screen.onCycleModel = (dir) => {
|
|
560
|
+
const list = AVAILABLE_MODELS;
|
|
561
|
+
const cur = list.indexOf(model.model);
|
|
562
|
+
const next = ((cur === -1 ? 0 : cur) + dir + list.length) % list.length;
|
|
563
|
+
model.model = list[next];
|
|
564
|
+
saveAgentPrefs({ model: model.model });
|
|
565
|
+
refresh();
|
|
566
|
+
};
|
|
567
|
+
screen.onCycleReasoning = (dir) => {
|
|
568
|
+
const list = REASONING_LEVELS;
|
|
569
|
+
const cur = list.indexOf(currentReasoning(model));
|
|
570
|
+
const next = ((cur === -1 ? 0 : cur) + dir + list.length) % list.length;
|
|
571
|
+
setReasoning(model, list[next]);
|
|
572
|
+
saveAgentPrefs({ reasoning: list[next] });
|
|
573
|
+
refresh();
|
|
574
|
+
};
|
|
575
|
+
// Bare Esc clears the current input line (handled by the screen; nothing
|
|
576
|
+
// extra needed here, but expose a hook for symmetry / future use).
|
|
577
|
+
screen.onClearInput = () => {};
|
|
402
578
|
|
|
403
579
|
// Approval gate: show the command, then ask for y/n/a on the prompt row.
|
|
404
580
|
agent.confirm = async (command) => {
|
|
@@ -436,6 +612,27 @@ async function runStickyRepl(agent, model, yolo, intro = []) {
|
|
|
436
612
|
|
|
437
613
|
const before = usageSnapshot(model);
|
|
438
614
|
const t0 = Date.now();
|
|
615
|
+
|
|
616
|
+
// Bench mode: run the message as an autonomous task in an isolated
|
|
617
|
+
// workspace, record metrics, then drop back to agent mode.
|
|
618
|
+
if (mode === "bench") {
|
|
619
|
+
if (!runBench) {
|
|
620
|
+
log(C.red("[astra] bench runner unavailable in this session."));
|
|
621
|
+
mode = "agent"; refresh(); continue;
|
|
622
|
+
}
|
|
623
|
+
screen.startBusy("benching");
|
|
624
|
+
try {
|
|
625
|
+
await runBench(line, log);
|
|
626
|
+
} catch (err) {
|
|
627
|
+
log(C.red(`[astra] bench failed: ${err?.message || err}`));
|
|
628
|
+
} finally {
|
|
629
|
+
screen.stopBusy();
|
|
630
|
+
}
|
|
631
|
+
mode = "agent";
|
|
632
|
+
refresh();
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
|
|
439
636
|
agent.addUserMessage(line);
|
|
440
637
|
screen.startBusy("working");
|
|
441
638
|
try {
|