mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 00:47:52 +00:00
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:
co-authored by
Claude Opus 4.6
parent
afa50b0ff7
commit
cc1e14f1c3
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user