rlm-navigator
Advanced tools
+134
-0
@@ -35,2 +35,126 @@ #!/usr/bin/env node | ||
| // --------------------------------------------------------------------------- | ||
| // Enrichment Provider Data | ||
| // --------------------------------------------------------------------------- | ||
| const ENRICHMENT_PROVIDERS = [ | ||
| { | ||
| name: "Anthropic", | ||
| key: "anthropic", | ||
| desc: "Claude Haiku 4.5", | ||
| api_key_env: "ANTHROPIC_API_KEY", | ||
| models: [ | ||
| { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (recommended)" }, | ||
| { id: "claude-sonnet-4-5-20250514", label: "Claude Sonnet 4.5" }, | ||
| ], | ||
| }, | ||
| { | ||
| name: "OpenAI", | ||
| key: "openai", | ||
| desc: "GPT-4o-mini, GPT-4o, GPT-4.1-mini", | ||
| api_key_env: "OPENAI_API_KEY", | ||
| models: [ | ||
| { id: "gpt-4o-mini", label: "GPT-4o-mini (recommended)" }, | ||
| { id: "gpt-4o", label: "GPT-4o" }, | ||
| { id: "gpt-4.1-mini", label: "GPT-4.1-mini" }, | ||
| ], | ||
| }, | ||
| { | ||
| name: "OpenRouter", | ||
| key: "openrouter", | ||
| desc: "Multi-provider proxy", | ||
| api_key_env: "OPENROUTER_API_KEY", | ||
| models: [ | ||
| { id: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5 (recommended)" }, | ||
| { id: "openai/gpt-4o-mini", label: "GPT-4o-mini" }, | ||
| { id: "google/gemini-2.0-flash", label: "Gemini 2.0 Flash" }, | ||
| { id: "meta-llama/llama-3.3-70b-instruct", label: "Llama 3.3 70B" }, | ||
| ], | ||
| }, | ||
| ]; | ||
| function apiKeyInstructions(envVar) { | ||
| const isWindows = process.platform === "win32"; | ||
| if (isWindows) { | ||
| return [ | ||
| chalk.bold(" Set your API key (PowerShell):"), | ||
| chalk.dim(` $env:${envVar} = "your-key-here"`) + chalk.dim(" # current session"), | ||
| chalk.dim(` [System.Environment]::SetEnvironmentVariable("${envVar}", "your-key-here", "User")`) + chalk.dim(" # permanent"), | ||
| ]; | ||
| } | ||
| return [ | ||
| chalk.bold(" Set your API key (bash/zsh):"), | ||
| chalk.dim(` export ${envVar}="your-key-here"`) + chalk.dim(" # current session"), | ||
| chalk.dim(` echo 'export ${envVar}="your-key-here"' >> ~/.bashrc`) + chalk.dim(" # permanent"), | ||
| ]; | ||
| } | ||
| async function configureEnrichment() { | ||
| console.log(""); | ||
| console.log(chalk.bold.cyan(" Enrichment Provider")); | ||
| console.log(chalk.dim(" Enrichment adds semantic summaries to code skeletons using a small LLM.")); | ||
| console.log(chalk.dim(" Requires an API key from your chosen provider.")); | ||
| console.log(""); | ||
| // Provider selection | ||
| for (let i = 0; i < ENRICHMENT_PROVIDERS.length; i++) { | ||
| const p = ENRICHMENT_PROVIDERS[i]; | ||
| console.log(` ${chalk.cyan(i + 1 + ")")} ${chalk.white(p.name)} ${chalk.dim("(" + p.desc + ")")}`); | ||
| } | ||
| console.log(` ${chalk.cyan(ENRICHMENT_PROVIDERS.length + 1 + ")")} ${chalk.dim("Skip (no enrichment)")}`); | ||
| console.log(""); | ||
| const providerAnswer = await ask(chalk.cyan(" ? ") + "Select provider " + chalk.dim(`[1-${ENRICHMENT_PROVIDERS.length + 1}] `) ); | ||
| const providerIdx = parseInt(providerAnswer, 10) - 1; | ||
| if (isNaN(providerIdx) || providerIdx < 0 || providerIdx >= ENRICHMENT_PROVIDERS.length) { | ||
| console.log(chalk.dim(" Skipping enrichment configuration.")); | ||
| return null; | ||
| } | ||
| const provider = ENRICHMENT_PROVIDERS[providerIdx]; | ||
| // Model selection | ||
| console.log(""); | ||
| console.log(chalk.bold(` ${provider.name} Models:`)); | ||
| for (let i = 0; i < provider.models.length; i++) { | ||
| console.log(` ${chalk.cyan(i + 1 + ")")} ${provider.models[i].label}`); | ||
| } | ||
| console.log(""); | ||
| const modelAnswer = await ask(chalk.cyan(" ? ") + "Select model " + chalk.dim(`[1-${provider.models.length}] `)); | ||
| const modelIdx = parseInt(modelAnswer, 10) - 1; | ||
| const model = (modelIdx >= 0 && modelIdx < provider.models.length) | ||
| ? provider.models[modelIdx] | ||
| : provider.models[0]; | ||
| // API key instructions | ||
| console.log(""); | ||
| const instructions = apiKeyInstructions(provider.api_key_env); | ||
| for (const line of instructions) { | ||
| console.log(line); | ||
| } | ||
| console.log(""); | ||
| return { | ||
| provider: provider.key, | ||
| model: model.id, | ||
| api_key_env: provider.api_key_env, | ||
| }; | ||
| } | ||
| function writeEnrichmentConfig(enrichment) { | ||
| const configPath = path.join(RLM_DIR, "config.json"); | ||
| let config = {}; | ||
| if (fs.existsSync(configPath)) { | ||
| try { | ||
| config = JSON.parse(fs.readFileSync(configPath, "utf-8")); | ||
| } catch (err) { | ||
| console.log(chalk.dim(` Warning: could not parse existing config.json, overwriting. (${err.message})`)); | ||
| } | ||
| } | ||
| config.enrichment = enrichment || { provider: null, model: null, api_key_env: null }; | ||
| fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); | ||
| } | ||
| function banner() { | ||
@@ -414,2 +538,12 @@ console.log(BANNER); | ||
| // Enrichment provider selection | ||
| const enrichment = await configureEnrichment(); | ||
| let enrichSpinner = step("Saving enrichment configuration..."); | ||
| writeEnrichmentConfig(enrichment); | ||
| if (enrichment) { | ||
| enrichSpinner.succeed(`Enrichment: ${enrichment.provider} / ${enrichment.model}`); | ||
| } else { | ||
| enrichSpinner.succeed("Enrichment: skipped"); | ||
| } | ||
| // .gitignore prompt (install-only) | ||
@@ -416,0 +550,0 @@ let spinner = step("Checking .gitignore..."); |
+61
-4
| """Configuration for RLM Navigator — manages API keys, feature flags, and dependency detection.""" | ||
| import json | ||
| import os | ||
@@ -15,7 +16,12 @@ | ||
| # Fallback defaults when no config file exists | ||
| _DEFAULT_ANTHROPIC_MODEL = "claude-haiku-4-5-20251001" | ||
| class RLMConfig: | ||
| """Centralized configuration. Reads from environment variables.""" | ||
| """Centralized configuration. Reads .rlm/config.json then falls back to env vars.""" | ||
| def __init__(self): | ||
| def __init__(self, root: str = "."): | ||
| self._root = root | ||
| # Legacy env vars (kept for backward compat + doc indexing) | ||
| self.anthropic_api_key: str | None = os.environ.get("ANTHROPIC_API_KEY") | ||
@@ -26,7 +32,58 @@ self.openai_api_key: str | None = os.environ.get("CHATGPT_API_KEY") | ||
| self._anthropic_available = _UNCHECKED | ||
| # Enrichment config from .rlm/config.json | ||
| self._enrichment_provider: str | None = None | ||
| self._enrichment_model: str | None = None | ||
| self._enrichment_api_key_env: str | None = None | ||
| self._config_loaded = False | ||
| self._load_rlm_config() | ||
| def _load_rlm_config(self): | ||
| """Read .rlm/config.json for enrichment provider settings.""" | ||
| config_path = os.path.join(self._root, ".rlm", "config.json") | ||
| if not os.path.isfile(config_path): | ||
| return | ||
| try: | ||
| with open(config_path, "r", encoding="utf-8") as f: | ||
| data = json.load(f) | ||
| except (json.JSONDecodeError, OSError): | ||
| return | ||
| enrichment = data.get("enrichment") | ||
| if not isinstance(enrichment, dict): | ||
| return | ||
| self._enrichment_provider = enrichment.get("provider") | ||
| self._enrichment_model = enrichment.get("model") | ||
| self._enrichment_api_key_env = enrichment.get("api_key_env") | ||
| self._config_loaded = True | ||
| @property | ||
| def enrichment_provider(self) -> str | None: | ||
| if self._config_loaded: | ||
| return self._enrichment_provider | ||
| # Fallback: if ANTHROPIC_API_KEY is set, assume anthropic | ||
| if self.anthropic_api_key: | ||
| return "anthropic" | ||
| return None | ||
| @property | ||
| def enrichment_model(self) -> str | None: | ||
| if self._config_loaded: | ||
| return self._enrichment_model | ||
| # Fallback default | ||
| if self.anthropic_api_key: | ||
| return _DEFAULT_ANTHROPIC_MODEL | ||
| return None | ||
| @property | ||
| def enrichment_api_key(self) -> str | None: | ||
| if self._config_loaded: | ||
| if self._enrichment_api_key_env: | ||
| return os.environ.get(self._enrichment_api_key_env) | ||
| return None # Config loaded but no env var name -> no key | ||
| # Fallback: no config file, use ANTHROPIC_API_KEY directly | ||
| return self.anthropic_api_key | ||
| @property | ||
| def enrichment_enabled(self) -> bool: | ||
| """Haiku enrichment requires Anthropic key + SDK.""" | ||
| return self.anthropic_api_key is not None and self.anthropic_available | ||
| """Enrichment requires a provider and a resolved API key.""" | ||
| return self.enrichment_provider is not None and self.enrichment_api_key is not None | ||
@@ -33,0 +90,0 @@ @property |
+80
-28
@@ -1,7 +0,8 @@ | ||
| """Node enricher — generates semantic summaries for AST skeleton nodes using Haiku. | ||
| """Node enricher — generates semantic summaries for AST skeleton nodes. | ||
| Parses skeleton output from squeezer.py, batches symbols, calls Haiku for 1-line | ||
| summaries, caches results by file path + mtime. | ||
| Parses skeleton output from squeezer.py, batches symbols, calls the configured | ||
| LLM provider for 1-line summaries, caches results by file path + mtime. | ||
| """ | ||
| import importlib | ||
| import queue | ||
@@ -13,3 +14,69 @@ import re | ||
| # Haiku prompt template | ||
| # Lazy-loaded SDK modules and client instances | ||
| _sdk_cache: dict[str, object] = {} | ||
| _client_cache: dict[tuple, object] = {} | ||
| OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" | ||
| def _get_sdk(name: str): | ||
| """Lazy-import an SDK module by name.""" | ||
| if name not in _sdk_cache: | ||
| _sdk_cache[name] = importlib.import_module(name) | ||
| return _sdk_cache[name] | ||
| def _get_client(provider: str, api_key: str, base_url: str | None = None): | ||
| """Get or create a cached SDK client instance.""" | ||
| key = (provider, api_key, base_url) | ||
| if key not in _client_cache: | ||
| if provider == "anthropic": | ||
| sdk = _get_sdk("anthropic") | ||
| _client_cache[key] = sdk.Anthropic(api_key=api_key) | ||
| else: | ||
| sdk = _get_sdk("openai") | ||
| _client_cache[key] = sdk.OpenAI(api_key=api_key, base_url=base_url) | ||
| return _client_cache[key] | ||
| def _parse_enrichment_response(text: str) -> Optional[dict]: | ||
| """Parse LLM response text into enrichment dict. Handles markdown fences.""" | ||
| text = text.strip() | ||
| if text.startswith("```"): | ||
| text = text.split("\n", 1)[1].rsplit("```", 1)[0] | ||
| return json.loads(text) | ||
| def call_enrichment_api(prompt: str, config) -> Optional[str]: | ||
| """Dispatch enrichment call to the configured provider. Returns raw text or None.""" | ||
| provider = config.enrichment_provider | ||
| api_key = config.enrichment_api_key | ||
| model = config.enrichment_model | ||
| if not provider or not api_key or not model: | ||
| return None | ||
| if provider == "anthropic": | ||
| client = _get_client("anthropic", api_key) | ||
| response = client.messages.create( | ||
| model=model, | ||
| max_tokens=1024, | ||
| messages=[{"role": "user", "content": prompt}], | ||
| ) | ||
| return response.content[0].text | ||
| elif provider in ("openai", "openrouter"): | ||
| base_url = OPENROUTER_BASE_URL if provider == "openrouter" else None | ||
| client = _get_client(provider, api_key, base_url) | ||
| response = client.chat.completions.create( | ||
| model=model, | ||
| max_tokens=1024, | ||
| messages=[{"role": "user", "content": prompt}], | ||
| ) | ||
| return response.choices[0].message.content | ||
| return None | ||
| # Enrichment prompt template | ||
| ENRICHMENT_PROMPT = """You are a code analyst. Given these code signatures from {filename}, provide a concise 1-line semantic summary for each symbol describing what it does (not what it is). | ||
@@ -124,3 +191,3 @@ | ||
| async def enrich_file(file_path: str, skeleton: str, config) -> Optional[dict]: | ||
| """Call Haiku to generate enrichments for a file's skeleton. | ||
| """Call enrichment API to generate enrichments for a file's skeleton. | ||
@@ -139,14 +206,6 @@ Returns dict mapping symbol names to summaries, or None on failure. | ||
| try: | ||
| import anthropic | ||
| client = anthropic.Anthropic(api_key=config.anthropic_api_key) | ||
| response = client.messages.create( | ||
| model="claude-haiku-4-5-20251001", | ||
| max_tokens=1024, | ||
| messages=[{"role": "user", "content": prompt}], | ||
| ) | ||
| text = response.content[0].text.strip() | ||
| # Parse JSON from response (may be wrapped in ```json blocks) | ||
| if text.startswith("```"): | ||
| text = text.split("\n", 1)[1].rsplit("```", 1)[0] | ||
| return json.loads(text) | ||
| text = call_enrichment_api(prompt, config) | ||
| if text is None: | ||
| return None | ||
| return _parse_enrichment_response(text) | ||
| except Exception: | ||
@@ -200,13 +259,6 @@ return None | ||
| try: | ||
| import anthropic | ||
| client = anthropic.Anthropic(api_key=self._config.anthropic_api_key) | ||
| response = client.messages.create( | ||
| model="claude-haiku-4-5-20251001", | ||
| max_tokens=1024, | ||
| messages=[{"role": "user", "content": prompt}], | ||
| ) | ||
| text = response.content[0].text.strip() | ||
| if text.startswith("```"): | ||
| text = text.split("\n", 1)[1].rsplit("```", 1)[0] | ||
| enrichments = json.loads(text) | ||
| text = call_enrichment_api(prompt, self._config) | ||
| if text is None: | ||
| return True | ||
| enrichments = _parse_enrichment_response(text) | ||
| self._cache.put(file_path, mtime, enrichments) | ||
@@ -213,0 +265,0 @@ except Exception: |
@@ -15,3 +15,5 @@ watchdog>=4.0 | ||
| anthropic>=0.40 | ||
| # OpenAI/OpenRouter enrichment provider support | ||
| openai>=1.0 | ||
| # .env file support | ||
| python-dotenv>=1.0 |
@@ -654,3 +654,3 @@ """RLM Navigator Daemon — file watcher + TCP server + skeleton cache. | ||
| from config import RLMConfig | ||
| cfg = RLMConfig() | ||
| cfg = RLMConfig(root=root) | ||
| resp = { | ||
@@ -765,3 +765,3 @@ "status": "alive", | ||
| cfg = RLMConfig() | ||
| cfg = RLMConfig(root=root) | ||
| tree = index_document(abs_path, cfg) | ||
@@ -787,3 +787,3 @@ if tree is None: | ||
| from config import RLMConfig | ||
| cfg = RLMConfig() | ||
| cfg = RLMConfig(root=root) | ||
| tree = index_document(abs_path, cfg) | ||
@@ -790,0 +790,0 @@ if not tree: |
+1
-1
| { | ||
| "name": "rlm-navigator", | ||
| "version": "1.1.6", | ||
| "version": "1.2.0", | ||
| "description": "Token-efficient codebase navigation for AI-assisted coding", | ||
@@ -5,0 +5,0 @@ "bin": { |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
227703
3.96%4429
4.95%