From cc1e14f1c3bbe04b6bb82cb0b808936e1819e85d Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:23:39 -0700 Subject: [PATCH] fix: engine discovery fallback, FTS5 case/scoring, init engine picker, Windows support (#74) - Engine discovery (#73): get_engine() now falls back to any healthy engine when the explicitly-requested key fails, instead of returning None. Fixes LM Studio (and other non-default engines) not being found when deploying agents. - FTS5 case sensitivity & scoring (#67): Add explicit unicode61 tokenizer to the FTS5 virtual table for case-insensitive search. Replace ambiguous `rank` column with explicit `bm25()` call with column weights (id=0, content=1, source=0.5) to produce correct positive scores. Includes auto-migration for existing databases. - Interactive engine selection (#72): `jarvis init` now detects running engines and presents an interactive picker. Also accepts `--engine` flag to skip the prompt. Engine choice flows through to config generation. - Windows desktop support (#68): Add RAM detection via wmic, Windows binary paths (LOCALAPPDATA, ProgramFiles, cargo), port cleanup via netstat+taskkill, and USERPROFILE fallback for HOME env var. Co-authored-by: Claude Opus 4.6 (1M context) --- desktop/src-tauri/src/lib.rs | 68 ++++++++++- .../openjarvis-tools/src/storage/sqlite.rs | 66 ++++++++++- src/openjarvis/cli/init_cmd.py | 107 +++++++++++++++++- src/openjarvis/core/config.py | 8 +- src/openjarvis/engine/_discovery.py | 25 ++-- tests/cli/test_cli.py | 2 +- tests/cli/test_init_guidance.py | 8 +- tests/engine/test_discovery.py | 24 ++++ 8 files changed, 273 insertions(+), 35 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b3b40171..14dcf64e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -54,6 +54,25 @@ fn total_ram_gb() -> f64 { } } } + #[cfg(target_os = "windows")] + { + use std::process::Command; + // wmic returns TotalVisibleMemorySize in KB + if let Ok(output) = Command::new("wmic") + .args(["OS", "get", "TotalVisibleMemorySize", "/value"]) + .output() + { + if let Ok(s) = String::from_utf8(output.stdout) { + for line in s.lines() { + if let Some(val) = line.strip_prefix("TotalVisibleMemorySize=") { + if let Ok(kb) = val.trim().parse::() { + return kb as f64 / (1024.0 * 1024.0); + } + } + } + } + } + } 8.0 } @@ -81,17 +100,43 @@ fn preferred_model() -> &'static str { } } +/// Get the user home directory, handling both Unix (HOME) and Windows (USERPROFILE). +fn home_dir() -> String { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_default() +} + /// Resolve full path to a binary by checking common locations. /// macOS .app bundles don't inherit the shell PATH, so we probe manually. fn resolve_bin(name: &str) -> String { - let home = std::env::var("HOME").unwrap_or_default(); - let candidates = [ + let home = home_dir(); + + #[cfg(not(target_os = "windows"))] + let candidates = vec![ format!("/opt/homebrew/bin/{name}"), format!("{home}/.local/bin/{name}"), format!("{home}/.cargo/bin/{name}"), format!("/usr/local/bin/{name}"), format!("/usr/bin/{name}"), ]; + + #[cfg(target_os = "windows")] + let candidates = { + let localappdata = std::env::var("LOCALAPPDATA").unwrap_or_default(); + let programfiles = std::env::var("ProgramFiles").unwrap_or_default(); + vec![ + format!("{home}\\.cargo\\bin\\{name}.exe"), + format!("{home}\\.local\\bin\\{name}.exe"), + format!("{localappdata}\\Programs\\{name}\\{name}.exe"), + format!("{programfiles}\\{name}\\{name}.exe"), + // Ollama installs to LOCALAPPDATA on Windows + format!("{localappdata}\\Programs\\Ollama\\{name}.exe"), + // uv installs via pip/pipx + format!("{home}\\AppData\\Roaming\\Python\\Scripts\\{name}.exe"), + ] + }; + for path in &candidates { if std::path::Path::new(path).exists() { return path.clone(); @@ -126,7 +171,7 @@ fn find_project_root() -> Option { } // 3. Fallback: well-known direct paths - let home = std::env::var("HOME").unwrap_or_default(); + let home = home_dir(); let direct = [ format!("{home}/OpenJarvis"), format!("{home}/projects/hazy/OpenJarvis"), @@ -449,6 +494,21 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) { .await; tokio::time::sleep(Duration::from_secs(2)).await; } + #[cfg(target_os = "windows")] + { + // Find the PID holding the port via netstat, then kill it + if let Ok(output) = tokio::process::Command::new("cmd") + .args(["/C", &format!( + "for /f \"tokens=5\" %a in ('netstat -ano ^| findstr :{port} ^| findstr LISTENING') do taskkill /PID %a /F", + port = JARVIS_PORT, + )]) + .output() + .await + { + let _ = output; // best-effort + } + tokio::time::sleep(Duration::from_secs(2)).await; + } } } @@ -809,7 +869,7 @@ async fn submit_savings( /// Path to the cloud keys file (~/.openjarvis/cloud-keys.env). fn cloud_keys_path() -> std::path::PathBuf { - let home = std::env::var("HOME").unwrap_or_default(); + let home = home_dir(); std::path::PathBuf::from(home) .join(".openjarvis") .join("cloud-keys.env") diff --git a/rust/crates/openjarvis-tools/src/storage/sqlite.rs b/rust/crates/openjarvis-tools/src/storage/sqlite.rs index 433b2378..bfa9f97c 100644 --- a/rust/crates/openjarvis-tools/src/storage/sqlite.rs +++ b/rust/crates/openjarvis-tools/src/storage/sqlite.rs @@ -36,7 +36,7 @@ impl SQLiteMemory { created_at REAL DEFAULT (julianday('now')) ); CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5( - id, content, source + id, content, source, tokenize='unicode61' );", ) .map_err(|e| { @@ -45,6 +45,33 @@ impl SQLiteMemory { )) })?; + // Migrate existing FTS5 tables that lack the unicode61 tokenizer + // (ensures case-insensitive search on databases created before this fix). + let needs_migration: bool = conn + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='documents_fts'", + [], + |row| row.get::<_, String>(0), + ) + .map(|sql| !sql.contains("unicode61")) + .unwrap_or(false); + + if needs_migration { + conn.execute_batch( + "DROP TABLE IF EXISTS documents_fts; + CREATE VIRTUAL TABLE documents_fts USING fts5( + id, content, source, tokenize='unicode61' + ); + INSERT INTO documents_fts (id, content, source) + SELECT id, content, source FROM documents;", + ) + .map_err(|e| { + OpenJarvisError::Io(std::io::Error::other( + e.to_string(), + )) + })?; + } + Ok(Self { conn: Mutex::new(conn), _db_path: db_path.to_path_buf(), @@ -113,11 +140,11 @@ impl MemoryBackend for SQLiteMemory { let mut stmt = conn .prepare( "SELECT d.content, d.source, d.metadata, - rank * -1 as score + bm25(documents_fts, 0.0, 1.0, 0.5) * -1 as score FROM documents_fts f JOIN documents d ON d.id = f.id WHERE documents_fts MATCH ?1 - ORDER BY rank + ORDER BY bm25(documents_fts, 0.0, 1.0, 0.5) LIMIT ?2", ) .map_err(|e| { @@ -231,4 +258,37 @@ mod tests { mem.clear().unwrap(); assert_eq!(mem.count().unwrap(), 0); } + + #[test] + fn test_sqlite_case_insensitive_search() { + let mem = SQLiteMemory::in_memory().unwrap(); + mem.store("Medication dosage guidelines for patients", "medical", None).unwrap(); + mem.store("The medication was prescribed yesterday", "medical", None).unwrap(); + + // Lowercase query should match uppercase content + let lower = mem.retrieve("medication", 10).unwrap(); + assert_eq!(lower.len(), 2, "lowercase query should find both documents"); + + // Uppercase query should also match + let upper = mem.retrieve("MEDICATION", 10).unwrap(); + assert_eq!(upper.len(), 2, "uppercase query should find both documents"); + + // Mixed case + let mixed = mem.retrieve("Medication", 10).unwrap(); + assert_eq!(mixed.len(), 2, "mixed-case query should find both documents"); + } + + #[test] + fn test_sqlite_scores_are_positive() { + let mem = SQLiteMemory::in_memory().unwrap(); + mem.store("Rust is a systems programming language", "docs", None).unwrap(); + mem.store("Python is a high-level programming language", "docs", None).unwrap(); + mem.store("Cooking recipes for beginners", "other", None).unwrap(); + + let results = mem.retrieve("programming", 5).unwrap(); + assert!(!results.is_empty()); + for r in &results { + assert!(r.score > 0.0, "score should be positive, got {}", r.score); + } + } } diff --git a/src/openjarvis/cli/init_cmd.py b/src/openjarvis/cli/init_cmd.py index ce1cd58a..a70d646f 100644 --- a/src/openjarvis/cli/init_cmd.py +++ b/src/openjarvis/cli/init_cmd.py @@ -20,6 +20,37 @@ from openjarvis.core.config import ( recommend_model, ) +# Engines supported by ``jarvis init --engine``. +_SUPPORTED_ENGINES = [ + "ollama", "vllm", "sglang", "llamacpp", "mlx", "lmstudio", + "exo", "nexa", +] + + +def _detect_running_engines() -> list[str]: + """Probe well-known ports and return engine keys that respond.""" + import httpx + + _PROBES: dict[str, str] = { + "ollama": "http://localhost:11434/api/tags", + "vllm": "http://localhost:8000/v1/models", + "sglang": "http://localhost:30000/v1/models", + "llamacpp": "http://localhost:8080/v1/models", + "mlx": "http://localhost:8080/v1/models", + "lmstudio": "http://localhost:1234/v1/models", + "exo": "http://localhost:52415/v1/models", + "nexa": "http://localhost:18181/v1/models", + } + running: list[str] = [] + for key, url in _PROBES.items(): + try: + resp = httpx.get(url, timeout=2.0) + if resp.status_code < 500: + running.append(key) + except Exception: + pass + return running + def _next_steps_text(engine: str, model: str = "") -> str: """Return engine-specific next-steps guidance after init.""" @@ -120,7 +151,18 @@ def _next_steps_text(engine: str, model: str = "") -> str: is_flag=True, help="Generate full reference config with all sections", ) -def init(force: bool, config: Optional[Path], full_config: bool = False) -> None: +@click.option( + "--engine", + type=click.Choice(_SUPPORTED_ENGINES, case_sensitive=False), + default=None, + help="Inference engine to use (skips interactive selection).", +) +def init( + force: bool, + config: Optional[Path], + full_config: bool = False, + engine: Optional[str] = None, +) -> None: """Detect hardware and generate ~/.openjarvis/config.toml.""" console = Console() @@ -146,13 +188,66 @@ def init(force: bool, config: Optional[Path], full_config: bool = False) -> None else: console.print(" GPU : none detected") + # Resolve engine: explicit flag > interactive selection > auto-detect + if engine is None and config is None: + recommended = recommend_engine(hw) + console.print() + console.print("[bold]Detecting running inference engines...[/bold]") + running = _detect_running_engines() + if running: + console.print( + f" Found running: [green]{', '.join(running)}[/green]" + ) + else: + console.print(" No running engines detected.") + + # Build choices: show running engines first, then recommended, then rest + seen: set[str] = set() + choices: list[str] = [] + for r in running: + if r not in seen: + choices.append(r) + seen.add(r) + if recommended not in seen: + choices.append(recommended) + seen.add(recommended) + for e in _SUPPORTED_ENGINES: + if e not in seen: + choices.append(e) + seen.add(e) + + # Default: first running engine, or hardware recommendation + default = running[0] if running else recommended + + labels = [] + for c in choices: + parts = [c] + if c in running: + parts.append("running") + if c == recommended: + parts.append("recommended") + labels.append( + f" {c}" + (f" ({', '.join(parts[1:])})" if len(parts) > 1 else "") + ) + + console.print() + console.print("[bold]Available engines:[/bold]") + for label in labels: + console.print(label) + + engine = click.prompt( + "\nSelect inference engine", + type=click.Choice(choices, case_sensitive=False), + default=default, + ) + if config: toml_content = config.read_text() else: if full_config: - toml_content = generate_default_toml(hw) + toml_content = generate_default_toml(hw, engine=engine) else: - toml_content = generate_minimal_toml(hw) + toml_content = generate_minimal_toml(hw, engine=engine) DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True) if config: @@ -170,14 +265,14 @@ def init(force: bool, config: Optional[Path], full_config: bool = False) -> None ) console.print("[green]Config written successfully.[/green]") - engine = recommend_engine(hw) - model = recommend_model(hw, engine) + selected_engine = engine or recommend_engine(hw) + model = recommend_model(hw, selected_engine) if model: console.print(f"\n [bold]Recommended model:[/bold] {model}") console.print() console.print( Panel( - _next_steps_text(engine, model), + _next_steps_text(selected_engine, model), title="Getting Started", border_style="cyan", ) diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index 416854ab..1067c1a4 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -1199,9 +1199,9 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig: # --------------------------------------------------------------------------- -def generate_minimal_toml(hw: HardwareInfo) -> str: +def generate_minimal_toml(hw: HardwareInfo, engine: str | None = None) -> str: """Render a minimal TOML config with only essential settings.""" - engine = recommend_engine(hw) + engine = engine or recommend_engine(hw) model = recommend_model(hw, engine) gpu_comment = "" if hw.gpu: @@ -1231,9 +1231,9 @@ enabled = ["code_interpreter", "web_search", "file_read", "shell_exec"] """ -def generate_default_toml(hw: HardwareInfo) -> str: +def generate_default_toml(hw: HardwareInfo, engine: str | None = None) -> str: """Render a commented TOML string suitable for ``~/.openjarvis/config.toml``.""" - engine = recommend_engine(hw) + engine = engine or recommend_engine(hw) model = recommend_model(hw, engine) gpu_line = "" if hw.gpu: diff --git a/src/openjarvis/engine/_discovery.py b/src/openjarvis/engine/_discovery.py index fd030bd6..4e2fc695 100644 --- a/src/openjarvis/engine/_discovery.py +++ b/src/openjarvis/engine/_discovery.py @@ -84,25 +84,24 @@ def get_engine( Returns ``(key, engine_instance)`` or ``None`` if no engine is available. """ + # Build an ordered list of keys to try, then fall back to full discovery. + keys_to_try: list[str] = [] if engine_key: - if EngineRegistry.contains(engine_key): - try: - engine = _make_engine(engine_key, config) - if engine.health(): - return (engine_key, engine) - except Exception as exc: - logger.debug("Engine %r health check failed: %s", engine_key, exc) - return None + keys_to_try.append(engine_key) - # Try default first default_key = config.engine.default - if EngineRegistry.contains(default_key): + if default_key and default_key not in keys_to_try: + keys_to_try.append(default_key) + + for key in keys_to_try: + if not EngineRegistry.contains(key): + continue try: - engine = _make_engine(default_key, config) + engine = _make_engine(key, config) if engine.health(): - return (default_key, engine) + return (key, engine) except Exception as exc: - logger.debug("Default engine %r health check failed: %s", default_key, exc) + logger.debug("Engine %r health check failed: %s", key, exc) # Fallback to any healthy engine healthy = discover_engines(config) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 29761c46..d283cfb3 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -89,7 +89,7 @@ class TestCLI: "openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path ), ): - result = CliRunner().invoke(cli, ["init"]) + result = CliRunner().invoke(cli, ["init", "--engine", "ollama"]) assert result.exit_code == 0 assert config_path.exists() content = config_path.read_text() diff --git a/tests/cli/test_init_guidance.py b/tests/cli/test_init_guidance.py index ad2b0ed1..21ddbe9f 100644 --- a/tests/cli/test_init_guidance.py +++ b/tests/cli/test_init_guidance.py @@ -24,7 +24,7 @@ class TestInitShowsNextSteps: "openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path ), ): - result = CliRunner().invoke(cli, ["init"]) + result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp"]) assert result.exit_code == 0 assert "Getting Started" in result.output assert "jarvis ask" in result.output @@ -42,7 +42,7 @@ class TestInitShowsNextSteps: "openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path ), ): - result = CliRunner().invoke(cli, ["init"]) + result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp"]) assert result.exit_code == 0 assert "[engine]" in result.output assert "[intelligence]" in result.output @@ -105,7 +105,7 @@ class TestMinimalConfig: "openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path ), ): - result = CliRunner().invoke(cli, ["init"]) + result = CliRunner().invoke(cli, ["init", "--engine", "ollama"]) assert result.exit_code == 0 content = config_path.read_text() # Minimal config should be short @@ -126,7 +126,7 @@ class TestMinimalConfig: "openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path ), ): - result = CliRunner().invoke(cli, ["init", "--full"]) + result = CliRunner().invoke(cli, ["init", "--full", "--engine", "ollama"]) assert result.exit_code == 0 content = config_path.read_text() # Full config should have many sections diff --git a/tests/engine/test_discovery.py b/tests/engine/test_discovery.py index ba7e4d04..fe3cb888 100644 --- a/tests/engine/test_discovery.py +++ b/tests/engine/test_discovery.py @@ -102,3 +102,27 @@ class TestGetEngine: result = get_engine(cfg) assert result is not None assert result[0] == "good" + + def test_explicit_key_falls_back_to_any_healthy(self) -> None: + """When an explicit engine_key fails, fallback to any healthy engine. + + Fixes #73: LM Studio running but not found because get_engine() + returned None when the explicitly-requested key failed. + """ + _reg("requested", "requested") + _reg("running", "running") + + cfg = JarvisConfig() + cfg.engine.default = "requested" + + def _make(k, c): # noqa: ANN001 + return _FakeEngine(healthy=(k == "running")) + + with mock.patch( + "openjarvis.engine._discovery._make_engine", + side_effect=_make, + ): + # Explicit key "requested" is unhealthy, but "running" is healthy + result = get_engine(cfg, engine_key="requested") + assert result is not None + assert result[0] == "running"