rlm-cli 0.2.15 → 0.2.17

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 CHANGED
@@ -83,6 +83,41 @@ Or set context first, then ask multiple questions:
83
83
 
84
84
  Type `/help` inside the terminal for all commands.
85
85
 
86
+ ### Loading Context
87
+
88
+ You can load single files, multiple files, entire directories, or glob patterns as context.
89
+
90
+ **Single file:**
91
+
92
+ ```bash
93
+ > @src/main.ts what does this do?
94
+ > /file src/main.ts
95
+ ```
96
+
97
+ **Multiple files:**
98
+
99
+ ```bash
100
+ > @src/main.ts @src/config.ts how do these interact?
101
+ > /file src/main.ts src/config.ts
102
+ ```
103
+
104
+ **Directory** (recursive — skips `node_modules`, `.git`, `dist`, binaries, etc.):
105
+
106
+ ```bash
107
+ > @src/ summarize this codebase
108
+ > /file src/
109
+ ```
110
+
111
+ **Glob patterns:**
112
+
113
+ ```bash
114
+ > @src/**/*.ts list all exports
115
+ > /file src/**/*.ts
116
+ > /file lib/*.{js,ts}
117
+ ```
118
+
119
+ Safety limits: max 100 files, max 10MB total. Use `/context` to see what's loaded.
120
+
86
121
  ### Single-Shot Mode
87
122
 
88
123
  For scripting or one-off queries:
