Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
4 changes: 4 additions & 0 deletions src/cmd/stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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());
Expand All @@ -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}");
}
Expand Down
96 changes: 96 additions & 0 deletions src/laya.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -1620,6 +1620,7 @@ fn call_tool(name: &str, params: &Value, registry: &ProjectRegistry) -> Result<V
"local": local,
"keywords": kw_union.len(),
"scopes": scopes,
"laya": crate::laya::status(&config.laya),
}),
&project_name,
))
Expand Down
Loading