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