@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/cli.js ADDED
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * astra CLI — a minimal, zero-dependency AI coding agent for the
4
+ * HackerRank AI Gateway.
5
+ *
6
+ * Usage:
7
+ * astra -m <model> interactive assistant (chat REPL)
8
+ * astra -m <model> -t "<task text>" autonomous task run
9
+ * astra -m <model> -f path/to/instruction.md
10
+ * astra --resume <session-id> resume a saved session
11
+ * astra --sessions list saved sessions
12
+ *
13
+ * Options:
14
+ * -m, --model <id> Model id on the gateway (required unless --sessions)
15
+ * -t, --task <text> Task text -> autonomous mode (run to completion)
16
+ * -f, --task-file <path> Read task text from a file -> autonomous mode
17
+ * -C, --cwd <path> Working directory for commands (default: cwd)
18
+ * -o, --output <path> Also write trajectory JSON here (autonomous mode)
19
+ * -s, --steps <n> Step limit (default: 40)
20
+ * -w, --wall <seconds> Wall-clock limit (default: 0 = none)
21
+ * --timeout <seconds> Per-command timeout (default: 60)
22
+ * --max-output <n> Max chars of command output kept (default: 16000)
23
+ * --base-url <url> Override gateway base URL
24
+ * --api-key <key> Gateway API key (else env/config/prompt)
25
+ * --resume <id> Resume a saved session by id
26
+ * --sessions List saved sessions and exit
27
+ * -y, --yolo Auto-run commands without confirmation
28
+ * (always on in autonomous mode)
29
+ * -q, --quiet Do not stream steps (autonomous mode)
30
+ * -h, --help Show this help
31
+ *
32
+ * API key resolution (first hit wins):
33
+ * 1. --api-key flag
34
+ * 2. ASTRA_GATEWAY_API_KEY env var
35
+ * 3. ~/.astra/config.json (dedicated astra config)
36
+ * 4. interactive prompt (when run in a terminal); offers to save to (3)
37
+ */
38
+
39
+ import fs from "node:fs";
40
+ import path from "node:path";
41
+ import { GatewayModel } from "./model.js";
42
+ import { LocalEnvironment } from "./environment.js";
43
+ import { Agent } from "./agent.js";
44
+ import { resolveCredentials, promptForCredentials, canPrompt } from "./config.js";
45
+ import {
46
+ newSessionId,
47
+ saveSession,
48
+ loadSession,
49
+ sessionExists,
50
+ listSessions,
51
+ deriveTitle,
52
+ } from "./session.js";
53
+ import { runRepl } from "./repl.js";
54
+
55
+ function parseArgs(argv) {
56
+ const args = { steps: 40, wall: 0, timeout: 60, quiet: false, "max-output": 16000 };
57
+ const alias = {
58
+ "-m": "model", "--model": "model",
59
+ "-t": "task", "--task": "task",
60
+ "-f": "task-file", "--task-file": "task-file",
61
+ "-C": "cwd", "--cwd": "cwd",
62
+ "-o": "output", "--output": "output",
63
+ "-s": "steps", "--steps": "steps",
64
+ "-w": "wall", "--wall": "wall",
65
+ "--timeout": "timeout",
66
+ "--max-output": "max-output",
67
+ "--base-url": "base-url",
68
+ "--api-key": "api-key",
69
+ "--resume": "resume",
70
+ "--sessions": "sessions",
71
+ "-q": "quiet", "--quiet": "quiet",
72
+ "-y": "yolo", "--yolo": "yolo",
73
+ "-h": "help", "--help": "help",
74
+ };
75
+ const flags = new Set(["quiet", "yolo", "help", "sessions"]);
76
+ for (let i = 2; i < argv.length; i++) {
77
+ const key = alias[argv[i]];
78
+ if (!key) { console.error(`Unknown option: ${argv[i]}`); process.exit(2); }
79
+ if (flags.has(key)) { args[key] = true; continue; }
80
+ args[key] = argv[++i];
81
+ }
82
+ return args;
83
+ }
84
+
85
+ function resolveApiKey(cliKey, baseUrl) {
86
+ const { apiKey, baseUrl: resolvedBase } = resolveCredentials({ apiKey: cliKey, baseUrl });
87
+ if (resolvedBase) process.env.ASTRA_GATEWAY_BASE_URL = resolvedBase;
88
+ return apiKey;
89
+ }
90
+
91
+ const HELP = fs.readFileSync(new URL(import.meta.url), "utf8")
92
+ .split("\n").filter((l) => l.startsWith(" *")).map((l) => l.slice(3)).join("\n");
93
+
94
+ async function main() {
95
+ const args = parseArgs(process.argv);
96
+
97
+ if (args.sessions) { printSessions(); process.exit(0); }
98
+ if (args.help) { console.log(HELP); process.exit(0); }
99
+
100
+ // Load a session to resume (if any) to infer defaults.
101
+ let resumeDoc = null;
102
+ if (args.resume) {
103
+ if (!sessionExists(args.resume)) {
104
+ console.error(`\x1b[31m[astra] no such session: ${args.resume}\x1b[0m`);
105
+ process.exit(2);
106
+ }
107
+ resumeDoc = loadSession(args.resume);
108
+ }
109
+
110
+ const modelId = args.model || resumeDoc?.info?.model;
111
+ if (!modelId) {
112
+ console.error("\x1b[31m[astra] --model is required.\x1b[0m\n");
113
+ console.log(HELP);
114
+ process.exit(2);
115
+ }
116
+
117
+ // Task text (flag/file) selects autonomous mode; otherwise interactive.
118
+ let task = args.task;
119
+ if (args["task-file"]) task = fs.readFileSync(args["task-file"], "utf8");
120
+ const mode = task
121
+ ? "autonomous"
122
+ : resumeDoc?.info?.mode === "autonomous"
123
+ ? "autonomous"
124
+ : "interactive";
125
+
126
+ if (mode === "interactive" && !canPrompt()) {
127
+ console.error(
128
+ "\x1b[31m[astra] interactive mode needs a terminal. Provide a task with -t/-f\n" +
129
+ "to run autonomously, or run in a TTY.\x1b[0m"
130
+ );
131
+ process.exit(2);
132
+ }
133
+
134
+ let apiKey = resolveApiKey(args["api-key"], args["base-url"]);
135
+ if (!apiKey) {
136
+ if (canPrompt()) {
137
+ const creds = await promptForCredentials({ baseUrl: args["base-url"] });
138
+ if (creds?.apiKey) {
139
+ apiKey = creds.apiKey;
140
+ process.env.ASTRA_GATEWAY_BASE_URL = creds.baseUrl;
141
+ }
142
+ }
143
+ if (!apiKey) {
144
+ console.error(
145
+ "\x1b[31m[astra] No API key. Provide one via --api-key, the\n" +
146
+ "ASTRA_GATEWAY_API_KEY env var, or run interactively to be prompted.\x1b[0m"
147
+ );
148
+ process.exit(2);
149
+ }
150
+ }
151
+
152
+ const cwd = args.cwd ? path.resolve(args.cwd) : process.cwd();
153
+ const quiet = !!args.quiet;
154
+
155
+ const model = new GatewayModel({
156
+ model: modelId,
157
+ baseUrl: args["base-url"],
158
+ apiKey,
159
+ onRetry: quiet ? () => {} : (r) => printRetry(r),
160
+ });
161
+ const env = new LocalEnvironment({
162
+ cwd,
163
+ timeout: Number(args.timeout),
164
+ maxOutputChars: Number(args["max-output"]),
165
+ });
166
+
167
+ const sessionId = resumeDoc?.id || args.resume || newSessionId();
168
+ const agent = new Agent(model, env, {
169
+ mode,
170
+ stepLimit: Number(args.steps),
171
+ wallTimeLimitSeconds: Number(args.wall),
172
+ outputPath: args.output ? path.resolve(args.output) : null,
173
+ sessionId,
174
+ saveSession,
175
+ onEvent: quiet || mode === "interactive" ? () => {} : (msg) => printEvent(msg),
176
+ onStep: quiet || mode === "interactive" ? () => {} : (s) => printStep(s),
177
+ });
178
+
179
+ if (resumeDoc) {
180
+ agent.restore(resumeDoc);
181
+ console.error(`\x1b[2m[astra] resumed ${sessionId} (${agent.messages.length} messages)\x1b[0m`);
182
+ }
183
+
184
+ // -------------------- INTERACTIVE MODE --------------------
185
+ if (mode === "interactive") {
186
+ if (!resumeDoc) agent.start();
187
+ agent.title = agent.title || "interactive";
188
+ await runRepl(agent, { model, autoRun: !!args.yolo, fresh: !resumeDoc });
189
+ process.exit(0);
190
+ }
191
+
192
+ // -------------------- AUTONOMOUS MODE --------------------
193
+ if (!task && !resumeDoc) {
194
+ console.error("\x1b[31m[astra] provide a task with -t or -f.\x1b[0m");
195
+ process.exit(2);
196
+ }
197
+ if (task) agent.title = deriveTitle({ info: { task }, messages: [] });
198
+
199
+ if (!quiet) {
200
+ console.error(`\x1b[2m[astra] autonomous · model=${modelId} cwd=${cwd} steps<=${args.steps}\x1b[0m`);
201
+ }
202
+
203
+ const result =
204
+ resumeDoc && !task ? await continueAutonomous(agent) : await agent.run(task);
205
+ agent.save();
206
+
207
+ if (!quiet) {
208
+ const pt = model.totalPromptTokens;
209
+ const ct = model.totalCompletionTokens;
210
+ console.error(
211
+ `\n\x1b[1m[astra] exit=${result.exit_status} steps=${agent.nSteps} calls=${model.nCalls}\x1b[0m`
212
+ );
213
+ console.error(
214
+ `\x1b[1m[astra] tokens: prompt=${fmt(pt)} completion=${fmt(ct)} total=${fmt(pt + ct)} ` +
215
+ `| last context=${fmt(agent.lastContextTokens)}\x1b[0m`
216
+ );
217
+ console.error(
218
+ `\x1b[1m[astra] cost: ${fmtUsd(model.totalCostUsd)} (${model.costSource ?? "n/a"})` +
219
+ (model.costSource === "mixed"
220
+ ? ` — reported ${fmtUsd(model.reportedCostUsd)} + estimated ${fmtUsd(model.estimatedCostUsd)}`
221
+ : model.costSource === "estimated"
222
+ ? " — public-price estimate"
223
+ : "") +
224
+ `\x1b[0m`
225
+ );
226
+ console.error(`\x1b[2m[astra] session -> ${sessionId}\x1b[0m`);
227
+ }
228
+ if (result.submission) console.log(result.submission);
229
+ process.exit(result.exit_status === "Submitted" ? 0 : 1);
230
+ }
231
+
232
+ /** Drive an already-seeded autonomous agent to completion (used on resume). */
233
+ async function continueAutonomous(agent) {
234
+ while (true) {
235
+ if (agent.stepLimit > 0 && agent.nSteps >= agent.stepLimit) return agent.exit("LimitsExceeded", "");
236
+ const turn = await agent.runTurn();
237
+ if (turn.kind === "exit") return { exit_status: turn.exit_status, submission: turn.submission };
238
+ }
239
+ }
240
+
241
+ /** Print the saved-session table for --sessions. */
242
+ function printSessions() {
243
+ const list = listSessions();
244
+ if (list.length === 0) {
245
+ console.error("\x1b[2m[astra] no saved sessions.\x1b[0m");
246
+ return;
247
+ }
248
+ console.error("\x1b[1mid mode model steps title\x1b[0m");
249
+ for (const s of list) {
250
+ console.error(
251
+ s.id.padEnd(23) + " " +
252
+ String(s.mode).padEnd(13) + " " +
253
+ String(s.model).padEnd(20) + " " +
254
+ String(s.n_steps).padStart(5) + " " +
255
+ s.title
256
+ );
257
+ }
258
+ console.error(`\n\x1b[2m[astra] resume with: astra --resume <id>\x1b[0m`);
259
+ }
260
+
261
+ function printEvent(msg) {
262
+ const c = { system: 90, user: 36, assistant: 33, exit: 35 }[msg.role] ?? 0;
263
+ if (msg.role === "system") return; // too long, skip
264
+ const label = msg.role.toUpperCase().padEnd(9);
265
+ const body = msg.content.length > 1200 ? msg.content.slice(0, 1200) + " …[truncated]" : msg.content;
266
+ console.error(`\x1b[${c}m--- ${label} ---\x1b[0m\n${body}\n`);
267
+ }
268
+
269
+ /** Compact per-step status line with exact token usage from the API. */
270
+ function printStep(s) {
271
+ const limit = s.stepLimit > 0 ? `/${s.stepLimit}` : "";
272
+ const cached = s.usage.cached_tokens ? ` (cached ${fmt(s.usage.cached_tokens)})` : "";
273
+ const cost = s.costUsd != null
274
+ ? ` · ${fmtUsd(s.costUsd)}${s.costKind === "estimated" ? "~" : ""}`
275
+ : "";
276
+ console.error(
277
+ `\x1b[36m[astra] step ${s.step}${limit} · call ${s.nCalls} · ` +
278
+ `↑${fmt(s.usage.prompt_tokens)} ↓${fmt(s.usage.completion_tokens)} tok · ` +
279
+ `ctx ${fmt(s.contextTokens)}${cached}${cost} · ${s.elapsedSeconds.toFixed(1)}s\x1b[0m`
280
+ );
281
+ }
282
+
283
+ /** Thousands separator for readability. */
284
+ function fmt(n) {
285
+ return Number(n).toLocaleString("en-US");
286
+ }
287
+
288
+ /** Format a USD amount with sensible precision for tiny costs. */
289
+ function fmtUsd(n) {
290
+ const v = Number(n) || 0;
291
+ if (v === 0) return "$0";
292
+ if (v < 0.01) return "$" + v.toFixed(5);
293
+ if (v < 1) return "$" + v.toFixed(4);
294
+ return "$" + v.toFixed(2);
295
+ }
296
+
297
+ /** Log a retry attempt to stderr. */
298
+ function printRetry(r) {
299
+ const secs = (r.waitMs / 1000).toFixed(1);
300
+ console.error(
301
+ `\x1b[33m[astra] retry ${r.attempt}/${r.maxRetries} · ${r.reason} · waiting ${secs}s…\x1b[0m`
302
+ );
303
+ }
304
+
305
+ main().catch((err) => {
306
+ // Classified gateway errors already carry an actionable hint; show that
307
+ // cleanly instead of a raw stack trace.
308
+ if (err && (err.name === "GatewayError" || err.name === "ContextWindowError")) {
309
+ console.error(`\x1b[31m[astra] ${err.message}\x1b[0m`);
310
+ } else {
311
+ console.error(`\x1b[31m[astra] fatal: ${err.stack || err.message}\x1b[0m`);
312
+ }
313
+ process.exit(1);
314
+ });
package/src/config.js ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Dedicated astra configuration.
3
+ *
4
+ * astra keeps its own config at ~/.astra/config.json so the tool is fully
5
+ * self-contained (important now that this ships as an npm package).
6
+ *
7
+ * Config shape:
8
+ * {
9
+ * "gateway": {
10
+ * "apiKey": "...",
11
+ * "baseUrl": "https://gateway-central.ai.private.hackerrank.link/v1"
12
+ * }
13
+ * }
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import readline from "node:readline";
20
+
21
+ export const DEFAULT_BASE_URL = "https://gateway-central.ai.private.hackerrank.link/v1";
22
+
23
+ export function configDir() {
24
+ return path.join(os.homedir(), ".astra");
25
+ }
26
+
27
+ export function configPath() {
28
+ return path.join(configDir(), "config.json");
29
+ }
30
+
31
+ /** Read the dedicated astra config, or {} if absent/unreadable. */
32
+ export function readConfig() {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(configPath(), "utf8"));
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ /** Write the dedicated astra config with restrictive permissions. */
41
+ export function writeConfig(cfg) {
42
+ const dir = configDir();
43
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
44
+ fs.writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
45
+ }
46
+
47
+ /** Save gateway credentials into the dedicated config (merging existing). */
48
+ export function saveGatewayCredentials({ apiKey, baseUrl }) {
49
+ const cfg = readConfig();
50
+ cfg.gateway = { ...(cfg.gateway || {}) };
51
+ if (apiKey) cfg.gateway.apiKey = apiKey;
52
+ if (baseUrl) cfg.gateway.baseUrl = baseUrl;
53
+ writeConfig(cfg);
54
+ return configPath();
55
+ }
56
+
57
+ /**
58
+ * Resolve gateway credentials in priority order (no prompting):
59
+ * 1. explicit args (CLI flags)
60
+ * 2. environment variables
61
+ * 3. dedicated ~/.astra/config.json
62
+ * Returns { apiKey, baseUrl, source }.
63
+ */
64
+ export function resolveCredentials({ apiKey, baseUrl } = {}) {
65
+ if (apiKey) return { apiKey, baseUrl: baseUrl || DEFAULT_BASE_URL, source: "flag" };
66
+
67
+ if (process.env.ASTRA_GATEWAY_API_KEY) {
68
+ return {
69
+ apiKey: process.env.ASTRA_GATEWAY_API_KEY,
70
+ baseUrl: baseUrl || process.env.ASTRA_GATEWAY_BASE_URL || DEFAULT_BASE_URL,
71
+ source: "env",
72
+ };
73
+ }
74
+
75
+ const cfg = readConfig();
76
+ if (cfg.gateway?.apiKey) {
77
+ return {
78
+ apiKey: cfg.gateway.apiKey,
79
+ baseUrl: baseUrl || cfg.gateway.baseUrl || DEFAULT_BASE_URL,
80
+ source: "config",
81
+ };
82
+ }
83
+
84
+ return { apiKey: "", baseUrl: baseUrl || DEFAULT_BASE_URL, source: "none" };
85
+ }
86
+
87
+ /** True when we can interactively prompt the user. */
88
+ export function canPrompt() {
89
+ return process.stdin.isTTY && process.stdout.isTTY;
90
+ }
91
+
92
+ /** Ask a question on the TTY; hides input when `secret` is true. */
93
+ export function ask(question, { secret = false } = {}) {
94
+ return new Promise((resolve) => {
95
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
96
+ if (secret) {
97
+ // Suppress echo of typed characters.
98
+ const onData = () => {
99
+ rl.output.write("\x1b[2K\r" + question);
100
+ };
101
+ rl.input.on("data", onData);
102
+ rl.question(question, (answer) => {
103
+ rl.input.off("data", onData);
104
+ rl.output.write("\n");
105
+ rl.close();
106
+ resolve(answer.trim());
107
+ });
108
+ } else {
109
+ rl.question(question, (answer) => {
110
+ rl.close();
111
+ resolve(answer.trim());
112
+ });
113
+ }
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Interactively obtain a gateway API key when none was found, and offer to
119
+ * persist it to the dedicated config. Returns { apiKey, baseUrl } or null if
120
+ * the user provides nothing.
121
+ */
122
+ export async function promptForCredentials({ baseUrl } = {}) {
123
+ const effectiveBase = baseUrl || DEFAULT_BASE_URL;
124
+ console.error("\x1b[33m[astra] No HackerRank AI Gateway API key found.\x1b[0m");
125
+ console.error(`\x1b[2m[astra] Gateway: ${effectiveBase}\x1b[0m`);
126
+ const apiKey = await ask("Enter gateway API key (leave blank to abort): ", { secret: true });
127
+ if (!apiKey) return null;
128
+
129
+ const save = (await ask(`Save to ${configPath()}? [Y/n] `)).toLowerCase();
130
+ if (save === "" || save === "y" || save === "yes") {
131
+ const saved = saveGatewayCredentials({ apiKey, baseUrl: effectiveBase });
132
+ console.error(`\x1b[2m[astra] Saved credentials to ${saved}\x1b[0m`);
133
+ }
134
+ return { apiKey, baseUrl: effectiveBase };
135
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Local execution environment.
3
+ *
4
+ * Every action is executed in a fresh subshell via `bash -c`: stateful cd /
5
+ * env changes do NOT persist between actions. The model must chain state with
6
+ * `cd /path && ...` in a single command.
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import os from "node:os";
11
+
12
+ export class LocalEnvironment {
13
+ /**
14
+ * @param {object} opts
15
+ * @param {string} [opts.cwd] working directory for commands
16
+ * @param {number} [opts.timeout] per-command timeout in seconds
17
+ * @param {object} [opts.env] extra env vars merged over process.env
18
+ * @param {number} [opts.maxOutputChars] cap on observation size (default 16000)
19
+ */
20
+ constructor({ cwd = process.cwd(), timeout = 60, env = {}, maxOutputChars = 16000 } = {}) {
21
+ this.cwd = cwd;
22
+ this.timeout = timeout;
23
+ this.env = env;
24
+ this.maxOutputChars = maxOutputChars;
25
+ }
26
+
27
+ /**
28
+ * Execute a shell command.
29
+ * @param {string} command
30
+ * @returns {Promise<{output:string, returncode:number, exception_info:string}>}
31
+ */
32
+ execute(command) {
33
+ return new Promise((resolve) => {
34
+ const child = spawn("bash", ["-c", command], {
35
+ cwd: this.cwd,
36
+ env: { ...process.env, ...this.env },
37
+ // New process group so we can kill the whole tree on timeout.
38
+ detached: process.platform !== "win32",
39
+ });
40
+
41
+ let out = "";
42
+ let killedByTimeout = false;
43
+
44
+ const timer = setTimeout(() => {
45
+ killedByTimeout = true;
46
+ try {
47
+ if (process.platform !== "win32") {
48
+ process.kill(-child.pid, "SIGKILL");
49
+ } else {
50
+ child.kill("SIGKILL");
51
+ }
52
+ } catch {
53
+ /* already dead */
54
+ }
55
+ }, this.timeout * 1000);
56
+
57
+ child.stdout.on("data", (d) => (out += d.toString()));
58
+ child.stderr.on("data", (d) => (out += d.toString()));
59
+
60
+ child.on("error", (err) => {
61
+ clearTimeout(timer);
62
+ resolve({
63
+ output: truncateOutput(out, this.maxOutputChars),
64
+ returncode: -1,
65
+ exception_info: `Failed to execute command: ${err.message}`,
66
+ });
67
+ });
68
+
69
+ child.on("close", (code) => {
70
+ clearTimeout(timer);
71
+ if (killedByTimeout) {
72
+ resolve({
73
+ output: truncateOutput(out, this.maxOutputChars),
74
+ returncode: -1,
75
+ exception_info: `Command timed out after ${this.timeout}s.`,
76
+ });
77
+ } else {
78
+ resolve({
79
+ output: truncateOutput(out, this.maxOutputChars),
80
+ returncode: code ?? 0,
81
+ exception_info: "",
82
+ });
83
+ }
84
+ });
85
+ });
86
+ }
87
+
88
+ templateVars() {
89
+ return {
90
+ cwd: this.cwd,
91
+ timeout: this.timeout,
92
+ system: os.type(),
93
+ release: os.release(),
94
+ machine: os.machine ? os.machine() : os.arch(),
95
+ };
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Keep command output within a character budget so a single noisy command
101
+ * (a big `cat`, `npm install`, etc.) can't blow up the model context.
102
+ * Keeps the head and tail and drops the middle, since both ends usually carry
103
+ * the most signal (command intent + final result / error).
104
+ */
105
+ export function truncateOutput(text, maxChars) {
106
+ if (!maxChars || text.length <= maxChars) return text;
107
+
108
+ // Reserve room for the marker; split the remaining budget head-heavy.
109
+ const headChars = Math.floor(maxChars * 0.6);
110
+ const tailChars = maxChars - headChars;
111
+ const head = text.slice(0, headChars);
112
+ const tail = text.slice(text.length - tailChars);
113
+
114
+ const omitted = text.length - headChars - tailChars;
115
+ const omittedLines = text.slice(headChars, text.length - tailChars).split("\n").length - 1;
116
+ const marker =
117
+ `\n\n[... ${omitted} characters (~${omittedLines} lines) omitted; ` +
118
+ `output truncated to ${maxChars} chars ...]\n\n`;
119
+
120
+ return head + marker + tail;
121
+ }