pub mod cache;
pub mod handler;
pub mod listener;
pub mod protocol;
#[cfg(test)]
mod tests;
use crate::config::SemantexConfig;
use crate::search::hybrid::HybridSearcher;
use anyhow::{Context, Result};
use cache::SearcherCache;
use listener::Listener;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 1800;
pub struct SemantexServer {
index_dir: PathBuf,
project_path: PathBuf,
config: SemantexConfig,
idle_timeout: Duration,
shutdown: Arc<AtomicBool>,
}
impl SemantexServer {
pub fn new(project_path: &Path, config: &SemantexConfig) -> Self {
let index_dir = SemantexConfig::project_index_dir(project_path);
Self {
index_dir,
project_path: project_path.to_path_buf(),
config: config.clone(),
idle_timeout: Duration::from_secs(DEFAULT_IDLE_TIMEOUT_SECS),
shutdown: Arc::new(AtomicBool::new(false)),
}
}
pub fn with_idle_timeout(mut self, timeout: Duration) -> Self {
self.idle_timeout = timeout;
self
}
pub fn port_file_path(&self) -> PathBuf {
self.index_dir.join("semantex.port")
}
pub fn pid_path(&self) -> PathBuf {
self.index_dir.join("semantex.pid")
}
pub fn run(&self) -> Result<()> {
if !self.index_dir.join("chunks.db").exists() {
anyhow::bail!(
"No index found at {}. Run 'semantex index' first.",
self.index_dir.display()
);
}
let pid = std::process::id();
std::fs::write(self.pid_path(), pid.to_string())
.with_context(|| format!("Failed to write PID file: {}", self.pid_path().display()))?;
install_signal_handler(self.shutdown.clone())?;
let _prefetch = listener::prefetch_index(&self.index_dir);
let _warm_handle = listener::spawn_embedder_warm_thread(&self.config);
#[cfg(feature = "llm")]
let llm_backend: Option<std::sync::Arc<dyn crate::llm::LlmCapability>> =
match crate::llm::LlmBackend::from_env() {
Ok(Some(backend)) => {
let cap = backend.into_arc();
tracing::warn!("LLM enabled: {}", cap.label());
Some(cap)
}
Ok(None) => {
log_llm_discovery_hint();
None
}
Err(e) => {
tracing::warn!("LLM backend init failed: {e}; disabling LLM features");
None
}
};
tracing::info!("Loading search index...");
let searcher = HybridSearcher::open(&self.index_dir, &self.config)
.context("Failed to open search index")?;
tracing::info!("Search index loaded");
let cache = Arc::new(SearcherCache::seeded(
self.config.clone(),
&self.project_path,
searcher,
));
let listener = Listener::bind_with_cache(
&self.port_file_path(),
cache,
self.project_path.clone(),
self.idle_timeout,
self.shutdown.clone(),
)?;
#[cfg(feature = "llm")]
let listener = listener.with_llm(llm_backend);
let result = listener.run();
let _ = std::fs::remove_file(self.pid_path());
result
}
pub fn shutdown_flag(&self) -> Arc<AtomicBool> {
self.shutdown.clone()
}
}
#[allow(clippy::needless_pass_by_value)] fn install_signal_handler(shutdown: Arc<AtomicBool>) -> Result<()> {
let flag = Arc::clone(&shutdown);
ctrlc::set_handler(move || {
flag.store(true, std::sync::atomic::Ordering::Relaxed);
})
.context("Failed to install Ctrl-C handler")?;
Ok(())
}
pub fn read_daemon_port(project_path: &Path) -> Result<u16> {
let index_dir = SemantexConfig::project_index_dir(project_path);
let port_file = index_dir.join("semantex.port");
let content = std::fs::read_to_string(&port_file)
.with_context(|| format!("Failed to read port file: {}", port_file.display()))?;
content.trim().parse::<u16>().with_context(|| {
format!(
"Invalid port in {}: {}",
port_file.display(),
content.trim()
)
})
}
pub fn daemon_healthy(project_path: &Path) -> bool {
let Ok(port) = read_daemon_port(project_path) else {
return false;
};
if let Ok(response) = send_request_to_port(port, r#"{"type":"health"}"#) {
response.contains("\"status\":\"ok\"")
} else {
let index_dir = SemantexConfig::project_index_dir(project_path);
let _ = std::fs::remove_file(index_dir.join("semantex.port"));
let _ = std::fs::remove_file(index_dir.join("semantex.pid"));
false
}
}
fn send_request_to_port(port: u16, request_json: &str) -> Result<String> {
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))
.with_context(|| format!("Failed to connect to daemon at 127.0.0.1:{port}"))?;
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
stream.write_all(request_json.as_bytes())?;
stream.write_all(b"\n")?;
stream.flush()?;
let mut reader = BufReader::new(&stream);
let mut response = String::new();
reader.read_line(&mut response)?;
Ok(response)
}
pub fn daemon_search(
project_path: &Path,
request: &protocol::SearchRequest,
) -> Result<protocol::SearchResponse> {
let port = read_daemon_port(project_path)?;
let request_json = serde_json::json!({
"type": "search",
"query": request.query,
"max_results": request.max_results,
"use_dense": request.use_dense,
"use_sparse": request.use_sparse,
"use_rerank": request.use_rerank,
"include_types": request.include_types,
"exclude_types": request.exclude_types,
"code_only": request.code_only,
"include_content": request.include_content,
"snippet": request.snippet,
});
let response_str = send_request_to_port(port, &request_json.to_string())?;
let response: serde_json::Value =
serde_json::from_str(&response_str).context("Failed to parse daemon response")?;
if response.get("type").and_then(|t| t.as_str()) == Some("error") {
let msg = response
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
anyhow::bail!("Daemon error: {msg}");
}
let results: Vec<protocol::SearchResultItem> = response
.get("results")
.and_then(|r| serde_json::from_value(r.clone()).ok())
.unwrap_or_default();
Ok(protocol::SearchResponse {
results,
duration_ms: response
.get("duration_ms")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
dense_count: response
.get("dense_count")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as usize,
sparse_count: response
.get("sparse_count")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as usize,
fused_count: response
.get("fused_count")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as usize,
metrics: response
.get("metrics")
.and_then(|m| serde_json::from_value(m.clone()).ok()),
confidence: response
.get("confidence")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string),
disambiguation: response
.get("disambiguation")
.and_then(|d| serde_json::from_value(d.clone()).ok()),
})
}
fn send_binary_request(
port: u16,
req: &protocol::BinaryRequest,
read_timeout_s: u64,
) -> Result<protocol::BinaryResponse> {
use std::io::Read;
let frame = protocol::encode_binary_request(req);
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))
.with_context(|| format!("Failed to connect to daemon at 127.0.0.1:{port}"))?;
stream.set_read_timeout(Some(Duration::from_secs(read_timeout_s)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
stream.write_all(&frame)?;
stream.flush()?;
let mut magic = [0u8; 1];
stream.read_exact(&mut magic)?;
if magic[0] != protocol::BINARY_MAGIC {
anyhow::bail!("Expected binary response, got 0x{:02x}", magic[0]);
}
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf)?;
let len = u32::from_le_bytes(len_buf) as usize;
let mut body = vec![0u8; len];
stream.read_exact(&mut body)?;
protocol::decode_binary_response(&body)
.map_err(|e| anyhow::anyhow!("Failed to decode binary response: {e}"))
}
pub fn daemon_search_binary(
port: u16,
request: protocol::SearchRequest,
) -> Result<protocol::SearchResponse> {
let bin_req = protocol::BinaryRequest::Search(request);
match send_binary_request(port, &bin_req, 30)? {
protocol::BinaryResponse::Search(sr) => Ok(sr),
protocol::BinaryResponse::Error(e) => anyhow::bail!("Daemon error: {}", e.message),
other => anyhow::bail!("Unexpected response type: {other:?}"),
}
}
pub fn daemon_graph_walk_binary(
port: u16,
request: protocol::GraphWalkRequest,
) -> Result<protocol::GraphWalkResponse> {
let bin_req = protocol::BinaryRequest::GraphWalk(request);
match send_binary_request(port, &bin_req, 30)? {
protocol::BinaryResponse::GraphWalk(gr) => Ok(gr),
protocol::BinaryResponse::Error(e) => anyhow::bail!("Daemon error: {}", e.message),
other => anyhow::bail!("Unexpected response type: {other:?}"),
}
}
pub fn daemon_multi_search_binary(
port: u16,
request: protocol::MultiSearchRequest,
) -> Result<protocol::MultiSearchResponse> {
let bin_req = protocol::BinaryRequest::MultiSearch(request);
match send_binary_request(port, &bin_req, 30)? {
protocol::BinaryResponse::MultiSearch(mr) => Ok(mr),
protocol::BinaryResponse::Error(e) => anyhow::bail!("Daemon error: {}", e.message),
other => anyhow::bail!("Unexpected response type: {other:?}"),
}
}
pub fn daemon_deep_search_binary(
port: u16,
request: protocol::DeepSearchRequest,
) -> Result<protocol::DeepSearchResponse> {
let bin_req = protocol::BinaryRequest::DeepSearch(request);
match send_binary_request(port, &bin_req, 60)? {
protocol::BinaryResponse::DeepSearch(dr) => Ok(dr),
protocol::BinaryResponse::Error(e) => anyhow::bail!("Daemon error: {}", e.message),
other => anyhow::bail!("Unexpected response type: {other:?}"),
}
}
pub fn daemon_agent_binary(
port: u16,
request: protocol::AgentRequest,
) -> Result<protocol::AgentResponse> {
let bin_req = protocol::BinaryRequest::Agent(request);
match send_binary_request(port, &bin_req, 60)? {
protocol::BinaryResponse::Agent(ar) => Ok(ar),
protocol::BinaryResponse::Error(e) => anyhow::bail!("Daemon error: {}", e.message),
other => anyhow::bail!("Unexpected response type: {other:?}"),
}
}
pub fn daemon_agent_hits_binary(
port: u16,
request: protocol::AgentRequest,
) -> Result<protocol::AgentHitsResponse> {
let bin_req = protocol::BinaryRequest::AgentHits(request);
match send_binary_request(port, &bin_req, 60)? {
protocol::BinaryResponse::AgentHits(ar) => Ok(ar),
protocol::BinaryResponse::Error(e) => anyhow::bail!("Daemon error: {}", e.message),
other => anyhow::bail!("Unexpected response type: {other:?}"),
}
}
pub fn graph_walk_direct(
symbol: &str,
index_dir: &std::path::Path,
config: &SemantexConfig,
) -> Result<protocol::GraphWalkResponse> {
let searcher = HybridSearcher::open_sparse_only(index_dir, config)
.context("Failed to open search index for graph walk")?;
searcher.with_store(|store| handler::graph_walk_from_store(store, symbol))
}
pub fn daemon_healthy_binary(port: u16) -> bool {
use std::io::Read;
let bin_req = protocol::BinaryRequest::Health;
let frame = protocol::encode_binary_request(&bin_req);
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_secs(2)) else {
return false;
};
if stream
.set_read_timeout(Some(Duration::from_secs(2)))
.is_err()
{
return false;
}
if stream.write_all(&frame).is_err() || stream.flush().is_err() {
return false;
}
let mut magic = [0u8; 1];
if stream.read_exact(&mut magic).is_err() || magic[0] != protocol::BINARY_MAGIC {
return false;
}
let mut len_buf = [0u8; 4];
if stream.read_exact(&mut len_buf).is_err() {
return false;
}
let len = u32::from_le_bytes(len_buf) as usize;
let mut body = vec![0u8; len];
if stream.read_exact(&mut body).is_err() {
return false;
}
matches!(
protocol::decode_binary_response(&body),
Ok(protocol::BinaryResponse::Health(
protocol::HealthResponse { .. }
))
)
}
pub fn daemon_starting(project_path: &Path) -> bool {
let index_dir = SemantexConfig::project_index_dir(project_path);
let port_path = index_dir.join("semantex.port");
let pid_path = index_dir.join("semantex.pid");
if port_path.exists() {
return false;
}
let Ok(pid_str) = std::fs::read_to_string(&pid_path) else {
return false;
};
let Ok(pid) = pid_str.trim().parse::<u32>() else {
let _ = std::fs::remove_file(&pid_path);
return false;
};
if is_process_alive(pid) {
true
} else {
let _ = std::fs::remove_file(&pid_path);
false
}
}
#[cfg(unix)]
fn is_process_alive(pid: u32) -> bool {
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
#[cfg(not(unix))]
fn is_process_alive(_pid: u32) -> bool {
true
}
#[cfg(feature = "llm")]
fn log_llm_discovery_hint() {
if which::which("claude").is_ok() {
tracing::warn!(
"LLM features disabled. Detected Claude Code (claude on PATH). \
To enable: set SEMANTEX_LLM_BACKEND=cli:claude"
);
} else if which::which("codex").is_ok() {
tracing::warn!(
"LLM features disabled. Detected OpenAI Codex (codex on PATH). \
To enable: set SEMANTEX_LLM_BACKEND=cli:codex"
);
} else if which::which("antigravity").is_ok() {
tracing::warn!(
"LLM features disabled. Detected Google Antigravity (antigravity on PATH), \
but its headless surface isn't supported yet (Spec L §5 Item 2.4). \
Install claude or codex to enable LLM features."
);
}
}
pub fn stop_daemon(project_path: &Path) -> Result<bool> {
let index_dir = SemantexConfig::project_index_dir(project_path);
let port_file = index_dir.join("semantex.port");
let pid_path = index_dir.join("semantex.pid");
let Ok(port) = read_daemon_port(project_path) else {
return Ok(false);
};
if send_request_to_port(port, r#"{"type":"shutdown"}"#).is_ok() {
std::thread::sleep(Duration::from_millis(200));
}
let _ = std::fs::remove_file(&port_file);
let _ = std::fs::remove_file(&pid_path);
Ok(true)
}