@@ -296,48 +296,80 @@ function printWelcome() {
296
296
  // ── Help ────────────────────────────────────────────────────────────────────
297
297
  function printCommandHelp() {
298
298
  console.log(`
299
- ${c.bold}Context${c.reset}
300
- ${c.cyan}/file${c.reset} <path> Load file as context
301
- ${c.cyan}/url${c.reset} <url> Fetch URL as context
302
- ${c.cyan}/paste${c.reset} Multi-line paste mode (EOF to finish)
303
- ${c.cyan}/context${c.reset} Show loaded context info
304
- ${c.cyan}/clear-context${c.reset} Unload context
299
+ ${c.bold}Loading Context${c.reset}
300
+ ${c.cyan}/file${c.reset} <path> Load a single file
301
+ ${c.cyan}/file${c.reset} <p1> <p2> ... Load multiple files
302
+ ${c.cyan}/file${c.reset} <dir>/ Load all files in a directory (recursive)
303
+ ${c.cyan}/file${c.reset} src/**/*.ts Load files matching a glob pattern
304
+ ${c.cyan}/url${c.reset} <url> Fetch URL as context
305
+ ${c.cyan}/paste${c.reset} Multi-line paste mode (type EOF to finish)
306
+ ${c.cyan}/context${c.reset} Show loaded context info + file list
307
+ ${c.cyan}/clear-context${c.reset} Unload context
305
308
 
306
- ${c.bold}Model${c.reset}
307
- ${c.cyan}/model${c.reset} List models for current provider
308
- ${c.cyan}/model${c.reset} <#|id> Switch model by number or ID
309
- ${c.cyan}/provider${c.reset} Switch provider
309
+ ${c.bold}@ Shorthand${c.reset} ${c.dim}(inline file loading)${c.reset}
310
+ ${c.cyan}@file.ts${c.reset} <query> Load file and ask in one shot
311
+ ${c.cyan}@a.ts @b.ts${c.reset} <query> Load multiple files + query
312
+ ${c.cyan}@src/${c.reset} <query> Load directory + query
313
+ ${c.cyan}@src/**/*.ts${c.reset} <query> Load glob + query
314
+
315
+ ${c.bold}Model & Provider${c.reset}
316
+ ${c.cyan}/model${c.reset} List models for current provider
317
+ ${c.cyan}/model${c.reset} <#|id> Switch model by number or ID
318
+ ${c.cyan}/provider${c.reset} Switch provider (Anthropic, OpenAI, Google, ...)
310
319
 
311
320
  ${c.bold}Tools${c.reset}
312
- ${c.cyan}/trajectories${c.reset} List saved runs
321
+ ${c.cyan}/trajectories${c.reset} List saved runs
313
322
 
314
323
  ${c.bold}General${c.reset}
315
- ${c.cyan}/clear${c.reset} Clear screen
316
- ${c.cyan}/help${c.reset} Show this help
317
- ${c.cyan}/quit${c.reset} Exit
324
+ ${c.cyan}/clear${c.reset} Clear screen
325
+ ${c.cyan}/help${c.reset} Show this help
326
+ ${c.cyan}/quit${c.reset} Exit
318
327
 
319
- ${c.dim}Or just paste a URL or 4+ lines of code, then type your query.${c.reset}
328
+ ${c.bold}Tips${c.reset}
329
+ ${c.dim}•${c.reset} Just type a question — no context needed for general queries
330
+ ${c.dim}•${c.reset} Paste a URL directly to fetch it as context
331
+ ${c.dim}•${c.reset} Paste 4+ lines of text to set it as context
332
+ ${c.dim}•${c.reset} ${c.bold}Ctrl+C${c.reset} stops a running query, ${c.bold}Ctrl+C twice${c.reset} exits
333
+ ${c.dim}•${c.reset} Directories skip node_modules, .git, dist, binaries, etc.
334
+ ${c.dim}•${c.reset} Limits: ${MAX_FILES} files max, ${MAX_TOTAL_BYTES / 1024 / 1024}MB total
320
335
  `);
321
336
  }
322
337
  // ── Slash command handlers ──────────────────────────────────────────────────
323
338
  async function handleFile(arg) {
324
339
  if (!arg) {
325
- console.log(` ${c.red}Usage: /file <path>${c.reset}`);
340
+ console.log(` ${c.red}Usage: /file <path|dir|glob> [...]${c.reset}`);
341
+ console.log(` ${c.dim}Examples: /file src/main.ts | /file src/ | /file src/**/*.ts${c.reset}`);
326
342
  return;
327
343
  }
328
- const filePath = path.resolve(arg);
329
- if (!fs.existsSync(filePath)) {
330
- console.log(` ${c.red}File not found: ${filePath}${c.reset}`);
344
+ const args = arg.split(/\s+/).filter(Boolean);
345
+ const filePaths = resolveFileArgs(args);
346
+ if (filePaths.length === 0) {
347
+ console.log(` ${c.red}No files found.${c.reset}`);
331
348
  return;
332
349
  }
333
- try {
334
- contextText = fs.readFileSync(filePath, "utf-8");
335
- contextSource = arg;
336
- const lines = contextText.split("\n").length;
337
- console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines) from ${c.underline}${arg}${c.reset}`);
350
+ if (filePaths.length === 1) {
351
+ try {
352
+ contextText = fs.readFileSync(filePaths[0], "utf-8");
353
+ contextSource = path.relative(process.cwd(), filePaths[0]) || filePaths[0];
354
+ const lines = contextText.split("\n").length;
355
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines.toLocaleString()} lines) from ${c.underline}${contextSource}${c.reset}`);
356
+ }
357
+ catch (err) {
358
+ console.log(` ${c.red}Could not read file: ${err.message}${c.reset}`);
359
+ }
338
360
  }
