diff --git a/README.md b/README.md index bf51cc8..843f94d 100644 --- a/README.md +++ b/README.md @@ -475,7 +475,7 @@ Once installed, clients have access to these tools and prompt templates: | `get_knowledge` | Retrieve full content of an entry by ID | | `edit_knowledge` | Edit title, content, keywords, status, or recorded_project of an entry (CLI: `lk edit`) | | `supersede_knowledge` | Mark an entry as superseded by another (bidirectional) | -| `get_stats` | Get knowledge base statistics | +| `get_stats` | Get knowledge base statistics and Laya status | | `list_projects` | List registered projects (multi-project mode only) | #### MCP Prompts (`prompts/list`, `prompts/get`) @@ -664,6 +664,7 @@ This feature is **disabled by default (opt-in)**. To enable it, set `[laya] enab - **On-Demand & Singleton**: A single Python daemon process is spawned on the first command requiring semantic decisions, communicating via a Unix Domain Socket (`~/.cache/lk/laya.sock`). All CLI and MCP sessions share this single daemon without redundant memory usage or model loading latency. - **Auto-Shutdown**: If no requests are received for 10 minutes (configurable via `idle_timeout`), the daemon shuts down automatically and frees all unified memory. - **Graceful Fallback**: If Python/MLX or dependencies are unavailable, or on non-macOS platforms, `lk` falls back transparently to trigram FTS5 and frequency-based keyword heuristics without errors. +- **Checking Status**: `lk stats` (and the MCP `get_stats` tool, under `laya`) shows whether Laya is enabled and whether the daemon is running. The check only pings a running daemon and never spawns one, so `daemon stopped` is normal while idle — it starts on the next command that needs it. When reranking was applied, search results from the MCP `search_knowledge` tool and `lk search --json` carry a `semantic_score` field (the plain-text output does not show it). ## Supported Platforms diff --git a/src/cmd/stats.rs b/src/cmd/stats.rs index 6cdbdab..fced3c9 100644 --- a/src/cmd/stats.rs +++ b/src/cmd/stats.rs @@ -182,6 +182,8 @@ pub fn cmd_stats( )); } let unique_keywords = kw_union.len(); + let laya = + crate::laya::status(&crate::config::Config::load(&crate::util::get_knowledge_dir()).laya); // Merged across scopes: the same project can hold entries in both stores, and // seeing them split by DB answers a different question than "how much do I know @@ -206,6 +208,7 @@ pub fn cmd_stats( "local_entries": local, "unique_keywords": unique_keywords, "scopes": per_scope, + "laya": laya, }); if verbose { obj["project_root"] = serde_json::json!(get_project_root().to_string_lossy()); @@ -228,6 +231,7 @@ pub fn cmd_stats( println!(" Shared entries: {shared}"); println!(" Local entries: {local}"); println!(" Unique keywords: {unique_keywords}"); + println!(" Laya: {}", crate::laya::status_line(&laya)); for line in &per_scope_text { println!("{line}"); } diff --git a/src/laya.rs b/src/laya.rs index a1ac590..95775c7 100644 --- a/src/laya.rs +++ b/src/laya.rs @@ -398,6 +398,63 @@ pub fn compute_rerank_scores( HashMap::new() } +/// Report whether Laya is usable, for `lk stats` / MCP `get_stats`. +/// +/// Only pings an already-running daemon — never spawns one — so checking the +/// status stays fast and does not load the model. A stopped daemon is normal: +/// it starts on the next command that needs it and exits after `idle_timeout`. +pub fn status(config: &LayaConfig) -> serde_json::Value { + let supported = cfg!(all(target_os = "macos", target_arch = "aarch64")); + let socket_path = resolve_socket_path(config); + let running = if supported { + LayaClient::try_connect(&socket_path, &config.model).map(|(mut client, _)| { + client + .ping() + .ok() + .and_then(|r| r.get("model").and_then(|m| m.as_str()).map(String::from)) + }) + } else { + None + }; + + let mut obj = json!({ + "supported": supported, + "enabled": config.enabled, + "daemon": if running.is_some() { "running" } else { "stopped" }, + "model": config.model, + "socket_path": socket_path.to_string_lossy(), + }); + if let Some(Some(model)) = running { + obj["daemon_model"] = json!(model); + } + obj +} + +/// One-line human-readable summary of [`status`] for `lk stats`. +pub fn status_line(status: &serde_json::Value) -> String { + if !status["supported"].as_bool().unwrap_or(false) { + return "unsupported (macOS Apple Silicon only)".to_string(); + } + let enabled = if status["enabled"].as_bool().unwrap_or(false) { + "enabled" + } else { + "disabled" + }; + match status["daemon"].as_str() { + Some("running") => format!( + "{enabled}, daemon running ({})", + status["daemon_model"] + .as_str() + .or(status["model"].as_str()) + .unwrap_or("unknown model") + ), + _ if enabled == "enabled" => { + format!("{enabled}, daemon stopped (starts on demand)") + } + _ => format!("{enabled}, daemon stopped"), + } +} + /// Resolve the socket path to use, defaulting to `~/.cache/lk/laya.sock`. #[allow(dead_code)] pub fn resolve_socket_path(config: &LayaConfig) -> PathBuf { @@ -463,6 +520,45 @@ mod tests { assert_eq!(content, EMBEDDED_SERVER_SCRIPT); } + #[test] + fn test_status_does_not_spawn_when_socket_missing() { + let dir = tempfile::tempdir().unwrap(); + let cfg = LayaConfig { + enabled: true, + socket_path: Some(dir.path().join("missing.sock")), + ..LayaConfig::default() + }; + let st = status(&cfg); + assert_eq!(st["enabled"], json!(true)); + assert_eq!(st["daemon"], json!("stopped")); + assert!(st.get("daemon_model").is_none()); + assert!( + !dir.path().join("missing.pid").exists(), + "status must not spawn the daemon" + ); + } + + #[test] + fn test_status_line() { + let unsupported = json!({"supported": false, "enabled": true, "daemon": "stopped"}); + assert!(status_line(&unsupported).starts_with("unsupported")); + + let stopped = json!({"supported": true, "enabled": true, "daemon": "stopped"}); + assert_eq!( + status_line(&stopped), + "enabled, daemon stopped (starts on demand)" + ); + + let running = json!({ + "supported": true, "enabled": true, "daemon": "running", + "model": "a/b", "daemon_model": "c/d", + }); + assert_eq!(status_line(&running), "enabled, daemon running (c/d)"); + + let disabled = json!({"supported": true, "enabled": false, "daemon": "stopped"}); + assert_eq!(status_line(&disabled), "disabled, daemon stopped"); + } + #[test] fn test_resolve_socket_path() { let mut cfg = LayaConfig::default(); diff --git a/src/mcp.rs b/src/mcp.rs index 4000d68..4928620 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -598,7 +598,7 @@ fn tool_def_supersede(registry: &ProjectRegistry) -> Value { fn tool_def_stats(registry: &ProjectRegistry) -> Value { let mut def = json!({ "name": "get_stats", - "description": "Get a quick overview of the knowledge base: total number of entries, shared vs local counts, and unique keyword count. Useful to check if a knowledge base exists and how much content is available before searching. Includes a per-scope breakdown.", + "description": "Get a quick overview of the knowledge base: total number of entries, shared vs local counts, and unique keyword count, plus Laya (semantic reranking/duplicate detection) status: whether it is enabled and whether its daemon is running. Useful to check if a knowledge base exists and how much content is available before searching. Includes a per-scope breakdown.", "inputSchema": { "type": "object", "properties": { @@ -1620,6 +1620,7 @@ fn call_tool(name: &str, params: &Value, registry: &ProjectRegistry) -> Result