rlm-cli 0.2.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.
@@ -0,0 +1,789 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * RLM Interactive — Production-quality interactive terminal REPL.
4
+ *
5
+ * Launch with `rlm` and get a persistent session where you can:
6
+ * - Set context (file/URL/paste)
7
+ * - Type queries and watch the RLM loop run with smooth, real-time output
8
+ * - Browse previous trajectories
9
+ */
10
+ import "./env.js";
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import * as readline from "node:readline";
14
+ import { stdin, stdout } from "node:process";
15
+ const { getModels, getProviders } = await import("@mariozechner/pi-ai");
16
+ const { PythonRepl } = await import("./repl.js");
17
+ const { runRlmLoop } = await import("./rlm.js");
18
+ const { loadConfig } = await import("./config.js");
19
+ const config = loadConfig();
20
+ // ── ANSI helpers ────────────────────────────────────────────────────────────
21
+ const c = {
22
+ reset: "\x1b[0m",
23
+ bold: "\x1b[1m",
24
+ dim: "\x1b[2m",
25
+ italic: "\x1b[3m",
26
+ underline: "\x1b[4m",
27
+ red: "\x1b[31m",
28
+ green: "\x1b[32m",
29
+ yellow: "\x1b[33m",
30
+ blue: "\x1b[34m",
31
+ magenta: "\x1b[35m",
32
+ cyan: "\x1b[36m",
33
+ white: "\x1b[37m",
34
+ gray: "\x1b[90m",
35
+ clearLine: "\x1b[2K\r",
36
+ };
37
+ // ── Spinner ─────────────────────────────────────────────────────────────────
38
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
39
+ class Spinner {
40
+ interval = null;
41
+ frameIndex = 0;
42
+ message = "";
43
+ startTime = Date.now();
44
+ start(message) {
45
+ this.stop();
46
+ this.message = message;
47
+ this.startTime = Date.now();
48
+ this.frameIndex = 0;
49
+ this.render();
50
+ this.interval = setInterval(() => this.render(), 80);
51
+ }
52
+ update(message) {
53
+ this.message = message;
54
+ }
55
+ stop() {
56
+ if (this.interval) {
57
+ clearInterval(this.interval);
58
+ this.interval = null;
59
+ process.stdout.write(c.clearLine);
60
+ }
61
+ }
62
+ render() {
63
+ const frame = SPINNER_FRAMES[this.frameIndex % SPINNER_FRAMES.length];
64
+ const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
65
+ process.stdout.write(`${c.clearLine} ${c.cyan}${frame}${c.reset} ${this.message} ${c.dim}${elapsed}s${c.reset}`);
66
+ this.frameIndex++;
67
+ }
68
+ }
69
+ // ── Constants ───────────────────────────────────────────────────────────────
70
+ const DEFAULT_MODEL = process.env.RLM_MODEL || "claude-sonnet-4-5-20250929";
71
+ const TRAJ_DIR = path.resolve(process.cwd(), "trajectories");
72
+ const W = Math.min(process.stdout.columns || 80, 100);
73
+ // ── Session state ───────────────────────────────────────────────────────────
74
+ let currentModelId = DEFAULT_MODEL;
75
+ let currentModel;
76
+ let contextText = "";
77
+ let contextSource = "";
78
+ let queryCount = 0;
79
+ let isRunning = false;
80
+ // Exposed so the readline SIGINT handler can abort the running query
81
+ let activeAc = null;
82
+ let activeRepl = null;
83
+ let activeSpinner = null;
84
+ // ── Resolve model ───────────────────────────────────────────────────────────
85
+ function resolveModel(modelId) {
86
+ for (const provider of getProviders()) {
87
+ for (const m of getModels(provider)) {
88
+ if (m.id === modelId)
89
+ return m;
90
+ }
91
+ }
92
+ return undefined;
93
+ }
94
+ function detectProvider() {
95
+ if (process.env.ANTHROPIC_API_KEY)
96
+ return "anthropic";
97
+ if (process.env.OPENAI_API_KEY) {
98
+ if (process.env.OPENAI_BASE_URL?.includes("openrouter"))
99
+ return "openrouter";
100
+ return "openai";
101
+ }
102
+ return "unknown";
103
+ }
104
+ // ── Paste detection ─────────────────────────────────────────────────────────
105
+ function isMultiLineInput(input) {
106
+ return input.includes("\n");
107
+ }
108
+ function handleMultiLineAsContext(input) {
109
+ const lines = input.split("\n");
110
+ if (lines.length > 3) {
111
+ const sizeKB = (input.length / 1024).toFixed(1);
112
+ console.log(` ${c.green}✓${c.reset} Pasted ${c.bold}${lines.length} lines${c.reset} ${c.dim}(${sizeKB}KB)${c.reset}`);
113
+ return { context: input, query: "" };
114
+ }
115
+ return null;
116
+ }
117
+ // ── Banner ──────────────────────────────────────────────────────────────────
118
+ function printBanner() {
119
+ console.log(`
120
+ ${c.cyan}${c.bold}
121
+ ██████╗ ██╗ ███╗ ███╗
122
+ ██╔══██╗██║ ████╗ ████║
123
+ ██████╔╝██║ ██╔████╔██║
124
+ ██╔══██╗██║ ██║╚██╔╝██║
125
+ ██║ ██║███████╗██║ ╚═╝ ██║
126
+ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝
127
+ ${c.reset}
128
+ ${c.dim} Recursive Language Models — arXiv:2512.24601${c.reset}
129
+ `);
130
+ }
131
+ // ── Status line ─────────────────────────────────────────────────────────────
132
+ function printStatusLine() {
133
+ const provider = detectProvider();
134
+ const modelShort = currentModelId.length > 35
135
+ ? currentModelId.slice(0, 32) + "..."
136
+ : currentModelId;
137
+ const ctx = contextText
138
+ ? `${c.green}●${c.reset} ${(contextText.length / 1024).toFixed(1)}KB${contextSource ? ` ${c.dim}(${contextSource})${c.reset}` : ""}`
139
+ : `${c.dim}○${c.reset}`;
140
+ console.log(` ${c.dim}${modelShort}${c.reset} ${c.dim}(${provider})${c.reset} ${ctx} ${c.dim}Q:${queryCount}${c.reset}`);
141
+ }
142
+ // ── Welcome ─────────────────────────────────────────────────────────────────
143
+ function printWelcome() {
144
+ console.clear();
145
+ printBanner();
146
+ printStatusLine();
147
+ console.log(` ${c.dim}max ${config.max_iterations} iterations · depth ${config.max_depth} · ${config.max_sub_queries} sub-queries${c.reset}`);
148
+ console.log();
149
+ console.log(` ${c.dim}/help for commands${c.reset}`);
150
+ console.log();
151
+ }
152
+ // ── Help ────────────────────────────────────────────────────────────────────
153
+ function printCommandHelp() {
154
+ console.log(`
155
+ ${c.bold}Context${c.reset}
156
+ ${c.yellow}/file${c.reset} <path> Load file as context
157
+ ${c.yellow}/url${c.reset} <url> Fetch URL as context
158
+ ${c.yellow}/paste${c.reset} Multi-line paste mode (EOF to finish)
159
+ ${c.yellow}/context${c.reset} Show loaded context info
160
+ ${c.yellow}/clear-context${c.reset} Unload context
161
+
162
+ ${c.bold}Tools${c.reset}
163
+ ${c.yellow}/trajectories${c.reset} List saved runs
164
+
165
+ ${c.bold}General${c.reset}
166
+ ${c.yellow}/clear${c.reset} Clear screen
167
+ ${c.yellow}/help${c.reset} Show this help
168
+ ${c.yellow}/quit${c.reset} Exit
169
+
170
+ ${c.dim}Or just paste a URL or 4+ lines of code, then type your query.${c.reset}
171
+ `);
172
+ }
173
+ // ── Slash command handlers ──────────────────────────────────────────────────
174
+ async function handleFile(arg) {
175
+ if (!arg) {
176
+ console.log(` ${c.red}Usage: /file <path>${c.reset}`);
177
+ return;
178
+ }
179
+ const filePath = path.resolve(arg);
180
+ if (!fs.existsSync(filePath)) {
181
+ console.log(` ${c.red}File not found: ${filePath}${c.reset}`);
182
+ return;
183
+ }
184
+ contextText = fs.readFileSync(filePath, "utf-8");
185
+ contextSource = arg;
186
+ const lines = contextText.split("\n").length;
187
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines) from ${c.underline}${arg}${c.reset}`);
188
+ }
189
+ async function handleUrl(arg) {
190
+ if (!arg) {
191
+ console.log(` ${c.red}Usage: /url <url>${c.reset}`);
192
+ return;
193
+ }
194
+ console.log(` ${c.dim}Fetching ${arg}...${c.reset}`);
195
+ try {
196
+ const resp = await fetch(arg);
197
+ if (!resp.ok)
198
+ throw new Error(`${resp.status} ${resp.statusText}`);
199
+ contextText = await resp.text();
200
+ contextSource = arg;
201
+ const lines = contextText.split("\n").length;
202
+ console.log(` ${c.green}✓${c.reset} Fetched ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines)`);
203
+ }
204
+ catch (err) {
205
+ console.log(` ${c.red}Failed: ${err.message}${c.reset}`);
206
+ }
207
+ }
208
+ function handlePaste(rl) {
209
+ return new Promise((resolve) => {
210
+ console.log(` ${c.dim}Paste your context below. Type ${c.bold}EOF${c.reset}${c.dim} on an empty line to finish.${c.reset}`);
211
+ const lines = [];
212
+ const onLine = (line) => {
213
+ if (line.trim() === "EOF") {
214
+ rl.removeListener("line", onLine);
215
+ contextText = lines.join("\n");
216
+ contextSource = "(pasted)";
217
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.length} lines) from paste`);
218
+ resolve();
219
+ return;
220
+ }
221
+ lines.push(line);
222
+ };
223
+ rl.on("line", onLine);
224
+ });
225
+ }
226
+ function handleContext() {
227
+ if (!contextText) {
228
+ console.log(` ${c.dim}No context loaded. Use /file, /url, or /paste.${c.reset}`);
229
+ return;
230
+ }
231
+ const lines = contextText.split("\n").length;
232
+ console.log(` ${c.bold}Context:${c.reset} ${contextText.length.toLocaleString()} chars, ${lines.toLocaleString()} lines`);
233
+ console.log(` ${c.bold}Source:${c.reset} ${contextSource}`);
234
+ console.log();
235
+ const preview = contextText.slice(0, 500);
236
+ const previewLines = preview.split("\n").slice(0, 8);
237
+ for (const l of previewLines) {
238
+ console.log(` ${c.dim}│${c.reset} ${l}`);
239
+ }
240
+ if (contextText.length > 500) {
241
+ console.log(` ${c.dim}│ ...${c.reset}`);
242
+ }
243
+ }
244
+ function handleTrajectories() {
245
+ if (!fs.existsSync(TRAJ_DIR)) {
246
+ console.log(` ${c.dim}No trajectories yet.${c.reset}`);
247
+ return;
248
+ }
249
+ const files = fs
250
+ .readdirSync(TRAJ_DIR)
251
+ .filter((f) => f.endsWith(".json"))
252
+ .sort()
253
+ .reverse();
254
+ if (files.length === 0) {
255
+ console.log(` ${c.dim}No trajectories yet.${c.reset}`);
256
+ return;
257
+ }
258
+ console.log(`\n ${c.bold}Saved trajectories:${c.reset}\n`);
259
+ for (const f of files.slice(0, 15)) {
260
+ const stat = fs.statSync(path.join(TRAJ_DIR, f));
261
+ const size = (stat.size / 1024).toFixed(1);
262
+ console.log(` ${c.dim}•${c.reset} ${f} ${c.dim}(${size}K)${c.reset}`);
263
+ }
264
+ if (files.length > 15) {
265
+ console.log(` ${c.dim}... and ${files.length - 15} more${c.reset}`);
266
+ }
267
+ console.log();
268
+ }
269
+ // ── Display helpers ─────────────────────────────────────────────────────────
270
+ const BOX_W = Math.min(process.stdout.columns || 80, 96) - 6; // panel inner width
271
+ const MAX_CONTENT_W = BOX_W - 4; // usable chars inside │ … │
272
+ /** Wrap a raw text line into chunks that fit within maxWidth. */
273
+ function wrapText(text, maxWidth) {
274
+ if (text.length <= maxWidth)
275
+ return [text];
276
+ const result = [];
277
+ for (let i = 0; i < text.length; i += maxWidth) {
278
+ result.push(text.slice(i, i + maxWidth));
279
+ }
280
+ return result;
281
+ }
282
+ function boxTop(title, color) {
283
+ const inner = BOX_W - 2;
284
+ const t = ` ${title} `;
285
+ const right = Math.max(0, inner - t.length);
286
+ return ` ${color}╭${t}${"─".repeat(right)}╮${c.reset}`;
287
+ }
288
+ function boxBottom(color) {
289
+ return ` ${color}╰${"─".repeat(BOX_W - 2)}╯${c.reset}`;
290
+ }
291
+ function boxLine(text, color) {
292
+ const stripped = text.replace(/\x1b\[[0-9;]*m/g, "");
293
+ const pad = Math.max(0, MAX_CONTENT_W - stripped.length);
294
+ return ` ${color}│${c.reset} ${text}${" ".repeat(pad)} ${color}│${c.reset}`;
295
+ }
296
+ function displayCode(code) {
297
+ const lines = code.split("\n");
298
+ const lineNumWidth = String(lines.length).length;
299
+ const codeMaxW = MAX_CONTENT_W - lineNumWidth - 1;
300
+ console.log(boxTop("Code", c.blue));
301
+ for (let i = 0; i < lines.length; i++) {
302
+ const wrapped = wrapText(lines[i], codeMaxW);
303
+ for (let j = 0; j < wrapped.length; j++) {
304
+ const prefix = j === 0
305
+ ? `${c.dim}${String(i + 1).padStart(lineNumWidth)}${c.reset}`
306
+ : " ".repeat(lineNumWidth);
307
+ console.log(boxLine(`${prefix} ${c.cyan}${wrapped[j]}${c.reset}`, c.blue));
308
+ }
309
+ }
310
+ console.log(boxBottom(c.blue));
311
+ }
312
+ function displayOutput(output) {
313
+ const lines = output.split("\n").filter(l => l.trim() !== "");
314
+ console.log(boxTop("Output", c.green));
315
+ for (const line of lines) {
316
+ for (const chunk of wrapText(line, MAX_CONTENT_W)) {
317
+ console.log(boxLine(`${c.green}${chunk}${c.reset}`, c.green));
318
+ }
319
+ }
320
+ console.log(boxBottom(c.green));
321
+ }
322
+ function displayError(stderr) {
323
+ const lines = stderr.split("\n").filter(l => l.trim() !== "");
324
+ console.log(boxTop("Error", c.red));
325
+ for (const line of lines) {
326
+ for (const chunk of wrapText(line, MAX_CONTENT_W)) {
327
+ console.log(boxLine(`${c.red}${chunk}${c.reset}`, c.red));
328
+ }
329
+ }
330
+ console.log(boxBottom(c.red));
331
+ }
332
+ function formatSize(chars) {
333
+ return chars >= 1000 ? `${(chars / 1000).toFixed(1)}K` : `${chars}`;
334
+ }
335
+ function displaySubQueryStart(info) {
336
+ console.log(` ${c.magenta}┌─ Sub-query #${info.index}${c.reset} ${c.dim}sending ${formatSize(info.contextLength)} chars${c.reset}`);
337
+ const instrLines = info.instruction.split("\n").filter(l => l.trim());
338
+ for (const line of instrLines) {
339
+ for (const chunk of wrapText(line, MAX_CONTENT_W)) {
340
+ console.log(` ${c.magenta}│${c.reset} ${c.dim}${chunk}${c.reset}`);
341
+ }
342
+ }
343
+ }
344
+ function displaySubQueryResult(info) {
345
+ const elapsed = (info.elapsedMs / 1000).toFixed(1);
346
+ const resultLines = info.resultPreview.split("\n");
347
+ console.log(` ${c.magenta}│${c.reset}`);
348
+ console.log(` ${c.magenta}│${c.reset} ${c.green}${c.bold}Response:${c.reset}`);
349
+ for (const line of resultLines) {
350
+ for (const chunk of wrapText(line, MAX_CONTENT_W)) {
351
+ console.log(` ${c.magenta}│${c.reset} ${c.green}${chunk}${c.reset}`);
352
+ }
353
+ }
354
+ console.log(` ${c.magenta}└─${c.reset} ${c.dim}${elapsed}s · ${formatSize(info.resultLength)} received${c.reset}`);
355
+ }
356
+ // ── Truncate helper ─────────────────────────────────────────────────────────
357
+ function truncateStr(text, max) {
358
+ return text.length <= max ? text : text.slice(0, max - 3) + "...";
359
+ }
360
+ // ── Run RLM query ───────────────────────────────────────────────────────────
361
+ async function runQuery(query) {
362
+ const effectiveContext = contextText || query;
363
+ const isDirectMode = !contextText;
364
+ if (!currentModel) {
365
+ currentModel = resolveModel(currentModelId);
366
+ }
367
+ if (!currentModel) {
368
+ console.log(`\n ${c.red}Model "${currentModelId}" not found.${c.reset}`);
369
+ console.log(` ${c.dim}Check RLM_MODEL in your .env file.${c.reset}\n`);
370
+ return;
371
+ }
372
+ isRunning = true;
373
+ queryCount++;
374
+ const startTime = Date.now();
375
+ let subQueryCount = 0;
376
+ const spinner = new Spinner();
377
+ // Header — just model + context info, query is already visible on the prompt line
378
+ const ctxLabel = isDirectMode
379
+ ? `${c.dim}direct mode${c.reset}`
380
+ : `${c.dim}${(effectiveContext.length / 1024).toFixed(1)}KB context${c.reset}`;
381
+ console.log(`\n ${c.dim}${currentModelId}${c.reset} · ${ctxLabel}\n`);
382
+ // Trajectory bookkeeping
383
+ const trajectory = {
384
+ model: currentModelId,
385
+ query,
386
+ contextLength: effectiveContext.length,
387
+ contextLines: effectiveContext.split("\n").length,
388
+ startTime: new Date().toISOString(),
389
+ iterations: [],
390
+ result: null,
391
+ totalElapsedMs: 0,
392
+ };
393
+ let currentStep = null;
394
+ let iterStart = Date.now();
395
+ const repl = new PythonRepl();
396
+ const ac = new AbortController();
397
+ // Expose to the readline SIGINT handler
398
+ activeAc = ac;
399
+ activeRepl = repl;
400
+ activeSpinner = spinner;
401
+ try {
402
+ await repl.start(ac.signal);
403
+ const result = await runRlmLoop({
404
+ context: effectiveContext,
405
+ query,
406
+ model: currentModel,
407
+ repl,
408
+ signal: ac.signal,
409
+ onProgress: (info) => {
410
+ if (info.phase === "generating_code") {
411
+ iterStart = Date.now();
412
+ currentStep = {
413
+ iteration: info.iteration,
414
+ code: null,
415
+ stdout: "",
416
+ stderr: "",
417
+ subQueries: [],
418
+ hasFinal: false,
419
+ elapsedMs: 0,
420
+ userMessage: info.userMessage || "",
421
+ rawResponse: "",
422
+ systemPrompt: info.systemPrompt,
423
+ };
424
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
425
+ const bar = "─".repeat(BOX_W - 2);
426
+ console.log(` ${c.blue}${bar}${c.reset}`);
427
+ console.log(` ${c.blue}${c.bold} Step ${info.iteration}${c.reset}${c.dim}/${info.maxIterations}${c.reset} ${c.dim}${elapsed}s elapsed${c.reset}`);
428
+ console.log(` ${c.blue}${bar}${c.reset}`);
429
+ spinner.start("Generating code");
430
+ }
431
+ if (info.phase === "executing" && info.code) {
432
+ spinner.stop();
433
+ if (currentStep) {
434
+ currentStep.code = info.code;
435
+ currentStep.rawResponse = info.rawResponse || "";
436
+ }
437
+ displayCode(info.code);
438
+ spinner.start("Executing");
439
+ }
440
+ if (info.phase === "checking_final") {
441
+ spinner.stop();
442
+ if (currentStep) {
443
+ currentStep.stdout = info.stdout || "";
444
+ currentStep.stderr = info.stderr || "";
445
+ currentStep.elapsedMs = Date.now() - iterStart;
446
+ trajectory.iterations.push({ ...currentStep });
447
+ }
448
+ if (info.stdout) {
449
+ displayOutput(info.stdout);
450
+ }
451
+ if (info.stderr) {
452
+ displayError(info.stderr);
453
+ }
454
+ const iterElapsed = ((Date.now() - iterStart) / 1000).toFixed(1);
455
+ const sqLabel = info.subQueries > 0 ? ` · ${info.subQueries} sub-queries` : "";
456
+ console.log(` ${c.dim}${iterElapsed}s${sqLabel}${c.reset}`);
457
+ console.log();
458
+ }
459
+ },
460
+ onSubQueryStart: (info) => {
461
+ spinner.stop();
462
+ displaySubQueryStart(info);
463
+ spinner.start("");
464
+ },
465
+ onSubQuery: (info) => {
466
+ subQueryCount++;
467
+ if (currentStep) {
468
+ currentStep.subQueries.push(info);
469
+ }
470
+ spinner.stop();
471
+ displaySubQueryResult(info);
472
+ spinner.start("Executing");
473
+ },
474
+ });
475
+ trajectory.result = result;
476
+ trajectory.totalElapsedMs = Date.now() - startTime;
477
+ if (result.completed && trajectory.iterations.length > 0) {
478
+ trajectory.iterations[trajectory.iterations.length - 1].hasFinal = true;
479
+ }
480
+ // Final answer
481
+ const totalSec = ((Date.now() - startTime) / 1000).toFixed(1);
482
+ const stats = `${result.iterations} step${result.iterations !== 1 ? "s" : ""} · ${result.totalSubQueries} sub-quer${result.totalSubQueries !== 1 ? "ies" : "y"} · ${totalSec}s`;
483
+ const answerLines = result.answer.split("\n");
484
+ console.log(boxTop(`✔ Result ${c.dim}${stats}`, c.green));
485
+ for (const line of answerLines) {
486
+ for (const chunk of wrapText(line, MAX_CONTENT_W)) {
487
+ console.log(boxLine(chunk, c.green));
488
+ }
489
+ }
490
+ console.log(boxBottom(c.green));
491
+ console.log();
492
+ // Save trajectory
493
+ if (!fs.existsSync(TRAJ_DIR))
494
+ fs.mkdirSync(TRAJ_DIR, { recursive: true });
495
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
496
+ const trajFile = `trajectory-${ts}.json`;
497
+ fs.writeFileSync(path.join(TRAJ_DIR, trajFile), JSON.stringify(trajectory, null, 2), "utf-8");
498
+ console.log(` ${c.dim}Saved: ${trajFile}${c.reset}\n`);
499
+ }
500
+ catch (err) {
501
+ spinner.stop();
502
+ const msg = err?.message || String(err);
503
+ // Suppress expected abort/shutdown errors
504
+ if (err.name !== "AbortError" &&
505
+ !msg.includes("Aborted") &&
506
+ !msg.includes("not running") &&
507
+ !msg.includes("REPL subprocess") &&
508
+ !msg.includes("REPL shut down")) {
509
+ console.log(`\n ${c.red}Error: ${msg}${c.reset}\n`);
510
+ }
511
+ }
512
+ finally {
513
+ spinner.stop();
514
+ activeAc = null;
515
+ activeRepl = null;
516
+ activeSpinner = null;
517
+ try {
518
+ repl.shutdown();
519
+ }
520
+ catch { /* already dead */ }
521
+ isRunning = false;
522
+ }
523
+ }
524
+ // ── @file shorthand and auto-detect file paths ─────────────────────────────
525
+ function extractFilePath(input) {
526
+ const atMatch = input.match(/@(\S+)/);
527
+ if (atMatch) {
528
+ const filePath = path.resolve(atMatch[1]);
529
+ if (fs.existsSync(filePath)) {
530
+ const query = input.replace(atMatch[0], "").trim();
531
+ return { filePath, query };
532
+ }
533
+ }
534
+ const absPathMatch = input.match(/(\/[^\s]+)/);
535
+ if (absPathMatch) {
536
+ const filePath = absPathMatch[1];
537
+ if (fs.existsSync(filePath)) {
538
+ const query = input.replace(absPathMatch[1], "").trim();
539
+ return { filePath, query };
540
+ }
541
+ }
542
+ const relPathMatch = input.match(/([\w\-\.]+\/[\w\-\./]+\.\w{2,6})/);
543
+ if (relPathMatch) {
544
+ const filePath = path.resolve(relPathMatch[1]);
545
+ if (fs.existsSync(filePath)) {
546
+ const query = input.replace(relPathMatch[1], "").trim();
547
+ return { filePath, query };
548
+ }
549
+ }
550
+ return { filePath: null, query: input };
551
+ }
552
+ function expandAtFiles(input) {
553
+ const atMatch = input.match(/^@(\S+)\s*(.*)/);
554
+ if (atMatch) {
555
+ const filePath = path.resolve(atMatch[1]);
556
+ if (fs.existsSync(filePath)) {
557
+ contextText = fs.readFileSync(filePath, "utf-8");
558
+ contextSource = atMatch[1];
559
+ const lines = contextText.split("\n").length;
560
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines) from ${c.underline}${atMatch[1]}${c.reset}`);
561
+ return atMatch[2] || "";
562
+ }
563
+ else {
564
+ console.log(` ${c.red}File not found: ${atMatch[1]}${c.reset}`);
565
+ return "";
566
+ }
567
+ }
568
+ return input;
569
+ }
570
+ // ── Auto-detect URLs ────────────────────────────────────────────────────────
571
+ async function detectAndLoadUrl(input) {
572
+ const urlMatch = input.match(/^https?:\/\/\S+$/);
573
+ if (urlMatch) {
574
+ const url = urlMatch[0];
575
+ console.log(` ${c.dim}Fetching ${url}...${c.reset}`);
576
+ try {
577
+ const resp = await fetch(url);
578
+ if (!resp.ok)
579
+ throw new Error(`${resp.status} ${resp.statusText}`);
580
+ contextText = await resp.text();
581
+ contextSource = url;
582
+ const lines = contextText.split("\n").length;
583
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines)`);
584
+ return true;
585
+ }
586
+ catch (err) {
587
+ console.log(` ${c.red}Failed: ${err.message}${c.reset}`);
588
+ return false;
589
+ }
590
+ }
591
+ return false;
592
+ }
593
+ // ── Main interactive loop ───────────────────────────────────────────────────
594
+ async function interactive() {
595
+ // Validate env
596
+ const hasApiKey = process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY;
597
+ if (!hasApiKey) {
598
+ console.log(`\n ${c.red}No API key found.${c.reset}`);
599
+ console.log(` Set ${c.bold}ANTHROPIC_API_KEY${c.reset} or ${c.bold}OPENAI_API_KEY${c.reset} in your .env file.\n`);
600
+ process.exit(1);
601
+ }
602
+ // Resolve model
603
+ currentModel = resolveModel(currentModelId);
604
+ if (!currentModel) {
605
+ console.log(`\n ${c.red}Model "${currentModelId}" not found.${c.reset}`);
606
+ console.log(` Check ${c.bold}RLM_MODEL${c.reset} in your .env file.\n`);
607
+ process.exit(1);
608
+ }
609
+ printWelcome();
610
+ const rl = readline.createInterface({
611
+ input: stdin,
612
+ output: stdout,
613
+ prompt: `${c.cyan}>${c.reset} `,
614
+ terminal: true,
615
+ });
616
+ rl.prompt();
617
+ rl.on("line", async (rawLine) => {
618
+ if (isRunning)
619
+ return; // ignore input while a query is active
620
+ const line = rawLine.trim();
621
+ // URL auto-detect
622
+ if (line.startsWith("http://") || line.startsWith("https://")) {
623
+ const loaded = await detectAndLoadUrl(line);
624
+ if (loaded) {
625
+ printStatusLine();
626
+ console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
627
+ rl.prompt();
628
+ return;
629
+ }
630
+ }
631
+ // Multi-line paste detect
632
+ if (isMultiLineInput(rawLine)) {
633
+ const result = handleMultiLineAsContext(rawLine);
634
+ if (result) {
635
+ contextText = result.context;
636
+ contextSource = "(pasted)";
637
+ printStatusLine();
638
+ console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
639
+ rl.prompt();
640
+ return;
641
+ }
642
+ }
643
+ if (!line) {
644
+ rl.prompt();
645
+ return;
646
+ }
647
+ // Slash commands
648
+ if (line.startsWith("/")) {
649
+ const [cmd, ...rest] = line.slice(1).split(/\s+/);
650
+ const arg = rest.join(" ");
651
+ switch (cmd) {
652
+ case "help":
653
+ case "h":
654
+ printCommandHelp();
655
+ break;
656
+ case "file":
657
+ case "f":
658
+ await handleFile(arg);
659
+ break;
660
+ case "url":
661
+ case "u":
662
+ await handleUrl(arg);
663
+ break;
664
+ case "paste":
665
+ case "p":
666
+ await handlePaste(rl);
667
+ break;
668
+ case "context":
669
+ case "ctx":
670
+ handleContext();
671
+ break;
672
+ case "clear-context":
673
+ case "cc":
674
+ contextText = "";
675
+ contextSource = "";
676
+ console.log(` ${c.green}✓${c.reset} Context cleared.`);
677
+ break;
678
+ case "trajectories":
679
+ case "traj":
680
+ handleTrajectories();
681
+ break;
682
+ case "clear":
683
+ printWelcome();
684
+ break;
685
+ case "quit":
686
+ case "q":
687
+ case "exit":
688
+ console.log(`\n ${c.dim}Goodbye!${c.reset}\n`);
689
+ process.exit(0);
690
+ break;
691
+ default:
692
+ console.log(` ${c.red}Unknown command: /${cmd}${c.reset}. Type ${c.yellow}/help${c.reset} for commands.`);
693
+ }
694
+ rl.prompt();
695
+ return;
696
+ }
697
+ // @file shorthand
698
+ let query = expandAtFiles(line);
699
+ if (!query && line.startsWith("@")) {
700
+ rl.prompt();
701
+ return;
702
+ }
703
+ if (!query)
704
+ query = line;
705
+ // Inline URL detection — extract URL from query, fetch as context
706
+ if (!contextText) {
707
+ const urlInline = query.match(/(https?:\/\/\S+)/);
708
+ if (urlInline) {
709
+ const url = urlInline[1];
710
+ const queryWithoutUrl = query.replace(url, "").trim();
711
+ console.log(` ${c.dim}Fetching ${url}...${c.reset}`);
712
+ try {
713
+ const resp = await fetch(url);
714
+ if (!resp.ok)
715
+ throw new Error(`${resp.status} ${resp.statusText}`);
716
+ contextText = await resp.text();
717
+ contextSource = url;
718
+ const lines = contextText.split("\n").length;
719
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines) from URL`);
720
+ if (queryWithoutUrl) {
721
+ query = queryWithoutUrl;
722
+ }
723
+ else {
724
+ // URL only, no query — prompt for one
725
+ printStatusLine();
726
+ console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
727
+ rl.prompt();
728
+ return;
729
+ }
730
+ }
731
+ catch (err) {
732
+ console.log(` ${c.red}Failed to fetch URL: ${err.message}${c.reset}`);
733
+ console.log(` ${c.dim}Running query as-is...${c.reset}`);
734
+ }
735
+ }
736
+ }
737
+ // Auto-detect file paths
738
+ if (!contextText) {
739
+ const { filePath, query: extractedQuery } = extractFilePath(query);
740
+ if (filePath) {
741
+ contextText = fs.readFileSync(filePath, "utf-8");
742
+ contextSource = path.basename(filePath);
743
+ const lines = contextText.split("\n").length;
744
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines) from ${c.underline}${filePath}${c.reset}`);
745
+ query = extractedQuery || query;
746
+ }
747
+ }
748
+ // Run query
749
+ await runQuery(query);
750
+ printStatusLine();
751
+ console.log();
752
+ rl.prompt();
753
+ });
754
+ // Ctrl+C: abort running query, or double-tap to exit
755
+ let lastSigint = 0;
756
+ rl.on("SIGINT", () => {
757
+ if (isRunning && activeAc) {
758
+ activeSpinner?.stop();
759
+ console.log(`\n ${c.red}Stopped${c.reset}\n`);
760
+ activeAc.abort();
761
+ try {
762
+ activeRepl?.shutdown();
763
+ }
764
+ catch { /* ok */ }
765
+ isRunning = false;
766
+ lastSigint = 0;
767
+ }
768
+ else {
769
+ const now = Date.now();
770
+ if (now - lastSigint < 1000) {
771
+ // Double Ctrl+C — exit
772
+ console.log(`\n ${c.dim}Goodbye!${c.reset}\n`);
773
+ process.exit(0);
774
+ }
775
+ lastSigint = now;
776
+ console.log(`\n ${c.dim}Press Ctrl+C again to exit${c.reset}`);
777
+ rl.prompt();
778
+ }
779
+ });
780
+ rl.on("close", () => {
781
+ console.log(`\n ${c.dim}Goodbye!${c.reset}\n`);
782
+ process.exit(0);
783
+ });
784
+ }
785
+ interactive().catch((err) => {
786
+ console.error(`${c.red}Fatal error:${c.reset}`, err);
787
+ process.exit(1);
788
+ });
789
+ //# sourceMappingURL=interactive.js.map