@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/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +30 -0
- package/src/agent.js +319 -0
- package/src/cli.js +314 -0
- package/src/config.js +135 -0
- package/src/environment.js +121 -0
- package/src/model.js +314 -0
- package/src/prices.js +60 -0
- package/src/prompts.js +91 -0
- package/src/repl.js +601 -0
- package/src/session.js +99 -0
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
|
+
}
|