1use std::path::{Path, PathBuf};
2use std::sync::{Arc, Mutex, RwLock};
3
4use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
5use tokio::net::UnixListener;
6use tokio::signal::unix::{SignalKind, signal};
7
8use engram_embeddings::Embedder;
9use engram_llm_client::{EmbeddingProvider, TextGenerator};
10use engram_router::Router;
11use engram_storage::Database;
12
13use crate::config::{self, Config};
14use crate::dispatch;
15use crate::error::CoreError;
16use crate::indexes::IndexSet;
17use crate::protocol::{JsonRequest, JsonResponse};
18
19const PROJECT_DB_RELATIVE: &str = ".engram/engram.db";
20const PROJECT_SOCKET_RELATIVE: &str = ".engram/engram.sock";
21
22const ROUTER_DEFAULT_ALPHA: f32 = 0.1;
23const ROUTER_DEFAULT_EPSILON: f32 = 0.15;
24
25pub struct ServerState {
26 pub database: Mutex<Database>,
27 pub indexes: RwLock<IndexSet>,
28 pub embedder: Embedder,
29 pub router: Mutex<Router>,
30 pub config: Config,
31 pub database_path: String,
32 pub embedding_provider: Arc<dyn EmbeddingProvider + Send + Sync>,
33 pub text_generator: Option<Arc<dyn TextGenerator + Send + Sync>>,
34}
35
36pub async fn run(config: Config) -> Result<(), CoreError> {
37 let cwd = std::env::current_dir()
38 .map_err(|error| CoreError::SocketError(format!("cwd unavailable: {error}")))?;
39 let home = home_dir_or_error()?;
40 let project_dir = config::resolve_project_dir(&cwd, None)?;
41 let state = {
46 let config = config.clone();
47 let project_dir = project_dir.clone();
48 let home = home.clone();
49 tokio::task::spawn_blocking(move || initialize_state(&config, &project_dir, &home))
50 .await
51 .map_err(|error| CoreError::SocketError(error.to_string()))??
52 };
53 {
54 let database = state.database.lock().unwrap();
55 let configured_model = config
56 .embedding
57 .model
58 .as_deref()
59 .unwrap_or(config::DEFAULT_EMBEDDING_MODEL);
60 crate::migrations::embedding_model_v1::check(&database, configured_model)?;
61 }
62 warn_missing_api_keys(&config);
63 let shared_state = Arc::new(state);
64 let socket_path = resolve_socket_path(&project_dir, &config)?;
65 cleanup_stale_socket(&socket_path);
66 let listener = bind_listener(&socket_path)?;
67 accept_loop(listener, shared_state).await
68}
69
70pub fn check_legacy_database(project_dir: &Path, home_dir: &Path) -> Result<(), CoreError> {
71 let project_db = project_dir.join(PROJECT_DB_RELATIVE);
72 let home_db = home_dir.join(PROJECT_DB_RELATIVE);
73 if !project_db.exists() && home_db.exists() {
74 return Err(CoreError::LegacyDatabaseDetected {
75 legacy_path: home_db.to_string_lossy().to_string(),
76 project_path: project_dir.to_string_lossy().to_string(),
77 });
78 }
79 Ok(())
80}
81
82pub(crate) fn initialize_state(
83 config: &Config,
84 project_dir: &Path,
85 home_dir: &Path,
86) -> Result<ServerState, CoreError> {
87 check_legacy_database(project_dir, home_dir)?;
88 let database_path = resolve_database_path(project_dir);
89 let database = Database::open(&database_path)?;
90 crate::migrations::run_pending(&database)?;
91 let embedding_provider: Arc<dyn EmbeddingProvider + Send + Sync> =
92 Arc::from(config.build_embedding_provider()?);
93 let text_generator: Option<Arc<dyn TextGenerator + Send + Sync>> =
94 match config.build_text_generator() {
95 Ok(generator) => Some(Arc::from(generator)),
96 Err(error) => {
97 eprintln!("warning: text generator unavailable, running in degraded mode: {error}");
98 None
99 }
100 };
101 let hnsw_config = config.clone();
102 let indexes = crate::persistence::load_or_rebuild(
103 &resolve_index_directory(&database_path),
104 &database,
105 || hnsw_config.build_hnsw_params(),
106 )?;
107 let embedder = Embedder::new(config.embedding.hyde_threshold);
108 let router = Router::new(ROUTER_DEFAULT_ALPHA, ROUTER_DEFAULT_EPSILON);
109 Ok(ServerState {
110 database: Mutex::new(database),
111 indexes: RwLock::new(indexes),
112 embedder,
113 router: Mutex::new(router),
114 config: config.clone(),
115 database_path,
116 embedding_provider,
117 text_generator,
118 })
119}
120
121fn resolve_database_path(project_dir: &Path) -> String {
122 if let Ok(value) = std::env::var("ENGRAM_DB_PATH") {
123 return value;
124 }
125 project_dir
126 .join(PROJECT_DB_RELATIVE)
127 .to_string_lossy()
128 .to_string()
129}
130
131fn resolve_socket_path(project_dir: &Path, config: &Config) -> Result<String, CoreError> {
132 if let Ok(value) = std::env::var("ENGRAM_SOCKET_PATH") {
133 return Ok(value);
134 }
135 if project_dir.join(".engram").is_dir() {
137 return Ok(project_dir
138 .join(PROJECT_SOCKET_RELATIVE)
139 .to_string_lossy()
140 .to_string());
141 }
142 match config.server.socket_path.as_deref() {
143 Some(fallback) => Ok(expand_tilde(fallback)),
144 None => Err(CoreError::SocketError(
145 "no socket path available: no project .engram/ marker found, \
146 ENGRAM_SOCKET_PATH not set, and server.socket_path not configured"
147 .into(),
148 )),
149 }
150}
151
152fn home_dir_or_error() -> Result<PathBuf, CoreError> {
153 match std::env::var("HOME") {
154 Ok(value) if !value.is_empty() => Ok(PathBuf::from(value)),
155 _ => Err(CoreError::InitFailed(
156 "HOME environment variable not set".into(),
157 )),
158 }
159}
160
161pub(crate) fn resolve_index_directory(database_path: &str) -> String {
162 std::path::Path::new(database_path)
163 .parent()
164 .map(|parent| parent.to_string_lossy().to_string())
165 .unwrap_or_else(|| ".".to_string())
166}
167
168fn cleanup_stale_socket(socket_path: &str) {
169 let _ = std::fs::remove_file(socket_path);
170}
171
172fn bind_listener(socket_path: &str) -> Result<UnixListener, CoreError> {
173 if let Some(parent) = std::path::Path::new(socket_path).parent() {
174 std::fs::create_dir_all(parent)
175 .map_err(|error| CoreError::SocketError(error.to_string()))?;
176 }
177 UnixListener::bind(socket_path).map_err(|error| CoreError::SocketError(error.to_string()))
178}
179
180async fn accept_loop(listener: UnixListener, state: Arc<ServerState>) -> Result<(), CoreError> {
181 let mut sigterm =
182 signal(SignalKind::terminate()).map_err(|e| CoreError::SocketError(e.to_string()))?;
183 spawn_background_reindex(Arc::clone(&state));
184 loop {
185 tokio::select! {
186 accept_result = listener.accept() => {
187 let (stream, _address) = accept_result
188 .map_err(|error| CoreError::SocketError(error.to_string()))?;
189 let client_state = Arc::clone(&state);
190 tokio::spawn(async move {
191 if let Err(error) = handle_client(stream, client_state).await {
192 eprintln!("client error: {error}");
193 }
194 });
195 }
196 _ = sigterm.recv() => {
197 return save_indexes_on_shutdown(&state);
198 }
199 }
200 }
201}
202
203fn spawn_background_reindex(state: Arc<ServerState>) {
204 let interval_secs = state.config.server.reindex_interval_secs;
205 if interval_secs == 0 {
206 return;
207 }
208 tokio::spawn(async move {
209 let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
210 interval.tick().await; loop {
212 interval.tick().await;
213 let reindex_state = Arc::clone(&state);
214 let _ = tokio::task::spawn_blocking(move || {
215 reindex_unindexed_memories(&reindex_state);
216 })
217 .await;
218 }
219 });
220}
221
222#[doc(hidden)]
228pub fn reindex_unindexed_memories(state: &ServerState) {
229 let database = state.database.lock().unwrap();
230 let unindexed = match query_unindexed_ids(&database) {
231 Ok(ids) => ids,
232 Err(_) => return,
233 };
234 if unindexed.is_empty() {
235 return;
236 }
237 let mut indexes = state.indexes.write().unwrap();
238 for memory_id in &unindexed {
239 let _ = reindex_single_memory(&database, &mut indexes, memory_id);
240 }
241}
242
243fn query_unindexed_ids(database: &Database) -> Result<Vec<String>, CoreError> {
244 let mut statement = database
245 .connection()
246 .prepare(
247 "SELECT id FROM memories WHERE indexed = 0 \
248 AND embedding_context IS NOT NULL LIMIT 100",
249 )
250 .map_err(|e| CoreError::Storage(engram_storage::StorageError::from(e)))?;
251 let rows = statement
252 .query_map([], |row| row.get::<_, String>(0))
253 .map_err(|e| CoreError::Storage(engram_storage::StorageError::from(e)))?;
254 let ids: Vec<String> = rows.flatten().collect();
255 Ok(ids)
256}
257
258fn reindex_single_memory(
259 database: &Database,
260 indexes: &mut IndexSet,
261 memory_id: &str,
262) -> Result<(), CoreError> {
263 let memory = database.get_memory(memory_id)?;
264 let embedding = crate::persistence::extract_embeddings_from_memory(&memory)?;
265 let id_hash = crate::persistence::hash_string_to_u64(memory_id);
266 let rng_value = crate::persistence::deterministic_rng(id_hash);
267 if !indexes.contains(id_hash) {
268 indexes.insert(id_hash, memory_id, &embedding, rng_value)?;
269 }
270 database.set_memory_indexed(memory_id, true)?;
271 Ok(())
272}
273
274async fn handle_client(
275 stream: tokio::net::UnixStream,
276 state: Arc<ServerState>,
277) -> Result<(), CoreError> {
278 let (reader, mut writer) = stream.into_split();
279 let mut lines = BufReader::new(reader).lines();
280 while let Some(line) = lines
281 .next_line()
282 .await
283 .map_err(|error| CoreError::SocketError(error.to_string()))?
284 {
285 let response = process_request_line(&line, &state).await;
286 let serialized = serde_json::to_string(&response)
287 .map_err(|error| CoreError::SocketError(error.to_string()))?;
288 writer
289 .write_all(serialized.as_bytes())
290 .await
291 .map_err(|error| CoreError::SocketError(error.to_string()))?;
292 writer
293 .write_all(b"\n")
294 .await
295 .map_err(|error| CoreError::SocketError(error.to_string()))?;
296 }
297 Ok(())
298}
299
300async fn process_request_line(line: &str, state: &Arc<ServerState>) -> JsonResponse {
301 let request: JsonRequest = match serde_json::from_str(line) {
302 Ok(parsed) => parsed,
303 Err(error) => {
304 return JsonResponse::error(String::new(), 4000, format!("invalid json: {error}"));
305 }
306 };
307 let request_id = request.id.clone();
308 match dispatch::route(&request.method, state, request.params).await {
309 Ok(data) => JsonResponse::success(request_id, data),
310 Err(error) => JsonResponse::error(request_id, error_code(&error), error.to_string()),
311 }
312}
313
314fn error_code(error: &CoreError) -> u32 {
315 match error {
316 CoreError::ConfigNotFound => 6001,
317 CoreError::ConfigParseError(_) => 6002,
318 CoreError::InvalidProvider(_) => 6003,
319 CoreError::IndexCorrupted(_) => 6004,
320 CoreError::RebuildFailed(_) => 6005,
321 CoreError::SocketError(_) => 6006,
322 CoreError::DispatchError(_) => 6007,
323 CoreError::ConfigReadOnly => 6008,
324 CoreError::ExportFailed(_) => 6009,
325 CoreError::ImportVersionMismatch(_) => 6010,
326 CoreError::ImportFailed(_) => 6011,
327 CoreError::InitFailed(_) => 6012,
328 CoreError::TrainerFailed(_) => 6013,
329 CoreError::TrainerTimeout => 6014,
330 CoreError::TrainerMalformedOutput(_) => 6015,
331 CoreError::ProjectDirNotFound => 6016,
332 CoreError::LegacyDatabaseDetected { .. } => 6017,
333 CoreError::MigrationSourceNotFound => 6018,
334 CoreError::MigrationFailed(_) => 6019,
335 CoreError::EmbeddingModelMismatch { .. } => 6020,
336 CoreError::Storage(_) => 1000,
337 CoreError::Hnsw(_) => 3000,
338 CoreError::Api(_) => 2000,
339 CoreError::Consolidation(_) => 5000,
340 }
341}
342
343fn save_indexes_on_shutdown(state: &Arc<ServerState>) -> Result<(), CoreError> {
344 let index_directory = resolve_index_directory(&state.database_path);
345 let indexes = state.indexes.read().unwrap();
346 crate::persistence::save_to_disk(&index_directory, &indexes)
347}
348
349fn warn_missing_api_keys(config: &Config) {
350 let has_embedding_key = config
351 .embedding
352 .api_key
353 .as_deref()
354 .is_some_and(|k| !k.is_empty());
355 let has_llm_key = config.llm.api_key.as_deref().is_some_and(|k| !k.is_empty());
356
357 if !has_embedding_key && config.embedding.provider != "deterministic" {
358 eprintln!(
359 "warning: {} embedding provider configured without api key (set ENGRAM_VOYAGE_API_KEY)",
360 config.embedding.provider
361 );
362 }
363 if !has_llm_key && config.llm.provider != "local" {
364 eprintln!(
365 "warning: {} llm provider configured without api key (set ENGRAM_OPENAI_API_KEY)",
366 config.llm.provider
367 );
368 }
369}
370
371fn expand_tilde(path: &str) -> String {
372 if let Some(rest) = path.strip_prefix("~/")
373 && let Ok(home) = std::env::var("HOME")
374 {
375 return format!("{home}/{rest}");
376 }
377 path.to_string()
378}