1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4
5use serde::Deserialize;
6
7use engram_hnsw::HnswParams;
8use engram_llm_client::{
9 EmbeddingProvider, LocalTextGenerator, OpenAITextGenerator, TextGenerator,
10 VoyageEmbeddingProvider,
11};
12
13use crate::error::CoreError;
14
15const CONFIG_HOME_SUBDIR: &str = ".engram/engram.toml";
16const PROJECT_DIR_MARKER: &str = ".engram";
17
18const DEFAULT_EMBEDDING_PROVIDER: &str = "voyage";
19pub const DEFAULT_EMBEDDING_MODEL: &str = "voyage-4";
20const DEFAULT_EMBEDDING_DIMENSION: usize = 1024;
21const DEFAULT_HYDE_THRESHOLD: usize = 0;
22const DEFAULT_LLM_PROVIDER: &str = "openai";
23const DEFAULT_LLM_MODEL: &str = "gpt-4o-mini";
24const DEFAULT_REINDEX_INTERVAL_SECS: u64 = 3600;
25const DEFAULT_HNSW_MAX_CONNECTIONS: usize = 16;
26const DEFAULT_HNSW_EF_CONSTRUCTION: usize = 200;
27const DEFAULT_HNSW_EF_SEARCH: usize = 40;
28const DEFAULT_CONSOLIDATION_STALE_DAYS: u32 = 90;
29const DEFAULT_CONSOLIDATION_MIN_SCORE: f64 = 0.3;
30const DEFAULT_TRAINER_BINARY: &str = "engram-trainer";
31const DEFAULT_TRAINER_TIMEOUT_SECS: u64 = 300;
32const DEFAULT_MODELS_PATH: &str = "~/.engram/models";
33
34#[derive(Deserialize, Clone)]
35pub struct Config {
36 pub database: DatabaseConfig,
37 pub embedding: EmbeddingConfig,
38 pub llm: LlmConfig,
39 pub server: ServerConfig,
40 pub hnsw: HnswConfig,
41 #[serde(default)]
42 pub consolidation: ConsolidationConfig,
43 #[serde(default)]
44 pub trainer: TrainerConfig,
45}
46
47#[derive(Deserialize, Clone)]
48pub struct DatabaseConfig {
49 #[serde(default)]
54 pub path: Option<String>,
55}
56
57#[derive(Deserialize, Clone)]
58pub struct EmbeddingConfig {
59 pub provider: String,
60 pub api_key: Option<String>,
61 pub model: Option<String>,
62 pub dimension: Option<usize>,
63 #[serde(default)]
68 pub output_dimension: Option<usize>,
69 #[serde(default)]
70 pub hyde_threshold: usize,
71}
72
73#[derive(Deserialize, Clone)]
74pub struct LlmConfig {
75 pub provider: String,
76 pub api_key: Option<String>,
77 pub model: Option<String>,
78}
79
80#[derive(Deserialize, Clone)]
81pub struct ServerConfig {
82 #[serde(default)]
86 pub socket_path: Option<String>,
87 pub reindex_interval_secs: u64,
88}
89
90#[derive(Deserialize, Clone)]
91pub struct HnswConfig {
92 pub max_connections: usize,
93 pub ef_construction: usize,
94 pub ef_search: usize,
95 pub dimension: usize,
96}
97
98#[derive(Deserialize, Clone)]
99pub struct ConsolidationConfig {
100 pub stale_days: u32,
101 pub min_score: f64,
102}
103
104#[derive(Deserialize, Clone)]
105pub struct TrainerConfig {
106 pub trainer_binary: String,
107 pub trainer_timeout_secs: u64,
108 pub models_path: String,
109}
110
111impl Default for TrainerConfig {
112 fn default() -> Self {
113 Self {
114 trainer_binary: DEFAULT_TRAINER_BINARY.into(),
115 trainer_timeout_secs: DEFAULT_TRAINER_TIMEOUT_SECS,
116 models_path: DEFAULT_MODELS_PATH.into(),
117 }
118 }
119}
120
121impl Default for ConsolidationConfig {
122 fn default() -> Self {
123 Self {
124 stale_days: DEFAULT_CONSOLIDATION_STALE_DAYS,
125 min_score: DEFAULT_CONSOLIDATION_MIN_SCORE,
126 }
127 }
128}
129
130impl Config {
131 pub fn load() -> Result<Self, CoreError> {
132 if let Some(home) = home_directory() {
133 let home_config = Path::new(&home).join(CONFIG_HOME_SUBDIR);
134 if home_config.exists() {
135 return Self::load_from_path(home_config.to_str().unwrap_or_default());
136 }
137 }
138
139 let mut config = Self::default();
140 config.apply_env_overrides();
141 Ok(config)
142 }
143
144 pub fn load_from_path(path: &str) -> Result<Self, CoreError> {
145 let content = fs::read_to_string(path).map_err(|_| CoreError::ConfigNotFound)?;
146 let mut config: Config = toml::from_str(&content)
147 .map_err(|error| CoreError::ConfigParseError(error.to_string()))?;
148 config.apply_env_overrides();
149 Ok(config)
150 }
151
152 pub fn build_embedding_provider(
153 &self,
154 ) -> Result<Box<dyn EmbeddingProvider + Send + Sync>, CoreError> {
155 let dimension = self
156 .embedding
157 .dimension
158 .unwrap_or(DEFAULT_EMBEDDING_DIMENSION);
159 match self.embedding.provider.as_str() {
160 "voyage" => {
161 let api_key = self
162 .embedding
163 .api_key
164 .as_deref()
165 .filter(|key| !key.is_empty())
166 .ok_or_else(|| CoreError::InvalidProvider("voyage requires api_key".into()))?;
167 let model = self
168 .embedding
169 .model
170 .clone()
171 .unwrap_or_else(|| DEFAULT_EMBEDDING_MODEL.into());
172 let provider = VoyageEmbeddingProvider::with_config(
173 api_key.to_owned(),
174 model,
175 dimension,
176 self.embedding.output_dimension,
177 engram_llm_client::RetryConfig::default(),
178 "https://api.voyageai.com".into(),
179 )?;
180 Ok(Box::new(provider))
181 }
182 "deterministic" => Ok(Box::new(DeterministicEmbeddingProvider { dimension })),
183 other => Err(CoreError::InvalidProvider(format!(
184 "{other} embedding not supported"
185 ))),
186 }
187 }
188
189 pub fn build_text_generator(&self) -> Result<Box<dyn TextGenerator + Send + Sync>, CoreError> {
190 match self.llm.provider.as_str() {
191 "openai" => {
192 let api_key = self
193 .llm
194 .api_key
195 .as_deref()
196 .filter(|key| !key.is_empty())
197 .ok_or_else(|| CoreError::InvalidProvider("openai requires api_key".into()))?;
198 let generator = OpenAITextGenerator::new(api_key.to_owned())?;
199 Ok(Box::new(generator))
200 }
201 "local" => {
202 let models_path = expand_tilde(&self.trainer.models_path);
203 let model_path = format!("{models_path}/text_generator.onnx");
204 let tokenizer_path = format!("{models_path}/tokenizer.json");
205 let generator = LocalTextGenerator::new(&model_path, &tokenizer_path)
206 .map_err(|error| CoreError::InvalidProvider(error.to_string()))?;
207 Ok(Box::new(generator))
208 }
209 other => Err(CoreError::InvalidProvider(format!(
210 "{other} llm not supported"
211 ))),
212 }
213 }
214
215 pub fn build_hnsw_params(&self) -> Result<HnswParams, CoreError> {
216 HnswParams::new(self.hnsw.dimension)?
217 .with_max_connections(self.hnsw.max_connections)?
218 .with_ef_construction(self.hnsw.ef_construction)?
219 .with_ef_search(self.hnsw.ef_search)
220 .map_err(CoreError::Hnsw)
221 }
222
223 fn apply_env_overrides(&mut self) {
224 if let Ok(value) = std::env::var("ENGRAM_EMBEDDING_MODEL") {
229 self.embedding.model = Some(value);
230 }
231 if let Ok(value) = std::env::var("ENGRAM_LLM_MODEL") {
232 self.llm.model = Some(value);
233 }
234 if let Ok(value) = std::env::var("ENGRAM_TRAINER_BINARY") {
235 self.trainer.trainer_binary = value;
236 }
237 if let Ok(value) = std::env::var("ENGRAM_TRAINER_TIMEOUT")
238 && let Ok(secs) = value.parse::<u64>()
239 {
240 self.trainer.trainer_timeout_secs = secs;
241 }
242 if let Ok(value) = std::env::var("ENGRAM_MODELS_PATH") {
243 self.trainer.models_path = value;
244 }
245 self.apply_provider_api_key_overrides();
246 }
247
248 fn apply_provider_api_key_overrides(&mut self) {
249 if self.embedding.provider == "voyage"
250 && let Ok(value) = std::env::var("ENGRAM_VOYAGE_API_KEY")
251 {
252 self.embedding.api_key = Some(value);
253 }
254 if self.llm.provider == "openai"
255 && let Ok(value) = std::env::var("ENGRAM_OPENAI_API_KEY")
256 {
257 self.llm.api_key = Some(value);
258 }
259 }
260}
261
262impl Default for Config {
263 fn default() -> Self {
264 Self {
265 database: DatabaseConfig { path: None },
266 embedding: EmbeddingConfig {
267 provider: DEFAULT_EMBEDDING_PROVIDER.into(),
268 api_key: None,
269 model: Some(DEFAULT_EMBEDDING_MODEL.into()),
270 dimension: Some(DEFAULT_EMBEDDING_DIMENSION),
271 output_dimension: None,
272 hyde_threshold: DEFAULT_HYDE_THRESHOLD,
273 },
274 llm: LlmConfig {
275 provider: DEFAULT_LLM_PROVIDER.into(),
276 api_key: None,
277 model: Some(DEFAULT_LLM_MODEL.into()),
278 },
279 server: ServerConfig {
280 socket_path: None,
281 reindex_interval_secs: DEFAULT_REINDEX_INTERVAL_SECS,
282 },
283 hnsw: HnswConfig {
284 max_connections: DEFAULT_HNSW_MAX_CONNECTIONS,
285 ef_construction: DEFAULT_HNSW_EF_CONSTRUCTION,
286 ef_search: DEFAULT_HNSW_EF_SEARCH,
287 dimension: DEFAULT_EMBEDDING_DIMENSION,
288 },
289 consolidation: ConsolidationConfig::default(),
290 trainer: TrainerConfig::default(),
291 }
292 }
293}
294
295pub fn resolve_project_dir(
296 start: &Path,
297 explicit_override: Option<&Path>,
298) -> Result<PathBuf, CoreError> {
299 if let Some(path) = explicit_override {
300 return Ok(path.to_path_buf());
301 }
302 if let Ok(env_path) = std::env::var("ENGRAM_PROJECT_DIR") {
303 let candidate = PathBuf::from(env_path);
304 if candidate.is_absolute() {
305 return Ok(candidate);
306 }
307 }
308 let mut current = start.to_path_buf();
309 loop {
310 if current.join(PROJECT_DIR_MARKER).is_dir() {
311 return Ok(current);
312 }
313 if !current.pop() {
315 break;
316 }
317 }
318 Err(CoreError::ProjectDirNotFound)
319}
320
321pub fn expand_tilde(path: &str) -> String {
322 if let Some(rest) = path.strip_prefix("~/")
323 && let Some(home) = home_directory()
324 {
325 return format!("{home}/{rest}");
326 }
327 path.to_string()
328}
329
330pub fn home_directory() -> Option<String> {
331 std::env::var("HOME").ok()
332}
333
334struct DeterministicEmbeddingProvider {
335 dimension: usize,
336}
337
338static DETERMINISTIC_PROVIDER_INSTRUMENTATION: AtomicBool = AtomicBool::new(false);
345static DETERMINISTIC_PROVIDER_ENTRIES: AtomicUsize = AtomicUsize::new(0);
346static DETERMINISTIC_PROVIDER_MAX_CONCURRENT: AtomicUsize = AtomicUsize::new(0);
347
348pub fn enable_deterministic_provider_instrumentation() {
352 DETERMINISTIC_PROVIDER_INSTRUMENTATION.store(true, Ordering::Relaxed);
353}
354
355pub fn disable_deterministic_provider_instrumentation() {
358 DETERMINISTIC_PROVIDER_INSTRUMENTATION.store(false, Ordering::Relaxed);
359}
360
361pub fn deterministic_provider_max_concurrent() -> usize {
362 DETERMINISTIC_PROVIDER_MAX_CONCURRENT.load(Ordering::Relaxed)
363}
364
365pub fn reset_deterministic_provider_counters() {
366 DETERMINISTIC_PROVIDER_ENTRIES.store(0, Ordering::Relaxed);
367 DETERMINISTIC_PROVIDER_MAX_CONCURRENT.store(0, Ordering::Relaxed);
368}
369
370impl EmbeddingProvider for DeterministicEmbeddingProvider {
371 fn embed(
372 &self,
373 text: &str,
374 _input_type: Option<&str>,
375 ) -> Result<Vec<f32>, engram_llm_client::ApiError> {
376 let instrumented = DETERMINISTIC_PROVIDER_INSTRUMENTATION.load(Ordering::Relaxed);
383 if instrumented {
384 let entries = DETERMINISTIC_PROVIDER_ENTRIES.fetch_add(1, Ordering::SeqCst) + 1;
385 DETERMINISTIC_PROVIDER_MAX_CONCURRENT.fetch_max(entries, Ordering::SeqCst);
386 std::thread::sleep(std::time::Duration::from_millis(20));
387 }
388
389 let mut embedding = vec![0.0_f32; self.dimension];
390 for (index, byte) in text.bytes().enumerate() {
391 embedding[index % self.dimension] += byte as f32 / 255.0;
392 }
393 let norm: f32 = embedding
394 .iter()
395 .map(|v| v * v)
396 .sum::<f32>()
397 .sqrt()
398 .max(1e-10);
399 for value in &mut embedding {
400 *value /= norm;
401 }
402
403 if instrumented {
404 DETERMINISTIC_PROVIDER_ENTRIES.fetch_sub(1, Ordering::SeqCst);
405 }
406 Ok(embedding)
407 }
408
409 fn dimension(&self) -> usize {
410 self.dimension
411 }
412
413 fn model_name(&self) -> &str {
414 "deterministic"
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn deterministic_provider_ignores_input_type() {
424 let provider = DeterministicEmbeddingProvider { dimension: 8 };
425 let text = "rust ownership rules govern compile-time memory access";
426
427 let document = provider.embed(text, Some("document")).unwrap();
428 let query = provider.embed(text, Some("query")).unwrap();
429 let none = provider.embed(text, None).unwrap();
430
431 assert_eq!(document, query);
434 assert_eq!(document, none);
435 }
436
437 #[test]
438 fn embedding_config_deserializes_without_output_dimension() {
439 let toml_input = r#"
440 provider = "voyage"
441 model = "voyage-code-3"
442 dimension = 1024
443 "#;
444 let config: EmbeddingConfig = toml::from_str(toml_input).unwrap();
445 assert_eq!(config.provider, "voyage");
446 assert_eq!(config.output_dimension, None);
447 }
448
449 #[test]
450 fn embedding_config_deserializes_with_output_dimension() {
451 let toml_input = r#"
452 provider = "voyage"
453 model = "voyage-4"
454 dimension = 1024
455 output_dimension = 1024
456 "#;
457 let config: EmbeddingConfig = toml::from_str(toml_input).unwrap();
458 assert_eq!(config.output_dimension, Some(1024));
459 }
460}