swarm-code 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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +384 -0
  3. package/bin/swarm.mjs +45 -0
  4. package/dist/agents/aider.d.ts +12 -0
  5. package/dist/agents/aider.js +182 -0
  6. package/dist/agents/claude-code.d.ts +9 -0
  7. package/dist/agents/claude-code.js +216 -0
  8. package/dist/agents/codex.d.ts +14 -0
  9. package/dist/agents/codex.js +193 -0
  10. package/dist/agents/direct-llm.d.ts +9 -0
  11. package/dist/agents/direct-llm.js +78 -0
  12. package/dist/agents/mock.d.ts +9 -0
  13. package/dist/agents/mock.js +77 -0
  14. package/dist/agents/opencode.d.ts +23 -0
  15. package/dist/agents/opencode.js +571 -0
  16. package/dist/agents/provider.d.ts +11 -0
  17. package/dist/agents/provider.js +31 -0
  18. package/dist/cli.d.ts +15 -0
  19. package/dist/cli.js +285 -0
  20. package/dist/compression/compressor.d.ts +28 -0
  21. package/dist/compression/compressor.js +265 -0
  22. package/dist/config.d.ts +42 -0
  23. package/dist/config.js +170 -0
  24. package/dist/core/repl.d.ts +69 -0
  25. package/dist/core/repl.js +336 -0
  26. package/dist/core/rlm.d.ts +63 -0
  27. package/dist/core/rlm.js +409 -0
  28. package/dist/core/runtime.py +335 -0
  29. package/dist/core/types.d.ts +131 -0
  30. package/dist/core/types.js +19 -0
  31. package/dist/env.d.ts +10 -0
  32. package/dist/env.js +75 -0
  33. package/dist/interactive-swarm.d.ts +20 -0
  34. package/dist/interactive-swarm.js +1041 -0
  35. package/dist/interactive.d.ts +10 -0
  36. package/dist/interactive.js +1765 -0
  37. package/dist/main.d.ts +15 -0
  38. package/dist/main.js +242 -0
  39. package/dist/mcp/server.d.ts +15 -0
  40. package/dist/mcp/server.js +72 -0
  41. package/dist/mcp/session.d.ts +73 -0
  42. package/dist/mcp/session.js +184 -0
  43. package/dist/mcp/tools.d.ts +15 -0
  44. package/dist/mcp/tools.js +377 -0
  45. package/dist/memory/episodic.d.ts +132 -0
  46. package/dist/memory/episodic.js +390 -0
  47. package/dist/prompts/orchestrator.d.ts +5 -0
  48. package/dist/prompts/orchestrator.js +191 -0
  49. package/dist/routing/model-router.d.ts +130 -0
  50. package/dist/routing/model-router.js +515 -0
  51. package/dist/swarm.d.ts +14 -0
  52. package/dist/swarm.js +557 -0
  53. package/dist/threads/cache.d.ts +58 -0
  54. package/dist/threads/cache.js +198 -0
  55. package/dist/threads/manager.d.ts +85 -0
  56. package/dist/threads/manager.js +659 -0
  57. package/dist/ui/banner.d.ts +14 -0
  58. package/dist/ui/banner.js +42 -0
  59. package/dist/ui/dashboard.d.ts +33 -0
  60. package/dist/ui/dashboard.js +151 -0
  61. package/dist/ui/index.d.ts +10 -0
  62. package/dist/ui/index.js +11 -0
  63. package/dist/ui/log.d.ts +39 -0
  64. package/dist/ui/log.js +126 -0
  65. package/dist/ui/onboarding.d.ts +14 -0
  66. package/dist/ui/onboarding.js +518 -0
  67. package/dist/ui/spinner.d.ts +25 -0
  68. package/dist/ui/spinner.js +113 -0
  69. package/dist/ui/summary.d.ts +18 -0
  70. package/dist/ui/summary.js +113 -0
  71. package/dist/ui/theme.d.ts +63 -0
  72. package/dist/ui/theme.js +97 -0
  73. package/dist/viewer.d.ts +12 -0
  74. package/dist/viewer.js +1284 -0
  75. package/dist/worktree/manager.d.ts +45 -0
  76. package/dist/worktree/manager.js +266 -0
  77. package/dist/worktree/merge.d.ts +28 -0
  78. package/dist/worktree/merge.js +138 -0
  79. package/package.json +69 -0
