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) <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-03-15 16:23:39 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent afa50b0ff7
commit cc1e14f1c3
8 changed files with 273 additions and 35 deletions
+64 -4
View File
@@ -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::<u64>() {
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<std::path::PathBuf> {
}
// 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")
@@ -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);
}
}
}
+101 -6
View File
@@ -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",
)
+4 -4
View File
@@ -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:
+12 -13
View File
@@ -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)
+1 -1
View File
@@ -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()
+4 -4
View File
@@ -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
+24
View File
@@ -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"