rlm-cli 0.2.15 → 0.2.16

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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlm-cli",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "Standalone CLI for Recursive Language Models (RLMs) — implements Algorithm 1 from arXiv:2512.24601",
5
5
  "type": "module",
6
6
  "bin": {