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