1pub mod config;
7pub mod health;
8pub mod mcp;
9pub mod provider;
10pub mod skills;
11
12pub use config::{parse_vault_args, resolve_config_path};
13pub use health::{health_check, warmup_provider};
14pub use mcp::{create_mcp_manager, create_mcp_registry};
15#[cfg(feature = "candle")]
16pub use provider::select_device;
17pub use provider::{
18 BootstrapError, build_orchestrator, create_named_provider, create_provider,
19 create_provider_from_config, create_summary_provider,
20};
21pub use skills::{create_skill_matcher, effective_embedding_model, managed_skills_dir};
22
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26use tokio::sync::{mpsc, watch};
27use zeph_llm::any::AnyProvider;
28use zeph_llm::provider::LlmProvider;
29use zeph_memory::GraphStore;
30use zeph_memory::QdrantOps;
31use zeph_memory::semantic::SemanticMemory;
32use zeph_skills::loader::SkillMeta;
33use zeph_skills::matcher::SkillMatcherBackend;
34use zeph_skills::registry::SkillRegistry;
35use zeph_skills::watcher::{SkillEvent, SkillWatcher};
36
37use crate::config::Config;
38use crate::config_watcher::{ConfigEvent, ConfigWatcher};
39use crate::vault::AgeVaultProvider;
40use crate::vault::{EnvVaultProvider, VaultProvider};
41
42pub struct AppBuilder {
43 config: Config,
44 config_path: PathBuf,
45 vault: Box<dyn VaultProvider>,
46 qdrant_ops: Option<QdrantOps>,
47}
48
49pub struct VaultArgs {
50 pub backend: String,
51 pub key_path: Option<String>,
52 pub vault_path: Option<String>,
53}
54
55pub struct WatcherBundle {
56 pub skill_watcher: Option<SkillWatcher>,
57 pub skill_reload_rx: mpsc::Receiver<SkillEvent>,
58 pub config_watcher: Option<ConfigWatcher>,
59 pub config_reload_rx: mpsc::Receiver<ConfigEvent>,
60}
61
62impl AppBuilder {
63 pub async fn new(
72 config_override: Option<&Path>,
73 vault_override: Option<&str>,
74 vault_key_override: Option<&Path>,
75 vault_path_override: Option<&Path>,
76 ) -> Result<Self, BootstrapError> {
77 let config_path = resolve_config_path(config_override);
78 let mut config = Config::load(&config_path)?;
79 config.validate()?;
80
81 let vault_args = parse_vault_args(
82 &config,
83 vault_override,
84 vault_key_override,
85 vault_path_override,
86 );
87 let vault: Box<dyn VaultProvider> = match vault_args.backend.as_str() {
88 "env" => Box::new(EnvVaultProvider),
89 "age" => {
90 let key = vault_args.key_path.ok_or_else(|| {
91 BootstrapError::Provider("--vault-key required for age backend".into())
92 })?;
93 let path = vault_args.vault_path.ok_or_else(|| {
94 BootstrapError::Provider("--vault-path required for age backend".into())
95 })?;
96 Box::new(
97 AgeVaultProvider::new(Path::new(&key), Path::new(&path))
98 .map_err(BootstrapError::VaultInit)?,
99 )
100 }
101 other => {
102 return Err(BootstrapError::Provider(format!(
103 "unknown vault backend: {other}"
104 )));
105 }
106 };
107
108 config.resolve_secrets(vault.as_ref()).await?;
109
110 let qdrant_ops = match config.memory.vector_backend {
111 crate::config::VectorBackend::Qdrant => {
112 let ops = QdrantOps::new(&config.memory.qdrant_url).map_err(|e| {
113 BootstrapError::Provider(format!(
114 "invalid qdrant_url '{}': {e}",
115 config.memory.qdrant_url
116 ))
117 })?;
118 Some(ops)
119 }
120 crate::config::VectorBackend::Sqlite => None,
121 };
122
123 Ok(Self {
124 config,
125 config_path,
126 vault,
127 qdrant_ops,
128 })
129 }
130
131 pub fn qdrant_ops(&self) -> Option<&QdrantOps> {
132 self.qdrant_ops.as_ref()
133 }
134
135 pub fn config(&self) -> &Config {
136 &self.config
137 }
138
139 pub fn config_mut(&mut self) -> &mut Config {
140 &mut self.config
141 }
142
143 pub fn config_path(&self) -> &Path {
144 &self.config_path
145 }
146
147 pub fn vault(&self) -> &dyn VaultProvider {
152 self.vault.as_ref()
153 }
154
155 pub async fn build_provider(
159 &self,
160 ) -> Result<(AnyProvider, tokio::sync::mpsc::UnboundedReceiver<String>), BootstrapError> {
161 let mut provider = create_provider(&self.config)?;
162
163 let (status_tx, status_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
164 provider.set_status_tx(status_tx);
165
166 health_check(&provider).await;
167
168 if let AnyProvider::Ollama(ref mut ollama) = provider
169 && let Ok(info) = ollama.fetch_model_info().await
170 && let Some(ctx) = info.context_length
171 {
172 ollama.set_context_window(ctx);
173 tracing::info!(context_window = ctx, "detected Ollama model context window");
174 }
175
176 if let AnyProvider::Orchestrator(ref mut orch) = provider {
177 orch.auto_detect_context_window().await;
178 }
179 if let Some(ctx) = provider.context_window()
180 && !matches!(provider, AnyProvider::Ollama(_))
181 {
182 tracing::info!(context_window = ctx, "detected orchestrator context window");
183 }
184
185 Ok((provider, status_rx))
186 }
187
188 pub fn auto_budget_tokens(&self, provider: &AnyProvider) -> usize {
189 if self.config.memory.auto_budget && self.config.memory.context_budget_tokens == 0 {
190 if let Some(ctx_size) = provider.context_window() {
191 tracing::info!(model_context = ctx_size, "auto-configured context budget");
192 ctx_size
193 } else {
194 0
195 }
196 } else {
197 self.config.memory.context_budget_tokens
198 }
199 }
200
201 pub async fn build_memory(
207 &self,
208 provider: &AnyProvider,
209 ) -> Result<SemanticMemory, BootstrapError> {
210 let embed_model = self.embedding_model();
211 let mut memory = match self.config.memory.vector_backend {
212 crate::config::VectorBackend::Sqlite => {
213 SemanticMemory::with_sqlite_backend_and_pool_size(
214 &self.config.memory.sqlite_path,
215 provider.clone(),
216 &embed_model,
217 self.config.memory.semantic.vector_weight,
218 self.config.memory.semantic.keyword_weight,
219 self.config.memory.sqlite_pool_size,
220 )
221 .await
222 .map_err(|e| BootstrapError::Memory(e.to_string()))?
223 }
224 crate::config::VectorBackend::Qdrant => {
225 let ops = self
226 .qdrant_ops
227 .as_ref()
228 .ok_or_else(|| {
229 BootstrapError::Memory(
230 "qdrant_ops must be Some when vector_backend = Qdrant".into(),
231 )
232 })?
233 .clone();
234 SemanticMemory::with_qdrant_ops(
235 &self.config.memory.sqlite_path,
236 ops,
237 provider.clone(),
238 &embed_model,
239 self.config.memory.semantic.vector_weight,
240 self.config.memory.semantic.keyword_weight,
241 self.config.memory.sqlite_pool_size,
242 )
243 .await
244 .map_err(|e| BootstrapError::Memory(e.to_string()))?
245 }
246 };
247
248 memory = memory.with_ranking_options(
249 self.config.memory.semantic.temporal_decay_enabled,
250 self.config.memory.semantic.temporal_decay_half_life_days,
251 self.config.memory.semantic.mmr_enabled,
252 self.config.memory.semantic.mmr_lambda,
253 );
254
255 if self.config.memory.semantic.enabled && memory.is_vector_store_connected().await {
256 tracing::info!("semantic memory enabled, vector store connected");
257 match memory.embed_missing().await {
258 Ok(n) if n > 0 => tracing::info!("backfilled {n} missing embedding(s)"),
259 Ok(_) => {}
260 Err(e) => tracing::warn!("embed_missing failed: {e:#}"),
261 }
262 }
263
264 if self.config.memory.graph.enabled {
265 let pool = memory.sqlite().pool().clone();
266 let store = Arc::new(GraphStore::new(pool));
267 memory = memory.with_graph_store(store);
268 tracing::info!("graph memory enabled, GraphStore attached");
269 }
270
271 Ok(memory)
272 }
273
274 pub async fn build_skill_matcher(
275 &self,
276 provider: &AnyProvider,
277 meta: &[&SkillMeta],
278 memory: &SemanticMemory,
279 ) -> Option<SkillMatcherBackend> {
280 let embed_model = self.embedding_model();
281 create_skill_matcher(
282 &self.config,
283 provider,
284 meta,
285 memory,
286 &embed_model,
287 self.qdrant_ops.as_ref(),
288 )
289 .await
290 }
291
292 pub fn build_registry(&self) -> SkillRegistry {
293 let skill_paths: Vec<PathBuf> =
294 self.config.skills.paths.iter().map(PathBuf::from).collect();
295 SkillRegistry::load(&skill_paths)
296 }
297
298 pub fn skill_paths(&self) -> Vec<PathBuf> {
299 let mut paths: Vec<PathBuf> = self.config.skills.paths.iter().map(PathBuf::from).collect();
300 let managed_dir = managed_skills_dir();
301 if !paths.contains(&managed_dir) {
302 paths.push(managed_dir);
303 }
304 paths
305 }
306
307 pub fn managed_skills_dir() -> PathBuf {
308 managed_skills_dir()
309 }
310
311 pub fn build_watchers(&self) -> WatcherBundle {
312 let skill_paths = self.skill_paths();
313 let (reload_tx, skill_reload_rx) = mpsc::channel(4);
314 let skill_watcher = match SkillWatcher::start(&skill_paths, reload_tx) {
315 Ok(w) => {
316 tracing::info!("skill watcher started");
317 Some(w)
318 }
319 Err(e) => {
320 tracing::warn!("skill watcher unavailable: {e:#}");
321 None
322 }
323 };
324
325 let (config_reload_tx, config_reload_rx) = mpsc::channel(4);
326 let config_watcher = match ConfigWatcher::start(&self.config_path, config_reload_tx) {
327 Ok(w) => {
328 tracing::info!("config watcher started");
329 Some(w)
330 }
331 Err(e) => {
332 tracing::warn!("config watcher unavailable: {e:#}");
333 None
334 }
335 };
336
337 WatcherBundle {
338 skill_watcher,
339 skill_reload_rx,
340 config_watcher,
341 config_reload_rx,
342 }
343 }
344
345 pub fn build_shutdown() -> (watch::Sender<bool>, watch::Receiver<bool>) {
346 watch::channel(false)
347 }
348
349 pub fn embedding_model(&self) -> String {
350 effective_embedding_model(&self.config)
351 }
352
353 pub fn build_summary_provider(&self) -> Option<AnyProvider> {
354 if let Some(ref pcfg) = self.config.llm.summary_provider {
356 return match create_provider_from_config(pcfg, &self.config) {
357 Ok(sp) => {
358 tracing::info!(
359 provider_type = %pcfg.provider_type,
360 model = ?pcfg.model,
361 "summary provider configured via [llm.summary_provider]"
362 );
363 Some(sp)
364 }
365 Err(e) => {
366 tracing::warn!("failed to create summary provider: {e:#}, using primary");
367 None
368 }
369 };
370 }
371 self.config.llm.summary_model.as_ref().and_then(
372 |model_spec| match create_summary_provider(model_spec, &self.config) {
373 Ok(sp) => {
374 tracing::info!(model = %model_spec, "summary provider configured via llm.summary_model");
375 Some(sp)
376 }
377 Err(e) => {
378 tracing::warn!("failed to create summary provider: {e:#}, using primary");
379 None
380 }
381 },
382 )
383 }
384
385 pub fn build_quarantine_provider(
390 &self,
391 ) -> Option<(AnyProvider, crate::sanitizer::QuarantineConfig)> {
392 let qc = &self.config.security.content_isolation.quarantine;
393 if !qc.enabled {
394 return None;
395 }
396 match create_named_provider(&qc.model, &self.config) {
397 Ok(p) => {
398 tracing::info!(model = %qc.model, "quarantine provider configured");
399 Some((p, qc.clone()))
400 }
401 Err(e) => {
402 tracing::warn!(
403 model = %qc.model,
404 error = %e,
405 "quarantine provider resolution failed, quarantine disabled"
406 );
407 None
408 }
409 }
410 }
411
412 pub fn build_judge_provider(&self) -> Option<AnyProvider> {
417 use crate::config::DetectorMode;
418 let learning = &self.config.skills.learning;
419 if learning.detector_mode != DetectorMode::Judge {
420 return None;
421 }
422 if learning.judge_model.is_empty() {
423 tracing::warn!(
424 provider = ?self.config.llm.provider,
425 "detector_mode=judge but judge_model is empty — primary provider will be used for judging"
426 );
427 return None;
428 }
429 match create_named_provider(&learning.judge_model, &self.config) {
430 Ok(jp) => {
431 tracing::info!(model = %learning.judge_model, "judge provider configured");
432 Some(jp)
433 }
434 Err(e) => {
435 tracing::warn!("failed to create judge provider: {e:#}, using primary");
436 None
437 }
438 }
439 }
440}
441
442#[cfg(test)]
443mod tests;