@@ -0,0 +1,1765 @@
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 os from "node:os";
13
+ import * as path from "node:path";
14
+ import { stdin, stdout } from "node:process";
15
+ import * as readline from "node:readline";
16
+ // Global error handlers — prevent raw stack traces from leaking to terminal
17
+ process.on("uncaughtException", (err) => {
18
+ console.error(`\n \x1b[31mUnexpected error: ${err.message}\x1b[0m\n`);
19
+ process.exit(1);
20
+ });
21
+ process.on("unhandledRejection", (err) => {
22
+ console.error(`\n \x1b[31mUnexpected error: ${err?.message || err}\x1b[0m\n`);
23
+ process.exit(1);
24
+ });
25
+ const { getModels, getProviders } = await import("@mariozechner/pi-ai");
26
+ const { PythonRepl } = await import("./core/repl.js");
27
+ const { runRlmLoop } = await import("./core/rlm.js");
28
+ const { loadConfig } = await import("./config.js");
29
+ const config = loadConfig();
30
+ // ── ANSI helpers ────────────────────────────────────────────────────────────
31
+ const c = {
32
+ reset: "\x1b[0m",
33
+ bold: "\x1b[1m",
34
+ dim: "\x1b[2m",
35
+ italic: "\x1b[3m",
36
+ underline: "\x1b[4m",
37
+ red: "\x1b[31m",
38
+ green: "\x1b[32m",
39
+ yellow: "\x1b[33m",
40
+ blue: "\x1b[34m",
41
+ magenta: "\x1b[35m",
42
+ cyan: "\x1b[36m",
43
+ white: "\x1b[37m",
44
+ gray: "\x1b[90m",
45
+ clearLine: "\x1b[2K\r",
46
+ };
47
+ // ── Spinner ─────────────────────────────────────────────────────────────────
48
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
49
+ class Spinner {
50
+ interval = null;
51
+ frameIndex = 0;
52
+ message = "";
53
+ startTime = Date.now();
54
+ start(message) {
55
+ this.stop();
56
+ this.message = message;
57
+ this.startTime = Date.now();
58
+ this.frameIndex = 0;
59
+ this.render();
60
+ this.interval = setInterval(() => this.render(), 80);
61
+ }
62
+ update(message) {
63
+ this.message = message;
64
+ }
65
+ stop() {
66
+ if (this.interval) {
67
+ clearInterval(this.interval);
68
+ this.interval = null;
69
+ process.stdout.write(c.clearLine);
70
+ }
71
+ }
72
+ render() {
73
+ const frame = SPINNER_FRAMES[this.frameIndex % SPINNER_FRAMES.length];
74
+ const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
75
+ process.stdout.write(`${c.clearLine} ${c.cyan}${frame}${c.reset} ${this.message} ${c.dim}${elapsed}s${c.reset}`);
76
+ this.frameIndex++;
77
+ }
78
+ }
79
+ // ── Constants ───────────────────────────────────────────────────────────────
80
+ const DEFAULT_MODEL = process.env.RLM_MODEL || "claude-sonnet-4-6";
81
+ const RLM_HOME = path.join(os.homedir(), ".rlm");
82
+ const TRAJ_DIR = path.join(RLM_HOME, "trajectories");
83
+ let _W = Math.min(process.stdout.columns || 80, 100);
84
+ // ── Session state ───────────────────────────────────────────────────────────
85
+ let currentModelId = DEFAULT_MODEL;
86
+ let currentModel;
87
+ let currentProviderName = "";
88
+ let contextText = "";
89
+ let contextSource = "";
90
+ let queryCount = 0;
91
+ let isRunning = false;
92
+ // Exposed so the readline SIGINT handler can abort the running query
93
+ let activeAc = null;
94
+ let activeRepl = null;
95
+ let activeSpinner = null;
96
+ // ── Resolve model ───────────────────────────────────────────────────────────
97
+ function resolveModel(modelId) {
98
+ const result = resolveModelWithProvider(modelId);
99
+ return result?.model;
100
+ }
101
+ // Provider → env var mapping for well-known providers
102
+ const PROVIDER_KEYS = {
103
+ anthropic: "ANTHROPIC_API_KEY",
104
+ openai: "OPENAI_API_KEY",
105
+ google: "GEMINI_API_KEY",
106
+ };
107
+ // User-facing provider list for setup & /provider command
108
+ const SETUP_PROVIDERS = [
109
+ { name: "Anthropic", label: "Claude", env: "ANTHROPIC_API_KEY", piProvider: "anthropic" },
110
+ { name: "OpenAI", label: "GPT", env: "OPENAI_API_KEY", piProvider: "openai" },
111
+ { name: "Google", label: "Gemini", env: "GEMINI_API_KEY", piProvider: "google" },
112
+ ];
113
+ function providerEnvKey(provider) {
114
+ return PROVIDER_KEYS[provider] || `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`;
115
+ }
116
+ function detectProvider() {
117
+ for (const provider of Object.keys(PROVIDER_KEYS)) {
118
+ if (process.env[PROVIDER_KEYS[provider]])
119
+ return provider;
120
+ }
121
+ return "unknown";
122
+ }
123
+ function hasAnyApiKey() {
124
+ return detectProvider() !== "unknown";
125
+ }
126
+ /** Returns the pi-ai provider name + model for a given model ID, searching all providers.
127
+ * Prioritises SETUP_PROVIDERS (with API key) so e.g. "gpt-4o" resolves to "openai" not "azure-openai-responses". */
128
+ function resolveModelWithProvider(modelId) {
129
+ const knownNames = new Set(SETUP_PROVIDERS.map((p) => p.piProvider));
130
+ let firstMatch;
131
+ // First pass: well-known providers that have an API key set (best match)
132
+ for (const provider of getProviders()) {
133
+ if (!knownNames.has(provider))
134
+ continue;
135
+ for (const m of getModels(provider)) {
136
+ if (m.id === modelId) {
137
+ if (process.env[providerEnvKey(provider)]) {
138
+ return { model: m, provider };
139
+ }
140
+ if (!firstMatch)
141
+ firstMatch = { model: m, provider };
142
+ }
143
+ }
144
+ }
145
+ // Second pass: well-known providers without key (user may enter it later)
146
+ if (firstMatch)
147
+ return firstMatch;
148
+ // Third pass: all remaining providers
149
+ for (const provider of getProviders()) {
150
+ if (knownNames.has(provider))
151
+ continue;
152
+ for (const m of getModels(provider)) {
153
+ if (m.id === modelId)
154
+ return { model: m, provider };
155
+ }
156
+ }
157
+ return undefined;
158
+ }
159
+ /** Sensible default model per provider. */
160
+ const PROVIDER_DEFAULT_MODELS = {
161
+ anthropic: "claude-sonnet-4-6",
162
+ openai: "gpt-4o",
163
+ google: "gemini-2.5-flash",
164
+ };
165
+ /** Returns the recommended default model for a provider. */
166
+ function getDefaultModelForProvider(provider) {
167
+ const preferred = PROVIDER_DEFAULT_MODELS[provider];
168
+ if (preferred) {
169
+ const model = resolveModel(preferred);
170
+ if (model)
171
+ return preferred;
172
+ }
173
+ // Fallback: first non-excluded model
174
+ const models = getModelsForProvider(provider);
175
+ return models.length > 0 ? models[0].id : undefined;
176
+ }
177
+ /** Wrap rl.question with ESC-to-cancel. Returns user input, empty string, or null on ESC.
178
+ * When secret=true, suppresses character echo (for API keys). */
179
+ function questionWithEsc(rlInstance, promptText, opts) {
180
+ return new Promise((resolve) => {
181
+ let escaped = false;
182
+ const rlAny = rlInstance;
183
+ let savedWrite;
184
+ if (opts?.secret) {
185
+ // Suppress readline's echo — write prompt ourselves, hide typed chars
186
+ savedWrite = rlAny._writeToOutput;
187
+ rlAny._writeToOutput = () => { };
188
+ process.stdout.write(promptText);
189
+ }
190
+ const onKeypress = (_str, key) => {
191
+ if (key?.name === "escape" && !escaped) {
192
+ escaped = true;
193
+ stdin.removeListener("keypress", onKeypress);
194
+ if (savedWrite)
195
+ rlAny._writeToOutput = savedWrite;
196
+ process.stdout.write("\r\x1b[2K");
197
+ rlInstance.write("\n");
198
+ }
199
+ };
200
+ stdin.on("keypress", onKeypress);
201
+ rlInstance.question(opts?.secret ? "" : promptText, (answer) => {
202
+ stdin.removeListener("keypress", onKeypress);
203
+ if (savedWrite) {
204
+ rlAny._writeToOutput = savedWrite;
205
+ process.stdout.write("\n");
206
+ }
207
+ resolve(escaped ? null : answer.trim());
208
+ });
209
+ });
210
+ }
211
+ /** Prompt user for a provider's API key (only if not already set).
212
+ * Returns true (got key / already set), false (empty input), or null (ESC pressed). */
213
+ async function promptForProviderKey(rlInstance, providerInfo) {
214
+ if (process.env[providerInfo.env])
215
+ return true;
216
+ const rawKey = await questionWithEsc(rlInstance, ` ${c.cyan}${providerInfo.env}:${c.reset} `, { secret: true });
217
+ if (rawKey === null)
218
+ return null; // ESC
219
+ if (!rawKey)
220
+ return false; // empty
221
+ // Sanitize: strip newlines, control chars, whitespace
222
+ const key = rawKey.replace(/[\r\n\x00-\x1f]/g, "").trim();
223
+ if (!key)
224
+ return false;
225
+ process.env[providerInfo.env] = key;
226
+ // Save to ~/.rlm/credentials (persistent across sessions, replaces existing key)
227
+ const credPath = path.join(RLM_HOME, "credentials");
228
+ try {
229
+ if (!fs.existsSync(RLM_HOME))
230
+ fs.mkdirSync(RLM_HOME, { recursive: true });
231
+ // Remove existing entry for this key to avoid duplicates
232
+ if (fs.existsSync(credPath)) {
233
+ const existing = fs.readFileSync(credPath, "utf-8");
234
+ const filtered = existing
235
+ .split("\n")
236
+ .filter((l) => {
237
+ const t = l.trim();
238
+ if (t.startsWith("export "))
239
+ return !t.slice(7).startsWith(`${providerInfo.env}=`);
240
+ return !t.startsWith(`${providerInfo.env}=`);
241
+ })
242
+ .join("\n");
243
+ fs.writeFileSync(credPath, filtered.endsWith("\n") ? filtered : `${filtered}\n`);
244
+ }
245
+ fs.appendFileSync(credPath, `${providerInfo.env}=${key}\n`);
246
+ // Restrict permissions (owner-only read/write)
247
+ try {
248
+ fs.chmodSync(credPath, 0o600);
249
+ }
250
+ catch {
251
+ /* Windows etc. */
252
+ }
253
+ console.log(`\n ${c.green}✓${c.reset} ${providerInfo.name} key saved to ${c.dim}~/.rlm/credentials${c.reset}`);
254
+ }
255
+ catch {
256
+ console.log(`\n ${c.yellow}!${c.reset} Could not save key. Add manually:`);
257
+ console.log(` ${c.yellow}export ${providerInfo.env}=<your-key>${c.reset}`);
258
+ }
259
+ return true;
260
+ }
261
+ /** Persist the user's model choice to ~/.rlm/credentials so it survives restarts. */
262
+ function saveModelPreference(modelId) {
263
+ const credPath = path.join(RLM_HOME, "credentials");
264
+ try {
265
+ if (!fs.existsSync(RLM_HOME))
266
+ fs.mkdirSync(RLM_HOME, { recursive: true });
267
+ // Remove existing RLM_MODEL entry
268
+ if (fs.existsSync(credPath)) {
269
+ const existing = fs.readFileSync(credPath, "utf-8");
270
+ const filtered = existing
271
+ .split("\n")
272
+ .filter((l) => {
273
+ const t = l.trim();
274
+ if (t.startsWith("export "))
275
+ return !t.slice(7).startsWith("RLM_MODEL=");
276
+ return !t.startsWith("RLM_MODEL=");
277
+ })
278
+ .join("\n");
279
+ fs.writeFileSync(credPath, filtered.endsWith("\n") ? filtered : `${filtered}\n`);
280
+ }
281
+ fs.appendFileSync(credPath, `RLM_MODEL=${modelId}\n`);
282
+ try {
283
+ fs.chmodSync(credPath, 0o600);
284
+ }
285
+ catch { }
286
+ }
287
+ catch {
288
+ /* best-effort */
289
+ }
290
+ }
291
+ /** Find the SETUP_PROVIDERS entry that owns a given pi-ai provider name. */
292
+ function findSetupProvider(piProvider) {
293
+ return SETUP_PROVIDERS.find((p) => p.piProvider === piProvider);
294
+ }
295
+ // ── Paste detection ─────────────────────────────────────────────────────────
296
+ function isMultiLineInput(input) {
297
+ return input.includes("\n");
298
+ }
299
+ function handleMultiLineAsContext(input) {
300
+ const lines = input.split("\n");
301
+ if (lines.length > 3) {
302
+ const sizeKB = (input.length / 1024).toFixed(1);
303
+ console.log(` ${c.green}✓${c.reset} Pasted ${c.bold}${lines.length} lines${c.reset} ${c.dim}(${sizeKB}KB)${c.reset}`);
304
+ return { context: input, query: "" };
305
+ }
306
+ return null;
307
+ }
308
+ // ── Banner ──────────────────────────────────────────────────────────────────
309
+ function printBanner() {
310
+ console.log(`
311
+ ${c.cyan}${c.bold}
312
+ ██████╗ ██╗ ███╗ ███╗
313
+ ██╔══██╗██║ ████╗ ████║
314
+ ██████╔╝██║ ██╔████╔██║
315
+ ██╔══██╗██║ ██║╚██╔╝██║
316
+ ██║ ██║███████╗██║ ╚═╝ ██║
317
+ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝
318
+ ${c.reset}
319
+ ${c.dim} Recursive Language Models — arXiv:2512.24601${c.reset}
320
+ `);
321
+ }
322
+ // ── Status line ─────────────────────────────────────────────────────────────
323
+ function printStatusLine() {
324
+ const provider = currentProviderName || detectProvider();
325
+ const modelShort = currentModelId.length > 35 ? `${currentModelId.slice(0, 32)}...` : currentModelId;
326
+ const ctx = contextText
327
+ ? `${c.green}●${c.reset} ${(contextText.length / 1024).toFixed(1)}KB${contextSource ? ` ${c.dim}(${contextSource})${c.reset}` : ""}`
328
+ : `${c.dim}○${c.reset}`;
329
+ console.log(` ${c.dim}${modelShort}${c.reset} ${c.dim}(${provider})${c.reset} ${ctx} ${c.dim}Q:${queryCount}${c.reset}`);
330
+ }
331
+ // ── Directory tree ──────────────────────────────────────────────────────────
332
+ /** Generate a concise directory tree string (like `tree -L 2`). */
333
+ function generateDirTree(dir, prefix = "", depth = 0, maxDepth = 2) {
334
+ if (depth > maxDepth)
335
+ return "";
336
+ let entries;
337
+ try {
338
+ entries = fs.readdirSync(dir, { withFileTypes: true });
339
+ }
340
+ catch {
341
+ return "";
342
+ }
343
+ // Filter and sort: dirs first, skip hidden/ignored
344
+ const filtered = entries
345
+ .filter((e) => {
346
+ if (e.name.startsWith(".") && e.name !== ".env")
347
+ return false;
348
+ if (e.isDirectory() && SKIP_DIRS.has(e.name))
349
+ return false;
350
+ if (e.isSymbolicLink())
351
+ return false;
352
+ if (e.isFile() && isBinaryFile(path.join(dir, e.name)))
353
+ return false;
354
+ return true;
355
+ })
356
+ .sort((a, b) => {
357
+ if (a.isDirectory() !== b.isDirectory())
358
+ return a.isDirectory() ? -1 : 1;
359
+ return a.name.localeCompare(b.name);
360
+ });
361
+ // Cap entries per level to keep it concise
362
+ const MAX_PER_LEVEL = 25;
363
+ const shown = filtered.slice(0, MAX_PER_LEVEL);
364
+ const omitted = filtered.length - shown.length;
365
+ const lines = [];
366
+ for (let i = 0; i < shown.length; i++) {
367
+ const entry = shown[i];
368
+ const isLast = i === shown.length - 1 && omitted === 0;
369
+ const connector = isLast ? "└── " : "├── ";
370
+ const childPrefix = isLast ? " " : "│ ";
371
+ const suffix = entry.isDirectory() ? "/" : "";
372
+ lines.push(`${prefix}${connector}${entry.name}${suffix}`);
373
+ if (entry.isDirectory()) {
374
+ const sub = generateDirTree(path.join(dir, entry.name), prefix + childPrefix, depth + 1, maxDepth);
375
+ if (sub)
376
+ lines.push(sub);
377
+ }
378
+ }
379
+ if (omitted > 0)
380
+ lines.push(`${prefix}└── ... ${omitted} more`);
381
+ return lines.join("\n");
382
+ }
383
+ /** Build a cwd context string with directory tree. */
384
+ function buildCwdContext() {
385
+ const cwd = process.cwd();
386
+ const tree = generateDirTree(cwd);
387
+ const parts = [`Working directory: ${cwd}\n`];
388
+ if (tree)
389
+ parts.push(`File tree:\n${tree}`);
390
+ return parts.join("\n");
391
+ }
392
+ // ── Welcome ─────────────────────────────────────────────────────────────────
393
+ function printWelcome() {
394
+ console.clear();
395
+ printBanner();
396
+ const cwdShort = process.cwd().replace(os.homedir(), "~");
397
+ console.log(` ${c.dim}${cwdShort}${c.reset}`);
398
+ printStatusLine();
399
+ console.log(` ${c.dim}max ${config.max_iterations} iters · ${config.max_sub_queries} sub-queries · /help${c.reset}\n`);
400
+ }
401
+ // ── Help ────────────────────────────────────────────────────────────────────
402
+ function printCommandHelp() {
403
+ console.log(`
404
+ ${c.bold}Loading Context${c.reset}
405
+ ${c.cyan}/file${c.reset} <path> Load a single file
406
+ ${c.cyan}/file${c.reset} <p1> <p2> ... Load multiple files
407
+ ${c.cyan}/file${c.reset} <dir>/ Load all files in a directory (recursive)
408
+ ${c.cyan}/file${c.reset} src/**/*.ts Load files matching a glob pattern
409
+ ${c.cyan}/url${c.reset} <url> Fetch URL as context
410
+ ${c.cyan}/paste${c.reset} Multi-line paste mode (type EOF to finish)
411
+ ${c.cyan}/context${c.reset} Show loaded context info + file list
412
+ ${c.cyan}/clear-context${c.reset} Unload context
413
+
414
+ ${c.bold}@ Shorthand${c.reset} ${c.dim}(inline file loading)${c.reset}
415
+ ${c.cyan}@file.ts${c.reset} <query> Load file and ask in one shot
416
+ ${c.cyan}@a.ts @b.ts${c.reset} <query> Load multiple files + query
417
+ ${c.cyan}@src/${c.reset} <query> Load directory + query
418
+ ${c.cyan}@src/**/*.ts${c.reset} <query> Load glob + query
419
+
420
+ ${c.bold}Model & Provider${c.reset}
421
+ ${c.cyan}/model${c.reset} List models for current provider
422
+ ${c.cyan}/model${c.reset} <#|id> Switch model by number or ID
423
+ ${c.cyan}/provider${c.reset} Switch provider (Anthropic, OpenAI, Google)
424
+ ${c.cyan}/key${c.reset} Update an API key
425
+
426
+ ${c.bold}Tools${c.reset}
427
+ ${c.cyan}/trajectories${c.reset} List saved runs
428
+
429
+ ${c.bold}General${c.reset}
430
+ ${c.cyan}/clear${c.reset} Clear screen
431
+ ${c.cyan}/help${c.reset} Show this help
432
+ ${c.cyan}/quit${c.reset} Exit
433
+
434
+ ${c.bold}Tips${c.reset}
435
+ ${c.dim}•${c.reset} Just type a question — no context needed for general queries
436
+ ${c.dim}•${c.reset} Paste a URL directly to fetch it as context
437
+ ${c.dim}•${c.reset} Paste 4+ lines of text to set it as context
438
+ ${c.dim}•${c.reset} ${c.bold}Ctrl+C${c.reset} stops a running query, ${c.bold}Ctrl+C twice${c.reset} exits
439
+ ${c.dim}•${c.reset} Directories skip node_modules, .git, dist, binaries, etc.
440
+ ${c.dim}•${c.reset} Limits: ${MAX_FILES} files max, ${MAX_TOTAL_BYTES / 1024 / 1024}MB total
441
+ `);
442
+ }
443
+ // ── Slash command handlers ──────────────────────────────────────────────────
444
+ /** Check if a token looks like a file path (vs. plain query text). */
445
+ function looksLikePath(token) {
446
+ if (token.includes("/") || token.includes("\\"))
447
+ return true;
448
+ if (token.includes("*") || token.includes("?"))
449
+ return true;
450
+ if (token.startsWith("~"))
451
+ return true;
452
+ if (token.startsWith("."))
453
+ return true;
454
+ if (/\.\w{1,6}$/.test(token))
455
+ return true; // has file extension
456
+ return false;
457
+ }
458
+ async function handleFile(arg) {
459
+ if (!arg) {
460
+ console.log(` ${c.red}Usage: /file <path> [query]${c.reset}`);
461
+ console.log(` ${c.dim}Examples: /file src/main.ts | /file src/ | /file src/**/*.ts${c.reset}`);
462
+ return;
463
+ }
464
+ const tokens = arg.split(/\s+/).filter(Boolean);
465
+ // Separate paths from query text
466
+ const pathTokens = [];
467
+ const queryTokens = [];
468
+ let pastPaths = false;
469
+ for (const t of tokens) {
470
+ if (!pastPaths && looksLikePath(t)) {
471
+ pathTokens.push(t);
472
+ }
473
+ else {
474
+ pastPaths = true;
475
+ queryTokens.push(t);
476
+ }
477
+ }
478
+ if (pathTokens.length === 0) {
479
+ console.log(` ${c.red}No file path found in: ${arg}${c.reset}`);
480
+ return;
481
+ }
482
+ const filePaths = resolveFileArgs(pathTokens);
483
+ if (filePaths.length === 0) {
484
+ console.log(` ${c.red}No files found.${c.reset}`);
485
+ return;
486
+ }
487
+ if (filePaths.length === 1) {
488
+ try {
489
+ contextText = fs.readFileSync(filePaths[0], "utf-8");
490
+ contextSource = path.relative(process.cwd(), filePaths[0]) || filePaths[0];
491
+ const lines = contextText.split("\n").length;
492
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines) from ${c.underline}${contextSource}${c.reset}`);
493
+ }
494
+ catch (err) {
495
+ console.log(` ${c.red}Could not read file: ${err.message}${c.reset}`);
496
+ }
497
+ }
498
+ else {
499
+ const { text, count, totalBytes } = loadMultipleFiles(filePaths);
500
+ contextText = text;
501
+ contextSource = `${count} files`;
502
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${count}${c.reset} files (${(totalBytes / 1024).toFixed(1)}KB total)`);
503
+ // Show file list
504
+ for (const fp of filePaths.slice(0, 20)) {
505
+ console.log(` ${c.dim}•${c.reset} ${path.relative(process.cwd(), fp)}`);
506
+ }
507
+ if (filePaths.length > 20) {
508
+ console.log(` ${c.dim}... and ${filePaths.length - 20} more${c.reset}`);
509
+ }
510
+ }
511
+ // Return query text if provided after paths
512
+ if (queryTokens.length > 0)
513
+ return queryTokens.join(" ");
514
+ }
515
+ async function handleUrl(arg) {
516
+ if (!arg) {
517
+ console.log(` ${c.red}Usage: /url <url>${c.reset}`);
518
+ return;
519
+ }
520
+ console.log(` ${c.dim}Fetching ${arg}...${c.reset}`);
521
+ try {
522
+ contextText = await safeFetch(arg);
523
+ contextSource = arg;
524
+ const lines = contextText.split("\n").length;
525
+ console.log(` ${c.green}✓${c.reset} Fetched ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines)`);
526
+ }
527
+ catch (err) {
528
+ console.log(` ${c.red}Failed: ${err.message}${c.reset}`);
529
+ }
530
+ }
531
+ function handlePaste(rl) {
532
+ return new Promise((resolve) => {
533
+ console.log(` ${c.dim}Paste your context below. Type ${c.bold}EOF${c.reset}${c.dim} on an empty line to finish.${c.reset}`);
534
+ const lines = [];
535
+ const onLine = (line) => {
536
+ if (line.trim() === "EOF") {
537
+ rl.removeListener("line", onLine);
538
+ contextText = lines.join("\n");
539
+ contextSource = "(pasted)";
540
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.length} lines) from paste`);
541
+ resolve();
542
+ return;
543
+ }
544
+ lines.push(line);
545
+ };
546
+ rl.on("line", onLine);
547
+ });
548
+ }
549
+ function handleContext() {
550
+ if (!contextText) {
551
+ console.log(` ${c.dim}No context loaded. Use /file, /url, @file, or /paste.${c.reset}`);
552
+ return;
553
+ }
554
+ const lines = contextText.split("\n").length;
555
+ const sizeKB = (contextText.length / 1024).toFixed(1);
556
+ console.log(` ${c.bold}Context:${c.reset} ${contextText.length.toLocaleString()} chars (${sizeKB}KB), ${lines.toLocaleString()} lines`);
557
+ console.log(` ${c.bold}Source:${c.reset} ${contextSource}`);
558
+ // For multi-file context, extract and display individual file paths
559
+ const fileSeparators = contextText.match(/^=== .+ ===$/gm);
560
+ if (fileSeparators && fileSeparators.length > 1) {
561
+ console.log(` ${c.bold}Files:${c.reset} ${fileSeparators.length}`);
562
+ for (const sep of fileSeparators.slice(0, 20)) {
563
+ const name = sep.replace(/^=== /, "").replace(/ ===$/, "");
564
+ console.log(` ${c.dim}•${c.reset} ${name}`);
565
+ }
566
+ if (fileSeparators.length > 20) {
567
+ console.log(` ${c.dim}... and ${fileSeparators.length - 20} more${c.reset}`);
568
+ }
569
+ }
570
+ else {
571
+ console.log();
572
+ const preview = contextText.slice(0, 500);
573
+ const previewLines = preview.split("\n").slice(0, 8);
574
+ for (const l of previewLines) {
575
+ console.log(` ${c.dim}│${c.reset} ${l}`);
576
+ }
577
+ if (contextText.length > 500) {
578
+ console.log(` ${c.dim}│ ...${c.reset}`);
579
+ }
580
+ }
581
+ }
582
+ function handleTrajectories() {
583
+ if (!fs.existsSync(TRAJ_DIR)) {
584
+ console.log(` ${c.dim}No trajectories yet.${c.reset}`);
585
+ return;
586
+ }
587
+ const files = fs
588
+ .readdirSync(TRAJ_DIR)
589
+ .filter((f) => f.endsWith(".json"))
590
+ .sort()
591
+ .reverse();
592
+ if (files.length === 0) {
593
+ console.log(` ${c.dim}No trajectories yet.${c.reset}`);
594
+ return;
595
+ }
596
+ console.log(`\n ${c.bold}Saved trajectories:${c.reset}\n`);
597
+ for (const f of files.slice(0, 15)) {
598
+ const stat = fs.statSync(path.join(TRAJ_DIR, f));
599
+ const size = (stat.size / 1024).toFixed(1);
600
+ console.log(` ${c.dim}•${c.reset} ${f} ${c.dim}(${size}K)${c.reset}`);
601
+ }
602
+ if (files.length > 15) {
603
+ console.log(` ${c.dim}... and ${files.length - 15} more${c.reset}`);
604
+ }
605
+ console.log();
606
+ }
607
+ // ── Display helpers ─────────────────────────────────────────────────────────
608
+ let BOX_W = Math.min(process.stdout.columns || 80, 96) - 4;
609
+ let MAX_CONTENT_W = BOX_W - 4;
610
+ // Update dimensions on terminal resize
611
+ process.stdout.on("resize", () => {
612
+ _W = Math.min(process.stdout.columns || 80, 100);
613
+ BOX_W = Math.min(process.stdout.columns || 80, 96) - 4;
614
+ MAX_CONTENT_W = BOX_W - 4;
615
+ });
616
+ /** Strip ANSI escape codes to get visible string length. */
617
+ function stripAnsi(str) {
618
+ return str.replace(/\x1b\[[0-9;]*m/g, "");
619
+ }
620
+ /** Wrap a raw text line into chunks that fit within maxWidth. */
621
+ function wrapText(text, maxWidth) {
622
+ if (text.length <= maxWidth)
623
+ return [text];
624
+ const result = [];
625
+ for (let i = 0; i < text.length; i += maxWidth) {
626
+ result.push(text.slice(i, i + maxWidth));
627
+ }
628
+ return result;
629
+ }
630
+ // ── Box-drawing helpers ─────────────────────────────────────────────────────
631
+ function boxTop(label, color = c.dim) {
632
+ const visLen = stripAnsi(label).length;
633
+ const right = Math.max(0, BOX_W - visLen - 5);
634
+ return ` ${color}╭─ ${c.reset}${label}${color} ${"─".repeat(right)}╮${c.reset}`;
635
+ }
636
+ function boxLine(text, color = c.dim) {
637
+ return ` ${color}│${c.reset} ${text}`;
638
+ }
639
+ function boxBottom(color = c.dim) {
640
+ return ` ${color}╰${"─".repeat(BOX_W - 2)}╯${c.reset}`;
641
+ }
642
+ function stepRule() {
643
+ console.log(` ${c.dim}${"─".repeat(BOX_W - 2)}${c.reset}`);
644
+ }
645
+ // ── Display functions ───────────────────────────────────────────────────────
646
+ function displayCode(code) {
647
+ const lines = code.split("\n");
648
+ const lineNumWidth = String(lines.length).length;
649
+ const codeMaxW = MAX_CONTENT_W - lineNumWidth - 1;
650
+ console.log(boxTop("Code", c.cyan));
651
+ for (let i = 0; i < lines.length; i++) {
652
+ const wrapped = wrapText(lines[i], codeMaxW);
653
+ for (let j = 0; j < wrapped.length; j++) {
654
+ const prefix = j === 0 ? `${c.dim}${String(i + 1).padStart(lineNumWidth)}${c.reset}` : " ".repeat(lineNumWidth);
655
+ console.log(boxLine(`${prefix} ${c.cyan}${wrapped[j]}${c.reset}`, c.cyan));
656
+ }
657
+ }
658
+ console.log(boxBottom(c.cyan));
659
+ }
660
+ function displayOutput(output) {
661
+ const lines = output.split("\n").filter((l) => l.trim() !== "");
662
+ if (lines.length === 0)
663
+ return;
664
+ console.log(boxTop("Output", c.green));
665
+ for (const line of lines) {
666
+ for (const chunk of wrapText(line, MAX_CONTENT_W)) {
667
+ console.log(boxLine(`${c.green}${chunk}${c.reset}`, c.green));
668
+ }
669
+ }
670
+ console.log(boxBottom(c.green));
671
+ }
672
+ function displayError(stderr) {
673
+ const lines = stderr.split("\n").filter((l) => l.trim() !== "");
674
+ if (lines.length === 0)
675
+ return;
676
+ console.log(boxTop("Error", c.red));
677
+ for (const line of lines) {
678
+ for (const chunk of wrapText(line, MAX_CONTENT_W)) {
679
+ console.log(boxLine(`${c.red}${chunk}${c.reset}`, c.red));
680
+ }
681
+ }
682
+ console.log(boxBottom(c.red));
683
+ }
684
+ function showErrorMsg(msg) {
685
+ const lines = msg.split(/\n/).filter((l) => l.trim());
686
+ console.log(boxTop("Error", c.red));
687
+ for (const line of lines) {
688
+ for (const chunk of wrapText(line, MAX_CONTENT_W)) {
689
+ console.log(boxLine(chunk, c.red));
690
+ }
691
+ }
692
+ console.log(boxBottom(c.red));
693
+ }
694
+ function formatSize(chars) {
695
+ return chars >= 1000 ? `${(chars / 1000).toFixed(1)}K` : `${chars}`;
696
+ }
697
+ function displaySubQueryStart(info) {
698
+ console.log(boxTop(`${c.magenta}Sub-query #${info.index}${c.reset} ${c.dim}${formatSize(info.contextLength)} chars`, c.magenta));
699
+ const instrLines = info.instruction.split("\n").filter((l) => l.trim());
700
+ for (const line of instrLines) {
701
+ for (const chunk of wrapText(line, MAX_CONTENT_W)) {
702
+ console.log(boxLine(`${c.dim}${chunk}${c.reset}`, c.magenta));
703
+ }
704
+ }
705
+ }
706
+ function displaySubQueryResult(info) {
707
+ const elapsed = (info.elapsedMs / 1000).toFixed(1);
708
+ const resultLines = info.resultPreview.split("\n");
709
+ console.log(boxLine("", c.magenta));
710
+ console.log(boxLine(`${c.green}Response:${c.reset}`, c.magenta));
711
+ for (const line of resultLines) {
712
+ for (const chunk of wrapText(line, MAX_CONTENT_W)) {
713
+ console.log(boxLine(`${c.green}${chunk}${c.reset}`, c.magenta));
714
+ }
715
+ }
716
+ console.log(boxBottom(c.magenta));
717
+ console.log(` ${c.dim}${elapsed}s · ${formatSize(info.resultLength)} received${c.reset}`);
718
+ }
719
+ // ── Available models list ────────────────────────────────────────────────────
720
+ /** Filter out deprecated, retired, and non-chat models (Feb 2026). */
721
+ const EXCLUDED_MODEL_PATTERNS = [
722
+ // ── Anthropic retired / old gen ──
723
+ /^claude-3-/, // all claude 3.x retired (haiku, sonnet, opus, 3-5-*, 3-7-*)
724
+ // ── OpenAI legacy / specialized ──
725
+ /^gpt-4$/, // superseded by gpt-4.1
726
+ /^gpt-4-turbo/, // superseded by gpt-4.1
727
+ /^gpt-4o-2024-/, // dated snapshots
728
+ /-chat-latest$/, // chat variants (use base model)
729
+ /^codex-/, // code-only
730
+ /-codex/, // all codex variants
731
+ // ── Google retired / deprecated ──
732
+ /^gemini-1\.5-/, // all 1.5 retired
733
+ /^gemini-3-pro-preview$/, // deprecated, shuts down Mar 9, 2026
734
+ /^gemini-live-/, // real-time streaming, not standard chat
735
+ // ── Dated snapshots / previews ──
736
+ /preview-\d{2}-\d{2}$/, // e.g. preview-04-17
737
+ /preview-\d{2}-\d{4}$/, // e.g. preview-09-2025
738
+ /^labs-/,
739
+ /-customtools$/,
740
+ /deep-research$/,
741
+ ];
742
+ function isModelExcluded(modelId) {
743
+ return EXCLUDED_MODEL_PATTERNS.some((p) => p.test(modelId));
744
+ }
745
+ /** Collect models from providers that have an API key set. */
746
+ function _getAvailableModels() {
747
+ const items = [];
748
+ for (const provider of getProviders()) {
749
+ if (!process.env[providerEnvKey(provider)])
750
+ continue;
751
+ for (const m of getModels(provider)) {
752
+ if (!isModelExcluded(m.id))
753
+ items.push({ id: m.id, provider });
754
+ }
755
+ }
756
+ return items;
757
+ }
758
+ /** Get models for a specific provider (matching by pi-ai provider name or SETUP_PROVIDERS piProvider). */
759
+ function getModelsForProvider(providerName) {
760
+ const items = [];
761
+ for (const provider of getProviders()) {
762
+ if (provider !== providerName)
763
+ continue;
764
+ for (const m of getModels(provider)) {
765
+ if (!isModelExcluded(m.id))
766
+ items.push({ id: m.id, provider });
767
+ }
768
+ }
769
+ return items;
770
+ }
771
+ // ── Truncate helper ─────────────────────────────────────────────────────────
772
+ function _truncateStr(text, max) {
773
+ return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
774
+ }
775
+ // ── Multi-file context loading ──────────────────────────────────────────────
776
+ const MAX_FILES = 100;
777
+ const MAX_TOTAL_BYTES = 10 * 1024 * 1024; // 10MB
778
+ const FETCH_TIMEOUT_MS = 30_000;
779
+ const MAX_RESPONSE_BYTES = 50 * 1024 * 1024; // 50MB
780
+ /** Fetch a URL with timeout and size limits. */
781
+ async function safeFetch(url) {
782
+ const resp = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
783
+ if (!resp.ok)
784
+ throw new Error(`${resp.status} ${resp.statusText}`);
785
+ const contentLength = resp.headers.get("content-length");
786
+ if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
787
+ throw new Error(`Response too large (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB)`);
788
+ }
789
+ const text = await resp.text();
790
+ if (text.length > MAX_RESPONSE_BYTES) {
791
+ throw new Error(`Response too large (${(text.length / 1024 / 1024).toFixed(1)}MB)`);
792
+ }
793
+ return text;
794
+ }
795
+ const BINARY_EXTENSIONS = new Set([
796
+ ".png",
797
+ ".jpg",
798
+ ".jpeg",
799
+ ".gif",
800
+ ".bmp",
801
+ ".ico",
802
+ ".webp",
803
+ ".svg",
804
+ ".mp3",
805
+ ".mp4",
806
+ ".wav",
807
+ ".ogg",
808
+ ".flac",
809
+ ".avi",
810
+ ".mov",
811
+ ".mkv",
812
+ ".zip",
813
+ ".gz",
814
+ ".tar",
815
+ ".bz2",
816
+ ".7z",
817
+ ".rar",
818
+ ".xz",
819
+ ".exe",
820
+ ".dll",
821
+ ".so",
822
+ ".dylib",
823
+ ".bin",
824
+ ".o",
825
+ ".a",
826
+ ".woff",
827
+ ".woff2",
828
+ ".ttf",
829
+ ".otf",
830
+ ".eot",
831
+ ".pdf",
832
+ ".doc",
833
+ ".docx",
834
+ ".xls",
835
+ ".xlsx",
836
+ ".ppt",
837
+ ".pptx",
838
+ ".pyc",
839
+ ".pyo",
840
+ ".class",
841
+ ".jar",
842
+ ".db",
843
+ ".sqlite",
844
+ ".sqlite3",
845
+ ".DS_Store",
846
+ ]);
847
+ const SKIP_DIRS = new Set([
848
+ "node_modules",
849
+ ".git",
850
+ "dist",
851
+ "build",
852
+ "__pycache__",
853
+ ".venv",
854
+ "venv",
855
+ ".next",
856
+ ".nuxt",
857
+ "coverage",
858
+ ".cache",
859
+ ".tsc-output",
860
+ ".svelte-kit",
861
+ "target",
862
+ "out",
863
+ ]);
864
+ function isBinaryFile(filePath) {
865
+ const ext = path.extname(filePath).toLowerCase();
866
+ if (BINARY_EXTENSIONS.has(ext))
867
+ return true;
868
+ // Quick null-byte check on first 512 bytes
869
+ let fd;
870
+ try {
871
+ fd = fs.openSync(filePath, "r");
872
+ const buf = Buffer.alloc(512);
873
+ const bytesRead = fs.readSync(fd, buf, 0, 512, 0);
874
+ for (let i = 0; i < bytesRead; i++) {
875
+ if (buf[i] === 0)
876
+ return true;
877
+ }
878
+ }
879
+ catch {
880
+ /* unreadable → skip */ return true;
881
+ }
882
+ finally {
883
+ if (fd !== undefined)
884
+ try {
885
+ fs.closeSync(fd);
886
+ }
887
+ catch { }
888
+ }
889
+ return false;
890
+ }
891
+ const MAX_DIR_DEPTH = 30;
892
+ function walkDir(dir, depth = 0) {
893
+ if (depth > MAX_DIR_DEPTH)
894
+ return [];
895
+ const results = [];
896
+ let entries;
897
+ try {
898
+ entries = fs.readdirSync(dir, { withFileTypes: true });
899
+ }
900
+ catch {
901
+ return results;
902
+ }
903
+ for (const entry of entries) {
904
+ if (results.length >= MAX_FILES)
905
+ break;
906
+ if (entry.name.startsWith(".") && entry.name !== ".env")
907
+ continue;
908
+ if (entry.isSymbolicLink())
909
+ continue;
910
+ const full = path.join(dir, entry.name);
911
+ if (entry.isDirectory()) {
912
+ if (SKIP_DIRS.has(entry.name))
913
+ continue;
914
+ const sub = walkDir(full, depth + 1);
915
+ const remaining = MAX_FILES - results.length;
916
+ results.push(...sub.slice(0, remaining));
917
+ }
918
+ else if (entry.isFile()) {
919
+ if (!isBinaryFile(full))
920
+ results.push(full);
921
+ }
922
+ }
923
+ return results;
924
+ }
925
+ /** Normalize path separators to forward slash for consistent matching. */
926
+ function toForwardSlash(p) {
927
+ return p.replace(/\\/g, "/");
928
+ }
929
+ function simpleGlobMatch(pattern, filePath, _braceDepth = 0) {
930
+ // Normalize both to forward slashes for cross-platform matching
931
+ pattern = toForwardSlash(pattern);
932
+ filePath = toForwardSlash(filePath);
933
+ // Expand {a,b,c} braces into alternatives (with depth limit)
934
+ const braceMatch = pattern.match(/\{([^}]+)\}/);
935
+ if (braceMatch && _braceDepth < 5) {
936
+ const alternatives = braceMatch[1].split(",").slice(0, 50);
937
+ return alternatives.some((alt) => simpleGlobMatch(pattern.replace(braceMatch[0], alt.trim()), filePath, _braceDepth + 1));
938
+ }
939
+ // Convert glob to regex
940
+ let regex = "^";
941
+ let i = 0;
942
+ while (i < pattern.length) {
943
+ const ch = pattern[i];
944
+ if (ch === "*" && pattern[i + 1] === "*") {
945
+ // ** matches any path segment(s)
946
+ regex += ".*";
947
+ i += 2;
948
+ if (pattern[i] === "/")
949
+ i++; // skip trailing slash after **
950
+ }
951
+ else if (ch === "*") {
952
+ regex += "[^/]*";
953
+ i++;
954
+ }
955
+ else if (ch === "?") {
956
+ regex += "[^/]";
957
+ i++;
958
+ }
959
+ else if (".+^$|()[]\\".includes(ch)) {
960
+ regex += `\\${ch}`;
961
+ i++;
962
+ }
963
+ else {
964
+ regex += ch;
965
+ i++;
966
+ }
967
+ }
968
+ regex += "$";
969
+ return new RegExp(regex).test(filePath);
970
+ }
971
+ /** Expand ~ to home directory (shell doesn't do this for us). */
972
+ function expandTilde(p) {
973
+ if (p === "~")
974
+ return os.homedir();
975
+ if (p.startsWith("~/") || p.startsWith("~\\"))
976
+ return path.join(os.homedir(), p.slice(2));
977
+ return p;
978
+ }
979
+ function resolveFileArgs(args) {
980
+ const files = [];
981
+ for (const rawArg of args) {
982
+ const arg = expandTilde(rawArg);
983
+ const resolved = path.resolve(arg);
984
+ // Glob pattern (contains * or ?)
985
+ if (arg.includes("*") || arg.includes("?")) {
986
+ // Find the base directory (portion before the first glob char)
987
+ const normalized = toForwardSlash(arg);
988
+ const firstGlob = normalized.search(/[*?{]/);
989
+ const baseDir = firstGlob > 0
990
+ ? path.resolve(normalized.slice(0, normalized.lastIndexOf("/", firstGlob) + 1) || ".")
991
+ : process.cwd();
992
+ const allFiles = walkDir(baseDir);
993
+ for (const f of allFiles) {
994
+ const rel = path.relative(process.cwd(), f);
995
+ if (simpleGlobMatch(arg, rel) || simpleGlobMatch(arg, f)) {
996
+ files.push(f);
997
+ }
998
+ }
999
+ continue;
1000
+ }
1001
+ // Directory
1002
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
1003
+ files.push(...walkDir(resolved));
1004
+ continue;
1005
+ }
1006
+ // Regular file
1007
+ if (fs.existsSync(resolved)) {
1008
+ if (!isBinaryFile(resolved))
1009
+ files.push(resolved);
1010
+ continue;
1011
+ }
1012
+ console.log(` ${c.yellow}⚠${c.reset} Not found: ${arg}`);
1013
+ }
1014
+ return [...new Set(files)]; // deduplicate
1015
+ }
1016
+ function loadMultipleFiles(filePaths) {
1017
+ if (filePaths.length > MAX_FILES) {
1018
+ console.log(` ${c.yellow}⚠${c.reset} Too many files (${filePaths.length}). Limit is ${MAX_FILES}.`);
1019
+ filePaths = filePaths.slice(0, MAX_FILES);
1020
+ }
1021
+ const parts = [];
1022
+ let totalBytes = 0;
1023
+ for (const fp of filePaths) {
1024
+ try {
1025
+ const content = fs.readFileSync(fp, "utf-8");
1026
+ if (totalBytes + content.length > MAX_TOTAL_BYTES) {
1027
+ console.log(` ${c.yellow}⚠${c.reset} Size limit reached (${(MAX_TOTAL_BYTES / 1024 / 1024).toFixed(0)}MB). Loaded ${parts.length} of ${filePaths.length} files.`);
1028
+ break;
1029
+ }
1030
+ const rel = path.relative(process.cwd(), fp);
1031
+ parts.push(`=== ${rel} ===\n${content}`);
1032
+ totalBytes += content.length;
1033
+ }
1034
+ catch {
1035
+ /* skip unreadable */
1036
+ }
1037
+ }
1038
+ return { text: parts.join("\n\n"), count: parts.length, totalBytes };
1039
+ }
1040
+ // ── Run RLM query ───────────────────────────────────────────────────────────
1041
+ async function runQuery(query) {
1042
+ const effectiveContext = contextText || query;
1043
+ if (!currentModel) {
1044
+ const resolved = resolveModelWithProvider(currentModelId);
1045
+ if (resolved) {
1046
+ currentModel = resolved.model;
1047
+ currentProviderName = resolved.provider;
1048
+ }
1049
+ }
1050
+ // Safety: verify the current provider still has an API key — re-resolve if not
1051
+ if (currentModel && currentProviderName) {
1052
+ const key = providerEnvKey(currentProviderName);
1053
+ if (!process.env[key]) {
1054
+ const resolved = resolveModelWithProvider(currentModelId);
1055
+ if (resolved && process.env[providerEnvKey(resolved.provider)]) {
1056
+ currentModel = resolved.model;
1057
+ currentProviderName = resolved.provider;
1058
+ }
1059
+ }
1060
+ }
1061
+ if (!currentModel) {
1062
+ console.log(`\n ${c.red}Model "${currentModelId}" not found.${c.reset}`);
1063
+ console.log(` ${c.dim}Check RLM_MODEL in your .env file.${c.reset}\n`);
1064
+ return;
1065
+ }
1066
+ isRunning = true;
1067
+ queryCount++;
1068
+ const startTime = Date.now();
1069
+ const spinner = new Spinner();
1070
+ // ── RLM mode ─────────────────────────────────────────────────────────
1071
+ let _subQueryCount = 0;
1072
+ console.log(`\n ${c.dim}${currentModelId} · ${(effectiveContext.length / 1024).toFixed(1)}KB context${c.reset}`);
1073
+ // Trajectory bookkeeping
1074
+ const trajectory = {
1075
+ model: currentModelId,
1076
+ query,
1077
+ contextLength: effectiveContext.length,
1078
+ contextLines: effectiveContext.split("\n").length,
1079
+ startTime: new Date().toISOString(),
1080
+ iterations: [],
1081
+ result: null,
1082
+ totalElapsedMs: 0,
1083
+ };
1084
+ let currentStep = null;
1085
+ let iterStart = Date.now();
1086
+ const repl = new PythonRepl();
1087
+ const ac = new AbortController();
1088
+ // Expose to the readline SIGINT handler
1089
+ activeAc = ac;
1090
+ activeRepl = repl;
1091
+ activeSpinner = spinner;
1092
+ try {
1093
+ await repl.start(ac.signal);
1094
+ const result = await runRlmLoop({
1095
+ context: effectiveContext,
1096
+ query,
1097
+ model: currentModel,
1098
+ repl,
1099
+ signal: ac.signal,
1100
+ onProgress: (info) => {
1101
+ if (info.phase === "generating_code") {
1102
+ iterStart = Date.now();
1103
+ currentStep = {
1104
+ iteration: info.iteration,
1105
+ code: null,
1106
+ stdout: "",
1107
+ stderr: "",
1108
+ subQueries: [],
1109
+ hasFinal: false,
1110
+ elapsedMs: 0,
1111
+ userMessage: info.userMessage || "",
1112
+ rawResponse: "",
1113
+ systemPrompt: info.systemPrompt,
1114
+ };
1115
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
1116
+ console.log(`\n ${c.bold}Step ${info.iteration}${c.reset}${c.dim}/${info.maxIterations} ${elapsed}s elapsed${c.reset}`);
1117
+ stepRule();
1118
+ spinner.start("Generating code");
1119
+ }
1120
+ if (info.phase === "executing" && info.code) {
1121
+ spinner.stop();
1122
+ if (currentStep) {
1123
+ currentStep.code = info.code;
1124
+ currentStep.rawResponse = info.rawResponse || "";
1125
+ }
1126
+ displayCode(info.code);
1127
+ spinner.start("Executing");
1128
+ }
1129
+ if (info.phase === "checking_final") {
1130
+ spinner.stop();
1131
+ if (currentStep) {
1132
+ currentStep.stdout = info.stdout || "";
1133
+ currentStep.stderr = info.stderr || "";
1134
+ currentStep.elapsedMs = Date.now() - iterStart;
1135
+ trajectory.iterations.push({ ...currentStep });
1136
+ }
1137
+ if (info.stdout) {
1138
+ displayOutput(info.stdout);
1139
+ }
1140
+ if (info.stderr) {
1141
+ displayError(info.stderr);
1142
+ }
1143
+ const iterElapsed = ((Date.now() - iterStart) / 1000).toFixed(1);
1144
+ const sqLabel = info.subQueries > 0 ? ` · ${info.subQueries} sub-queries` : "";
1145
+ console.log(`\n ${c.dim}${iterElapsed}s${sqLabel}${c.reset}`);
1146
+ }
1147
+ },
1148
+ onSubQueryStart: (info) => {
1149
+ spinner.stop();
1150
+ displaySubQueryStart(info);
1151
+ spinner.start("");
1152
+ },
1153
+ onSubQuery: (info) => {
1154
+ _subQueryCount++;
1155
+ if (currentStep) {
1156
+ currentStep.subQueries.push(info);
1157
+ }
1158
+ spinner.stop();
1159
+ displaySubQueryResult(info);
1160
+ spinner.start("Executing");
1161
+ },
1162
+ });
1163
+ trajectory.result = result;
1164
+ trajectory.totalElapsedMs = Date.now() - startTime;
1165
+ if (result.completed && trajectory.iterations.length > 0) {
1166
+ trajectory.iterations[trajectory.iterations.length - 1].hasFinal = true;
1167
+ }
1168
+ // Final answer
1169
+ const totalSec = ((Date.now() - startTime) / 1000).toFixed(1);
1170
+ const stats = `${result.iterations} step${result.iterations !== 1 ? "s" : ""} · ${result.totalSubQueries} sub-quer${result.totalSubQueries !== 1 ? "ies" : "y"} · ${totalSec}s`;
1171
+ const answerLines = result.answer.split("\n");
1172
+ console.log();
1173
+ console.log(boxTop(`${c.green}✔ Result${c.reset} ${c.dim}${stats}`, c.green));
1174
+ for (const line of answerLines) {
1175
+ for (const chunk of wrapText(line, MAX_CONTENT_W)) {
1176
+ console.log(boxLine(chunk, c.green));
1177
+ }
1178
+ }
1179
+ console.log(boxBottom(c.green));
1180
+ console.log();
1181
+ // Save trajectory
1182
+ try {
1183
+ if (!fs.existsSync(TRAJ_DIR))
1184
+ fs.mkdirSync(TRAJ_DIR, { recursive: true });
1185
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1186
+ const trajFile = `trajectory-${ts}.json`;
1187
+ fs.writeFileSync(path.join(TRAJ_DIR, trajFile), JSON.stringify(trajectory, null, 2), "utf-8");
1188
+ console.log(` ${c.dim}Saved: ~/.rlm/trajectories/${trajFile}${c.reset}\n`);
1189
+ }
1190
+ catch {
1191
+ console.log(` ${c.yellow}Could not save trajectory.${c.reset}\n`);
1192
+ }
1193
+ }
1194
+ catch (err) {
1195
+ spinner.stop();
1196
+ const msg = err?.message || String(err);
1197
+ // Suppress expected abort/shutdown errors
1198
+ if (err?.name !== "AbortError" &&
1199
+ !msg.includes("Aborted") &&
1200
+ !msg.includes("not running") &&
1201
+ !msg.includes("REPL subprocess") &&
1202
+ !msg.includes("REPL shut down")) {
1203
+ showErrorMsg(msg);
1204
+ }
1205
+ }
1206
+ finally {
1207
+ spinner.stop();
1208
+ activeAc = null;
1209
+ activeRepl = null;
1210
+ activeSpinner = null;
1211
+ try {
1212
+ repl.shutdown();
1213
+ }
1214
+ catch {
1215
+ /* already dead */
1216
+ }
1217
+ isRunning = false;
1218
+ }
1219
+ }
1220
+ // ── @file shorthand and auto-detect file paths ─────────────────────────────
1221
+ function expandAtFiles(input) {
1222
+ // Extract all @tokens from input
1223
+ const tokens = [];
1224
+ const remaining = [];
1225
+ for (const part of input.split(/\s+/)) {
1226
+ if (part.startsWith("@") && part.length > 1) {
1227
+ tokens.push(expandTilde(part.slice(1)));
1228
+ }
1229
+ else {
1230
+ remaining.push(part);
1231
+ }
1232
+ }
1233
+ if (tokens.length === 0)
1234
+ return input;
1235
+ const filePaths = resolveFileArgs(tokens);
1236
+ if (filePaths.length === 0) {
1237
+ console.log(` ${c.red}No files found for: ${tokens.join(", ")}${c.reset}`);
1238
+ return "";
1239
+ }
1240
+ if (filePaths.length === 1) {
1241
+ // Single file — simple load
1242
+ try {
1243
+ contextText = fs.readFileSync(filePaths[0], "utf-8");
1244
+ contextSource = path.relative(process.cwd(), filePaths[0]) || filePaths[0];
1245
+ const lines = contextText.split("\n").length;
1246
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines) from ${c.underline}${contextSource}${c.reset}`);
1247
+ }
1248
+ catch (err) {
1249
+ console.log(` ${c.red}Could not read file: ${err.message}${c.reset}`);
1250
+ return "";
1251
+ }
1252
+ }
1253
+ else {
1254
+ // Multiple files — concatenate with separators
1255
+ const { text, count, totalBytes } = loadMultipleFiles(filePaths);
1256
+ contextText = text;
1257
+ contextSource = `${count} files`;
1258
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${count}${c.reset} files (${(totalBytes / 1024).toFixed(1)}KB total)`);
1259
+ }
1260
+ return remaining.join(" ");
1261
+ }
1262
+ // ── Auto-detect URLs ────────────────────────────────────────────────────────
1263
+ async function detectAndLoadUrl(input) {
1264
+ const urlMatch = input.match(/^https?:\/\/\S+$/);
1265
+ if (urlMatch) {
1266
+ const url = urlMatch[0];
1267
+ console.log(` ${c.dim}Fetching ${url}...${c.reset}`);
1268
+ try {
1269
+ contextText = await safeFetch(url);
1270
+ contextSource = url;
1271
+ const lines = contextText.split("\n").length;
1272
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines)`);
1273
+ return true;
1274
+ }
1275
+ catch (err) {
1276
+ console.log(` ${c.red}Failed: ${err.message}${c.reset}`);
1277
+ return false;
1278
+ }
1279
+ }
1280
+ return false;
1281
+ }
1282
+ // ── Main interactive loop ───────────────────────────────────────────────────
1283
+ async function interactive() {
1284
+ // Validate env
1285
+ if (!hasAnyApiKey()) {
1286
+ printBanner();
1287
+ console.log(` ${c.bold}Welcome! Let's get you set up.${c.reset}\n`);
1288
+ const setupRl = readline.createInterface({ input: stdin, output: stdout, terminal: true });
1289
+ let setupDone = false;
1290
+ while (!setupDone) {
1291
+ console.log(` ${c.bold}Select your provider:${c.reset}\n`);
1292
+ for (let i = 0; i < SETUP_PROVIDERS.length; i++) {
1293
+ console.log(` ${c.dim}${i + 1}${c.reset} ${SETUP_PROVIDERS[i].name} ${c.dim}(${SETUP_PROVIDERS[i].label})${c.reset}`);
1294
+ }
1295
+ console.log();
1296
+ const choice = await questionWithEsc(setupRl, ` ${c.cyan}Provider [1-${SETUP_PROVIDERS.length}]:${c.reset} `);
1297
+ if (choice === null) {
1298
+ // ESC at provider selection → exit
1299
+ console.log(`\n ${c.dim}Exiting.${c.reset}\n`);
1300
+ setupRl.close();
1301
+ process.exit(0);
1302
+ }
1303
+ const idx = parseInt(choice, 10) - 1;
1304
+ if (Number.isNaN(idx) || idx < 0 || idx >= SETUP_PROVIDERS.length) {
1305
+ console.log(`\n ${c.dim}Invalid choice.${c.reset}\n`);
1306
+ continue;
1307
+ }
1308
+ const provider = SETUP_PROVIDERS[idx];
1309
+ const gotKey = await promptForProviderKey(setupRl, provider);
1310
+ if (gotKey === null) {
1311
+ // ESC at key entry → back to provider selection
1312
+ console.log();
1313
+ continue;
1314
+ }
1315
+ if (!gotKey) {
1316
+ console.log(`\n ${c.dim}No key provided. Exiting.${c.reset}\n`);
1317
+ setupRl.close();
1318
+ process.exit(0);
1319
+ }
1320
+ // Auto-select default model for chosen provider
1321
+ currentProviderName = provider.piProvider;
1322
+ const defaultModel = getDefaultModelForProvider(provider.piProvider);
1323
+ if (defaultModel) {
1324
+ currentModelId = defaultModel;
1325
+ saveModelPreference(currentModelId);
1326
+ console.log(` ${c.green}✓${c.reset} Default model: ${c.bold}${currentModelId}${c.reset}`);
1327
+ }
1328
+ console.log();
1329
+ setupDone = true;
1330
+ }
1331
+ setupRl.close();
1332
+ }
1333
+ // Resolve model — ensure the resolved provider actually has an API key
1334
+ const initialResolved = resolveModelWithProvider(currentModelId);
1335
+ if (initialResolved) {
1336
+ const resolvedKey = providerEnvKey(initialResolved.provider);
1337
+ if (process.env[resolvedKey]) {
1338
+ // Provider has a key — use it
1339
+ currentModel = initialResolved.model;
1340
+ currentProviderName = initialResolved.provider;
1341
+ }
1342
+ }
1343
+ // If default model's provider has no key, fall back to a provider that does
1344
+ if (!currentModel) {
1345
+ const activeProvider = detectProvider();
1346
+ if (activeProvider !== "unknown") {
1347
+ const fallbackModel = getDefaultModelForProvider(activeProvider);
1348
+ if (fallbackModel) {
1349
+ const fallbackResolved = resolveModelWithProvider(fallbackModel);
1350
+ if (fallbackResolved) {
1351
+ currentModelId = fallbackModel;
1352
+ currentModel = fallbackResolved.model;
1353
+ currentProviderName = fallbackResolved.provider;
1354
+ const label = findSetupProvider(activeProvider)?.name || activeProvider;
1355
+ console.log(` ${c.dim}Using ${label} (${currentModelId})${c.reset}`);
1356
+ }
1357
+ }
1358
+ }
1359
+ }
1360
+ if (!currentModel) {
1361
+ console.log(`\n ${c.red}Model "${currentModelId}" not found.${c.reset}`);
1362
+ console.log(` Check ${c.bold}RLM_MODEL${c.reset} in your .env file.\n`);
1363
+ process.exit(1);
1364
+ }
1365
+ // Auto-load cwd context so the LLM knows the project structure
1366
+ contextText = buildCwdContext();
1367
+ contextSource = path.basename(process.cwd());
1368
+ printWelcome();
1369
+ const rl = readline.createInterface({
1370
+ input: stdin,
1371
+ output: stdout,
1372
+ prompt: `${c.cyan}>${c.reset} `,
1373
+ terminal: true,
1374
+ });
1375
+ // Color slash commands cyan as the user types
1376
+ const rlAny = rl;
1377
+ const promptStr = rl.getPrompt();
1378
+ rlAny._writeToOutput = (str) => {
1379
+ if (!rlAny.line?.startsWith("/")) {
1380
+ rlAny.output.write(str);
1381
+ return;
1382
+ }
1383
+ if (str.startsWith(promptStr)) {
1384
+ rlAny.output.write(promptStr + c.cyan + str.slice(promptStr.length) + c.reset);
1385
+ }
1386
+ else {
1387
+ rlAny.output.write(c.cyan + str + c.reset);
1388
+ }
1389
+ };
1390
+ rl.prompt();
1391
+ rl.on("line", async (rawLine) => {
1392
+ try {
1393
+ if (isRunning)
1394
+ return; // ignore input while a query is active
1395
+ const line = rawLine.trim();
1396
+ // URL auto-detect
1397
+ if (line.startsWith("http://") || line.startsWith("https://")) {
1398
+ const loaded = await detectAndLoadUrl(line);
1399
+ if (loaded) {
1400
+ printStatusLine();
1401
+ console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
1402
+ rl.prompt();
1403
+ return;
1404
+ }
1405
+ }
1406
+ // Multi-line paste detect
1407
+ if (isMultiLineInput(rawLine)) {
1408
+ const result = handleMultiLineAsContext(rawLine);
1409
+ if (result) {
1410
+ contextText = result.context;
1411
+ contextSource = "(pasted)";
1412
+ printStatusLine();
1413
+ console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
1414
+ rl.prompt();
1415
+ return;
1416
+ }
1417
+ }
1418
+ if (!line) {
1419
+ rl.prompt();
1420
+ return;
1421
+ }
1422
+ // Slash commands
1423
+ if (line.startsWith("/")) {
1424
+ const [cmd, ...rest] = line.slice(1).split(/\s+/);
1425
+ const arg = rest.join(" ");
1426
+ switch (cmd) {
1427
+ case "help":
1428
+ case "h":
1429
+ printCommandHelp();
1430
+ break;
1431
+ case "file":
1432
+ case "f": {
1433
+ const fileQuery = await handleFile(arg);
1434
+ if (fileQuery && contextText) {
1435
+ await runQuery(fileQuery);
1436
+ printStatusLine();
1437
+ }
1438
+ break;
1439
+ }
1440
+ case "url":
1441
+ case "u":
1442
+ await handleUrl(arg);
1443
+ break;
1444
+ case "paste":
1445
+ case "p":
1446
+ await handlePaste(rl);
1447
+ break;
1448
+ case "context":
1449
+ case "ctx":
1450
+ handleContext();
1451
+ break;
1452
+ case "clear-context":
1453
+ case "cc":
1454
+ contextText = "";
1455
+ contextSource = "";
1456
+ console.log(` ${c.green}✓${c.reset} Context cleared.`);
1457
+ break;
1458
+ case "model":
1459
+ case "m": {
1460
+ const curProvider = currentProviderName || detectProvider();
1461
+ if (arg) {
1462
+ // Accept a number (from current provider list) or a model ID
1463
+ const curModels = getModelsForProvider(curProvider);
1464
+ let pick;
1465
+ if (/^\d+$/.test(arg)) {
1466
+ pick = curModels[parseInt(arg, 10) - 1]?.id;
1467
+ }
1468
+ else {
1469
+ pick = arg;
1470
+ }
1471
+ if (!pick) {
1472
+ console.log(` ${c.red}Invalid selection.${c.reset} Use ${c.cyan}/model${c.reset} to list available models.`);
1473
+ break;
1474
+ }
1475
+ // Check if this model belongs to a different provider
1476
+ const resolved = resolveModelWithProvider(pick);
1477
+ if (!resolved) {
1478
+ console.log(` ${c.red}Model "${arg}" not found.${c.reset} Use ${c.cyan}/model${c.reset} to list available models.`);
1479
+ break;
1480
+ }
1481
+ if (resolved.provider !== curProvider) {
1482
+ // Cross-provider switch
1483
+ const setupInfo = findSetupProvider(resolved.provider);
1484
+ const envVar = setupInfo?.env || providerEnvKey(resolved.provider);
1485
+ const provName = setupInfo?.name || resolved.provider;
1486
+ if (!process.env[envVar]) {
1487
+ console.log(` ${c.yellow}That model requires ${provName}.${c.reset}`);
1488
+ const gotKey = await promptForProviderKey(rl, { name: provName, env: envVar });
1489
+ if (!gotKey) {
1490
+ console.log(` ${c.dim}Cancelled.${c.reset}`);
1491
+ break;
1492
+ }
1493
+ }
1494
+ }
1495
+ currentModelId = pick;
1496
+ currentModel = resolved.model;
1497
+ currentProviderName = resolved.provider;
1498
+ saveModelPreference(currentModelId);
1499
+ console.log(` ${c.green}✓${c.reset} Switched to ${c.bold}${currentModelId}${c.reset}`);
1500
+ console.log();
1501
+ printStatusLine();
1502
+ }
1503
+ else {
1504
+ // List models for current provider
1505
+ const models = getModelsForProvider(curProvider);
1506
+ const provLabel = findSetupProvider(curProvider)?.name || curProvider;
1507
+ console.log(`\n ${c.bold}Current model:${c.reset} ${c.cyan}${currentModelId}${c.reset} ${c.dim}(${provLabel})${c.reset}\n`);
1508
+ const pad = String(models.length).length;
1509
+ for (let i = 0; i < models.length; i++) {
1510
+ const m = models[i];
1511
+ const num = String(i + 1).padStart(pad);
1512
+ const dot = m.id === currentModelId ? `${c.green}●${c.reset}` : ` `;
1513
+ const label = m.id === currentModelId ? `${c.cyan}${m.id}${c.reset}` : `${c.dim}${m.id}${c.reset}`;
1514
+ console.log(` ${c.dim}${num}${c.reset} ${dot} ${label}`);
1515
+ }
1516
+ console.log(`\n ${c.dim}${models.length} models · scroll up to see full list.${c.reset}`);
1517
+ console.log(` ${c.dim}Type${c.reset} ${c.cyan}/model <number>${c.reset} ${c.dim}or${c.reset} ${c.cyan}/model <id>${c.reset} ${c.dim}to switch.${c.reset}`);
1518
+ console.log(` ${c.dim}Type${c.reset} ${c.cyan}/provider${c.reset} ${c.dim}to switch provider.${c.reset}`);
1519
+ }
1520
+ break;
1521
+ }
1522
+ case "provider":
1523
+ case "prov": {
1524
+ const curProvider = currentProviderName || detectProvider();
1525
+ const curLabel = findSetupProvider(curProvider)?.name || curProvider;
1526
+ console.log(`\n ${c.bold}Current provider:${c.reset} ${c.cyan}${curLabel}${c.reset}\n`);
1527
+ for (let i = 0; i < SETUP_PROVIDERS.length; i++) {
1528
+ const p = SETUP_PROVIDERS[i];
1529
+ const isCurrent = p.piProvider === curProvider;
1530
+ const dot = isCurrent ? `${c.green}●${c.reset}` : ` `;
1531
+ const label = isCurrent
1532
+ ? `${c.cyan}${p.name}${c.reset} ${c.dim}(${p.label})${c.reset}`
1533
+ : `${p.name} ${c.dim}(${p.label})${c.reset}`;
1534
+ console.log(` ${c.dim}${i + 1}${c.reset} ${dot} ${label}`);
1535
+ }
1536
+ console.log();
1537
+ const provChoice = await questionWithEsc(rl, ` ${c.cyan}Provider [1-${SETUP_PROVIDERS.length}]:${c.reset} ${c.dim}(ESC to cancel)${c.reset} `);
1538
+ if (provChoice === null)
1539
+ break; // ESC
1540
+ const idx = parseInt(provChoice, 10) - 1;
1541
+ if (Number.isNaN(idx) || idx < 0 || idx >= SETUP_PROVIDERS.length) {
1542
+ console.log(` ${c.dim}Cancelled.${c.reset}`);
1543
+ break;
1544
+ }
1545
+ const chosen = SETUP_PROVIDERS[idx];
1546
+ const gotKey = await promptForProviderKey(rl, chosen);
1547
+ if (!gotKey) {
1548
+ // null (ESC) or false (empty) → cancel
1549
+ break;
1550
+ }
1551
+ // Auto-select first model from new provider
1552
+ const defaultModel = getDefaultModelForProvider(chosen.piProvider);
1553
+ if (defaultModel) {
1554
+ currentModelId = defaultModel;
1555
+ const provResolved = resolveModelWithProvider(currentModelId);
1556
+ currentModel = provResolved?.model;
1557
+ currentProviderName = provResolved?.provider || chosen.piProvider;
1558
+ saveModelPreference(currentModelId);
1559
+ console.log(` ${c.green}✓${c.reset} ${chosen.name} · ${c.bold}${currentModelId}${c.reset}`);
1560
+ printStatusLine();
1561
+ }
1562
+ else {
1563
+ console.log(` ${c.red}No models available for ${chosen.name}.${c.reset}`);
1564
+ }
1565
+ break;
1566
+ }
1567
+ case "key": {
1568
+ // Update API key for a provider
1569
+ const _curProvider = currentProviderName || detectProvider();
1570
+ console.log();
1571
+ for (let i = 0; i < SETUP_PROVIDERS.length; i++) {
1572
+ const p = SETUP_PROVIDERS[i];
1573
+ const hasKey = process.env[p.env] ? `${c.green}✓${c.reset}` : `${c.dim}○${c.reset}`;
1574
+ console.log(` ${c.dim}${i + 1}${c.reset} ${hasKey} ${p.name} ${c.dim}(${p.label})${c.reset}`);
1575
+ }
1576
+ console.log();
1577
+ const keyChoice = await questionWithEsc(rl, ` ${c.cyan}Update key for [1-${SETUP_PROVIDERS.length}]:${c.reset} ${c.dim}(ESC to cancel)${c.reset} `);
1578
+ if (keyChoice === null || !keyChoice)
1579
+ break;
1580
+ const keyIdx = parseInt(keyChoice, 10) - 1;
1581
+ if (Number.isNaN(keyIdx) || keyIdx < 0 || keyIdx >= SETUP_PROVIDERS.length) {
1582
+ console.log(` ${c.dim}Cancelled.${c.reset}`);
1583
+ break;
1584
+ }
1585
+ const keyProvider = SETUP_PROVIDERS[keyIdx];
1586
+ const newKey = await questionWithEsc(rl, ` ${c.cyan}${keyProvider.env}:${c.reset} `, { secret: true });
1587
+ if (newKey === null || !newKey)
1588
+ break;
1589
+ const sanitized = newKey.replace(/[\r\n\x00-\x1f]/g, "").trim();
1590
+ if (!sanitized)
1591
+ break;
1592
+ process.env[keyProvider.env] = sanitized;
1593
+ const credPath = path.join(RLM_HOME, "credentials");
1594
+ try {
1595
+ if (!fs.existsSync(RLM_HOME))
1596
+ fs.mkdirSync(RLM_HOME, { recursive: true });
1597
+ if (fs.existsSync(credPath)) {
1598
+ const content = fs.readFileSync(credPath, "utf-8");
1599
+ const filtered = content
1600
+ .split("\n")
1601
+ .filter((l) => {
1602
+ const t = l.trim();
1603
+ if (t.startsWith("export "))
1604
+ return !t.slice(7).startsWith(`${keyProvider.env}=`);
1605
+ return !t.startsWith(`${keyProvider.env}=`);
1606
+ })
1607
+ .join("\n");
1608
+ fs.writeFileSync(credPath, filtered.endsWith("\n") ? filtered : `${filtered}\n`);
1609
+ }
1610
+ fs.appendFileSync(credPath, `${keyProvider.env}=${sanitized}\n`);
1611
+ try {
1612
+ fs.chmodSync(credPath, 0o600);
1613
+ }
1614
+ catch { }
1615
+ console.log(` ${c.green}✓${c.reset} ${keyProvider.name} key updated`);
1616
+ }
1617
+ catch {
1618
+ console.log(` ${c.yellow}!${c.reset} Could not save key.`);
1619
+ }
1620
+ break;
1621
+ }
1622
+ case "trajectories":
1623
+ case "traj":
1624
+ handleTrajectories();
1625
+ break;
1626
+ case "clear":
1627
+ printWelcome();
1628
+ break;
1629
+ case "quit":
1630
+ case "q":
1631
+ case "exit":
1632
+ console.log(`\n ${c.dim}Goodbye!${c.reset}\n`);
1633
+ process.exit(0);
1634
+ break;
1635
+ default:
1636
+ console.log(` ${c.red}Unknown command: /${cmd}${c.reset}. Type ${c.cyan}/help${c.reset} for commands.`);
1637
+ }
1638
+ rl.prompt();
1639
+ return;
1640
+ }
1641
+ // @file shorthand
1642
+ let query = expandAtFiles(line);
1643
+ if (!query && line.startsWith("@")) {
1644
+ rl.prompt();
1645
+ return;
1646
+ }
1647
+ if (!query)
1648
+ query = line;
1649
+ // Inline URL detection — extract URL from query, fetch as context
1650
+ if (!contextText) {
1651
+ const urlInline = query.match(/(https?:\/\/\S+)/);
1652
+ if (urlInline) {
1653
+ const url = urlInline[1];
1654
+ const queryWithoutUrl = query.replace(url, "").trim();
1655
+ console.log(` ${c.dim}Fetching ${url}...${c.reset}`);
1656
+ try {
1657
+ contextText = await safeFetch(url);
1658
+ contextSource = url;
1659
+ const lines = contextText.split("\n").length;
1660
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines) from URL`);
1661
+ if (queryWithoutUrl) {
1662
+ query = queryWithoutUrl;
1663
+ }
1664
+ else {
1665
+ // URL only, no query — prompt for one
1666
+ printStatusLine();
1667
+ console.log(`\n ${c.dim}Now type your query...${c.reset}\n`);
1668
+ rl.prompt();
1669
+ return;
1670
+ }
1671
+ }
1672
+ catch (err) {
1673
+ console.log(` ${c.red}Failed to fetch URL: ${err.message}${c.reset}`);
1674
+ console.log(` ${c.dim}Running query as-is...${c.reset}`);
1675
+ }
1676
+ }
1677
+ }
1678
+ // Auto-detect bare file/directory paths (tilde, absolute, relative)
1679
+ if (!contextText) {
1680
+ const tokens = query.split(/\s+/);
1681
+ const pathTokens = [];
1682
+ for (const t of tokens) {
1683
+ if (looksLikePath(t))
1684
+ pathTokens.push(t);
1685
+ else
1686
+ break;
1687
+ }
1688
+ if (pathTokens.length > 0) {
1689
+ const existing = pathTokens.filter((t) => {
1690
+ const p = path.resolve(expandTilde(t));
1691
+ return fs.existsSync(p);
1692
+ });
1693
+ if (existing.length > 0) {
1694
+ const filePaths = resolveFileArgs(existing);
1695
+ if (filePaths.length === 1) {
1696
+ try {
1697
+ contextText = fs.readFileSync(filePaths[0], "utf-8");
1698
+ contextSource = path.relative(process.cwd(), filePaths[0]) || filePaths[0];
1699
+ const lines = contextText.split("\n").length;
1700
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines) from ${c.underline}${contextSource}${c.reset}`);
1701
+ }
1702
+ catch (err) {
1703
+ console.log(` ${c.red}Could not read file: ${err.message}${c.reset}`);
1704
+ }
1705
+ }
1706
+ else if (filePaths.length > 1) {
1707
+ const { text, count, totalBytes } = loadMultipleFiles(filePaths);
1708
+ contextText = text;
1709
+ contextSource = `${count} files`;
1710
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${count}${c.reset} files (${(totalBytes / 1024).toFixed(1)}KB total)`);
1711
+ }
1712
+ if (contextText) {
1713
+ query = tokens.slice(pathTokens.length).join(" ") || query;
1714
+ }
1715
+ }
1716
+ }
1717
+ }
1718
+ // Run query
1719
+ await runQuery(query);
1720
+ printStatusLine();
1721
+ rl.prompt();
1722
+ }
1723
+ catch (err) {
1724
+ showErrorMsg(String(err?.message || err));
1725
+ rl.prompt();
1726
+ }
1727
+ });
1728
+ // Ctrl+C: abort running query, or double-tap to exit
1729
+ let lastSigint = 0;
1730
+ rl.on("SIGINT", () => {
1731
+ if (isRunning && activeAc) {
1732
+ activeSpinner?.stop();
1733
+ console.log(`\n ${c.red}Stopped${c.reset}`);
1734
+ activeAc.abort();
1735
+ try {
1736
+ activeRepl?.shutdown();
1737
+ }
1738
+ catch {
1739
+ /* ok */
1740
+ }
1741
+ isRunning = false;
1742
+ lastSigint = 0;
1743
+ }
1744
+ else {
1745
+ const now = Date.now();
1746
+ if (now - lastSigint < 1000) {
1747
+ // Double Ctrl+C — exit
1748
+ console.log(`\n ${c.dim}Goodbye!${c.reset}\n`);
1749
+ process.exit(0);
1750
+ }
1751
+ lastSigint = now;
1752
+ console.log(`\n ${c.dim}Press Ctrl+C again to exit${c.reset}`);
1753
+ rl.prompt();
1754
+ }
1755
+ });
1756
+ rl.on("close", () => {
1757
+ console.log(`\n ${c.dim}Goodbye!${c.reset}\n`);
1758
+ process.exit(0);
1759
+ });
1760
+ }
1761
+ interactive().catch((err) => {
1762
+ console.error(`${c.red}Fatal error:${c.reset}`, err);
1763
+ process.exit(1);
1764
+ });
1765
+ //# sourceMappingURL=interactive.js.map