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.
- package/README.md +184 -0
- package/bin/rlm.mjs +45 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +185 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +73 -0
- package/dist/env.d.ts +9 -0
- package/dist/env.js +34 -0
- package/dist/interactive.d.ts +10 -0
- package/dist/interactive.js +789 -0
- package/dist/main.d.ts +11 -0
- package/dist/main.js +144 -0
- package/dist/repl.d.ts +47 -0
- package/dist/repl.js +183 -0
- package/dist/rlm.d.ts +55 -0
- package/dist/rlm.js +354 -0
- package/dist/runtime.py +185 -0
- package/dist/viewer.d.ts +12 -0
- package/dist/viewer.js +828 -0
- package/package.json +48 -0
- package/rlm_config.yaml +17 -0
|
@@ -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
|