use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixListener;
use tokio::signal::unix::{SignalKind, signal};
use engram_embeddings::Embedder;
use engram_llm_client::{EmbeddingProvider, TextGenerator};
use engram_router::Router;
use engram_storage::Database;
use crate::config::{self, Config};
use crate::dispatch;
use crate::error::CoreError;
use crate::indexes::IndexSet;
use crate::lock_helpers;
use crate::protocol::{JsonRequest, JsonResponse};
const PROJECT_DB_RELATIVE: &str = ".engram/engram.db";
const PROJECT_SOCKET_RELATIVE: &str = ".engram/engram.sock";
const ROUTER_DEFAULT_ALPHA: f32 = 0.1;
const ROUTER_DEFAULT_EPSILON: f32 = 0.15;
pub struct ServerState {
pub database: Mutex<Database>,
pub indexes: RwLock<IndexSet>,
pub embedder: Embedder,
pub router: Mutex<Router>,
pub config: Config,
pub database_path: String,
pub embedding_provider: Arc<dyn EmbeddingProvider + Send + Sync>,
pub text_generator: Option<Arc<dyn TextGenerator + Send + Sync>>,
}
pub async fn run(config: Config) -> Result<(), CoreError> {
let cwd = std::env::current_dir()
.map_err(|error| CoreError::SocketError(format!("cwd unavailable: {error}")))?;
let home = home_dir_or_error()?;
let project_dir = config::resolve_project_dir(&cwd, None)?;
let state = {
let config = config.clone();
let project_dir = project_dir.clone();
let home = home.clone();
tokio::task::spawn_blocking(move || initialize_state(&config, &project_dir, &home))
.await
.map_err(|error| CoreError::SocketError(error.to_string()))??
};
{
let database = lock_helpers::lock_db(&state);
crate::migrations::embedding_model_v1::check(
&database,
state.embedding_provider.model_name(),
)?;
}
warn_missing_api_keys(&config);
let shared_state = Arc::new(state);
let socket_path = resolve_socket_path(&project_dir, &config)?;
cleanup_stale_socket(&socket_path);
let listener = bind_listener(&socket_path)?;
accept_loop(listener, shared_state).await
}
pub fn check_legacy_database(project_dir: &Path, home_dir: &Path) -> Result<(), CoreError> {
let project_db = project_dir.join(PROJECT_DB_RELATIVE);
let home_db = home_dir.join(PROJECT_DB_RELATIVE);
if !project_db.exists() && home_db.exists() {
return Err(CoreError::LegacyDatabaseDetected {
legacy_path: home_db.to_string_lossy().to_string(),
project_path: project_dir.to_string_lossy().to_string(),
});
}
Ok(())
}
pub(crate) fn initialize_state(
config: &Config,
project_dir: &Path,
home_dir: &Path,
) -> Result<ServerState, CoreError> {
crate::config::validate_dedup_threshold(config.deduplication.threshold)?;
crate::config::validate_search_config(&config.search)?;
crate::config::validate_fts_similarity_floor(config.consolidation.fts_similarity_floor)?;
check_legacy_database(project_dir, home_dir)?;
let database_path = resolve_database_path(project_dir);
let database = Database::open(&database_path)?;
crate::migrations::run_pending(&database)?;
let embedding_provider: Arc<dyn EmbeddingProvider + Send + Sync> =
Arc::from(config.build_embedding_provider()?);
let text_generator: Option<Arc<dyn TextGenerator + Send + Sync>> =
match config.build_text_generator() {
Ok(generator) => Some(Arc::from(generator)),
Err(error) => {
eprintln!("warning: text generator unavailable, running in degraded mode: {error}");
None
}
};
let hnsw_config = config.clone();
let indexes = crate::persistence::load_or_rebuild(
&resolve_index_directory(&database_path),
&database,
|| hnsw_config.build_hnsw_params(),
)?;
let embedder = Embedder::new(config.embedding.hyde_threshold);
let router = Router::new(ROUTER_DEFAULT_ALPHA, ROUTER_DEFAULT_EPSILON);
Ok(ServerState {
database: Mutex::new(database),
indexes: RwLock::new(indexes),
embedder,
router: Mutex::new(router),
config: config.clone(),
database_path,
embedding_provider,
text_generator,
})
}
fn resolve_database_path(project_dir: &Path) -> String {
if let Ok(value) = std::env::var("ENGRAM_DB_PATH") {
return value;
}
project_dir
.join(PROJECT_DB_RELATIVE)
.to_string_lossy()
.to_string()
}
fn resolve_socket_path(project_dir: &Path, config: &Config) -> Result<String, CoreError> {
if let Ok(value) = std::env::var("ENGRAM_SOCKET_PATH") {
return Ok(value);
}
if project_dir.join(".engram").is_dir() {
return Ok(project_dir
.join(PROJECT_SOCKET_RELATIVE)
.to_string_lossy()
.to_string());
}
match config.server.socket_path.as_deref() {
Some(fallback) => Ok(expand_tilde(fallback)),
None => Err(CoreError::SocketError(
"no socket path available: no project .engram/ marker found, \
ENGRAM_SOCKET_PATH not set, and server.socket_path not configured"
.into(),
)),
}
}
fn home_dir_or_error() -> Result<PathBuf, CoreError> {
match std::env::var("HOME") {
Ok(value) if !value.is_empty() => Ok(PathBuf::from(value)),
_ => Err(CoreError::InitFailed(
"HOME environment variable not set".into(),
)),
}
}
pub(crate) fn resolve_index_directory(database_path: &str) -> String {
std::path::Path::new(database_path)
.parent()
.map(|parent| parent.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string())
}
fn cleanup_stale_socket(socket_path: &str) {
let _ = std::fs::remove_file(socket_path);
}
fn bind_listener(socket_path: &str) -> Result<UnixListener, CoreError> {
if let Some(parent) = std::path::Path::new(socket_path).parent() {
std::fs::create_dir_all(parent)
.map_err(|error| CoreError::SocketError(error.to_string()))?;
}
UnixListener::bind(socket_path).map_err(|error| CoreError::SocketError(error.to_string()))
}
async fn accept_loop(listener: UnixListener, state: Arc<ServerState>) -> Result<(), CoreError> {
let mut sigterm =
signal(SignalKind::terminate()).map_err(|e| CoreError::SocketError(e.to_string()))?;
spawn_background_reindex(Arc::clone(&state));
loop {
tokio::select! {
accept_result = listener.accept() => {
let (stream, _address) = accept_result
.map_err(|error| CoreError::SocketError(error.to_string()))?;
let client_state = Arc::clone(&state);
tokio::spawn(async move {
if let Err(error) = handle_client(stream, client_state).await {
eprintln!("client error: {error}");
}
});
}
_ = sigterm.recv() => {
return save_indexes_on_shutdown(&state);
}
}
}
}
fn spawn_background_reindex(state: Arc<ServerState>) {
let interval_secs = state.config.server.reindex_interval_secs;
if interval_secs == 0 {
return;
}
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
interval.tick().await; loop {
interval.tick().await;
let reindex_state = Arc::clone(&state);
let _ = tokio::task::spawn_blocking(move || {
reindex_unindexed_memories(&reindex_state);
})
.await;
}
});
}
#[doc(hidden)]
pub fn reindex_unindexed_memories(state: &ServerState) {
let database = lock_helpers::lock_db(state);
let unindexed = match query_unindexed_ids(&database) {
Ok(ids) => ids,
Err(_) => return,
};
if unindexed.is_empty() {
return;
}
let mut indexes = lock_helpers::write_indexes(state);
for memory_id in &unindexed {
let _ = reindex_single_memory(&database, &mut indexes, memory_id);
}
}
fn query_unindexed_ids(database: &Database) -> Result<Vec<String>, CoreError> {
let mut statement = database
.connection()
.prepare(
"SELECT id FROM memories WHERE indexed = 0 \
AND embedding_context IS NOT NULL LIMIT 100",
)
.map_err(|e| CoreError::Storage(engram_storage::StorageError::from(e)))?;
let rows = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| CoreError::Storage(engram_storage::StorageError::from(e)))?;
let ids: Vec<String> = rows.flatten().collect();
Ok(ids)
}
fn reindex_single_memory(
database: &Database,
indexes: &mut IndexSet,
memory_id: &str,
) -> Result<(), CoreError> {
let memory = database.get_memory(memory_id)?;
let embedding = crate::persistence::extract_embeddings_from_memory(&memory)?;
let id_hash = crate::persistence::hash_string_to_u64(memory_id);
let rng_value = crate::persistence::deterministic_rng(id_hash);
if !indexes.contains(id_hash) {
indexes.insert_atomic(id_hash, memory_id, &embedding, rng_value)?;
}
database.set_memory_indexed(memory_id, true)?;
Ok(())
}
async fn handle_client(
stream: tokio::net::UnixStream,
state: Arc<ServerState>,
) -> Result<(), CoreError> {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
while let Some(line) = lines
.next_line()
.await
.map_err(|error| CoreError::SocketError(error.to_string()))?
{
let response = process_request_line(&line, &state).await;
let serialized = serde_json::to_string(&response)
.map_err(|error| CoreError::SocketError(error.to_string()))?;
writer
.write_all(serialized.as_bytes())
.await
.map_err(|error| CoreError::SocketError(error.to_string()))?;
writer
.write_all(b"\n")
.await
.map_err(|error| CoreError::SocketError(error.to_string()))?;
}
Ok(())
}
async fn process_request_line(line: &str, state: &Arc<ServerState>) -> JsonResponse {
let request: JsonRequest = match serde_json::from_str(line) {
Ok(parsed) => parsed,
Err(error) => {
return JsonResponse::error(String::new(), 4000, format!("invalid json: {error}"));
}
};
let request_id = request.id.clone();
match dispatch::route(&request.method, state, request.params).await {
Ok(data) => JsonResponse::success(request_id, data),
Err(error) => JsonResponse::error(request_id, error_code(&error), error.to_string()),
}
}
fn error_code(error: &CoreError) -> u32 {
match error {
CoreError::ConfigNotFound => 6001,
CoreError::ConfigParseError(_) => 6002,
CoreError::InvalidProvider(_) => 6003,
CoreError::IndexCorrupted(_) => 6004,
CoreError::RebuildFailed(_) => 6005,
CoreError::SocketError(_) => 6006,
CoreError::DispatchError(_) => 6007,
CoreError::ConfigReadOnly => 6008,
CoreError::ExportFailed(_) => 6009,
CoreError::ImportVersionMismatch(_) => 6010,
CoreError::ImportFailed(_) => 6011,
CoreError::InitFailed(_) => 6012,
CoreError::TrainerFailed(_) => 6013,
CoreError::TrainerTimeout => 6014,
CoreError::TrainerMalformedOutput(_) => 6015,
CoreError::ProjectDirNotFound => 6016,
CoreError::LegacyDatabaseDetected { .. } => 6017,
CoreError::MigrationSourceNotFound => 6018,
CoreError::MigrationFailed(_) => 6019,
CoreError::EmbeddingModelMismatch { .. } => 6020,
CoreError::IndexHashCollision { .. } => 6021,
CoreError::ConfigValidation(_) => 6022,
CoreError::Storage(_) => 1000,
CoreError::Hnsw(_) => 3000,
CoreError::Api(_) => 2000,
CoreError::Consolidation(_) => 5000,
}
}
fn save_indexes_on_shutdown(state: &Arc<ServerState>) -> Result<(), CoreError> {
let index_directory = resolve_index_directory(&state.database_path);
let indexes = lock_helpers::read_indexes(state);
crate::persistence::save_to_disk(&index_directory, &indexes)
}
fn warn_missing_api_keys(config: &Config) {
let has_embedding_key = config
.embedding
.api_key
.as_deref()
.is_some_and(|k| !k.is_empty());
let has_llm_key = config.llm.api_key.as_deref().is_some_and(|k| !k.is_empty());
if !has_embedding_key
&& config.embedding.provider != "deterministic"
&& config.embedding.provider != "ollama"
{
eprintln!(
"warning: {} embedding provider configured without api key (set ENGRAM_VOYAGE_API_KEY)",
config.embedding.provider
);
}
if !has_llm_key && config.llm.provider != "local" && config.llm.provider != "ollama" {
eprintln!(
"warning: {} llm provider configured without api key (set ENGRAM_OPENAI_API_KEY)",
config.llm.provider
);
}
}
fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return format!("{home}/{rest}");
}
path.to_string()
}