339
- catch (err) {
340
- console.log(` ${c.red}Could not read file: ${err.message}${c.reset}`);
361
+ else {
362
+ const { text, count, totalBytes } = loadMultipleFiles(filePaths);
363
+ contextText = text;
364
+ contextSource = `${count} files`;
365
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${count}${c.reset} files (${(totalBytes / 1024).toFixed(1)}KB total)`);
366
+ // Show file list
367
+ for (const fp of filePaths.slice(0, 20)) {
368
+ console.log(` ${c.dim}•${c.reset} ${path.relative(process.cwd(), fp)}`);
369
+ }
370
+ if (filePaths.length > 20) {
371
+ console.log(` ${c.dim}... and ${filePaths.length - 20} more${c.reset}`);
372
+ }
341
373
  }
342
374
  }
343
375
  async function handleUrl(arg) {
@@ -379,20 +411,35 @@ function handlePaste(rl) {
379
411
  }
380
412
  function handleContext() {
381
413
  if (!contextText) {
382
- console.log(` ${c.dim}No context loaded. Use /file, /url, or /paste.${c.reset}`);
414
+ console.log(` ${c.dim}No context loaded. Use /file, /url, @file, or /paste.${c.reset}`);
383
415
  return;
384
416
  }
385
417
  const lines = contextText.split("\n").length;
386
- console.log(` ${c.bold}Context:${c.reset} ${contextText.length.toLocaleString()} chars, ${lines.toLocaleString()} lines`);
418
+ const sizeKB = (contextText.length / 1024).toFixed(1);
419
+ console.log(` ${c.bold}Context:${c.reset} ${contextText.length.toLocaleString()} chars (${sizeKB}KB), ${lines.toLocaleString()} lines`);
387
420
  console.log(` ${c.bold}Source:${c.reset} ${contextSource}`);
388
- console.log();
389
- const preview = contextText.slice(0, 500);
390
- const previewLines = preview.split("\n").slice(0, 8);
391
- for (const l of previewLines) {
392
- console.log(` ${c.dim}│${c.reset} ${l}`);
421
+ // For multi-file context, extract and display individual file paths
422
+ const fileSeparators = contextText.match(/^=== .+ ===$/gm);
423
+ if (fileSeparators && fileSeparators.length > 1) {
424
+ console.log(` ${c.bold}Files:${c.reset} ${fileSeparators.length}`);
425
+ for (const sep of fileSeparators.slice(0, 20)) {
426
+ const name = sep.replace(/^=== /, "").replace(/ ===$/, "");
427
+ console.log(` ${c.dim}•${c.reset} ${name}`);
428
+ }
429
+ if (fileSeparators.length > 20) {
430
+ console.log(` ${c.dim}... and ${fileSeparators.length - 20} more${c.reset}`);
431
+ }
393
432
  }
394
- if (contextText.length > 500) {
395
- console.log(` ${c.dim}│ ...${c.reset}`);
433
+ else {
434
+ console.log();
435
+ const preview = contextText.slice(0, 500);
436
+ const previewLines = preview.split("\n").slice(0, 8);
437
+ for (const l of previewLines) {
438
+ console.log(` ${c.dim}│${c.reset} ${l}`);
439
+ }
440
+ if (contextText.length > 500) {
441
+ console.log(` ${c.dim}│ ...${c.reset}`);
442
+ }
396
443
  }
397
444
  }
398
445
  function handleTrajectories() {
@@ -581,6 +628,166 @@ function getModelsForProvider(providerName) {
581
628
  function truncateStr(text, max) {
582
629
  return text.length <= max ? text : text.slice(0, max - 3) + "...";
583
630
  }
631
+ // ── Multi-file context loading ──────────────────────────────────────────────
632
+ const MAX_FILES = 100;
633
+ const MAX_TOTAL_BYTES = 10 * 1024 * 1024; // 10MB
634
+ const BINARY_EXTENSIONS = new Set([
635
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg",
636
+ ".mp3", ".mp4", ".wav", ".ogg", ".flac", ".avi", ".mov", ".mkv",
637
+ ".zip", ".gz", ".tar", ".bz2", ".7z", ".rar", ".xz",
638
+ ".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".a",
639
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
640
+ ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
641
+ ".pyc", ".pyo", ".class", ".jar",
642
+ ".db", ".sqlite", ".sqlite3",
643
+ ".DS_Store",
644
+ ]);
645
+ const SKIP_DIRS = new Set([
646
+ "node_modules", ".git", "dist", "build", "__pycache__", ".venv",
647
+ "venv", ".next", ".nuxt", "coverage", ".cache", ".tsc-output",
648
+ ".svelte-kit", "target", "out",
649
+ ]);
650
+ function isBinaryFile(filePath) {
651
+ const ext = path.extname(filePath).toLowerCase();
652
+ if (BINARY_EXTENSIONS.has(ext))
653
+ return true;
654
+ // Quick null-byte check on first 512 bytes
655
+ try {
656
+ const fd = fs.openSync(filePath, "r");
657
+ const buf = Buffer.alloc(512);
658
+ const bytesRead = fs.readSync(fd, buf, 0, 512, 0);
659
+ fs.closeSync(fd);
660
+ for (let i = 0; i < bytesRead; i++) {
661
+ if (buf[i] === 0)
662
+ return true;
663
+ }
664
+ }
665
+ catch { /* unreadable → skip */
666
+ return true;
667
+ }
668
+ return false;
669
+ }
670
+ function walkDir(dir) {
671
+ const results = [];
672
+ let entries;
673
+ try {
674
+ entries = fs.readdirSync(dir, { withFileTypes: true });
675
+ }
676
+ catch {
677
+ return results;
678
+ }
679
+ for (const entry of entries) {
680
+ if (entry.name.startsWith(".") && entry.name !== ".env")
681
+ continue;
682
+ const full = path.join(dir, entry.name);
683
+ if (entry.isDirectory()) {
684
+ if (SKIP_DIRS.has(entry.name))
685
+ continue;
686
+ results.push(...walkDir(full));
687
+ }
688
+ else if (entry.isFile()) {
689
+ if (!isBinaryFile(full))
690
+ results.push(full);
691
+ }
692
+ if (results.length > MAX_FILES)
693
+ break;
694
+ }
695
+ return results;
696
+ }
697
+ function simpleGlobMatch(pattern, filePath) {
698
+ // Expand {a,b,c} braces into alternatives
699
+ const braceMatch = pattern.match(/\{([^}]+)\}/);
700
+ if (braceMatch) {
701
+ const alternatives = braceMatch[1].split(",");
702
+ return alternatives.some((alt) => simpleGlobMatch(pattern.replace(braceMatch[0], alt.trim()), filePath));
703
+ }
704
+ // Convert glob to regex
705
+ let regex = "^";
706
+ let i = 0;
707
+ while (i < pattern.length) {
708
+ const ch = pattern[i];
709
+ if (ch === "*" && pattern[i + 1] === "*") {
710
+ // ** matches any path segment(s)
711
+ regex += ".*";
712
+ i += 2;
713
+ if (pattern[i] === "/")
714
+ i++; // skip trailing slash after **
715
+ }
716
+ else if (ch === "*") {
717
+ regex += "[^/]*";
718
+ i++;
719
+ }
720
+ else if (ch === "?") {
721
+ regex += "[^/]";
722
+ i++;
723
+ }
724
+ else if (".+^$|()[]\\".includes(ch)) {
725
+ regex += "\\" + ch;
726
+ i++;
727
+ }
728
+ else {
729
+ regex += ch;
730
+ i++;
731
+ }
732
+ }
733
+ regex += "$";
734
+ return new RegExp(regex).test(filePath);
735
+ }
736
+ function resolveFileArgs(args) {
737
+ const files = [];
738
+ for (const arg of args) {
739
+ const resolved = path.resolve(arg);
740
+ // Glob pattern (contains * or ?)
741
+ if (arg.includes("*") || arg.includes("?")) {
742
+ // Find the base directory (portion before the first glob char)
743
+ const firstGlob = arg.search(/[*?{]/);
744
+ const baseDir = firstGlob > 0 ? path.resolve(arg.slice(0, arg.lastIndexOf("/", firstGlob) + 1) || ".") : process.cwd();
745
+ const allFiles = walkDir(baseDir);
746
+ for (const f of allFiles) {
747
+ const rel = path.relative(process.cwd(), f);
748
+ if (simpleGlobMatch(arg, rel) || simpleGlobMatch(arg, f)) {
749
+ files.push(f);
750
+ }
751
+ }
752
+ continue;
753
+ }
754
+ // Directory
755
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
756
+ files.push(...walkDir(resolved));
757
+ continue;
758
+ }
759
+ // Regular file
760
+ if (fs.existsSync(resolved)) {
761
+ if (!isBinaryFile(resolved))
762
+ files.push(resolved);
763
+ continue;
764
+ }
765
+ console.log(` ${c.yellow}⚠${c.reset} Not found: ${arg}`);
766
+ }
767
+ return [...new Set(files)]; // deduplicate
768
+ }
769
+ function loadMultipleFiles(filePaths) {
770
+ if (filePaths.length > MAX_FILES) {
771
+ console.log(` ${c.yellow}⚠${c.reset} Too many files (${filePaths.length}). Limit is ${MAX_FILES}.`);
772
+ filePaths = filePaths.slice(0, MAX_FILES);
773
+ }
774
+ const parts = [];
775
+ let totalBytes = 0;
776
+ for (const fp of filePaths) {
777
+ try {
778
+ const content = fs.readFileSync(fp, "utf-8");
779
+ if (totalBytes + content.length > MAX_TOTAL_BYTES) {
780
+ console.log(` ${c.yellow}⚠${c.reset} Size limit reached (${(MAX_TOTAL_BYTES / 1024 / 1024).toFixed(0)}MB). Loaded ${parts.length} of ${filePaths.length} files.`);
781
+ break;
782
+ }
783
+ const rel = path.relative(process.cwd(), fp);
784
+ parts.push(`=== ${rel} ===\n${content}`);
785
+ totalBytes += content.length;
786
+ }
787
+ catch { /* skip unreadable */ }
788
+ }
789
+ return { text: parts.join("\n\n"), count: parts.length, totalBytes };
790
+ }
584
791
  // ── Run RLM query ───────────────────────────────────────────────────────────
585
792
  async function runQuery(query) {
586
793
  const effectiveContext = contextText || query;
@@ -783,28 +990,45 @@ function extractFilePath(input) {
783
990
  return { filePath: null, query: input };
784
991
  }
785
992
  function expandAtFiles(input) {
786
- const atMatch = input.match(/^@(\S+)\s*(.*)/);
787
- if (atMatch) {
788
- const filePath = path.resolve(atMatch[1]);
789
- if (fs.existsSync(filePath)) {
790
- try {
791
- contextText = fs.readFileSync(filePath, "utf-8");
792
- contextSource = atMatch[1];
793
- const lines = contextText.split("\n").length;
794
- console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines) from ${c.underline}${atMatch[1]}${c.reset}`);
795
- return atMatch[2] || "";
796
- }
797
- catch (err) {
798
- console.log(` ${c.red}Could not read file: ${err.message}${c.reset}`);
799
- return "";
800
- }
993
+ // Extract all @tokens from input
994
+ const tokens = [];
995
+ const remaining = [];
996
+ for (const part of input.split(/\s+/)) {
997
+ if (part.startsWith("@") && part.length > 1) {
998
+ tokens.push(part.slice(1));
801
999
  }
802
1000
  else {
803
- console.log(` ${c.red}File not found: ${atMatch[1]}${c.reset}`);
1001
+ remaining.push(part);
1002
+ }
1003
+ }
1004
+ if (tokens.length === 0)
1005
+ return input;
1006
+ const filePaths = resolveFileArgs(tokens);
1007
+ if (filePaths.length === 0) {
1008
+ console.log(` ${c.red}No files found for: ${tokens.join(", ")}${c.reset}`);
1009
+ return "";
1010
+ }
1011
+ if (filePaths.length === 1) {
1012
+ // Single file — simple load
1013
+ try {
1014
+ contextText = fs.readFileSync(filePaths[0], "utf-8");
1015
+ contextSource = path.relative(process.cwd(), filePaths[0]) || filePaths[0];
1016
+ const lines = contextText.split("\n").length;
1017
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${contextText.length.toLocaleString()}${c.reset} chars (${lines} lines) from ${c.underline}${contextSource}${c.reset}`);
1018
+ }
1019
+ catch (err) {
1020
+ console.log(` ${c.red}Could not read file: ${err.message}${c.reset}`);
804
1021
  return "";
805
1022
  }
806
1023
  }
807
- return input;
1024
+ else {
1025
+ // Multiple files — concatenate with separators
1026
+ const { text, count, totalBytes } = loadMultipleFiles(filePaths);
1027
+ contextText = text;
1028
+ contextSource = `${count} files`;
1029
+ console.log(` ${c.green}✓${c.reset} Loaded ${c.bold}${count}${c.reset} files (${(totalBytes / 1024).toFixed(1)}KB total)`);
1030
+ }
1031
+ return remaining.join(" ");
808
1032
  }
809
1033
  // ── Auto-detect URLs ────────────────────────────────────────────────────────
810
1034
  async function detectAndLoadUrl(input) {
@@ -868,6 +1092,7 @@ async function interactive() {
868
1092
  process.exit(0);
869
1093
  }
870
1094
  // Auto-select default model for chosen provider
1095
+ currentProviderName = provider.piProvider;
871
1096
  const defaultModel = getDefaultModelForProvider(provider.piProvider);
872
1097
  if (defaultModel) {
873
1098
  currentModelId = defaultModel;
@@ -976,7 +1201,7 @@ async function interactive() {
976
1201
  break;
977
1202
  case "model":
978
1203
  case "m": {
979
- const curProvider = detectProvider();
1204
+ const curProvider = currentProviderName || detectProvider();
980
1205
  if (arg) {
981
1206
  // Accept a number (from current provider list) or a model ID
982
1207
  const curModels = getModelsForProvider(curProvider);
@@ -1041,7 +1266,7 @@ async function interactive() {
1041
1266
  }
1042
1267
  case "provider":
1043
1268
  case "prov": {
1044
- const curProvider = detectProvider();
1269
+ const curProvider = currentProviderName || detectProvider();
1045
1270
  const curLabel = findSetupProvider(curProvider)?.name || curProvider;
1046
1271
  console.log(`\n ${c.bold}Current provider:${c.reset} ${c.cyan}${curLabel}${c.reset}\n`);
1047
1272
  for (let i = 0; i < SETUP_PROVIDERS.length; i++) {
@@ -1076,7 +1301,7 @@ async function interactive() {
1076
1301
  currentModelId = defaultModel;
1077
1302
  const provResolved = resolveModelWithProvider(currentModelId);
1078
1303
  currentModel = provResolved?.model;
1079
- currentProviderName = chosen.piProvider;
1304
+ currentProviderName = provResolved?.provider || chosen.piProvider;
1080
1305
  console.log(` ${c.green}✓${c.reset} Switched to ${c.bold}${chosen.name}${c.reset}`);
1081
1306
  console.log(` ${c.green}✓${c.reset} Default model: ${c.bold}${currentModelId}${c.reset}`);
1082
1307
  console.log();
package/dist/rlm.js CHANGED
@@ -247,7 +247,7 @@ export async function runRlmLoop(options) {
247
247
  const errMsg = response.errorMessage;
248
248
  if (errMsg.includes("authentication") || errMsg.includes("401")) {
249
249
  return {
250
- answer: `[API Authentication Error] ${errMsg}\n\nCheck your ANTHROPIC_API_KEY in .env.`,
250
+ answer: `[API Authentication Error] ${errMsg}\n\nCheck your API key in .env or run /provider to reconfigure.`,
251
251
  iterations: iteration,
252
252
  totalSubQueries,
253
253
  completed: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlm-cli",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Standalone CLI for Recursive Language Models (RLMs) — implements Algorithm 1 from arXiv:2512.24601",
5
5
  "type": "module",
6
6
  "bin": {