@hackerrank/astra-cli 0.1.0

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/repl.js ADDED
@@ -0,0 +1,601 @@
1
+ /**
2
+ * Interactive REPL — astra as a personal coding assistant (like cline / claude
3
+ * code). You type a request, the agent may run shell commands (with your
4
+ * approval by default) and then replies; you type the next thing. The whole
5
+ * conversation is a resumable session under ~/.astra/sessions/.
6
+ *
7
+ * Commands run one at a time (our text protocol). Between commands the agent
8
+ * loops autonomously within a single "turn" until it produces a chat reply
9
+ * (no bash block), which hands control back to you.
10
+ */
11
+
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { execFileSync } from "node:child_process";
16
+ import readline from "node:readline";
17
+ import { fileURLToPath } from "node:url";
18
+ import { ask } from "./config.js";
19
+
20
+ const C = {
21
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
22
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
23
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
24
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
25
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
26
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
27
+ };
28
+
29
+ // Rough context-window sizes (tokens) for the % readout in the footer. The
30
+ // gateway doesn't report these, so this is a best-effort lookup; unknown
31
+ // models simply omit the percentage.
32
+ const CONTEXT_WINDOWS = {
33
+ "claude-opus-5": 200000,
34
+ "claude-sonnet-5": 200000,
35
+ "gemini-3.7-flash": 1000000,
36
+ "gpt-5.6-luna": 400000,
37
+ "gpt-5.6-sol": 400000,
38
+ "gpt-5.6-terra": 400000,
39
+ "grok-4.6": 256000,
40
+ "deepseek-v4-pro": 128000,
41
+ "glm-5.2": 128000,
42
+ "kimi-k3": 256000,
43
+ "qwen-3.8": 256000,
44
+ };
45
+
46
+ function pkgVersion() {
47
+ try {
48
+ const here = path.dirname(fileURLToPath(import.meta.url));
49
+ return JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8")).version;
50
+ } catch {
51
+ return "0.0.0";
52
+ }
53
+ }
54
+
55
+ // ASCII wordmark (figlet "standard" style) shown when a new chat starts.
56
+ const BANNER = [
57
+ " _ ",
58
+ " __ _ ___ | |_ __ __ __ _ ",
59
+ " / _` |/ __| | __| |__ __| / _` |",
60
+ "| (_| |\\__ \\ | |_ | | | (_| |",
61
+ " \\__,_||___/ \\__| |_| \\__,_|",
62
+ ];
63
+
64
+ /** Center a string within the current terminal width. */
65
+ function center(s, cols) {
66
+ const pad = Math.max(0, Math.floor((cols - s.length) / 2));
67
+ return " ".repeat(pad) + s;
68
+ }
69
+
70
+ /** Build the centered banner lines (wordmark + version/status/session). */
71
+ function bannerLines({ session } = {}) {
72
+ const cols = process.stdout.columns || 80;
73
+ const out = [""];
74
+ for (const line of BANNER) out.push(C.cyan(center(line, cols)));
75
+ out.push("");
76
+ out.push(C.dim(center(`astra CLI v${pkgVersion()}`, cols)));
77
+ out.push(C.green(center("All set up!", cols)));
78
+ if (session) out.push(C.dim(center(`Initialized conversation ${session}`, cols)));
79
+ out.push("");
80
+ return out;
81
+ }
82
+
83
+ /** Thousands / compact number formatting (1.1M, 26k, 512). */
84
+ function fmt(n) {
85
+ const v = Number(n) || 0;
86
+ if (v >= 1_000_000) return (v / 1_000_000).toFixed(v >= 10_000_000 ? 0 : 1) + "M";
87
+ if (v >= 1_000) return (v / 1_000).toFixed(v >= 10_000 ? 0 : 1) + "k";
88
+ return String(v);
89
+ }
90
+
91
+ /** USD with sensible precision for tiny amounts. */
92
+ function fmtUsd(n) {
93
+ const v = Number(n) || 0;
94
+ if (v === 0) return "$0";
95
+ if (v < 0.01) return "$" + v.toFixed(5);
96
+ if (v < 1) return "$" + v.toFixed(4);
97
+ return "$" + v.toFixed(2);
98
+ }
99
+
100
+ /** Working directory shown with a leading ~ for $HOME, plus git branch. */
101
+ function repoLabel() {
102
+ let cwd = process.cwd();
103
+ const home = os.homedir();
104
+ if (cwd === home) cwd = "~";
105
+ else if (cwd.startsWith(home + path.sep)) cwd = "~" + cwd.slice(home.length);
106
+ let branch = "";
107
+ try {
108
+ branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
109
+ stdio: ["ignore", "pipe", "ignore"],
110
+ })
111
+ .toString()
112
+ .trim();
113
+ } catch {
114
+ /* not a git repo */
115
+ }
116
+ return branch ? `${cwd} (${branch})` : cwd;
117
+ }
118
+
119
+ /** Compact one-line summary printed after each turn hands back to the user. */
120
+ function turnSummary({ turn, upTok, downTok, costUsd, costKind, seconds }) {
121
+ const cost = costUsd != null
122
+ ? ` · ${fmtUsd(costUsd)}${costKind === "estimated" ? "~" : ""}`
123
+ : "";
124
+ return C.dim(
125
+ `turn ${turn} · ↑${fmt(upTok)} ↓${fmt(downTok)} tok${cost} · ${seconds.toFixed(1)}s`
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Build the footer lines: a full-width divider + repo/branch, model/reasoning,
131
+ * and cumulative token/context/cost usage. Returned as an array so the sticky
132
+ * screen can pin them to the bottom rows.
133
+ */
134
+ function footerLines(agent, model, yolo) {
135
+ const cols = process.stdout.columns || 100;
136
+ const rule = "─".repeat(cols);
137
+
138
+ const reasoning = model.modelKwargs?.reasoning_effort || model.modelKwargs?.reasoning?.effort;
139
+ const modelBits = [`model: ${model.model}`];
140
+ if (reasoning) modelBits.push(`reasoning: ${reasoning}`);
141
+ modelBits.push(yolo ? "auto-run" : "approve");
142
+
143
+ const up = model.totalPromptTokens;
144
+ const down = model.totalCompletionTokens;
145
+ const ctx = agent.lastContextTokens || 0;
146
+ const win = CONTEXT_WINDOWS[model.model];
147
+ const ctxStr = win
148
+ ? `${((ctx / win) * 100).toFixed(1)}%/${fmt(win)}`
149
+ : `${fmt(ctx)} ctx`;
150
+ const src = model.costSource && model.costSource !== "n/a" ? ` ${model.costSource}` : "";
151
+ const usage = `↑${fmt(up)} ↓${fmt(down)} · ${ctxStr} · ${fmtUsd(model.totalCostUsd || 0)}${src}`;
152
+
153
+ return [
154
+ C.dim(rule),
155
+ C.cyan(repoLabel()) + " " + C.dim(modelBits.join(" ")),
156
+ C.dim(usage),
157
+ ];
158
+ }
159
+
160
+ const FOOTER_LINES = 3; // divider + repo/model + usage
161
+ const PROMPT_PREFIX = C.green("› "); // live input row (minimal)
162
+ const HISTORY_PREFIX = C.green("you › "); // echoed into transcript/history
163
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
164
+
165
+ /**
166
+ * A bottom-anchored screen: conversation output scrolls in the top region
167
+ * (bounded by a DECSTBM scroll margin) while the footer + input prompt stay
168
+ * glued to the terminal's bottom rows. Input is read via raw keypresses (not
169
+ * readline) so nothing fights the scroll margin. Requires a TTY; callers use
170
+ * the readline fallback otherwise.
171
+ */
172
+ class Screen {
173
+ constructor(out = process.stdout, inp = process.stdin) {
174
+ this.out = out;
175
+ this.inp = inp;
176
+ this.footer = [];
177
+ this.buf = ""; // current input line
178
+ this.resolve = null; // pending readLine() resolver
179
+ this._lastWasCR = false;
180
+ this._guardEnterUntil = 0; // ignore stray Enter until this timestamp
181
+ this._echoOnCommit = true; // echo committed input to the transcript
182
+ this._busy = false; // loading state (agent is working)
183
+ this._busyTimer = null;
184
+ this._busyFrame = 0;
185
+ this._busyLabel = "working";
186
+ this._busyResume = false;
187
+ this.promptLabel = PROMPT_PREFIX;
188
+ this._onData = this._onData.bind(this);
189
+ this._onResize = this._onResize.bind(this);
190
+ }
191
+
192
+ get rows() { return this.out.rows || 24; }
193
+ // Footer block (4 rows) pinned to the bottom: divider, prompt, repo/model, usage.
194
+ get reserved() { return FOOTER_LINES + 1; } // +1 for the prompt row
195
+ get scrollBottom() { return Math.max(1, this.rows - this.reserved); } // last scrollable row
196
+ get footerTop() { return this.scrollBottom + 1; } // divider row
197
+ get promptRow() { return this.footerTop + 1; } // input row: right under the divider
198
+
199
+ /** Enter sticky mode: set scroll margin, park cursor, start listening. */
200
+ enter() {
201
+ this.out.write(`\x1b[1;${this.scrollBottom}r`); // scroll region = top area
202
+ this.out.write(`\x1b[${this.scrollBottom};1H`); // cursor at bottom of scroll area
203
+ if (this.inp.isTTY) this.inp.setRawMode(true);
204
+ this.inp.resume();
205
+ this.inp.setEncoding("utf8");
206
+ this.inp.on("data", this._onData);
207
+ this.out.on("resize", this._onResize);
208
+ }
209
+
210
+ /** Leave sticky mode: reset scroll region, restore cooked input. */
211
+ leave() {
212
+ this.inp.off("data", this._onData);
213
+ this.out.off("resize", this._onResize);
214
+ if (this.inp.isTTY) this.inp.setRawMode(false);
215
+ this.out.write("\x1b[r"); // reset scroll region
216
+ this.out.write(`\x1b[${this.rows};1H\n`); // move below footer
217
+ }
218
+
219
+ /** Write a block of text into the scrolling region (may contain newlines). */
220
+ log(text) {
221
+ this.out.write(`\x1b[${this.scrollBottom};1H`); // park at bottom of scroll area
222
+ this.out.write(String(text) + "\n"); // trailing \n scrolls the region
223
+ this.drawFooter();
224
+ this.drawPrompt();
225
+ }
226
+
227
+ /** Redraw the pinned footer + prompt (e.g. when usage changes). */
228
+ refresh(footer) {
229
+ if (footer) this.footer = footer;
230
+ this.drawFooter();
231
+ this.drawPrompt();
232
+ }
233
+
234
+ drawFooter() {
235
+ this.out.write("\x1b[s"); // save cursor
236
+ // footer[0] = divider (row footerTop), footer[1..] = status lines that go
237
+ // BELOW the prompt row (footerTop+1). So skip the prompt row when placing
238
+ // the status lines.
239
+ this.out.write(`\x1b[${this.footerTop};1H\x1b[2K`);
240
+ this.out.write(this.footer[0] || ""); // divider
241
+ for (let i = 1; i < FOOTER_LINES; i++) {
242
+ const row = this.promptRow + i; // status lines under the prompt
243
+ this.out.write(`\x1b[${row};1H\x1b[2K`);
244
+ this.out.write(this.footer[i] || "");
245
+ }
246
+ this.out.write("\x1b[u"); // restore cursor
247
+ }
248
+
249
+ drawPrompt() {
250
+ this.out.write(`\x1b[${this.promptRow};1H\x1b[2K`);
251
+ if (this._busy) {
252
+ const frame = SPINNER_FRAMES[this._busyFrame % SPINNER_FRAMES.length];
253
+ this.out.write(C.cyan(frame + " ") + C.dim(this._busyLabel + "…"));
254
+ return;
255
+ }
256
+ this.out.write((this.promptLabel || PROMPT_PREFIX) + this.buf);
257
+ }
258
+
259
+ /** Start the minimal loading state on the prompt row. */
260
+ startBusy(label = "working") {
261
+ this._busy = true;
262
+ this._busyLabel = label;
263
+ this._busyFrame = 0;
264
+ this.drawPrompt();
265
+ if (this._busyTimer) clearInterval(this._busyTimer);
266
+ this._busyTimer = setInterval(() => {
267
+ this._busyFrame++;
268
+ this.drawPrompt();
269
+ }, 90);
270
+ if (this._busyTimer.unref) this._busyTimer.unref();
271
+ }
272
+
273
+ /** Stop the loading state and restore the normal input prompt. */
274
+ stopBusy() {
275
+ if (this._busyTimer) { clearInterval(this._busyTimer); this._busyTimer = null; }
276
+ this._busy = false;
277
+ this._busyResume = false;
278
+ this.drawPrompt();
279
+ }
280
+
281
+ /**
282
+ * Resolve with the next full line the user types. An optional prompt label
283
+ * replaces the default "you › " (used by the approval gate so the question
284
+ * is shown right where the user is typing).
285
+ *
286
+ * `opts.guardEnter` ignores a bare Enter that lands within a short window of
287
+ * the prompt appearing. This prevents a stray newline still in the input
288
+ * buffer (from the previous line, a CRLF pair, or a paste) from instantly
289
+ * "answering" a confirmation prompt the user never actually saw.
290
+ */
291
+ readLine(promptLabel, opts = {}) {
292
+ return new Promise((res) => {
293
+ // A prompt (main input or approval) takes over the row; pause the spinner
294
+ // timer but remember whether we were busy so we can resume after.
295
+ this._busyResume = this._busy;
296
+ if (this._busyTimer) { clearInterval(this._busyTimer); this._busyTimer = null; }
297
+ this._busy = false;
298
+ this.promptLabel = promptLabel || PROMPT_PREFIX;
299
+ this.buf = "";
300
+ this.resolve = res;
301
+ this._guardEnterUntil = opts.guardEnter ? Date.now() + 250 : 0;
302
+ this._echoOnCommit = opts.echo !== false;
303
+ this.drawPrompt();
304
+ });
305
+ }
306
+
307
+ _onResize() {
308
+ this.out.write(`\x1b[1;${this.scrollBottom}r`);
309
+ this.footer = this.footer; // caller refreshes content separately
310
+ this.drawFooter();
311
+ this.drawPrompt();
312
+ }
313
+
314
+ _onData(chunk) {
315
+ for (const ch of chunk) {
316
+ if (ch === "\r" || ch === "\n") {
317
+ // Collapse a CRLF pair into one Enter: ignore a \n right after a \r.
318
+ if (ch === "\n" && this._lastWasCR) { this._lastWasCR = false; continue; }
319
+ this._lastWasCR = ch === "\r";
320
+ // Guard: ignore a bare Enter (empty buffer) that arrives immediately
321
+ // after a guarded prompt was shown. This stops a leftover newline from
322
+ // auto-resolving a confirmation the user never got to answer.
323
+ if (
324
+ this.buf === "" &&
325
+ this._guardEnterUntil &&
326
+ Date.now() < this._guardEnterUntil
327
+ ) {
328
+ continue;
329
+ }
330
+ const line = this.buf;
331
+ this.buf = "";
332
+ // Echo the committed line into the scroll area for a transcript.
333
+ // User input is echoed with the "you › " history prefix; approval
334
+ // prompts pass echo:false so their Q&A stays out of the transcript.
335
+ // Empty input is skipped so a bare Enter doesn't stack blank lines.
336
+ if (this._echoOnCommit && line.trim() !== "") {
337
+ const isUserPrompt = (this.promptLabel || PROMPT_PREFIX) === PROMPT_PREFIX;
338
+ this.log((isUserPrompt ? HISTORY_PREFIX : this.promptLabel) + line);
339
+ }
340
+ this.promptLabel = PROMPT_PREFIX;
341
+ this._echoOnCommit = true;
342
+ // Resume the loading state if a turn is still in progress (e.g. after
343
+ // an approval prompt hands control back to the running agent).
344
+ if (this._busyResume) { this._busyResume = false; this.startBusy(this._busyLabel); }
345
+ const r = this.resolve; this.resolve = null;
346
+ if (r) r(line);
347
+ } else if (ch === "\x7f" || ch === "\b") { // backspace
348
+ this.buf = this.buf.slice(0, -1);
349
+ this.drawPrompt();
350
+ } else if (ch === "\x03") { // Ctrl+C
351
+ const r = this.resolve; this.resolve = null;
352
+ if (r) r("__SIGINT__");
353
+ } else if (ch === "\x04") { // Ctrl+D
354
+ const r = this.resolve; this.resolve = null;
355
+ if (r) r("__EOF__");
356
+ } else if (ch >= " ") { // printable
357
+ this._lastWasCR = false;
358
+ this.buf += ch;
359
+ this.drawPrompt();
360
+ }
361
+ }
362
+ }
363
+ }
364
+
365
+ const HELP = `
366
+ Interactive commands:
367
+ /help show this help
368
+ /exit, /quit leave (session is saved)
369
+ /clear start a fresh conversation (new context)
370
+ /history print the message history
371
+ /tokens show token usage so far
372
+ /yolo toggle auto-run of commands (no confirmation)
373
+ `;
374
+
375
+ /**
376
+ * Run the interactive loop against a prepared Agent.
377
+ * @param {Agent} agent a started (or restored) agent in interactive mode
378
+ * @param {object} opts { model, autoRun }
379
+ */
380
+ export async function runRepl(agent, { model, autoRun = false, fresh = true } = {}) {
381
+ let yolo = autoRun;
382
+ const tty = !!process.stdout.isTTY && !!process.stdin.isTTY;
383
+
384
+ const intro = fresh
385
+ ? bannerLines({ session: agent.sessionId })
386
+ : [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
+
389
+ if (tty) {
390
+ await runStickyRepl(agent, model, yolo, intro);
391
+ } else {
392
+ for (const line of intro) console.error(line);
393
+ await runPlainRepl(agent, model, yolo);
394
+ }
395
+ }
396
+
397
+ /** Sticky bottom-anchored REPL (real terminals). */
398
+ async function runStickyRepl(agent, model, yolo, intro = []) {
399
+ const screen = new Screen();
400
+ const log = (text) => screen.log(text);
401
+ const refresh = () => screen.refresh(footerLines(agent, model, yolo));
402
+
403
+ // Approval gate: show the command, then ask for y/n/a on the prompt row.
404
+ agent.confirm = async (command) => {
405
+ log(C.yellow("$ " + command));
406
+ if (yolo) return true;
407
+ const ans = (await screen.readLine(C.yellow("run this command? [y/N/a=always] "), { guardEnter: true, echo: false })).trim().toLowerCase();
408
+ if (ans === "a" || ans === "always") { yolo = true; refresh(); return true; }
409
+ return ans === "y" || ans === "yes";
410
+ };
411
+
412
+ screen.enter();
413
+ refresh();
414
+ // Print the banner/intro into the scroll region so it sits above the pinned
415
+ // prompt instead of overlapping it.
416
+ for (const line of intro) log(line);
417
+ let turnNo = 0;
418
+ try {
419
+ while (true) {
420
+ const raw = await screen.readLine();
421
+ if (raw === "__EOF__" || raw === "__SIGINT__") break;
422
+ const line = raw.trim();
423
+ if (!line) { refresh(); continue; }
424
+
425
+ if (line.startsWith("/")) {
426
+ const [cmd] = line.slice(1).split(/\s+/);
427
+ if (cmd === "exit" || cmd === "quit") break;
428
+ if (cmd === "help") { log(HELP); continue; }
429
+ if (cmd === "clear") { agent.start(); log(C.dim("[astra] context cleared.")); refresh(); continue; }
430
+ if (cmd === "history") { printHistory(agent, log); continue; }
431
+ if (cmd === "tokens") { log(tokensLine(model)); continue; }
432
+ if (cmd === "yolo") { yolo = !yolo; log(C.dim(`[astra] auto-run ${yolo ? "ON" : "OFF"}.`)); refresh(); continue; }
433
+ log(C.dim(`[astra] unknown command: /${cmd} (try /help)`));
434
+ continue;
435
+ }
436
+
437
+ const before = usageSnapshot(model);
438
+ const t0 = Date.now();
439
+ agent.addUserMessage(line);
440
+ screen.startBusy("working");
441
+ try {
442
+ await driveUntilChat(agent, log);
443
+ } finally {
444
+ screen.stopBusy();
445
+ }
446
+ const after = usageSnapshot(model);
447
+ log(
448
+ turnSummary({
449
+ turn: ++turnNo,
450
+ upTok: after.up - before.up,
451
+ downTok: after.down - before.down,
452
+ costUsd: after.cost - before.cost,
453
+ costKind: model.costSource === "reported" ? "reported" : "estimated",
454
+ seconds: (Date.now() - t0) / 1000,
455
+ })
456
+ );
457
+ refresh();
458
+ }
459
+ } finally {
460
+ screen.leave();
461
+ agent.save();
462
+ console.error(C.dim(`[astra] session saved: ${agent.sessionId}`));
463
+ }
464
+ }
465
+
466
+ /** Plain inline REPL fallback (no TTY: piped input, CI, etc.). */
467
+ async function runPlainRepl(agent, model, yolo) {
468
+ agent.confirm = async (command) => {
469
+ console.error(C.yellow("\n$ " + command));
470
+ if (yolo) return true;
471
+ const ans = (await ask("Run this command? [y/N/a=always] ")).trim().toLowerCase();
472
+ if (ans === "a" || ans === "always") { yolo = true; return true; }
473
+ return ans === "y" || ans === "yes";
474
+ };
475
+
476
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
477
+ const prompt = () => new Promise((res) => rl.question("\n" + C.green("› "), res));
478
+ const log = (text) => console.error(text);
479
+
480
+ try {
481
+ printFooterInline(agent, model, yolo);
482
+ let turnNo = 0;
483
+ while (true) {
484
+ const line = (await prompt()).trim();
485
+ if (!line) continue;
486
+
487
+ if (line.startsWith("/")) {
488
+ const [cmd] = line.slice(1).split(/\s+/);
489
+ if (cmd === "exit" || cmd === "quit") break;
490
+ if (cmd === "help") { console.error(HELP); continue; }
491
+ if (cmd === "clear") { agent.start(); console.error(C.dim("[astra] context cleared.")); printFooterInline(agent, model, yolo); continue; }
492
+ if (cmd === "history") { printHistory(agent, log); continue; }
493
+ if (cmd === "tokens") { console.error(tokensLine(model)); continue; }
494
+ if (cmd === "yolo") { yolo = !yolo; console.error(C.dim(`[astra] auto-run ${yolo ? "ON" : "OFF"}.`)); continue; }
495
+ console.error(C.dim(`[astra] unknown command: /${cmd} (try /help)`));
496
+ continue;
497
+ }
498
+
499
+ const before = usageSnapshot(model);
500
+ const t0 = Date.now();
501
+ agent.addUserMessage(line);
502
+ await driveUntilChat(agent, log);
503
+ const after = usageSnapshot(model);
504
+ console.error(
505
+ turnSummary({
506
+ turn: ++turnNo,
507
+ upTok: after.up - before.up,
508
+ downTok: after.down - before.down,
509
+ costUsd: after.cost - before.cost,
510
+ costKind: model.costSource === "reported" ? "reported" : "estimated",
511
+ seconds: (Date.now() - t0) / 1000,
512
+ })
513
+ );
514
+ printFooterInline(agent, model, yolo);
515
+ }
516
+ } finally {
517
+ rl.close();
518
+ agent.save();
519
+ console.error(C.dim(`\n[astra] session saved: ${agent.sessionId}`));
520
+ }
521
+ }
522
+
523
+ /** Inline footer print for the non-TTY fallback. */
524
+ function printFooterInline(agent, model, yolo) {
525
+ for (const l of footerLines(agent, model, yolo)) console.error(l);
526
+ }
527
+
528
+ /** Snapshot cumulative usage counters so we can compute per-turn deltas. */
529
+ function usageSnapshot(model) {
530
+ return {
531
+ up: model.totalPromptTokens,
532
+ down: model.totalCompletionTokens,
533
+ cost: model.totalCostUsd || 0,
534
+ };
535
+ }
536
+
537
+ /**
538
+ * Take turns until the agent produces a chat reply (or terminates). Commands
539
+ * run in between; their output goes back to the model automatically.
540
+ */
541
+ async function driveUntilChat(agent, log) {
542
+ while (true) {
543
+ let turn;
544
+ try {
545
+ turn = await agent.runTurn();
546
+ } catch (err) {
547
+ if (err && (err.name === "GatewayError" || err.name === "ContextWindowError")) {
548
+ log(C.red(`[astra] ${err.message}`));
549
+ return;
550
+ }
551
+ throw err;
552
+ }
553
+
554
+ if (turn.kind === "chat") {
555
+ log("\n" + C.cyan("astra › ") + turn.content.trim());
556
+ return;
557
+ }
558
+ if (turn.kind === "command") {
559
+ const rc = turn.returncode;
560
+ const tag = rc === 0 ? C.dim(`[rc ${rc}]`) : C.red(`[rc ${rc}]`);
561
+ log(tag + "\n" + indent(turn.output));
562
+ continue; // keep going; model will react to the output
563
+ }
564
+ if (turn.kind === "declined") {
565
+ log(C.dim("[astra] command skipped."));
566
+ continue; // let the model suggest an alternative
567
+ }
568
+ if (turn.kind === "format_error") {
569
+ log(C.dim(`[astra] (reprompting: ${turn.error})`));
570
+ continue;
571
+ }
572
+ if (turn.kind === "exit") {
573
+ log(C.dim(`[astra] run ended: ${turn.exit_status}`));
574
+ return;
575
+ }
576
+ }
577
+ }
578
+
579
+ function indent(text, n = 2) {
580
+ const pad = " ".repeat(n);
581
+ const body = String(text || "").replace(/\s+$/, "");
582
+ return body ? body.split("\n").map((l) => pad + l).join("\n") : pad + "(no output)";
583
+ }
584
+
585
+ function printHistory(agent, log) {
586
+ for (const m of agent.messages) {
587
+ if (m.role === "system") continue;
588
+ const label = m.role === "assistant" ? "astra" : m.role;
589
+ log(C.dim(`--- ${label} ---`));
590
+ log(m.content.length > 800 ? m.content.slice(0, 800) + " …" : m.content);
591
+ }
592
+ }
593
+
594
+ function tokensLine(model) {
595
+ const pt = model.totalPromptTokens;
596
+ const ct = model.totalCompletionTokens;
597
+ const usd = model.totalCostUsd || 0;
598
+ const src = model.costSource ?? "n/a";
599
+ const dollars = usd < 0.01 ? "$" + usd.toFixed(5) : "$" + usd.toFixed(4);
600
+ return C.dim(`[astra] tokens: prompt=${pt} completion=${ct} total=${pt + ct} · cost=${dollars} (${src})`);
601
+ }
package/src/session.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Session persistence for astra.
3
+ *
4
+ * Every run (autonomous or interactive) is stored as a resumable session under
5
+ * ~/.astra/sessions/<id>.json. The file uses the same "astra-1" trajectory
6
+ * shape the Agent serializes, plus a small header (id, created, title).
7
+ *
8
+ * Interactive sessions can be resumed and continued; autonomous ones can be
9
+ * resumed for inspection / continuation too.
10
+ */
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { configDir } from "./config.js";
15
+
16
+ export function sessionsDir() {
17
+ return path.join(configDir(), "sessions");
18
+ }
19
+
20
+ export function sessionPath(id) {
21
+ return path.join(sessionsDir(), `${id}.json`);
22
+ }
23
+
24
+ /** Generate a short, sortable session id: YYYYMMDD-HHMMSS-<rand>. */
25
+ export function newSessionId() {
26
+ const d = new Date();
27
+ const p = (n, w = 2) => String(n).padStart(w, "0");
28
+ const stamp =
29
+ `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-` +
30
+ `${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
31
+ const rand = Math.random().toString(36).slice(2, 6);
32
+ return `${stamp}-${rand}`;
33
+ }
34
+
35
+ /** Persist a session document (trajectory + header) to disk. */
36
+ export function saveSession(id, doc) {
37
+ const dir = sessionsDir();
38
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
39
+ fs.writeFileSync(sessionPath(id), JSON.stringify(doc, null, 2) + "\n");
40
+ return sessionPath(id);
41
+ }
42
+
43
+ /** Load a session document by id. Throws if missing. */
44
+ export function loadSession(id) {
45
+ const p = sessionPath(id);
46
+ return JSON.parse(fs.readFileSync(p, "utf8"));
47
+ }
48
+
49
+ /** True if a session with this id exists. */
50
+ export function sessionExists(id) {
51
+ try {
52
+ fs.accessSync(sessionPath(id));
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * List sessions (newest first) with lightweight metadata for `--sessions`.
61
+ * Returns [{ id, created, mode, model, title, n_steps, exit_status }].
62
+ */
63
+ export function listSessions() {
64
+ const dir = sessionsDir();
65
+ let files;
66
+ try {
67
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
68
+ } catch {
69
+ return [];
70
+ }
71
+ const out = [];
72
+ for (const f of files) {
73
+ const id = f.replace(/\.json$/, "");
74
+ try {
75
+ const doc = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
76
+ out.push({
77
+ id,
78
+ created: doc.created ?? "",
79
+ mode: doc.info?.mode ?? "",
80
+ model: doc.info?.model ?? "",
81
+ title: doc.title ?? deriveTitle(doc),
82
+ n_steps: doc.info?.n_steps ?? 0,
83
+ exit_status: doc.info?.exit_status ?? "",
84
+ });
85
+ } catch {
86
+ out.push({ id, created: "", mode: "", model: "", title: "(unreadable)", n_steps: 0 });
87
+ }
88
+ }
89
+ out.sort((a, b) => (a.id < b.id ? 1 : -1));
90
+ return out;
91
+ }
92
+
93
+ /** Derive a short title from the first user/task message. */
94
+ export function deriveTitle(doc) {
95
+ if (doc.title) return doc.title;
96
+ const firstUser = (doc.messages || []).find((m) => m.role === "user");
97
+ const text = (doc.info?.task || firstUser?.content || "").trim().replace(/\s+/g, " ");
98
+ return text ? text.slice(0, 60) : "(empty)";
99
+ }