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